botmux 2.9.1 → 2.9.3

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 (330) hide show
  1. package/README.en.md +140 -76
  2. package/README.md +134 -75
  3. package/dist/adapters/backend/pty-backend.d.ts +6 -0
  4. package/dist/adapters/backend/pty-backend.d.ts.map +1 -1
  5. package/dist/adapters/backend/pty-backend.js +10 -0
  6. package/dist/adapters/backend/pty-backend.js.map +1 -1
  7. package/dist/adapters/backend/session-backend-selector.d.ts +11 -0
  8. package/dist/adapters/backend/session-backend-selector.d.ts.map +1 -0
  9. package/dist/adapters/backend/session-backend-selector.js +26 -0
  10. package/dist/adapters/backend/session-backend-selector.js.map +1 -0
  11. package/dist/adapters/backend/tmux-backend.d.ts +80 -3
  12. package/dist/adapters/backend/tmux-backend.d.ts.map +1 -1
  13. package/dist/adapters/backend/tmux-backend.js +301 -49
  14. package/dist/adapters/backend/tmux-backend.js.map +1 -1
  15. package/dist/adapters/backend/tmux-pipe-backend.d.ts +100 -0
  16. package/dist/adapters/backend/tmux-pipe-backend.d.ts.map +1 -0
  17. package/dist/adapters/backend/tmux-pipe-backend.js +473 -0
  18. package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -0
  19. package/dist/adapters/cli/aiden.d.ts.map +1 -1
  20. package/dist/adapters/cli/aiden.js +5 -0
  21. package/dist/adapters/cli/aiden.js.map +1 -1
  22. package/dist/adapters/cli/claude-code.d.ts +40 -1
  23. package/dist/adapters/cli/claude-code.d.ts.map +1 -1
  24. package/dist/adapters/cli/claude-code.js +470 -49
  25. package/dist/adapters/cli/claude-code.js.map +1 -1
  26. package/dist/adapters/cli/coco.d.ts.map +1 -1
  27. package/dist/adapters/cli/coco.js +191 -9
  28. package/dist/adapters/cli/coco.js.map +1 -1
  29. package/dist/adapters/cli/codex.d.ts.map +1 -1
  30. package/dist/adapters/cli/codex.js +94 -17
  31. package/dist/adapters/cli/codex.js.map +1 -1
  32. package/dist/adapters/cli/shared-hints.d.ts +2 -2
  33. package/dist/adapters/cli/shared-hints.d.ts.map +1 -1
  34. package/dist/adapters/cli/shared-hints.js +7 -5
  35. package/dist/adapters/cli/shared-hints.js.map +1 -1
  36. package/dist/adapters/cli/types.d.ts +38 -1
  37. package/dist/adapters/cli/types.d.ts.map +1 -1
  38. package/dist/autostart.d.ts +14 -0
  39. package/dist/autostart.d.ts.map +1 -0
  40. package/dist/autostart.js +357 -0
  41. package/dist/autostart.js.map +1 -0
  42. package/dist/bot-registry.d.ts +29 -3
  43. package/dist/bot-registry.d.ts.map +1 -1
  44. package/dist/bot-registry.js +91 -12
  45. package/dist/bot-registry.js.map +1 -1
  46. package/dist/cli/arg-utils.d.ts +11 -0
  47. package/dist/cli/arg-utils.d.ts.map +1 -0
  48. package/dist/cli/arg-utils.js +25 -0
  49. package/dist/cli/arg-utils.js.map +1 -0
  50. package/dist/cli/create-group-resolver.d.ts +32 -0
  51. package/dist/cli/create-group-resolver.d.ts.map +1 -0
  52. package/dist/cli/create-group-resolver.js +70 -0
  53. package/dist/cli/create-group-resolver.js.map +1 -0
  54. package/dist/cli/quoted-render.d.ts +30 -0
  55. package/dist/cli/quoted-render.d.ts.map +1 -0
  56. package/dist/cli/quoted-render.js +29 -0
  57. package/dist/cli/quoted-render.js.map +1 -0
  58. package/dist/cli.js +916 -272
  59. package/dist/cli.js.map +1 -1
  60. package/dist/config.d.ts +6 -0
  61. package/dist/config.d.ts.map +1 -1
  62. package/dist/config.js +18 -8
  63. package/dist/config.js.map +1 -1
  64. package/dist/core/command-handler.d.ts +43 -0
  65. package/dist/core/command-handler.d.ts.map +1 -1
  66. package/dist/core/command-handler.js +167 -64
  67. package/dist/core/command-handler.js.map +1 -1
  68. package/dist/core/dashboard-events.d.ts +57 -0
  69. package/dist/core/dashboard-events.d.ts.map +1 -0
  70. package/dist/core/dashboard-events.js +23 -0
  71. package/dist/core/dashboard-events.js.map +1 -0
  72. package/dist/core/dashboard-ipc-server.d.ts +43 -0
  73. package/dist/core/dashboard-ipc-server.d.ts.map +1 -0
  74. package/dist/core/dashboard-ipc-server.js +481 -0
  75. package/dist/core/dashboard-ipc-server.js.map +1 -0
  76. package/dist/core/dashboard-locate.d.ts +20 -0
  77. package/dist/core/dashboard-locate.d.ts.map +1 -0
  78. package/dist/core/dashboard-locate.js +26 -0
  79. package/dist/core/dashboard-locate.js.map +1 -0
  80. package/dist/core/dashboard-rows.d.ts +31 -0
  81. package/dist/core/dashboard-rows.d.ts.map +1 -0
  82. package/dist/core/dashboard-rows.js +65 -0
  83. package/dist/core/dashboard-rows.js.map +1 -0
  84. package/dist/core/inherit-peer.d.ts +14 -0
  85. package/dist/core/inherit-peer.d.ts.map +1 -0
  86. package/dist/core/inherit-peer.js +32 -0
  87. package/dist/core/inherit-peer.js.map +1 -0
  88. package/dist/core/scheduler.d.ts +24 -0
  89. package/dist/core/scheduler.d.ts.map +1 -1
  90. package/dist/core/scheduler.js +93 -2
  91. package/dist/core/scheduler.js.map +1 -1
  92. package/dist/core/session-activity.d.ts +3 -0
  93. package/dist/core/session-activity.d.ts.map +1 -0
  94. package/dist/core/session-activity.js +20 -0
  95. package/dist/core/session-activity.js.map +1 -0
  96. package/dist/core/session-discovery.d.ts +39 -0
  97. package/dist/core/session-discovery.d.ts.map +1 -1
  98. package/dist/core/session-discovery.js +114 -21
  99. package/dist/core/session-discovery.js.map +1 -1
  100. package/dist/core/session-manager.d.ts +72 -0
  101. package/dist/core/session-manager.d.ts.map +1 -1
  102. package/dist/core/session-manager.js +396 -106
  103. package/dist/core/session-manager.js.map +1 -1
  104. package/dist/core/types.d.ts +27 -2
  105. package/dist/core/types.d.ts.map +1 -1
  106. package/dist/core/types.js +14 -3
  107. package/dist/core/types.js.map +1 -1
  108. package/dist/core/worker-pool.d.ts +72 -3
  109. package/dist/core/worker-pool.d.ts.map +1 -1
  110. package/dist/core/worker-pool.js +459 -38
  111. package/dist/core/worker-pool.js.map +1 -1
  112. package/dist/daemon.d.ts.map +1 -1
  113. package/dist/daemon.js +645 -314
  114. package/dist/daemon.js.map +1 -1
  115. package/dist/dashboard/aggregator.d.ts +41 -0
  116. package/dist/dashboard/aggregator.d.ts.map +1 -0
  117. package/dist/dashboard/aggregator.js +125 -0
  118. package/dist/dashboard/aggregator.js.map +1 -0
  119. package/dist/dashboard/auth.d.ts +23 -0
  120. package/dist/dashboard/auth.d.ts.map +1 -0
  121. package/dist/dashboard/auth.js +66 -0
  122. package/dist/dashboard/auth.js.map +1 -0
  123. package/dist/dashboard/operator-selector.d.ts +20 -0
  124. package/dist/dashboard/operator-selector.d.ts.map +1 -0
  125. package/dist/dashboard/operator-selector.js +39 -0
  126. package/dist/dashboard/operator-selector.js.map +1 -0
  127. package/dist/dashboard/registry.d.ts +35 -0
  128. package/dist/dashboard/registry.d.ts.map +1 -0
  129. package/dist/dashboard/registry.js +74 -0
  130. package/dist/dashboard/registry.js.map +1 -0
  131. package/dist/dashboard/web/app.d.ts +2 -0
  132. package/dist/dashboard/web/app.d.ts.map +1 -0
  133. package/dist/dashboard/web/app.js +45 -0
  134. package/dist/dashboard/web/app.js.map +1 -0
  135. package/dist/dashboard/web/bot-defaults.d.ts +2 -0
  136. package/dist/dashboard/web/bot-defaults.d.ts.map +1 -0
  137. package/dist/dashboard/web/bot-defaults.js +201 -0
  138. package/dist/dashboard/web/bot-defaults.js.map +1 -0
  139. package/dist/dashboard/web/groups.d.ts +16 -0
  140. package/dist/dashboard/web/groups.d.ts.map +1 -0
  141. package/dist/dashboard/web/groups.js +584 -0
  142. package/dist/dashboard/web/groups.js.map +1 -0
  143. package/dist/dashboard/web/schedules.d.ts +2 -0
  144. package/dist/dashboard/web/schedules.d.ts.map +1 -0
  145. package/dist/dashboard/web/schedules.js +105 -0
  146. package/dist/dashboard/web/schedules.js.map +1 -0
  147. package/dist/dashboard/web/sessions.d.ts +2 -0
  148. package/dist/dashboard/web/sessions.d.ts.map +1 -0
  149. package/dist/dashboard/web/sessions.js +374 -0
  150. package/dist/dashboard/web/sessions.js.map +1 -0
  151. package/dist/dashboard/web/store.d.ts +23 -0
  152. package/dist/dashboard/web/store.d.ts.map +1 -0
  153. package/dist/dashboard/web/store.js +82 -0
  154. package/dist/dashboard/web/store.js.map +1 -0
  155. package/dist/dashboard-web/app.js +263 -0
  156. package/dist/dashboard-web/index.html +23 -0
  157. package/dist/dashboard-web/style.css +93 -0
  158. package/dist/dashboard.d.ts +2 -0
  159. package/dist/dashboard.d.ts.map +1 -0
  160. package/dist/dashboard.js +639 -0
  161. package/dist/dashboard.js.map +1 -0
  162. package/dist/im/lark/card-builder.d.ts +18 -1
  163. package/dist/im/lark/card-builder.d.ts.map +1 -1
  164. package/dist/im/lark/card-builder.js +70 -9
  165. package/dist/im/lark/card-builder.js.map +1 -1
  166. package/dist/im/lark/card-handler.d.ts.map +1 -1
  167. package/dist/im/lark/card-handler.js +123 -109
  168. package/dist/im/lark/card-handler.js.map +1 -1
  169. package/dist/im/lark/client.d.ts +35 -0
  170. package/dist/im/lark/client.d.ts.map +1 -1
  171. package/dist/im/lark/client.js +114 -11
  172. package/dist/im/lark/client.js.map +1 -1
  173. package/dist/im/lark/event-dispatcher.d.ts +88 -6
  174. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  175. package/dist/im/lark/event-dispatcher.js +398 -62
  176. package/dist/im/lark/event-dispatcher.js.map +1 -1
  177. package/dist/im/lark/forwarded-renderer.d.ts +23 -0
  178. package/dist/im/lark/forwarded-renderer.d.ts.map +1 -0
  179. package/dist/im/lark/forwarded-renderer.js +105 -0
  180. package/dist/im/lark/forwarded-renderer.js.map +1 -0
  181. package/dist/im/lark/md-card.d.ts +73 -0
  182. package/dist/im/lark/md-card.d.ts.map +1 -0
  183. package/dist/im/lark/md-card.js +332 -0
  184. package/dist/im/lark/md-card.js.map +1 -0
  185. package/dist/im/lark/merge-forward.d.ts +32 -0
  186. package/dist/im/lark/merge-forward.d.ts.map +1 -0
  187. package/dist/im/lark/merge-forward.js +110 -0
  188. package/dist/im/lark/merge-forward.js.map +1 -0
  189. package/dist/im/lark/message-parser.d.ts +9 -3
  190. package/dist/im/lark/message-parser.d.ts.map +1 -1
  191. package/dist/im/lark/message-parser.js +48 -13
  192. package/dist/im/lark/message-parser.js.map +1 -1
  193. package/dist/im/lark/quote-hint.d.ts +18 -0
  194. package/dist/im/lark/quote-hint.d.ts.map +1 -0
  195. package/dist/im/lark/quote-hint.js +23 -0
  196. package/dist/im/lark/quote-hint.js.map +1 -0
  197. package/dist/services/bridge-fallback-gate.d.ts +42 -0
  198. package/dist/services/bridge-fallback-gate.d.ts.map +1 -0
  199. package/dist/services/bridge-fallback-gate.js +12 -0
  200. package/dist/services/bridge-fallback-gate.js.map +1 -0
  201. package/dist/services/bridge-rotation-policy.d.ts +139 -0
  202. package/dist/services/bridge-rotation-policy.d.ts.map +1 -0
  203. package/dist/services/bridge-rotation-policy.js +125 -0
  204. package/dist/services/bridge-rotation-policy.js.map +1 -0
  205. package/dist/services/bridge-turn-queue.d.ts +154 -0
  206. package/dist/services/bridge-turn-queue.d.ts.map +1 -0
  207. package/dist/services/bridge-turn-queue.js +316 -0
  208. package/dist/services/bridge-turn-queue.js.map +1 -0
  209. package/dist/services/chat-first-seen-store.d.ts +27 -0
  210. package/dist/services/chat-first-seen-store.d.ts.map +1 -0
  211. package/dist/services/chat-first-seen-store.js +114 -0
  212. package/dist/services/chat-first-seen-store.js.map +1 -0
  213. package/dist/services/claude-transcript.d.ts +268 -0
  214. package/dist/services/claude-transcript.d.ts.map +1 -0
  215. package/dist/services/claude-transcript.js +798 -0
  216. package/dist/services/claude-transcript.js.map +1 -0
  217. package/dist/services/coco-transcript.d.ts +35 -0
  218. package/dist/services/coco-transcript.d.ts.map +1 -0
  219. package/dist/services/coco-transcript.js +192 -0
  220. package/dist/services/coco-transcript.js.map +1 -0
  221. package/dist/services/codex-bridge-queue.d.ts +56 -0
  222. package/dist/services/codex-bridge-queue.d.ts.map +1 -0
  223. package/dist/services/codex-bridge-queue.js +150 -0
  224. package/dist/services/codex-bridge-queue.js.map +1 -0
  225. package/dist/services/codex-transcript.d.ts +84 -0
  226. package/dist/services/codex-transcript.d.ts.map +1 -0
  227. package/dist/services/codex-transcript.js +298 -0
  228. package/dist/services/codex-transcript.js.map +1 -0
  229. package/dist/services/group-creator.d.ts +23 -0
  230. package/dist/services/group-creator.d.ts.map +1 -0
  231. package/dist/services/group-creator.js +75 -0
  232. package/dist/services/group-creator.js.map +1 -0
  233. package/dist/services/groups-store.d.ts +98 -0
  234. package/dist/services/groups-store.d.ts.map +1 -0
  235. package/dist/services/groups-store.js +213 -0
  236. package/dist/services/groups-store.js.map +1 -0
  237. package/dist/services/oncall-store.d.ts +80 -8
  238. package/dist/services/oncall-store.d.ts.map +1 -1
  239. package/dist/services/oncall-store.js +265 -55
  240. package/dist/services/oncall-store.js.map +1 -1
  241. package/dist/services/project-scanner.d.ts +1 -2
  242. package/dist/services/project-scanner.d.ts.map +1 -1
  243. package/dist/services/project-scanner.js +118 -68
  244. package/dist/services/project-scanner.js.map +1 -1
  245. package/dist/services/schedule-store.d.ts +5 -0
  246. package/dist/services/schedule-store.d.ts.map +1 -1
  247. package/dist/services/schedule-store.js +77 -1
  248. package/dist/services/schedule-store.js.map +1 -1
  249. package/dist/services/session-store.d.ts +22 -0
  250. package/dist/services/session-store.d.ts.map +1 -1
  251. package/dist/services/session-store.js +62 -4
  252. package/dist/services/session-store.js.map +1 -1
  253. package/dist/setup/bots-store.d.ts +3 -0
  254. package/dist/setup/bots-store.d.ts.map +1 -0
  255. package/dist/setup/bots-store.js +24 -0
  256. package/dist/setup/bots-store.js.map +1 -0
  257. package/dist/setup/detect-platform.d.ts +14 -0
  258. package/dist/setup/detect-platform.d.ts.map +1 -0
  259. package/dist/setup/detect-platform.js +139 -0
  260. package/dist/setup/detect-platform.js.map +1 -0
  261. package/dist/setup/ensure-fonts.d.ts +13 -0
  262. package/dist/setup/ensure-fonts.d.ts.map +1 -0
  263. package/dist/setup/ensure-fonts.js +225 -0
  264. package/dist/setup/ensure-fonts.js.map +1 -0
  265. package/dist/setup/ensure-tmux.d.ts +60 -0
  266. package/dist/setup/ensure-tmux.d.ts.map +1 -0
  267. package/dist/setup/ensure-tmux.js +236 -0
  268. package/dist/setup/ensure-tmux.js.map +1 -0
  269. package/dist/setup/index.d.ts +9 -0
  270. package/dist/setup/index.d.ts.map +1 -0
  271. package/dist/setup/index.js +46 -0
  272. package/dist/setup/index.js.map +1 -0
  273. package/dist/setup/lark-scopes.json +301 -0
  274. package/dist/setup/register-app.d.ts +52 -0
  275. package/dist/setup/register-app.d.ts.map +1 -0
  276. package/dist/setup/register-app.js +91 -0
  277. package/dist/setup/register-app.js.map +1 -0
  278. package/dist/setup/verify-permissions.d.ts +115 -0
  279. package/dist/setup/verify-permissions.d.ts.map +1 -0
  280. package/dist/setup/verify-permissions.js +207 -0
  281. package/dist/setup/verify-permissions.js.map +1 -0
  282. package/dist/skills/definitions.d.ts +4 -0
  283. package/dist/skills/definitions.d.ts.map +1 -1
  284. package/dist/skills/definitions.js +133 -19
  285. package/dist/skills/definitions.js.map +1 -1
  286. package/dist/skills/installer.d.ts +3 -1
  287. package/dist/skills/installer.d.ts.map +1 -1
  288. package/dist/skills/installer.js +18 -3
  289. package/dist/skills/installer.js.map +1 -1
  290. package/dist/types.d.ts +44 -0
  291. package/dist/types.d.ts.map +1 -1
  292. package/dist/utils/bot-routing.d.ts +6 -0
  293. package/dist/utils/bot-routing.d.ts.map +1 -0
  294. package/dist/utils/bot-routing.js +50 -0
  295. package/dist/utils/bot-routing.js.map +1 -0
  296. package/dist/utils/file-lock.d.ts +2 -0
  297. package/dist/utils/file-lock.d.ts.map +1 -0
  298. package/dist/utils/file-lock.js +114 -0
  299. package/dist/utils/file-lock.js.map +1 -0
  300. package/dist/utils/font-installer.js +1 -1
  301. package/dist/utils/font-installer.js.map +1 -1
  302. package/dist/utils/idle-detector.d.ts +6 -0
  303. package/dist/utils/idle-detector.d.ts.map +1 -1
  304. package/dist/utils/idle-detector.js +25 -4
  305. package/dist/utils/idle-detector.js.map +1 -1
  306. package/dist/utils/logger.d.ts +10 -0
  307. package/dist/utils/logger.d.ts.map +1 -1
  308. package/dist/utils/logger.js +60 -8
  309. package/dist/utils/logger.js.map +1 -1
  310. package/dist/utils/render-dimensions.d.ts +48 -0
  311. package/dist/utils/render-dimensions.d.ts.map +1 -0
  312. package/dist/utils/render-dimensions.js +55 -0
  313. package/dist/utils/render-dimensions.js.map +1 -0
  314. package/dist/utils/screen-analyzer.d.ts.map +1 -1
  315. package/dist/utils/screen-analyzer.js +24 -0
  316. package/dist/utils/screen-analyzer.js.map +1 -1
  317. package/dist/utils/screenshot-renderer.d.ts.map +1 -1
  318. package/dist/utils/screenshot-renderer.js +67 -23
  319. package/dist/utils/screenshot-renderer.js.map +1 -1
  320. package/dist/utils/terminal-renderer.d.ts +16 -0
  321. package/dist/utils/terminal-renderer.d.ts.map +1 -1
  322. package/dist/utils/terminal-renderer.js +40 -23
  323. package/dist/utils/terminal-renderer.js.map +1 -1
  324. package/dist/utils/transient-snapshot.d.ts +28 -0
  325. package/dist/utils/transient-snapshot.d.ts.map +1 -0
  326. package/dist/utils/transient-snapshot.js +96 -0
  327. package/dist/utils/transient-snapshot.js.map +1 -0
  328. package/dist/worker.js +2248 -83
  329. package/dist/worker.js.map +1 -1
  330. package/package.json +12 -5
package/dist/worker.js CHANGED
@@ -13,18 +13,30 @@
13
13
  * 7. On 'restart', kills CLI and re-spawns with --resume
14
14
  */
15
15
  import { randomBytes } from 'node:crypto';
16
- import { mkdirSync, writeFileSync, unlinkSync } from 'node:fs';
16
+ import { mkdirSync, writeFileSync, unlinkSync, existsSync, statSync, readdirSync, readlinkSync, readFileSync, watch as fsWatch } from 'node:fs';
17
17
  import { join } from 'node:path';
18
+ import { drainTranscript, joinAssistantText, findJsonlContainingFingerprint, findJsonlsContainingExactContent, findLatestJsonl, extractLastAssistantTurn, extractTurnStartText, splitTranscriptEventsByCutoff } from './services/claude-transcript.js';
19
+ import { BridgeTurnQueue, makeFingerprint, normaliseForFingerprint } from './services/bridge-turn-queue.js';
20
+ import { shouldSuppressBridgeEmit } from './services/bridge-fallback-gate.js';
21
+ import { shouldRunQuietRotation, evaluatePidResolverPullback, decideFingerprintSwitch, sessionIdFromJsonlPath, SESSION_ID_FILENAME_RE, } from './services/bridge-rotation-policy.js';
22
+ import { CodexBridgeQueue } from './services/codex-bridge-queue.js';
23
+ import { drainCodexRollout, findCodexRolloutBySessionId, findCodexRolloutByPid, splitCodexEventsByCutoff, extractLastCodexTurn } from './services/codex-transcript.js';
24
+ import { cocoEventsPathForSession, drainCocoEvents, findCocoSessionByPid } from './services/coco-transcript.js';
25
+ import { dirname } from 'node:path';
18
26
  import { createServer as createHttpServer } from 'node:http';
19
27
  import { WebSocketServer, WebSocket } from 'ws';
20
28
  import { TerminalRenderer } from './utils/terminal-renderer.js';
29
+ 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';
21
30
  import { createCliAdapterSync } from './adapters/cli/registry.js';
22
- import { claudeJsonlPathForSession } from './adapters/cli/claude-code.js';
23
- import { PtyBackend } from './adapters/backend/pty-backend.js';
31
+ import { claudeJsonlPathForSession, resolveJsonlFromPid, findOpenClaudeSessionIds } from './adapters/cli/claude-code.js';
24
32
  import { TmuxBackend } from './adapters/backend/tmux-backend.js';
33
+ import { TmuxPipeBackend } from './adapters/backend/tmux-pipe-backend.js';
34
+ import { selectSessionBackend } from './adapters/backend/session-backend-selector.js';
35
+ import { tmuxEnv } from './setup/ensure-tmux.js';
25
36
  import { IdleDetector } from './utils/idle-detector.js';
26
37
  import { ScreenAnalyzer } from './utils/screen-analyzer.js';
27
38
  import { captureToPng } from './utils/screenshot-renderer.js';
39
+ import { snapshotToPng, snapshotToText } from './utils/transient-snapshot.js';
28
40
  import { uploadImageBuffer } from './utils/lark-upload.js';
29
41
  import { config } from './config.js';
30
42
  import * as sessionStore from './services/session-store.js';
@@ -36,6 +48,10 @@ let backend = null;
36
48
  let cliPidMarker = null; // path to .botmux-cli-pids/<pid>
37
49
  let idleDetector = null;
38
50
  let isTmuxMode = false;
51
+ /** Adopt-bridge mode using TmuxPipeBackend: not a tmux attach client, all
52
+ * web-terminal updates flow through the shared scrollback fan-out instead
53
+ * of per-WS attach-session PTYs. Set in spawnCli's adopt branch. */
54
+ let isPipeMode = false;
39
55
  let httpServer = null;
40
56
  let wss = null;
41
57
  const wsClients = new Set();
@@ -51,15 +67,1571 @@ let isPromptReady = false;
51
67
  /** Mutex for async flushPending — prevents concurrent flush loops. */
52
68
  let isFlushing = false;
53
69
  const pendingMessages = [];
70
+ // ─── Adopt-bridge state (Claude Code only) ─────────────────────────────────
71
+ //
72
+ // In bridge mode the daemon adopted an existing CLI session that we do NOT
73
+ // own; the model never sees botmux. We harvest assistant turns by tailing
74
+ // Claude Code's transcript JSONL and forward only the bytes appended after
75
+ // each Lark-driven user turn — never the historical content present at
76
+ // attach time, never local-terminal-driven turns.
77
+ //
78
+ // Attribution lives in BridgeTurnQueue; this file only manages the
79
+ // fs.watch wakeup, byte-offset bookkeeping, lazy baseline, and IPC emit.
80
+ let bridgeJsonlPath;
81
+ /** Directory enclosing bridgeJsonlPath. We poll this dir for newer jsonl
82
+ * files so the bridge follows `/clear` / `/resume` in the user's CLI —
83
+ * those create a brand-new sessionId.jsonl, and a watcher pinned to the
84
+ * original path would silently stop receiving events. */
85
+ let bridgeJsonlDir;
86
+ /** PID + cwd of the adopted Claude Code process. Lets every poll re-read
87
+ * ~/.claude/sessions/<pid>.json — Claude's own pid-state record. Empirical
88
+ * scope (Claude Code 2.1.123): the pid file's `sessionId` is set ONCE at
89
+ * process start. `--resume` (which spawns a new process) does rotate the
90
+ * recorded sessionId; `/clear` / in-pane `/resume` do NOT — those rely on
91
+ * the fingerprint fallback (which anchors on a pending Lark turn) to
92
+ * follow the new jsonl. */
93
+ let bridgeCliPid;
94
+ let bridgeCliCwd;
95
+ /** Last sessionId we observed via the pid resolver — used to detect
96
+ * rotations cheaply (string compare instead of stat()ing every jsonl). */
97
+ let bridgeObservedCliSessionId;
98
+ /** Sibling-pane hijack guard state.
99
+ *
100
+ * Every sessionId we have evidence of belonging to our adopted Claude pid:
101
+ * initial attach path, pid resolver hits, `/proc/<pid>/fd` hits. The
102
+ * fingerprint fallback's two-phase decision (`decideFingerprintSwitch`
103
+ * in `src/services/bridge-rotation-policy.ts`) consumes this set:
104
+ * Phase 1 substring match runs against trusted sids only; Phase 2
105
+ * exact-content recovery runs against UNTRUSTED sids only. Unknown
106
+ * sessionIds never pass Phase 1 even when the file looks freshly
107
+ * created — freshness/timestamp signals cannot prove pane ownership
108
+ * across siblings in the same project dir. */
109
+ const bridgeKnownSessionIds = new Set();
110
+ /** Set when the fingerprint fallback accepts a candidate whose sessionId
111
+ * doesn't match the pid file's current sessionId (Claude's pid file isn't
112
+ * refreshed by in-pane `/clear`, so it keeps reporting the spawn-time sid
113
+ * even after the user rotated). Suppresses pid resolver from pulling the
114
+ * watcher back to that spawn-time sid every tick. Cleared when pid file
115
+ * reports a NEW sid (fresh `--resume` / spawn), at which point a real
116
+ * rotation has happened and we should follow it. */
117
+ let bridgeStalePidStateSessionId;
118
+ /** Old jsonl paths we keep polling AFTER a rotation switched
119
+ * bridgeJsonlPath away — needed when a started turn was stamped with the
120
+ * old path but its assistant text hasn't been written yet. We continue to
121
+ * drain each entry on every tick so trailing appends to that file land in
122
+ * the queue against the right turn, and prune the entry once no pending
123
+ * turn references the path anymore. */
124
+ const bridgeSecondaryPaths = new Map(); // path → offset
125
+ let bridgeOffset = 0;
126
+ let bridgePendingTail = '';
127
+ const bridgeQueue = new BridgeTurnQueue();
128
+ let bridgeWatcher = null;
129
+ let bridgeFallbackTimer = null;
130
+ /** True once we successfully baselined the transcript file. Until then,
131
+ * any data we see is treated as history — absorbed into the queue's seen
132
+ * set without being attributed to a pending Lark turn. This protects the
133
+ * first Lark turn from inheriting historical lines if Claude Code creates
134
+ * the JSONL file *after* attach. */
135
+ let bridgeBaselineDone = false;
136
+ /** Once-per-attach flag so a re-baseline after fs.watch lazy-fire doesn't
137
+ * re-send the preamble. Reset only when the bridge teardown happens. */
138
+ let bridgePreambleSent = false;
139
+ // ─── Codex bridge state ──────────────────────────────────────────────────
140
+ //
141
+ // Parallel to the Claude bridge above. Codex's transcript layout is
142
+ // different enough (separate file location, different event schema) that
143
+ // trying to share storage / readers would obscure both — so we keep state
144
+ // independent. Marker file (`<DATA_DIR>/turn-sends/<sid>.jsonl`) and the
145
+ // gate function are CLI-agnostic and shared.
146
+ let codexBridgeRolloutPath;
147
+ let codexBridgeOffset = 0;
148
+ let codexBridgePendingTail = '';
149
+ let codexBridgeBaselineDone = false;
150
+ const codexBridgeQueue = new CodexBridgeQueue();
151
+ let codexBridgeWatcher = null;
152
+ let codexBridgeTimer = null;
153
+ /** Codex sessionId we received via writeInput but haven't yet resolved a
154
+ * rollout file for. The poller keeps retrying — the file appears on
155
+ * Codex's first user submit, but with some race delay after our submit
156
+ * returns. Cleared once attached. */
157
+ let codexBridgePendingSessionId;
158
+ /** Adopt-only: PID of the externally-running Codex process. Used by the
159
+ * poller to fall back to /proc/<pid>/fd discovery when sessionId is
160
+ * unknown (e.g. discovery probe missed the rollout fd). */
161
+ let codexAdoptPendingPid;
162
+ /** Adopt-only: wall-clock millis at adopt-spawn time. Late-attach uses
163
+ * this as the cutoff for splitting an existing rollout into "history"
164
+ * (absorb) vs "live" (ingest) — so events the user produced AFTER adopt
165
+ * but BEFORE the rollout was located still reach the Lark thread. 5s
166
+ * skew tolerance is applied on top, mirroring the Lark/Claude bridges. */
167
+ let codexAdoptStartMs;
168
+ /** Adopt-only: 一次性发送的 "/adopt 前最后一轮" preamble 是否已经触发过。
169
+ * codexBridgeAttach 在 split-live 分支会查 history 取最后一对 user/assistant
170
+ * 发给 daemon —— late-attach poller 也会反复走这条分支(每秒一次),所以
171
+ * 必须有标志位防重发。镜像 claude 那套 bridgePreambleSent 的角色。 */
172
+ let codexBridgePreambleSent = false;
173
+ /** Cap the preamble text so an extremely long previous turn doesn't blow
174
+ * past Lark's per-message limit. The user only needs enough to recall
175
+ * context, not the entire transcript. */
176
+ const PREAMBLE_USER_MAX = 500;
177
+ const PREAMBLE_ASSISTANT_MAX = 4000;
178
+ /** Same intent as the preamble caps, but for live local-terminal turns
179
+ * forwarded to Lark. A long paste typed locally shouldn't be allowed to
180
+ * blow past Lark's per-message limit. */
181
+ const LOCAL_TURN_USER_MAX = 1000;
182
+ const LOCAL_TURN_ASSISTANT_MAX = 8000;
183
+ function truncatePreambleText(text, max) {
184
+ if (text.length <= max)
185
+ return text;
186
+ return text.slice(0, max) + '…';
187
+ }
188
+ /** Prepare a local-turn `final_output` payload. The daemon owns the card
189
+ * chrome (label/quote/markdown body), so we ship the user prompt and
190
+ * assistant text as separate fields — see card-builder `buildContextualReplyCard`.
191
+ * Returns null when both sides are empty so the caller can skip the emit. */
192
+ function formatLocalTurnFields(userText, assistantText) {
193
+ const u = truncatePreambleText(userText.trim(), LOCAL_TURN_USER_MAX);
194
+ const a = truncatePreambleText(assistantText.trim(), LOCAL_TURN_ASSISTANT_MAX);
195
+ if (!u && !a)
196
+ return null;
197
+ return { userText: u, content: a };
198
+ }
199
+ /** Same as `formatLocalTurnFields` but for HEADLESS local turns — daemon
200
+ * restart cut off an in-flight model stream so we have an assistant side
201
+ * with no resolvable user prompt. */
202
+ function formatHeadlessLocalTurnContent(assistantText) {
203
+ const a = truncatePreambleText(assistantText.trim(), LOCAL_TURN_ASSISTANT_MAX);
204
+ return a || null;
205
+ }
206
+ // ─── Bridge fallback marker (non-adopt) ────────────────────────────────────
207
+ //
208
+ // `botmux send` (cli.ts cmdSend) appends a line `{sentAtMs, messageId}\n` to
209
+ // `<DATA_DIR>/turn-sends/<sid>.jsonl` every time the model successfully posts
210
+ // a reply to its OWN session thread. The worker reads these markers at idle
211
+ // and suppresses transcript-driven final_output for any turn whose time
212
+ // window already contains a send — i.e. the model didn't forget, no fallback
213
+ // needed. Append-only over a shared file (instead of a per-turn marker) is
214
+ // type-ahead safe: type-ahead'd turns each have their own [markTimeMs,
215
+ // nextTurn.markTimeMs) window, and a stray send only fills its own bucket.
216
+ function bridgeMarkerPath() {
217
+ if (!process.env.SESSION_DATA_DIR || !sessionId)
218
+ return undefined;
219
+ return join(process.env.SESSION_DATA_DIR, 'turn-sends', `${sessionId}.jsonl`);
220
+ }
221
+ function readSendMarkers() {
222
+ const path = bridgeMarkerPath();
223
+ if (!path || !existsSync(path))
224
+ return [];
225
+ try {
226
+ const out = [];
227
+ for (const line of readFileSync(path, 'utf-8').split('\n')) {
228
+ if (!line.trim())
229
+ continue;
230
+ try {
231
+ const parsed = JSON.parse(line);
232
+ if (typeof parsed?.sentAtMs === 'number')
233
+ out.push(parsed);
234
+ }
235
+ catch { /* skip malformed line */ }
236
+ }
237
+ return out;
238
+ }
239
+ catch (err) {
240
+ log(`Bridge marker read failed: ${err.message}`);
241
+ return [];
242
+ }
243
+ }
244
+ function clearSendMarkers() {
245
+ const path = bridgeMarkerPath();
246
+ if (!path)
247
+ return;
248
+ try {
249
+ unlinkSync(path);
250
+ }
251
+ catch { /* already gone or fs.unavailable; not fatal */ }
252
+ }
253
+ function maybeEmitAdoptPreamble(events) {
254
+ // Preamble is an /adopt-only signal: it tells the user "here's the last
255
+ // turn from the Claude session you just attached to, so the Lark thread
256
+ // has context to continue from". In non-adopt sessions the user IS the
257
+ // Lark thread (every turn was already pushed there as a card), so
258
+ // surfacing the last turn again on daemon restart is just noise.
259
+ if (!lastInitConfig?.adoptMode)
260
+ return;
261
+ // Same logic for /adopt sessions restored after a daemon restart: the
262
+ // Lark thread already has every prior turn pushed as cards, AND the
263
+ // baseline jsonl persisted in session metadata may be stale (Claude
264
+ // could have /clear'd since the original /adopt), so a preamble here
265
+ // would surface old, out-of-context content.
266
+ if (lastInitConfig?.adoptRestoredFromMetadata)
267
+ return;
268
+ if (bridgePreambleSent)
269
+ return;
270
+ const turn = extractLastAssistantTurn(events);
271
+ if (!turn)
272
+ return;
273
+ bridgePreambleSent = true;
274
+ send({
275
+ type: 'adopt_preamble',
276
+ userText: truncatePreambleText(turn.userText, PREAMBLE_USER_MAX),
277
+ assistantText: truncatePreambleText(turn.assistantText, PREAMBLE_ASSISTANT_MAX),
278
+ });
279
+ log('Bridge adopt preamble emitted (last completed turn from baseline)');
280
+ }
281
+ /** Codex / CoCo 镜像版:split-live 攒齐 history 后挑最后一对 user/assistant_final
282
+ * 发回 daemon 渲染成 "📜 /adopt 前最后一轮" 卡片。语义、跳过条件、字数截断都
283
+ * 对齐 maybeEmitAdoptPreamble;区别只在事件取出方式(codex/coco 是结构化
284
+ * event,不需要走 claude 那套 jsonl turn assembly)。 */
285
+ function maybeEmitCodexAdoptPreamble(history) {
286
+ if (!lastInitConfig?.adoptMode)
287
+ return;
288
+ if (lastInitConfig?.adoptRestoredFromMetadata)
289
+ return;
290
+ if (codexBridgePreambleSent)
291
+ return;
292
+ const turn = extractLastCodexTurn(history);
293
+ if (!turn)
294
+ return;
295
+ if (!turn.userText.trim() && !turn.assistantText.trim())
296
+ return;
297
+ codexBridgePreambleSent = true;
298
+ send({
299
+ type: 'adopt_preamble',
300
+ userText: truncatePreambleText(turn.userText, PREAMBLE_USER_MAX),
301
+ assistantText: truncatePreambleText(turn.assistantText, PREAMBLE_ASSISTANT_MAX),
302
+ });
303
+ log('Codex bridge adopt preamble emitted (last completed turn from split-live history)');
304
+ }
305
+ /** Extract the sessionId from a Claude jsonl path and add it to the
306
+ * known-sid set. Validates the filename against Claude's UUID-shaped
307
+ * sessionId pattern so non-Claude jsonls in the project dir (accidental
308
+ * drops, third-party tooling) can't poison the trust set. No-op on
309
+ * parse failure. */
310
+ function bridgeRememberSessionIdForPath(path) {
311
+ if (!path)
312
+ return;
313
+ const sid = sessionIdFromJsonlPath(path);
314
+ if (!SESSION_ID_FILENAME_RE.test(sid))
315
+ return;
316
+ bridgeKnownSessionIds.add(sid);
317
+ }
318
+ /** Cheap per-tick probe: read /proc/<bridgeCliPid>/fd and add every jsonl
319
+ * the adopted Claude pid currently has open into the known-sid set. fd
320
+ * observation is intermittent (Claude opens-writes-closes per event), so
321
+ * running this every tick raises our chances of catching a post-/clear
322
+ * sessionId before the user's next Lark message arrives. No-op when there
323
+ * is no pid or /proc isn't available. */
324
+ function bridgeProbeOpenSessionIds() {
325
+ if (bridgeCliPid === undefined || !bridgeJsonlDir)
326
+ return;
327
+ const opened = findOpenJsonlsForPid(bridgeCliPid, bridgeJsonlDir);
328
+ for (const path of opened)
329
+ bridgeRememberSessionIdForPath(path);
330
+ }
331
+ function bridgeAbsorbBaseline() {
332
+ if (!bridgeJsonlPath)
333
+ return;
334
+ const result = drainTranscript(bridgeJsonlPath, 0);
335
+ bridgeOffset = result.newOffset;
336
+ bridgePendingTail = result.pendingTail;
337
+ bridgeQueue.absorb(result.events);
338
+ bridgeBaselineDone = true;
339
+ // After absorb (uuids registered as seen so they won't re-emit as a Lark
340
+ // turn), surface the last completed user/assistant exchange to Lark as a
341
+ // one-shot preamble — but only for real /adopt sessions. Non-adopt
342
+ // claude-code fallback bridge also uses baseline-existing on daemon
343
+ // restart/resume; it must not emit the "/adopt 前最后一轮" message.
344
+ if (lastInitConfig?.adoptMode)
345
+ maybeEmitAdoptPreamble(result.events);
346
+ }
347
+ /** Record `bridgeStalePidStateSessionId` if the pid file's current sid
348
+ * disagrees with the just-accepted candidate's sid. Stops the next pid
349
+ * resolver tick from pulling the watcher back to the stale spawn-time
350
+ * path Claude wrote into the pid file — which it never refreshes on
351
+ * in-pane `/clear`. No-op when pid file is unavailable or already
352
+ * agrees. */
353
+ function bridgeMarkStalePidStateForAcceptedSid(acceptedSid) {
354
+ if (bridgeCliPid === undefined || bridgeCliCwd === undefined)
355
+ return;
356
+ const pidResolved = resolveJsonlFromPid(bridgeCliPid, bridgeCliCwd);
357
+ if (pidResolved && pidResolved.cliSessionId !== acceptedSid) {
358
+ bridgeStalePidStateSessionId = pidResolved.cliSessionId;
359
+ }
360
+ }
361
+ /** Apply a fingerprint-driven switch: drain old path, retire watcher,
362
+ * pivot bridgeJsonlPath to `matched`, split the new path's existing
363
+ * content by `cutoffMs` (history → absorbed into the seen set, live →
364
+ * ingested), and install a new fs.watch. The split-live step is what
365
+ * prevents the "switched into a long-lived /clear file → all prior
366
+ * iTerm-typed turns get re-emitted as 🖥️ 终端本地对话" symptom: any
367
+ * user/assistant events written before the Lark mark are pre-existing
368
+ * pane history, not events to forward. `cutoffMs` should be the same
369
+ * `markTimeMs - 5s` used for the fingerprint scan's lower bound. */
370
+ function bridgeApplyFingerprintSwitch(matched, reason, cutoffMs) {
371
+ // Drain-before-switch: pull in any unread bytes from the old path so a
372
+ // late assistant append doesn't vanish. We do NOT emit here — emission
373
+ // only happens at idle (bridgeDrainAndMaybeEmit), otherwise drainEmittable
374
+ // would publish a half-finished assistant turn during fs.watch / poll
375
+ // ticks (drainEmittable's contract is "has visible text", not "model
376
+ // finished"). If the drained user/assistant events still need follow-up
377
+ // appends on the old path, retainSecondaryPathIfStillReferenced() keeps
378
+ // the old path in the polling rotation.
379
+ if (bridgeJsonlPath && bridgeBaselineDone) {
380
+ let postDrainOffset = bridgeOffset;
381
+ try {
382
+ const drained = drainPathInto(bridgeJsonlPath, bridgeOffset);
383
+ postDrainOffset = drained.offset;
384
+ }
385
+ catch (err) {
386
+ log(`Bridge final-drain on fingerprint switch failed (${err.message}); continuing`);
387
+ }
388
+ retainSecondaryPathIfStillReferenced(bridgeJsonlPath, postDrainOffset);
389
+ }
390
+ log(`Bridge transcript switched: ${bridgeJsonlPath} → ${matched} (${reason})`);
391
+ if (bridgeWatcher) {
392
+ try {
393
+ bridgeWatcher.close();
394
+ }
395
+ catch { /* ignore */ }
396
+ bridgeWatcher = null;
397
+ }
398
+ // Critically: do NOT clear pending turns. The switch was triggered by
399
+ // the FIRST pending turn already living in `matched`, so the immediate
400
+ // next ingest from offset 0 will find that user event and start the
401
+ // turn. Clearing here would race-drop exactly the message we're
402
+ // trying to deliver.
403
+ bridgeJsonlPath = matched;
404
+ bridgeJsonlDir = dirname(matched);
405
+ bridgePendingTail = '';
406
+ // Split-live: drain `matched` from offset 0, partition by cutoffMs.
407
+ // History (pre-mark) is absorbed into the seen set so the iTerm-side
408
+ // turns the user accumulated before this Lark message DON'T re-emit
409
+ // as "🖥️ 终端本地对话" cards. Live (post-mark) goes through ingest
410
+ // so the Lark fingerprint can start its turn. Mirrors what
411
+ // performRotationSwitch already does for fd-rotation rotations.
412
+ const drained = drainTranscript(matched, 0);
413
+ bridgeOffset = drained.newOffset;
414
+ bridgePendingTail = drained.pendingTail;
415
+ const { history, live } = splitTranscriptEventsByCutoff(drained.events, cutoffMs);
416
+ bridgeQueue.absorb(history);
417
+ if (live.length > 0)
418
+ bridgeQueue.ingest(live, matched);
419
+ bridgeBaselineDone = true;
420
+ log(`Bridge fingerprint switch split: ${history.length} historical events absorbed, ${live.length} live events ingested (cutoff=${cutoffMs})`);
421
+ bridgeRememberSessionIdForPath(matched);
422
+ bridgeMarkStalePidStateForAcceptedSid(sessionIdFromJsonlPath(matched));
423
+ try {
424
+ bridgeWatcher = fsWatch(matched, { persistent: false }, () => {
425
+ try {
426
+ bridgeIngest();
427
+ }
428
+ catch (err) {
429
+ log(`Bridge ingest error: ${err.message}`);
430
+ }
431
+ });
432
+ }
433
+ catch (err) {
434
+ log(`Bridge fs.watch unavailable on new target (${err.message}); relying on fallback poller`);
435
+ }
436
+ }
437
+ /** Detect /clear / /resume: when Claude Code starts a new session in the
438
+ * user's pane it writes to a brand-new sessionId.jsonl. Two-phase scan:
439
+ *
440
+ * - Phase 1 (known-sid substring): cheap path for trusted candidates
441
+ * only. Same content fingerprint substring search as before — safe
442
+ * here because we've gated it on the pid-derived trust set, so a
443
+ * sibling pane in the same project dir (different sessionId) can
444
+ * never be the match even when its content includes the fingerprint.
445
+ *
446
+ * - Phase 2 (unknown-sid exact-content recovery): in-pane `/clear`
447
+ * creates a new sessionId Claude does NOT write into its pid file.
448
+ * If the fd probe didn't catch the brief open window, the new sid is
449
+ * untrusted and Phase 1 rejects it. Phase 2 falls back to scanning
450
+ * every UNTRUSTED candidate jsonl for a user/queue event whose
451
+ * NORMALISED content equals our just-marked Lark message in full
452
+ * (not a substring) — strong enough that "test" doesn't false-match
453
+ * "run tests". When exactly one untrusted candidate matches, accept
454
+ * it; when multiple match, abstain and surface an unambiguous log
455
+ * line so the user can take recovery action.
456
+ *
457
+ * Pending turns are preserved across the switch so the next ingest
458
+ * can match and start the turn in the new file. */
459
+ /** Per-fingerprint rate limit for the full-directory fingerprint scan.
460
+ * Without this, a wedged pending turn (e.g. writeInput's Enter eaten by a
461
+ * Claude TUI prompt so the user line never lands in any jsonl) drives this
462
+ * function every 1s from the fallback timer and every idle tick — each
463
+ * call reads the trailing 1MB of every jsonl in the project dir (hundreds
464
+ * of files, 100s of MB total), pegging the worker at 99% CPU until
465
+ * restart. The cleanup paths in #1/#2 (dropPendingTurn / pruneExpired)
466
+ * are what actually *removes* the stuck mark; this rate limit just keeps
467
+ * the windows in between cheap.
468
+ *
469
+ * 10s is much wider than the milliseconds Claude needs to write a normal
470
+ * user line, but `maybeSwitchBridgeJsonl` is only consulted when the
471
+ * primary jsonl scan in `bridgeIngest` already failed to find the line —
472
+ * i.e. Claude rotated the file via `/clear` / `/resume`. Those rotations
473
+ * happen hours apart in practice, so a 10s detection delay is invisible. */
474
+ const BRIDGE_FINGERPRINT_SCAN_MIN_INTERVAL_MS = 10_000;
475
+ const bridgeFingerprintScanLastMs = new Map();
476
+ /** Pending+unstarted bridge marks expire after this long. Defensive TTL:
477
+ * every known path that creates a mark also has an explicit
478
+ * `dropPendingTurn` path, but TTL guarantees self-healing if a future
479
+ * code path forgets one. 120s is well past Claude's deferred recheck
480
+ * window (20s) and any plausible jsonl-flush delay; the only marks left
481
+ * this long are real failures. */
482
+ const BRIDGE_PENDING_TURN_TTL_MS = 120_000;
483
+ function maybeSwitchBridgeJsonl() {
484
+ if (!bridgeJsonlDir)
485
+ return false;
486
+ const pending = bridgeQueue.peek();
487
+ const candidate = pending.find(t => !t.started && !!t.contentFingerprint);
488
+ if (!candidate || !candidate.contentFingerprint)
489
+ return false;
490
+ // Per-fingerprint rate limit — see BRIDGE_FINGERPRINT_SCAN_MIN_INTERVAL_MS.
491
+ const lastScan = bridgeFingerprintScanLastMs.get(candidate.contentFingerprint);
492
+ const now = Date.now();
493
+ if (lastScan !== undefined && now - lastScan < BRIDGE_FINGERPRINT_SCAN_MIN_INTERVAL_MS) {
494
+ return false;
495
+ }
496
+ bridgeFingerprintScanLastMs.set(candidate.contentFingerprint, now);
497
+ // Bound the search to events written after the turn was marked. Short
498
+ // fingerprints ("hello", "test") would otherwise match old user lines
499
+ // in unrelated sibling jsonls. 5s skew absorbs clock drift between the
500
+ // mark and Claude's transcript write.
501
+ const minEventTimestampMs = candidate.markTimeMs !== undefined
502
+ ? candidate.markTimeMs - 5_000
503
+ : undefined;
504
+ const fingerprintScanOptions = {
505
+ excludePath: bridgeJsonlPath,
506
+ includeQueueOperations: true,
507
+ minEventTimestampMs,
508
+ };
509
+ const decision = decideFingerprintSwitch({
510
+ contentFingerprint: candidate.contentFingerprint,
511
+ contentNormalized: candidate.contentNormalized,
512
+ knownSessionIds: bridgeKnownSessionIds,
513
+ findSubstring: (acceptCandidate) => findJsonlContainingFingerprint(bridgeJsonlDir, candidate.contentFingerprint, {
514
+ ...fingerprintScanOptions,
515
+ acceptCandidate,
516
+ }),
517
+ findExact: (acceptCandidate) => candidate.contentNormalized
518
+ ? findJsonlsContainingExactContent(bridgeJsonlDir, candidate.contentNormalized, {
519
+ ...fingerprintScanOptions,
520
+ acceptCandidate,
521
+ })
522
+ : [],
523
+ });
524
+ if (decision.action === 'switch') {
525
+ const reason = decision.reason === 'known-sid-substring'
526
+ ? 'known-sid fingerprint match'
527
+ : 'unknown-sid exact-content recovery (in-pane /clear with stale pid file)';
528
+ // Boundary alignment with the fingerprint scanner:
529
+ //
530
+ // scanner.minEventTimestampMs is INCLUSIVE — events with
531
+ // timestamp >= (markTimeMs - 5s) are eligible to start the turn.
532
+ // splitTranscriptEventsByCutoff puts timestamp <= cutoffMs in
533
+ // history (absorbed) and > cutoffMs in live (ingested).
534
+ //
535
+ // If we hand split the same value as the scanner's lower bound, an
536
+ // event AT exactly that timestamp (e.g. the user's just-arrived
537
+ // Lark user event) is matched-eligible by the scanner — driving
538
+ // the switch — but absorbed as history by split, leaving the
539
+ // pending turn unstarted and the message silent. Subtract 1ms to
540
+ // make split's history strictly older than the scanner's
541
+ // eligibility floor.
542
+ const historyCutoffMs = ((candidate.markTimeMs ?? Date.now()) - 5_000) - 1;
543
+ bridgeApplyFingerprintSwitch(decision.path, reason, historyCutoffMs);
544
+ return true;
545
+ }
546
+ if (decision.action === 'abstain') {
547
+ log(`Bridge fingerprint switch ABSTAINED (${decision.reason}): ${decision.candidates.length} unknown jsonls have an exact-content match for the pending Lark turn (${decision.candidates.join(', ')}). User should re-/adopt or send a longer disambiguating message.`);
548
+ return false;
549
+ }
550
+ return false;
551
+ }
552
+ /** Last-resort rotation follower for the case where pid resolver returned
553
+ * `'unavailable'` (no /proc, missing/invalid pid file). Originally also
554
+ * ran on `'same'` to catch in-pane `/clear` with no pending Lark turn,
555
+ * but that path is now intentionally dropped — the directory-mtime
556
+ * heuristic in Path 2 below cannot tell our pane's rotation from a
557
+ * sibling Claude pane in the same cwd, and the sibling-pane hijack
558
+ * silently corrupts every multi-pane adopt setup (see
559
+ * `bridge-rotation-policy.ts`). The Lark-message-driven /clear recovery
560
+ * flow (fingerprint fallback) covers the dominant case.
561
+ *
562
+ * Detection priority:
563
+ * 1. Linux first-class: read `/proc/<pid>/fd` and pick the .jsonl the
564
+ * adopted Claude process actually has open. Bound to the real PID
565
+ * — a sibling Claude pane has a different PID and cannot hijack
566
+ * the result. Note: Claude Code opens-writes-closes per event, so
567
+ * this often returns 0 entries between writes; the gate above
568
+ * ensures we still skip Path 2 in that case when pid resolver
569
+ * confirmed our path.
570
+ * 2. Cross-platform fallback: directory-level mtime heuristic, gated
571
+ * on (a) our current jsonl quiet ≥ QUIET_ROTATION_MS, (b) candidate
572
+ * newer by ≥ QUIET_ROTATION_MS, (c) adopted Claude pid alive. Only
573
+ * runs when Path 1 returns 0 entries AND pid resolver was
574
+ * unavailable.
575
+ *
576
+ * When a rotation is detected, the new jsonl is drained from offset 0
577
+ * and events are split by timestamp against `rotationCutoffMs` (the
578
+ * old jsonl's last-write time): events before the cutoff are *history*
579
+ * (absorbed into the seen-set, not emitted), events after are *live*
580
+ * (ingested → local-turn synthesis runs). This is what lets a rotation
581
+ * to a long-history jsonl NOT replay the entire past as one giant
582
+ * local turn.
583
+ *
584
+ * Critically, we do NOT call `bridgeAbsorbBaseline` here — that helper
585
+ * also fires `maybeEmitAdoptPreamble`, which on rotation would surface
586
+ * the *previous session's* last turn as if it were a fresh "/adopt 前最
587
+ * 后一轮" preamble. Preamble belongs only to initial attach. */
588
+ const QUIET_ROTATION_MS = 8_000;
589
+ function statSafe(path) {
590
+ try {
591
+ const st = statSync(path);
592
+ if (!st.isFile())
593
+ return null;
594
+ return { mtimeMs: st.mtimeMs, size: st.size };
595
+ }
596
+ catch {
597
+ return null;
598
+ }
599
+ }
600
+ function isPidAlive(pid) {
601
+ if (!Number.isInteger(pid) || pid <= 0)
602
+ return false;
603
+ try {
604
+ process.kill(pid, 0);
605
+ return true;
606
+ }
607
+ catch {
608
+ return false;
609
+ }
610
+ }
611
+ /** List `.jsonl` files inside `dir` that are currently held open by `pid`.
612
+ * Returns [] on non-Linux platforms or if /proc lookup fails — the caller
613
+ * treats an empty result as "fd info unavailable, fall back to mtime". */
614
+ function findOpenJsonlsForPid(pid, dir) {
615
+ if (!Number.isInteger(pid) || pid <= 0)
616
+ return [];
617
+ if (process.platform !== 'linux')
618
+ return [];
619
+ let entries;
620
+ try {
621
+ entries = readdirSync(`/proc/${pid}/fd`);
622
+ }
623
+ catch {
624
+ return [];
625
+ }
626
+ const out = [];
627
+ for (const name of entries) {
628
+ let target;
629
+ try {
630
+ target = readlinkSync(`/proc/${pid}/fd/${name}`);
631
+ }
632
+ catch {
633
+ continue;
634
+ }
635
+ if (!target.endsWith('.jsonl'))
636
+ continue;
637
+ if (dirname(target) !== dir)
638
+ continue;
639
+ out.push(target);
640
+ }
641
+ return out;
642
+ }
643
+ /** Pick the most recently modified path among `paths`. Returns null if
644
+ * none of them stat. */
645
+ function newestPath(paths) {
646
+ let best = null;
647
+ for (const p of paths) {
648
+ const st = statSafe(p);
649
+ if (!st)
650
+ continue;
651
+ if (!best || st.mtimeMs > best.mtimeMs)
652
+ best = { path: p, mtimeMs: st.mtimeMs };
653
+ }
654
+ return best?.path ?? null;
655
+ }
656
+ /** Switch bridgeJsonlPath to `newPath` and split-baseline its existing
657
+ * content: events with timestamp ≤ `cutoffMs` are absorbed as history
658
+ * (seen-set only, no emission), events strictly after are ingested so
659
+ * local turn synthesis runs against them. The old path is retained in
660
+ * the secondary polling rotation if any started turn still references
661
+ * it. Does NOT emit `adopt_preamble` — that's an initial-attach signal,
662
+ * not a rotation signal. */
663
+ function performRotationSwitch(newPath, cutoffMs, reason) {
664
+ // Drain-before-switch: pull any unread bytes from the old path so a
665
+ // late assistant append doesn't vanish. Mirrors the other rotation
666
+ // helpers.
667
+ if (bridgeJsonlPath && bridgeBaselineDone) {
668
+ let postDrainOffset = bridgeOffset;
669
+ try {
670
+ const drained = drainPathInto(bridgeJsonlPath, bridgeOffset);
671
+ postDrainOffset = drained.offset;
672
+ }
673
+ catch (err) {
674
+ log(`Bridge final-drain on rotation (${reason}) failed (${err.message}); continuing`);
675
+ }
676
+ retainSecondaryPathIfStillReferenced(bridgeJsonlPath, postDrainOffset);
677
+ }
678
+ log(`Bridge transcript switched (${reason}): ${bridgeJsonlPath ?? '(none)'} → ${newPath}`);
679
+ if (bridgeWatcher) {
680
+ try {
681
+ bridgeWatcher.close();
682
+ }
683
+ catch { /* ignore */ }
684
+ bridgeWatcher = null;
685
+ }
686
+ bridgeJsonlPath = newPath;
687
+ bridgeJsonlDir = dirname(newPath);
688
+ bridgePendingTail = '';
689
+ // Drain the new path from 0 ourselves (do NOT call bridgeAbsorbBaseline
690
+ // — that would emit the preamble we want to suppress on rotation).
691
+ const result = drainTranscript(newPath, 0);
692
+ bridgeOffset = result.newOffset;
693
+ bridgePendingTail = result.pendingTail;
694
+ const { history, live } = splitTranscriptEventsByCutoff(result.events, cutoffMs);
695
+ bridgeQueue.absorb(history);
696
+ if (live.length > 0)
697
+ bridgeQueue.ingest(live, newPath);
698
+ bridgeBaselineDone = true;
699
+ log(`Bridge rotation split: ${history.length} historical events absorbed, ${live.length} live events ingested`);
700
+ try {
701
+ bridgeWatcher = fsWatch(newPath, { persistent: false }, () => {
702
+ try {
703
+ bridgeIngest();
704
+ }
705
+ catch (err) {
706
+ log(`Bridge ingest error: ${err.message}`);
707
+ }
708
+ });
709
+ }
710
+ catch (err) {
711
+ log(`Bridge fs.watch unavailable on rotated target (${err.message}); relying on fallback poller`);
712
+ }
713
+ }
714
+ function maybeFollowQuietRotation() {
715
+ if (!bridgeJsonlDir || !bridgeJsonlPath)
716
+ return;
717
+ // Need a known pid to do safe rotation tracking; if we don't have one,
718
+ // we can't bind to the adopted Claude process and a directory-mtime
719
+ // switch would risk sibling-pane hijack.
720
+ if (bridgeCliPid === undefined)
721
+ return;
722
+ if (!isPidAlive(bridgeCliPid))
723
+ return;
724
+ const currentStat = statSafe(bridgeJsonlPath);
725
+ if (!currentStat)
726
+ return;
727
+ // Path 1: Linux fd-based detection — definitive, can't be hijacked.
728
+ // Read /proc/<pid>/fd, find every .jsonl Claude has open in our cwd's
729
+ // project dir, pick the one with the most recent mtime. Differs from
730
+ // bridgeJsonlPath ⇒ rotation.
731
+ const opened = findOpenJsonlsForPid(bridgeCliPid, bridgeJsonlDir);
732
+ if (opened.length > 0) {
733
+ // Every fd-observed jsonl belongs to our pid — feed all of them
734
+ // into the sibling-pane hijack guard's trust list, not just the
735
+ // newest. This is how a post-/clear sessionId enters the trust
736
+ // set: Claude opens the new jsonl briefly during the /clear
737
+ // handshake; if a fd probe lands in that window, fingerprint
738
+ // fallback can later accept the new sessionId on the user's next
739
+ // Lark message.
740
+ for (const path of opened)
741
+ bridgeRememberSessionIdForPath(path);
742
+ const newest = newestPath(opened);
743
+ if (newest && newest !== bridgeJsonlPath) {
744
+ performRotationSwitch(newest, currentStat.mtimeMs, `pid fd → ${bridgeCliPid}`);
745
+ }
746
+ // fd lookup succeeded — even if it confirmed the current path, the
747
+ // mtime fallback below would only add risk. Stop here.
748
+ return;
749
+ }
750
+ // Path 2: non-Linux fallback (or /proc unavailable). Directory-mtime
751
+ // heuristic with three guards plus a trust-set filter on candidates.
752
+ //
753
+ // Without the trust-set filter, an actively-written sibling Claude pane
754
+ // in the same project dir always wins the mtime race; pid resolver then
755
+ // pulls the watcher back to our own (idle) jsonl on the next tick,
756
+ // re-arming the same condition. Result: 1 Hz path-flap that pegs CPU
757
+ // for as long as the sibling keeps writing (observed: 8 days, 6896
758
+ // switches on a single worker). Only candidates whose sid lives in
759
+ // `bridgeKnownSessionIds` (populated from initial attach, pid resolver
760
+ // hits, fd probes) are eligible — sibling sids are rejected.
761
+ const now = Date.now();
762
+ if (now - currentStat.mtimeMs < QUIET_ROTATION_MS)
763
+ return;
764
+ const latest = findLatestJsonl(bridgeJsonlDir, {
765
+ acceptCandidate: (path) => {
766
+ const sid = sessionIdFromJsonlPath(path);
767
+ return SESSION_ID_FILENAME_RE.test(sid) && bridgeKnownSessionIds.has(sid);
768
+ },
769
+ });
770
+ if (!latest || latest === bridgeJsonlPath)
771
+ return;
772
+ const latestStat = statSafe(latest);
773
+ if (!latestStat)
774
+ return;
775
+ if (latestStat.mtimeMs - currentStat.mtimeMs < QUIET_ROTATION_MS)
776
+ return;
777
+ performRotationSwitch(latest, currentStat.mtimeMs, `quiet mtime fallback (${Math.round((now - currentStat.mtimeMs) / 1000)}s quiet)`);
778
+ }
779
+ /** Pid-state rotation follow: re-read ~/.claude/sessions/<cliPid>.json
780
+ * and switch bridgeJsonlPath whenever the recorded sessionId differs
781
+ * from what we're watching. Same source as the writeInput pid resolver,
782
+ * with the same cwd + procStart validation.
783
+ *
784
+ * Empirical scope (Claude Code 2.1.123): the pid file's `sessionId` is
785
+ * written ONCE at process start. `--resume` rewrites it (it's a fresh
786
+ * spawn → fresh pid file). In-pane `/clear` does NOT rewrite it —
787
+ * `updatedAt` and `status` change but `sessionId` stays. So this probe
788
+ * catches spawn-time / `--resume` rotations; `/clear` (and in-pane
789
+ * `/resume` if Claude treats it the same) is left to the fingerprint
790
+ * fallback that anchors on a pending Lark turn. Returns a tri-state
791
+ * result rather than a bool so the caller can distinguish 'switched'
792
+ * (we moved) from 'same' (path confirmed) from 'unavailable' (no
793
+ * reliable answer) — the downstream gates use that distinction. */
794
+ /** Tri-state result so callers can distinguish "pid file unreadable, fall
795
+ * back to fingerprint heuristic" from "pid file confirmed current path"
796
+ * vs "pid file said rotate to a new path".
797
+ *
798
+ * Used by two downstream gates:
799
+ * - Fingerprint fallback (`maybeSwitchBridgeJsonl`): runs whenever the
800
+ * pid resolver did not actively switch (`!= 'switched'`). Safe even
801
+ * on `'same'` because the fingerprint scan requires a pending Lark
802
+ * turn — no risk of hijacking to a sibling pane.
803
+ * - Quiet-mtime fallback (`maybeFollowQuietRotation`): runs only on
804
+ * `'unavailable'`. The mtime heuristic can't distinguish our pane's
805
+ * rotation from a sibling pane in the same cwd, so even when pid
806
+ * resolver's `'same'` is not proof against in-process /clear (it
807
+ * isn't — Claude doesn't refresh `sessionId` on /clear), we still
808
+ * skip the heuristic. The cost is that a pure-local /clear with no
809
+ * pending Lark turn won't auto-follow until the user sends a Lark
810
+ * message; the alternative (running mtime fallback on 'same') would
811
+ * silently corrupt every multi-pane adopt setup.
812
+ *
813
+ * Type imported from `./services/bridge-rotation-policy` — the gate
814
+ * function lives there so it's testable without dragging worker fs/IPC
815
+ * side-effects into the unit suite. */
816
+ function maybeFollowSessionRotationViaPid() {
817
+ if (!bridgeCliPid || !bridgeCliCwd)
818
+ return 'unavailable';
819
+ const resolved = resolveJsonlFromPid(bridgeCliPid, bridgeCliCwd);
820
+ if (!resolved)
821
+ return 'unavailable';
822
+ if (bridgeObservedCliSessionId !== resolved.cliSessionId) {
823
+ bridgeObservedCliSessionId = resolved.cliSessionId;
824
+ }
825
+ // Pid resolver always reports the spawn-time sessionId — this is a sid
826
+ // that genuinely belongs to our adopted Claude pid, so remember it for
827
+ // the sibling-pane hijack guard.
828
+ bridgeRememberSessionIdForPath(resolved.path);
829
+ if (resolved.path === bridgeJsonlPath)
830
+ return 'same';
831
+ // Stale-pid suppression: when the fingerprint fallback accepted a
832
+ // post-/clear jsonl (Claude's pid file isn't refreshed by in-pane
833
+ // /clear, so it keeps reporting the spawn-time sid), pid resolver
834
+ // would otherwise pull the watcher back to that spawn-time sid every
835
+ // tick — re-creating the flap loop the user reported. The decision
836
+ // lives in `bridge-rotation-policy.evaluatePidResolverPullback` so
837
+ // the four-cell matrix can be unit-tested in isolation.
838
+ const pullback = evaluatePidResolverPullback({
839
+ resolvedCliSessionId: resolved.cliSessionId,
840
+ resolvedPath: resolved.path,
841
+ currentBridgeJsonlPath: bridgeJsonlPath,
842
+ stalePidStateSessionId: bridgeStalePidStateSessionId,
843
+ });
844
+ if (pullback.clearStale)
845
+ bridgeStalePidStateSessionId = undefined;
846
+ if (pullback.suppress)
847
+ return 'same';
848
+ // Drain-before-switch: pull in any unread bytes from the OLD path so a
849
+ // trailing assistant append doesn't vanish. We do NOT emit here — emit
850
+ // is reserved for idle ticks (bridgeDrainAndMaybeEmit), otherwise we'd
851
+ // publish a half-finished assistant during fs.watch / poll-driven
852
+ // bridgeIngest calls. If a started turn still references the old path
853
+ // and its assistant text might still be on the way, the old path stays
854
+ // in the polling rotation via bridgeSecondaryPaths.
855
+ if (bridgeJsonlPath && bridgeBaselineDone) {
856
+ let postDrainOffset = bridgeOffset;
857
+ try {
858
+ const drained = drainPathInto(bridgeJsonlPath, bridgeOffset);
859
+ postDrainOffset = drained.offset;
860
+ }
861
+ catch (err) {
862
+ log(`Bridge final-drain on rotation failed (${err.message}); continuing`);
863
+ }
864
+ retainSecondaryPathIfStillReferenced(bridgeJsonlPath, postDrainOffset);
865
+ }
866
+ log(`Bridge transcript switched (pid resolver): ${bridgeJsonlPath ?? '(none)'} → ${resolved.path}`);
867
+ if (bridgeWatcher) {
868
+ try {
869
+ bridgeWatcher.close();
870
+ }
871
+ catch { /* ignore */ }
872
+ bridgeWatcher = null;
873
+ }
874
+ // Preserve any pending Lark turn so the next ingest can attribute it
875
+ // when Claude appends our user event to the new jsonl. Skip baseline:
876
+ // we want to read from offset 0 so the pending turn's user event is
877
+ // visible to BridgeTurnQueue.ingest(). Turns already started on the
878
+ // old path keep their stamped sourceJsonlPath, so when their assistant
879
+ // text eventually arrives there too it still resolves correctly.
880
+ bridgeJsonlPath = resolved.path;
881
+ bridgeJsonlDir = dirname(resolved.path);
882
+ bridgeOffset = 0;
883
+ bridgePendingTail = '';
884
+ bridgeBaselineDone = true;
885
+ try {
886
+ bridgeWatcher = fsWatch(resolved.path, { persistent: false }, () => {
887
+ try {
888
+ bridgeIngest();
889
+ }
890
+ catch (err) {
891
+ log(`Bridge ingest error: ${err.message}`);
892
+ }
893
+ });
894
+ }
895
+ catch (err) {
896
+ log(`Bridge fs.watch unavailable on rotated target (${err.message}); relying on fallback poller`);
897
+ }
898
+ return 'switched';
899
+ }
900
+ function bridgeIngest() {
901
+ // Defensive TTL: sweep any pending+unstarted mark whose Lark message
902
+ // never matched a user line in the transcript (writeInput failure
903
+ // surface that didn't get caught, future paths that forget to call
904
+ // dropPendingTurn). Without this, a stranded mark drives
905
+ // `maybeSwitchBridgeJsonl` to do full-directory jsonl scans every tick
906
+ // until daemon restart — the 99% CPU bug. The explicit dropPendingTurn
907
+ // path in scheduleSubmitFailureNotify handles the known offender;
908
+ // this catches everything else.
909
+ const expired = bridgeQueue.pruneExpired(BRIDGE_PENDING_TURN_TTL_MS);
910
+ for (const t of expired) {
911
+ if (t.contentFingerprint)
912
+ bridgeFingerprintScanLastMs.delete(t.contentFingerprint);
913
+ log(`Bridge mark expired after ${Math.round(BRIDGE_PENDING_TURN_TTL_MS / 1000)}s without matching a jsonl user line (turnId=${t.turnId}) — dropped to prevent rotation-fallback scan loop.`);
914
+ }
915
+ // Drain secondary paths first so any trailing assistant text on an old
916
+ // jsonl reaches the queue before the rotation check considers retiring
917
+ // the path. Strictly read-only on the polling rotation; never triggers
918
+ // a rotate or shifts the primary path.
919
+ drainSecondaryPaths();
920
+ // Cheap probe: catch any jsonls our adopted pid currently has open
921
+ // and add their sessionIds to the sibling-pane hijack guard's trust
922
+ // list. Runs every tick (independent of rotation gates) because
923
+ // Claude opens-writes-closes the jsonl per event — fd observation
924
+ // is therefore intermittent, and more ticks = more chances to
925
+ // catch a post-/clear sessionId. This is the only hook by which
926
+ // an in-pane /clear becomes followable: without an fd-probe hit
927
+ // the fingerprint fallback will reject the new (unknown) sessionId
928
+ // and the user must re-adopt to recover.
929
+ bridgeProbeOpenSessionIds();
930
+ // Pid-resolver: catches *spawn-time* rotations (new Claude PID → new
931
+ // pid file → new sessionId), e.g. daemon restart that re-issues
932
+ // `--resume <id>` and Claude rotates the internal id.
933
+ const pidFollow = maybeFollowSessionRotationViaPid();
934
+ // Fingerprint fallback: catches *in-process* rotations Claude makes
935
+ // via /clear or /resume from the user's pane. Empirically (verified
936
+ // on Claude Code 2.1.123) the pid file's `sessionId` field is set
937
+ // ONCE at process start; /clear refreshes `updatedAt` but does NOT
938
+ // rewrite `sessionId`, so pid resolver returning 'same' is NOT proof
939
+ // that no rotation happened. We skip the fingerprint scan only when
940
+ // pid resolver actively switched the path — in that case the
941
+ // authoritative source already moved us, and running fingerprint on
942
+ // top would risk a redundant flip. Sibling-pane hijack protection is
943
+ // NOT delegated to the markTimeMs-5s event filter (short fingerprints
944
+ // substring-match unrelated content like "test" → "run tests"); the
945
+ // real gate is the sibling guard inside `maybeSwitchBridgeJsonl` that
946
+ // rejects every candidate whose sessionId isn't in the pid-derived
947
+ // trust set.
948
+ let switched = pidFollow === 'switched';
949
+ if (!switched) {
950
+ switched = maybeSwitchBridgeJsonl();
951
+ }
952
+ // Quiet-rotation fallback: directory-mtime heuristic that picks the
953
+ // newest jsonl in the same project dir when our current path goes
954
+ // quiet. Originally the safety net for "user runs /clear purely in
955
+ // iTerm with no pending Lark turn, so fingerprint fallback can't
956
+ // anchor on anything". Trade-off: when the user has a SIBLING Claude
957
+ // pane in the same cwd, that pane's busier jsonl always wins this
958
+ // race and the bridge gets hijacked, ingesting the sibling pane's
959
+ // user/assistant events as `isLocal: true` local turns and forwarding
960
+ // them to the adopted Lark thread (the user-reported "/adopt 一对话
961
+ // 出来一堆历史会话" symptom).
962
+ //
963
+ // We accept the asymmetry: sibling-pane hijack is silent, persistent
964
+ // and corrupts every adopted multi-pane setup; pure-local /clear
965
+ // without a pending Lark turn is a narrow corner case the user can
966
+ // unstick by sending one Lark message (which arms fingerprint
967
+ // fallback). So we ONLY consult the mtime heuristic when the pid
968
+ // probe was unavailable (non-Linux, missing/invalid pid file).
969
+ if (shouldRunQuietRotation(pidFollow, switched)) {
970
+ maybeFollowQuietRotation();
971
+ }
972
+ if (!bridgeJsonlPath)
973
+ return;
974
+ if (!bridgeBaselineDone) {
975
+ // Lazy baseline: file didn't exist at attach, baseline the moment it does.
976
+ if (!existsSyncSafe(bridgeJsonlPath))
977
+ return;
978
+ bridgeAbsorbBaseline();
979
+ return;
980
+ }
981
+ const result = drainTranscript(bridgeJsonlPath, bridgeOffset);
982
+ bridgeOffset = result.newOffset;
983
+ bridgePendingTail = result.pendingTail;
984
+ bridgeQueue.ingest(result.events, bridgeJsonlPath);
985
+ }
986
+ function startBridgeWatcher(jsonlPath, opts) {
987
+ bridgeJsonlPath = jsonlPath;
988
+ bridgeJsonlDir = dirname(jsonlPath);
989
+ bridgeCliPid = opts?.cliPid;
990
+ bridgeCliCwd = opts?.cliCwd;
991
+ const mode = opts?.mode ?? 'baseline-existing';
992
+ // Pid-state record ranks above the path the adopt scan computed. If
993
+ // Claude was launched with `--resume` (or the adopt scan picked a
994
+ // stale jsonl), the pid file points at the actual current sessionId
995
+ // and we swap to it before baseline so we don't waste a baseline on
996
+ // a frozen file.
997
+ if (bridgeCliPid && bridgeCliCwd) {
998
+ const resolved = resolveJsonlFromPid(bridgeCliPid, bridgeCliCwd);
999
+ if (resolved) {
1000
+ bridgeObservedCliSessionId = resolved.cliSessionId;
1001
+ bridgeRememberSessionIdForPath(resolved.path);
1002
+ if (resolved.path !== bridgeJsonlPath) {
1003
+ log(`Bridge transcript adjusted at start (pid resolver): ${bridgeJsonlPath} → ${resolved.path}`);
1004
+ bridgeJsonlPath = resolved.path;
1005
+ bridgeJsonlDir = dirname(resolved.path);
1006
+ }
1007
+ }
1008
+ }
1009
+ // fd probe at start: the pid file's `sessionId` is set ONCE at Claude's
1010
+ // process start and is NOT refreshed by in-pane `/clear`. So if the user
1011
+ // /clear'd between the original /adopt and this worker spawn (most
1012
+ // commonly: daemon restart that restored a long-lived adopt session),
1013
+ // pid resolver still points at the spawn-time jsonl while Claude has
1014
+ // rotated to a new one. `/proc/<pid>/fd` shows what Claude *currently*
1015
+ // has open — bound to our pid, so no sibling-pane hijack risk.
1016
+ //
1017
+ // Two signals matter: direct `.jsonl` fd (only present during a write
1018
+ // window — Claude opens-writes-closes per event) and `~/.claude/tasks/
1019
+ // <sid>` symlinks (Claude holds the tasks dir + its .lock file open
1020
+ // continuously for the active session, so this catches the rotation
1021
+ // even between writes). `findOpenClaudeSessionIds` unions both.
1022
+ if (bridgeCliPid !== undefined && bridgeJsonlDir && bridgeCliCwd) {
1023
+ const sids = findOpenClaudeSessionIds(bridgeCliPid);
1024
+ const candidates = [];
1025
+ for (const sid of sids) {
1026
+ const path = claudeJsonlPathForSession(sid, bridgeCliCwd);
1027
+ bridgeRememberSessionIdForPath(path);
1028
+ if (existsSyncSafe(path))
1029
+ candidates.push(path);
1030
+ }
1031
+ if (candidates.length > 0) {
1032
+ const newest = newestPath(candidates);
1033
+ if (newest && newest !== bridgeJsonlPath) {
1034
+ log(`Bridge transcript adjusted at start (pid fd probe — Claude rotated since worker spawn): ${bridgeJsonlPath} → ${newest}`);
1035
+ bridgeJsonlPath = newest;
1036
+ bridgeJsonlDir = dirname(newest);
1037
+ // Pid file's sessionId disagrees with the path Claude actually has
1038
+ // open — record it as stale so the per-tick pid resolver doesn't
1039
+ // pull us back to the spawn-time jsonl on every poll.
1040
+ bridgeMarkStalePidStateForAcceptedSid(sessionIdFromJsonlPath(newest));
1041
+ }
1042
+ }
1043
+ }
1044
+ // Remember the initial path's sessionId — this is the ground-truth
1045
+ // anchor for the sibling-pane hijack guard. Subsequent fingerprint
1046
+ // candidates are accepted only if their sessionId is in this set
1047
+ // (populated here, by pid resolver hits, and by per-tick fd probes).
1048
+ bridgeRememberSessionIdForPath(bridgeJsonlPath);
1049
+ if (mode === 'fresh-empty') {
1050
+ // Non-adopt fallback: brand-new session, jsonl gets created on the first
1051
+ // user submit. We must NOT lazy-absorb the file when it appears — that
1052
+ // would treat the first turn's user/assistant events as history and the
1053
+ // worker would never emit a final_output for them. Instead declare
1054
+ // baseline=done with offset=0 up front: the very first events drained
1055
+ // from the file are eligible for attribution against pending Lark turns.
1056
+ bridgeOffset = 0;
1057
+ bridgePendingTail = '';
1058
+ bridgeBaselineDone = true;
1059
+ log(`Bridge fresh-empty mode: ${bridgeJsonlPath} (waiting for file to appear; no baseline absorb)`);
1060
+ }
1061
+ else if (existsSyncSafe(bridgeJsonlPath)) {
1062
+ bridgeAbsorbBaseline();
1063
+ log(`Bridge baselined: ${bridgeJsonlPath} (offset=${bridgeOffset})`);
1064
+ }
1065
+ else {
1066
+ log(`Bridge transcript not yet present at ${bridgeJsonlPath}; will baseline on first appearance`);
1067
+ }
1068
+ // fs.watch is best-effort wakeup — actual data source is the byte offset.
1069
+ // The fallback poller covers fs.watch's gaps (NFS, rename-rotation, etc.)
1070
+ // and also drives lazy baseline when the file shows up after attach.
1071
+ try {
1072
+ bridgeWatcher = fsWatch(bridgeJsonlPath, { persistent: false }, () => {
1073
+ try {
1074
+ bridgeIngest();
1075
+ }
1076
+ catch (err) {
1077
+ log(`Bridge ingest error: ${err.message}`);
1078
+ }
1079
+ });
1080
+ }
1081
+ catch (err) {
1082
+ log(`Bridge fs.watch unavailable (${err.message}); relying on fallback poller`);
1083
+ }
1084
+ bridgeFallbackTimer = setInterval(() => {
1085
+ try {
1086
+ bridgeIngest();
1087
+ }
1088
+ catch (err) {
1089
+ log(`Bridge ingest error: ${err.message}`);
1090
+ }
1091
+ }, 1000);
1092
+ }
1093
+ function stopBridgeWatcher() {
1094
+ if (bridgeWatcher) {
1095
+ try {
1096
+ bridgeWatcher.close();
1097
+ }
1098
+ catch { /* ignore */ }
1099
+ bridgeWatcher = null;
1100
+ }
1101
+ if (bridgeFallbackTimer) {
1102
+ clearInterval(bridgeFallbackTimer);
1103
+ bridgeFallbackTimer = null;
1104
+ }
1105
+ bridgeCliPid = undefined;
1106
+ bridgeCliCwd = undefined;
1107
+ bridgeObservedCliSessionId = undefined;
1108
+ bridgeKnownSessionIds.clear();
1109
+ bridgeStalePidStateSessionId = undefined;
1110
+ bridgeSecondaryPaths.clear();
1111
+ bridgeFingerprintScanLastMs.clear();
1112
+ bridgePreambleSent = false;
1113
+ }
1114
+ /**
1115
+ * Push a pending turn for the next Lark message.
1116
+ *
1117
+ * Returns the turnId on success, undefined if bridge-final-output isn't
1118
+ * available for this message (transcript not yet baselined). On undefined
1119
+ * the worker still raw-writes the message into the pane — the user just
1120
+ * won't get a transcript-driven final_output reply for it. This keeps the
1121
+ * v3 promise: if we can't attribute correctly, we don't attribute at all.
1122
+ *
1123
+ * `messageText` is the raw Lark message body — we derive a short content
1124
+ * fingerprint from it so the next *matching* user event in the transcript
1125
+ * (and only that one) starts this turn. Local-terminal input that races
1126
+ * with the pane-write will not match the fingerprint and won't hijack the
1127
+ * Lark turn.
1128
+ *
1129
+ * The turnId is returned so the writeInput failure path can call
1130
+ * `bridgeQueue.dropPendingTurn(turnId)` after deferred recheck conclusively
1131
+ * fails — otherwise an Enter-eaten-by-TUI submit leaves a fingerprint that
1132
+ * no jsonl line will ever match, and `maybeSwitchBridgeJsonl` burns 99%
1133
+ * CPU scanning all sibling jsonls for it on every poll tick.
1134
+ */
1135
+ function bridgeMarkPendingTurn(messageText) {
1136
+ if (!bridgeJsonlPath)
1137
+ return undefined;
1138
+ if (!bridgeBaselineDone) {
1139
+ log('Bridge baseline not ready — this turn will not have transcript-driven final_output');
1140
+ return undefined;
1141
+ }
1142
+ const fingerprint = makeFingerprint(messageText);
1143
+ // Full normalised content powers the unknown-sid recovery path. When a
1144
+ // user runs `/clear` and the bridge can't see the new sessionId yet
1145
+ // (pid file lags, fd probe missed the brief open window), we fall back
1146
+ // to scanning every untrusted candidate jsonl for an EXACT equality
1147
+ // with this normalised string — substantially harder for a sibling
1148
+ // pane to false-match than the 30-char substring fingerprint.
1149
+ const normalised = normaliseForFingerprint(messageText);
1150
+ const contentNormalized = normalised.length > 0 ? normalised : undefined;
1151
+ const turnId = randomBytes(8).toString('hex');
1152
+ bridgeQueue.mark(turnId, fingerprint, Date.now(), contentNormalized);
1153
+ return turnId;
1154
+ }
1155
+ function bridgeDrainAndMaybeEmit() {
1156
+ if (!bridgeJsonlPath)
1157
+ return;
1158
+ bridgeIngest();
1159
+ emitReadyTurns();
1160
+ // Prune AFTER emit so a path is only retired once its turn has actually
1161
+ // been published. During non-idle ticks (fs.watch / 1s poll) we never
1162
+ // emit, so we never prune — the path stays put until idle resolves it.
1163
+ pruneSecondaryPaths();
1164
+ }
1165
+ /** Pop ready turns and emit their final_output. Resolves uuid → text via
1166
+ * each turn's own `sourceJsonlPath` (stamped at turn-start) so an in-flight
1167
+ * reply that started in an old jsonl still gets picked up after a sessionId
1168
+ * rotation has switched the global `bridgeJsonlPath` to a different file.
1169
+ * Falls back to `bridgeJsonlPath` for legacy turns without a stamped source.
1170
+ *
1171
+ * Caches per-path drains so a batch of turns from the same file only reads
1172
+ * the transcript once (O(jsonl size) per distinct path). */
1173
+ function emitReadyTurns() {
1174
+ const ready = bridgeQueue.drainEmittable();
1175
+ if (ready.length === 0)
1176
+ return;
1177
+ const adoptMode = lastInitConfig?.adoptMode === true;
1178
+ // Send markers (`botmux send` landed in own thread) + the queue's first
1179
+ // still-unready turn. The latter caps the LAST ready turn's window —
1180
+ // without it, a model that's still mid-tool-use for turn N+1 could leak
1181
+ // a send credit into turn N's window via shouldSuppressBridgeEmit.
1182
+ const markers = adoptMode ? [] : readSendMarkers();
1183
+ const remainingPending = bridgeQueue.peek();
1184
+ const nextPendingMarkTimeMs = remainingPending.length > 0 ? remainingPending[0].markTimeMs : undefined;
1185
+ const cache = new Map();
1186
+ for (let i = 0; i < ready.length; i++) {
1187
+ const turn = ready[i];
1188
+ const nextBoundaryMs = (i + 1 < ready.length ? ready[i + 1].markTimeMs : nextPendingMarkTimeMs);
1189
+ if (shouldSuppressBridgeEmit({ markTimeMs: turn.markTimeMs, isLocal: turn.isLocal }, nextBoundaryMs, markers, adoptMode)) {
1190
+ const reason = turn.isLocal ? 'local-typed' : 'model called botmux send within window';
1191
+ log(`Bridge fallback suppressed for turn ${turn.turnId.substring(0, 8)} (${reason})`);
1192
+ continue;
1193
+ }
1194
+ const path = turn.sourceJsonlPath ?? bridgeJsonlPath;
1195
+ if (!path)
1196
+ continue;
1197
+ let drained = cache.get(path);
1198
+ if (!drained) {
1199
+ drained = drainTranscript(path, 0);
1200
+ cache.set(path, drained);
1201
+ }
1202
+ const set = new Set(turn.assistantUuids);
1203
+ const matched = drained.events.filter(e => e.uuid && set.has(e.uuid));
1204
+ const assistantText = joinAssistantText(matched);
1205
+ if (assistantText.length === 0)
1206
+ continue;
1207
+ const lastUuid = turn.assistantUuids[turn.assistantUuids.length - 1];
1208
+ if (turn.isLocal) {
1209
+ if (turn.userUuid) {
1210
+ // Local turn (adopt mode only): also surface the user prompt so the
1211
+ // Lark thread shows both sides of the exchange. User text comes from
1212
+ // the same drained transcript via the userUuid stamped at start time.
1213
+ // extractTurnStartText handles both `role:user` events (text in
1214
+ // message.content) AND `attachment(queued_command)` events (text in
1215
+ // attachment.prompt) so type-ahead'd local input renders the same as
1216
+ // a normally-typed pane prompt.
1217
+ const userEv = drained.events.find(e => e.uuid === turn.userUuid);
1218
+ const rawUserText = userEv ? extractTurnStartText(userEv) : '';
1219
+ const fields = formatLocalTurnFields(rawUserText, assistantText);
1220
+ if (!fields)
1221
+ continue;
1222
+ send({
1223
+ type: 'final_output',
1224
+ content: fields.content,
1225
+ lastUuid,
1226
+ turnId: turn.turnId,
1227
+ kind: 'local-turn',
1228
+ userText: fields.userText,
1229
+ });
1230
+ continue;
1231
+ }
1232
+ // Headless local turn — see formatHeadlessLocalTurnContent for context.
1233
+ const headlessContent = formatHeadlessLocalTurnContent(assistantText);
1234
+ if (!headlessContent)
1235
+ continue;
1236
+ send({
1237
+ type: 'final_output',
1238
+ content: headlessContent,
1239
+ lastUuid,
1240
+ turnId: turn.turnId,
1241
+ kind: 'local-turn-headless',
1242
+ });
1243
+ continue;
1244
+ }
1245
+ send({ type: 'final_output', content: assistantText, lastUuid, turnId: turn.turnId });
1246
+ }
1247
+ }
1248
+ /** Drain `path` from `fromOffset` and feed the events to the bridge queue
1249
+ * with that path as the source stamp. Pure side-effects on bridgeQueue +
1250
+ * the returned cursor; does NOT touch bridgeJsonlPath / bridgeOffset, so
1251
+ * callers can use it to flush the old path during a rotation without
1252
+ * disturbing the watcher's normal cursor. Returns the new offset for the
1253
+ * caller to commit (or discard, if it's about to switch paths). */
1254
+ function drainPathInto(path, fromOffset) {
1255
+ const result = drainTranscript(path, fromOffset);
1256
+ bridgeQueue.ingest(result.events, path);
1257
+ return { offset: result.newOffset, tail: result.pendingTail };
1258
+ }
1259
+ // ─── Codex bridge wiring ─────────────────────────────────────────────────
1260
+ //
1261
+ // Codex's bridge fallback is intentionally simpler than Claude's: no /adopt
1262
+ // surface, no pid-resolver / quiet-rotation / fingerprint-jsonl-switch
1263
+ // machinery. The reader watches one rollout file (located by cliSessionId)
1264
+ // and the queue's only responsibility is "user fingerprint match → start;
1265
+ // assistant_final → close". Everything else (mark / emit gate / send
1266
+ // marker IO / type-ahead serialisation / one-write-per-idle break) is
1267
+ // shared with the Claude path.
1268
+ function codexBridgeFallbackActive() {
1269
+ // True for transcript-backed CLIs whose final output can be harvested
1270
+ // from append-only JSONL when the model forgets to call `botmux send`.
1271
+ // Codex uses ~/.codex rollouts; CoCo uses ~/.cache/coco events. Both
1272
+ // work in adopt mode now that CoCo's PID→sessionId discovery is wired.
1273
+ return lastInitConfig?.cliId === 'codex' || lastInitConfig?.cliId === 'coco';
1274
+ }
1275
+ function structuredBridgeIsCodex() {
1276
+ return lastInitConfig?.cliId === 'codex';
1277
+ }
1278
+ function structuredBridgeIngestPath(path, offset) {
1279
+ return structuredBridgeIsCodex()
1280
+ ? drainCodexRollout(path, offset)
1281
+ : drainCocoEvents(path, offset);
1282
+ }
1283
+ function codexBridgeStartTimer() {
1284
+ if (codexBridgeTimer)
1285
+ return;
1286
+ // Single 1s ticker that handles three jobs: late-attach (poll for the
1287
+ // rollout file once we know cliSessionId), ingest (fs.watch backup),
1288
+ // and idle-window emit. The last is critical for the late-attach race:
1289
+ // if the rollout path appears AFTER the CLI's idle event has fired,
1290
+ // the idle callback's emit already ran (and saw an empty queue), so
1291
+ // the next emit chance would be at the next idle — i.e. the user has
1292
+ // to send another message before the previous turn's fallback shows
1293
+ // up. Emitting here when isPromptReady=true closes that window.
1294
+ // Codex's queue only releases turns on `assistant_final` (the model's
1295
+ // declared end-of-turn), so a tick-driven emit can't accidentally
1296
+ // publish a half-streamed response.
1297
+ codexBridgeTimer = setInterval(() => {
1298
+ try {
1299
+ if (!codexBridgeRolloutPath) {
1300
+ // Two discovery paths, in order: cliSessionId (known via writeInput
1301
+ // result for non-adopt or daemon-side probe for adopt) → exact
1302
+ // file by name; PID (adopt only) → walk /proc/<pid>/fd. Adopt
1303
+ // attaches via split-live (history absorbed, live ingested);
1304
+ // non-adopt uses fresh-empty (queue's markTimeMs - 5s lower bound
1305
+ // gates historical fingerprint matches without needing a split).
1306
+ // Discovery primitives differ per CLI: codex walks ~/.codex/sessions
1307
+ // by sid suffix; CoCo's events.jsonl path is deterministic from
1308
+ // sid, so the lookup is just a path computation + existence check.
1309
+ const isCoco = lastInitConfig?.cliId === 'coco';
1310
+ let path;
1311
+ if (codexBridgePendingSessionId) {
1312
+ path = isCoco
1313
+ ? cocoEventsPathForSession(codexBridgePendingSessionId)
1314
+ : findCodexRolloutBySessionId(codexBridgePendingSessionId);
1315
+ if (path && isCoco && !existsSync(path))
1316
+ path = undefined;
1317
+ }
1318
+ if (!path && codexAdoptPendingPid) {
1319
+ if (isCoco) {
1320
+ const probed = findCocoSessionByPid(codexAdoptPendingPid);
1321
+ if (probed && existsSync(probed.eventsPath))
1322
+ path = probed.eventsPath;
1323
+ }
1324
+ else {
1325
+ const probed = findCodexRolloutByPid(codexAdoptPendingPid);
1326
+ if (probed)
1327
+ path = probed.path;
1328
+ }
1329
+ }
1330
+ if (path) {
1331
+ codexBridgePendingSessionId = undefined;
1332
+ codexAdoptPendingPid = undefined;
1333
+ // Adopt mode: split-live partitions drained events by
1334
+ // codexAdoptStartMs so anything the user did AFTER adopt but
1335
+ // BEFORE we found the rollout still emits (history is absorbed,
1336
+ // live is ingested). Non-adopt: fresh-empty as before — queue's
1337
+ // markTimeMs - 5s lower bound is enough since there's no
1338
+ // local-turn synthesis on that path.
1339
+ const mode = lastInitConfig?.adoptMode ? 'split-live' : 'fresh-empty';
1340
+ codexBridgeAttach(path, mode);
1341
+ }
1342
+ }
1343
+ codexBridgeIngest();
1344
+ if (isPromptReady)
1345
+ emitReadyCodexTurns();
1346
+ }
1347
+ catch (err) {
1348
+ log(`Codex bridge tick error: ${err.message}`);
1349
+ }
1350
+ }, 1000);
1351
+ }
1352
+ function codexBridgeAttach(rolloutPath, mode) {
1353
+ codexBridgeRolloutPath = rolloutPath;
1354
+ if (mode === 'fresh-empty') {
1355
+ // Brand-new session OR late-attach right after first submit. Either
1356
+ // way we want to ingest from offset 0 — pending turns marked before
1357
+ // attach are still in the queue, so the user_message that just landed
1358
+ // (or is about to land) will fingerprint-match them.
1359
+ codexBridgeOffset = 0;
1360
+ codexBridgePendingTail = '';
1361
+ codexBridgeBaselineDone = true;
1362
+ log(`Codex bridge fresh-empty: ${rolloutPath}`);
1363
+ }
1364
+ else if (mode === 'split-live' && existsSync(rolloutPath)) {
1365
+ // Adopt mode: drain everything, then split by adoptStartMs. History
1366
+ // (pre-adopt) is `absorb()`-ed so it can't replay; live (post-adopt)
1367
+ // is `ingest()`-ed so a Lark turn already marked or an iTerm-typed
1368
+ // local turn that landed before we found the rollout still gets
1369
+ // attributed. Without this split, baseline-existing would absorb()
1370
+ // the live events too, silently dropping anything the user did
1371
+ // between adopt and rollout-discovery — that's the user-reported
1372
+ // "iTerm 手动输入飞书没收到" symptom under late-attach.
1373
+ const result = structuredBridgeIngestPath(rolloutPath, 0);
1374
+ const cutoff = (codexAdoptStartMs ?? Date.now()) - 5_000;
1375
+ const { history, live } = splitCodexEventsByCutoff(result.events, cutoff);
1376
+ codexBridgeQueue.absorb(history);
1377
+ codexBridgeQueue.ingest(live);
1378
+ codexBridgeOffset = result.newOffset;
1379
+ codexBridgePendingTail = result.pendingTail;
1380
+ codexBridgeBaselineDone = true;
1381
+ log(`Codex bridge split-live: ${rolloutPath} (history=${history.length}, live=${live.length}, cutoff=${cutoff}, offset=${codexBridgeOffset})`);
1382
+ maybeEmitCodexAdoptPreamble(history);
1383
+ }
1384
+ else if (mode === 'split-live') {
1385
+ // split-live requested but file missing — degrade to fresh: the file
1386
+ // will appear later via fs.watch / poller, and ingest from offset 0
1387
+ // will pick up everything as live (consistent with split semantics
1388
+ // when there's no history to absorb).
1389
+ codexBridgeOffset = 0;
1390
+ codexBridgePendingTail = '';
1391
+ codexBridgeBaselineDone = true;
1392
+ log(`Codex bridge split-live degraded to fresh (file missing): ${rolloutPath}`);
1393
+ }
1394
+ else if (existsSync(rolloutPath)) {
1395
+ const result = structuredBridgeIngestPath(rolloutPath, 0);
1396
+ codexBridgeOffset = result.newOffset;
1397
+ codexBridgePendingTail = result.pendingTail;
1398
+ codexBridgeQueue.absorb(result.events);
1399
+ codexBridgeBaselineDone = true;
1400
+ log(`Codex bridge baselined: ${rolloutPath} (offset=${codexBridgeOffset})`);
1401
+ }
1402
+ else {
1403
+ // baseline-existing requested but file missing — degrade to fresh
1404
+ // semantics so the lazy-appearing file isn't accidentally absorbed.
1405
+ codexBridgeOffset = 0;
1406
+ codexBridgePendingTail = '';
1407
+ codexBridgeBaselineDone = true;
1408
+ log(`Codex bridge transcript not yet present at ${rolloutPath}; treating as fresh`);
1409
+ }
1410
+ try {
1411
+ codexBridgeWatcher = fsWatch(rolloutPath, { persistent: false }, () => {
1412
+ try {
1413
+ codexBridgeIngest();
1414
+ }
1415
+ catch (err) {
1416
+ log(`Codex bridge ingest error: ${err.message}`);
1417
+ }
1418
+ });
1419
+ }
1420
+ catch (err) {
1421
+ log(`Codex bridge fs.watch unavailable (${err.message}); relying on poller`);
1422
+ }
1423
+ // macOS 上 fs.watch 对 codex/coco 的外部进程追加 rollout / events.jsonl
1424
+ // 经常静默丢事件(FSEvents 跨进程不可靠),所以无论 watcher 是否 attach
1425
+ // 成功,都必须起 1s poller 兜底 —— 不然 split-live 成功的 adopt session
1426
+ // 在 macOS 上会卡死,永远收不到模型回复。Linux 上 poller 多 tick 也无害
1427
+ // (codexBridgeIngest 在 offset 未推进时是 no-op)。
1428
+ codexBridgeStartTimer();
1429
+ }
1430
+ /** Called from flushPending after writeInput first returns a cliSessionId.
1431
+ * Tries to locate the rollout file immediately; if it's not on disk yet,
1432
+ * remembers the sid so the 1s poller can keep retrying. */
1433
+ function codexBridgeNotifyCliSessionId(cliSessionId) {
1434
+ if (!codexBridgeFallbackActive() || codexBridgeRolloutPath)
1435
+ return;
1436
+ const path = findCodexRolloutBySessionId(cliSessionId);
1437
+ if (path) {
1438
+ codexBridgePendingSessionId = undefined;
1439
+ codexBridgeAttach(path, 'fresh-empty');
1440
+ }
1441
+ else {
1442
+ codexBridgePendingSessionId = cliSessionId;
1443
+ codexBridgeStartTimer();
1444
+ }
1445
+ }
1446
+ function codexBridgeIngest() {
1447
+ if (!codexBridgeRolloutPath || !codexBridgeBaselineDone)
1448
+ return;
1449
+ const result = structuredBridgeIngestPath(codexBridgeRolloutPath, codexBridgeOffset);
1450
+ codexBridgeOffset = result.newOffset;
1451
+ codexBridgePendingTail = result.pendingTail;
1452
+ codexBridgeQueue.ingest(result.events);
1453
+ // Transcript-driven idle: an `assistant_final` event is the CLI declaring
1454
+ // end-of-turn, far more reliable than the screen-pattern heuristic
1455
+ // (CoCo's status bar varies by --yolo flag, version, theme; codex has
1456
+ // its own moving targets). Pushing idle here lets the bridge emit
1457
+ // immediately instead of waiting for readyPattern + quiescence to
1458
+ // converge. Idempotent — IdleDetector.fireIdle no-ops while already idle.
1459
+ if (result.events.some(e => e.kind === 'assistant_final')) {
1460
+ idleDetector?.fireIdle();
1461
+ }
1462
+ }
1463
+ /** Mark a pending Lark turn for Codex. Crucially this works even before a
1464
+ * rollout path is known — the queue is path-agnostic, and ingest after
1465
+ * late-attach picks up the user_message and matches the fingerprint. */
1466
+ function codexBridgeMarkPendingTurn(messageText) {
1467
+ if (!codexBridgeFallbackActive())
1468
+ return false;
1469
+ const turnId = `codex-${randomBytes(8).toString('hex')}`;
1470
+ codexBridgeQueue.mark(turnId, messageText);
1471
+ return true;
1472
+ }
1473
+ function codexBridgeDrainAndMaybeEmit() {
1474
+ if (!codexBridgeFallbackActive())
1475
+ return;
1476
+ if (codexBridgeRolloutPath && codexBridgeBaselineDone) {
1477
+ try {
1478
+ codexBridgeIngest();
1479
+ }
1480
+ catch (err) {
1481
+ log(`Codex bridge ingest error: ${err.message}`);
1482
+ }
1483
+ }
1484
+ emitReadyCodexTurns();
1485
+ }
1486
+ function emitReadyCodexTurns() {
1487
+ const ready = codexBridgeQueue.drainEmittable();
1488
+ if (ready.length === 0)
1489
+ return;
1490
+ const adoptMode = lastInitConfig?.adoptMode === true;
1491
+ // Adopt mode: model is the user's external Codex, no botmux send to
1492
+ // gate against — every assistant turn (Lark-driven OR locally typed)
1493
+ // should reach the thread. Skip marker IO entirely.
1494
+ const markers = adoptMode ? [] : readSendMarkers();
1495
+ const remaining = codexBridgeQueue.peek();
1496
+ const nextPendingMarkTimeMs = remaining.length > 0 ? remaining[0].markTimeMs : undefined;
1497
+ for (let i = 0; i < ready.length; i++) {
1498
+ const turn = ready[i];
1499
+ if (!turn.finalText)
1500
+ continue;
1501
+ const nextBoundaryMs = (i + 1 < ready.length ? ready[i + 1].markTimeMs : nextPendingMarkTimeMs);
1502
+ if (shouldSuppressBridgeEmit({ markTimeMs: turn.markTimeMs, isLocal: turn.isLocal }, nextBoundaryMs, markers, adoptMode)) {
1503
+ log(`Codex bridge fallback suppressed for turn ${turn.turnId.substring(0, 8)} (gate)`);
1504
+ continue;
1505
+ }
1506
+ if (turn.isLocal) {
1507
+ // Local turn (adopt only): user typed in iTerm. Surface both sides
1508
+ // so the Lark thread sees a complete exchange instead of an orphan
1509
+ // reply. formatLocalTurnFields caps both texts to keep within
1510
+ // Lark's per-message limit; daemon owns the card chrome.
1511
+ const fields = formatLocalTurnFields(turn.userText ?? '', turn.finalText);
1512
+ if (!fields)
1513
+ continue;
1514
+ send({
1515
+ type: 'final_output',
1516
+ content: fields.content,
1517
+ lastUuid: turn.turnId,
1518
+ turnId: turn.turnId,
1519
+ kind: 'local-turn',
1520
+ userText: fields.userText,
1521
+ });
1522
+ continue;
1523
+ }
1524
+ send({ type: 'final_output', content: turn.finalText, lastUuid: turn.turnId, turnId: turn.turnId });
1525
+ }
1526
+ }
1527
+ function stopCodexBridge() {
1528
+ if (codexBridgeWatcher) {
1529
+ try {
1530
+ codexBridgeWatcher.close();
1531
+ }
1532
+ catch { /* ignore */ }
1533
+ codexBridgeWatcher = null;
1534
+ }
1535
+ if (codexBridgeTimer) {
1536
+ clearInterval(codexBridgeTimer);
1537
+ codexBridgeTimer = null;
1538
+ }
1539
+ codexBridgeRolloutPath = undefined;
1540
+ codexBridgeOffset = 0;
1541
+ codexBridgePendingTail = '';
1542
+ codexBridgeBaselineDone = false;
1543
+ codexBridgeQueue.clearPending();
1544
+ codexBridgeQueue.setLocalTurns(false);
1545
+ codexBridgePendingSessionId = undefined;
1546
+ codexAdoptPendingPid = undefined;
1547
+ codexAdoptStartMs = undefined;
1548
+ }
1549
+ /** When a rotation moves bridgeJsonlPath away from `oldPath`, queue turns
1550
+ * whose sourceJsonlPath equals oldPath may still be waiting on assistant
1551
+ * text that hasn't landed yet. Add oldPath to the secondary polling set
1552
+ * so subsequent ingests continue to drain it; the offset is whatever was
1553
+ * reached by the final pre-switch drain so we don't re-scan history. The
1554
+ * entry is later pruned after each idle emit when no started turn
1555
+ * references it anymore. */
1556
+ function retainSecondaryPathIfStillReferenced(oldPath, postDrainOffset) {
1557
+ const stillReferenced = bridgeQueue.peek().some(t => t.sourceJsonlPath === oldPath);
1558
+ if (!stillReferenced)
1559
+ return;
1560
+ const existing = bridgeSecondaryPaths.get(oldPath);
1561
+ // Don't rewind a higher existing offset — multiple rotations through
1562
+ // the same file shouldn't replay drained bytes.
1563
+ if (existing === undefined || postDrainOffset > existing) {
1564
+ bridgeSecondaryPaths.set(oldPath, postDrainOffset);
1565
+ }
1566
+ log(`Bridge retaining secondary path ${oldPath} (offset=${postDrainOffset}) for in-flight turn`);
1567
+ }
1568
+ /** Drain every secondary path once. Mirrors bridgeIngest's primary-path
1569
+ * drain but never touches bridgeJsonlPath / bridgeOffset and never
1570
+ * triggers further rotation checks — it's strictly a "catch up trailing
1571
+ * events on an old file" pass. */
1572
+ function drainSecondaryPaths() {
1573
+ for (const [path, offset] of bridgeSecondaryPaths) {
1574
+ try {
1575
+ const result = drainTranscript(path, offset);
1576
+ if (result.events.length > 0)
1577
+ bridgeQueue.ingest(result.events, path);
1578
+ bridgeSecondaryPaths.set(path, result.newOffset);
1579
+ }
1580
+ catch (err) {
1581
+ log(`Bridge secondary-path drain failed (${path}): ${err.message}`);
1582
+ }
1583
+ }
1584
+ }
1585
+ /** Drop secondary paths whose started turns are no longer in the queue —
1586
+ * i.e. they've been emitted (or discarded). Called after each idle emit so
1587
+ * pruning never races with an in-flight turn. */
1588
+ function pruneSecondaryPaths() {
1589
+ if (bridgeSecondaryPaths.size === 0)
1590
+ return;
1591
+ const referenced = new Set();
1592
+ for (const t of bridgeQueue.peek()) {
1593
+ if (t.sourceJsonlPath)
1594
+ referenced.add(t.sourceJsonlPath);
1595
+ }
1596
+ for (const path of [...bridgeSecondaryPaths.keys()]) {
1597
+ if (!referenced.has(path)) {
1598
+ bridgeSecondaryPaths.delete(path);
1599
+ log(`Bridge dropped secondary path ${path} (no remaining turns)`);
1600
+ }
1601
+ }
1602
+ }
1603
+ /** Tiny safe-existence check that doesn't throw. */
1604
+ function existsSyncSafe(p) {
1605
+ try {
1606
+ return existsSync(p);
1607
+ }
1608
+ catch {
1609
+ return false;
1610
+ }
1611
+ }
54
1612
  /** Suppress screen updates until first prompt detected (avoids history replay in card on --resume) */
55
1613
  let awaitingFirstPrompt = true;
56
1614
  // ─── PTY Dimensions ──────────────────────────────────────────────────────────
57
- // Matches SNAPSHOT_COLS / SHOT_COLS (160). Narrow enough for the web terminal
58
- // to render comfortably; the card PNG crops at this width anyway.
59
- const PTY_COLS = 160;
60
- const PTY_ROWS = 50;
1615
+ // Default for botmux-spawned CLIs: narrow enough for the web terminal to
1616
+ // render comfortably and for the card PNG to fit Lark's typical card width.
1617
+ // Adopt mode overrides this via resolveRenderDimensions() to match the
1618
+ // user's actual pane (often 200-270 cols) so the renderer doesn't wrap
1619
+ // wide ANSI into a stair-stepped / duplicated mess.
1620
+ const PTY_COLS = DEFAULT_RENDER_COLS;
1621
+ const PTY_ROWS = DEFAULT_RENDER_ROWS;
1622
+ /** Set in the `init` handler BEFORE startScreenUpdates() so the headless
1623
+ * xterm + screenshot canvas are sized to the source pane from the start.
1624
+ * Setting them later (after the renderer was built at the default size)
1625
+ * wouldn't retroactively re-size what xterm has already buffered,
1626
+ * leaving the wrap artefacts in place. */
1627
+ let renderCols = PTY_COLS;
1628
+ let renderRows = PTY_ROWS;
61
1629
  // ─── Headless Terminal for Screen Capture ────────────────────────────────────
62
1630
  let renderer = null;
1631
+ /** Most recent unfiltered viewport text — kept in sync by the screen_update
1632
+ * timer for pipe-pane backends so ScreenAnalyzer (which is synchronous) has
1633
+ * a fresh snapshot to read without needing its own tmux capture-pane call. */
1634
+ let lastAnalyzerSnapshot = '';
63
1635
  let screenUpdateTimer = null;
64
1636
  const SCREEN_UPDATE_INTERVAL_MS = 2_000;
65
1637
  // ─── Scrollback Buffer (replay to late-connecting WS clients) ───────────────
@@ -93,7 +1665,14 @@ function startScreenAnalyzer() {
93
1665
  extraHeaders: sa.extraHeaders,
94
1666
  extraBody: sa.extraBody,
95
1667
  }, {
96
- getSnapshot: () => renderer?.rawSnapshot() ?? '',
1668
+ getSnapshot: () => {
1669
+ // ScreenAnalyzer is called every ~5s for TUI-prompt detection. We
1670
+ // can't make this async without overhauling the analyzer, so cache
1671
+ // the last pipe-pane text snapshot here and refresh it eagerly.
1672
+ // For pipe-pane backends, the cache is repopulated by the screen
1673
+ // update timer; for others, fall through to the long-lived renderer.
1674
+ return lastAnalyzerSnapshot || renderer?.rawSnapshot() || '';
1675
+ },
97
1676
  onAnalyzing: () => { },
98
1677
  onTuiPrompt: (description, options, multiSelect) => {
99
1678
  tuiPromptBlocking = true;
@@ -117,8 +1696,10 @@ function stopScreenAnalyzer() {
117
1696
  // ─── Screenshot Capture (PNG → Feishu image_key) ────────────────────────────
118
1697
  const SCREENSHOT_INTERVAL_MS = 10_000;
119
1698
  const POST_ACTION_DELAY_MS = 1_000;
120
- const SHOT_COLS = 160;
121
- const SHOT_ROWS = 50;
1699
+ // PNG dimensions key off the renderer's actual size (renderCols / renderRows),
1700
+ // which adopt-mode peg to the source pane so wrap artefacts don't appear.
1701
+ // Re-clamping at MAX_RENDER_COLS/ROWS guards against a malformed init
1702
+ // payload sneaking past the resolver into a runaway canvas.
122
1703
  let displayMode = 'hidden';
123
1704
  let screenshotTimer = null;
124
1705
  let pendingShotTimer = null;
@@ -128,10 +1709,12 @@ let larkAppSecretForUpload = '';
128
1709
  function startScreenshotLoop() {
129
1710
  stopScreenshotLoop();
130
1711
  screenshotTimer = setInterval(() => { void captureAndUpload(); }, SCREENSHOT_INTERVAL_MS);
1712
+ log(`Screenshot loop started (interval=${SCREENSHOT_INTERVAL_MS}ms)`);
131
1713
  // Capture immediately so the user gets a first frame fast
132
1714
  void captureAndUpload();
133
1715
  }
134
1716
  function stopScreenshotLoop() {
1717
+ const wasRunning = !!screenshotTimer || !!pendingShotTimer;
135
1718
  if (screenshotTimer) {
136
1719
  clearInterval(screenshotTimer);
137
1720
  screenshotTimer = null;
@@ -140,6 +1723,26 @@ function stopScreenshotLoop() {
140
1723
  clearTimeout(pendingShotTimer);
141
1724
  pendingShotTimer = null;
142
1725
  }
1726
+ if (wasRunning)
1727
+ log('Screenshot loop stopped');
1728
+ }
1729
+ // Throttle silent-skip reasons so a wedged worker prints why once every 30s
1730
+ // without spamming. Each distinct reason has its own throttle clock.
1731
+ const screenshotSkipLogState = {};
1732
+ function logScreenshotSkip(reason) {
1733
+ const now = Date.now();
1734
+ if (now - (screenshotSkipLogState[reason] ?? 0) < 30_000)
1735
+ return;
1736
+ screenshotSkipLogState[reason] = now;
1737
+ log(`Screenshot skipped: ${reason}`);
1738
+ }
1739
+ // Worker stderr is piped through worker-pool, where most CLI stderr stays at
1740
+ // info level to avoid polluting error.log. Mark true worker faults so the
1741
+ // parent can selectively promote only these lines to logger.error.
1742
+ const WORKER_ERROR_MARKER = '[botmux-worker-error]';
1743
+ function logError(msg) {
1744
+ const ts = new Date().toISOString();
1745
+ process.stderr.write(`[${ts}] [worker:${sessionId.substring(0, 8) || '??'}] ${WORKER_ERROR_MARKER} ${msg}\n`);
143
1746
  }
144
1747
  /** Schedule a single capture +1s, then resume the regular 10s cadence. */
145
1748
  function scheduleOneShotAfterAction() {
@@ -160,28 +1763,55 @@ function scheduleOneShotAfterAction() {
160
1763
  }, POST_ACTION_DELAY_MS);
161
1764
  }
162
1765
  async function captureAndUpload() {
163
- if (displayMode !== 'screenshot')
1766
+ // displayMode mismatch should be impossible during a running loop (start/stop
1767
+ // gate on it). Logging here exists to surface the unexpected case — e.g. a
1768
+ // stray scheduleOneShotAfterAction firing after user toggled back to hidden.
1769
+ if (displayMode !== 'screenshot') {
1770
+ logScreenshotSkip(`displayMode=${displayMode}`);
164
1771
  return;
165
- if (awaitingFirstPrompt)
166
- return;
167
- if (!renderer)
168
- return;
169
- if (!larkAppIdForUpload || !larkAppSecretForUpload)
1772
+ }
1773
+ if (awaitingFirstPrompt) {
1774
+ logScreenshotSkip('awaitingFirstPrompt');
170
1775
  return;
171
- const term = renderer.xterm;
172
- const startY = term.buffer.active.baseY;
173
- // Hash dedup — same content → skip upload
174
- const snap = renderer.rawSnapshot();
175
- const hash = createHash('md5').update(snap).digest('hex');
176
- if (hash === lastShotHash)
1776
+ }
1777
+ if (!larkAppIdForUpload || !larkAppSecretForUpload) {
1778
+ logScreenshotSkip('lark credentials missing');
177
1779
  return;
178
- lastShotHash = hash;
1780
+ }
179
1781
  let png;
180
1782
  try {
181
- png = captureToPng(term, { cols: SHOT_COLS, rows: SHOT_ROWS, startY });
1783
+ // Preferred path: pipe-pane backends ask tmux for a fresh viewport
1784
+ // snapshot and render it through a transient xterm-headless. This
1785
+ // avoids the accumulated-buffer drift that produced duplicated /
1786
+ // staircase content under the legacy long-lived renderer.
1787
+ const pipeResult = await snapshotToPng(backend, renderCols, renderRows);
1788
+ if (pipeResult) {
1789
+ if (pipeResult.ansi === lastShotHash)
1790
+ return;
1791
+ lastShotHash = pipeResult.ansi;
1792
+ png = pipeResult.png;
1793
+ }
1794
+ else {
1795
+ // Fallback path: non-pipe backends (PtyBackend, legacy TmuxBackend)
1796
+ // still drive the long-lived renderer.
1797
+ if (!renderer) {
1798
+ logScreenshotSkip('renderer=null');
1799
+ return;
1800
+ }
1801
+ const term = renderer.xterm;
1802
+ const startY = term.buffer.active.baseY;
1803
+ const snap = renderer.rawSnapshot();
1804
+ const hash = createHash('md5').update(snap).digest('hex');
1805
+ if (hash === lastShotHash)
1806
+ return;
1807
+ lastShotHash = hash;
1808
+ const shotCols = clamp(term.cols, MIN_RENDER_COLS, MAX_RENDER_COLS);
1809
+ const shotRows = clamp(term.rows, MIN_RENDER_ROWS, MAX_RENDER_ROWS);
1810
+ png = captureToPng(term, { cols: shotCols, rows: shotRows, startY });
1811
+ }
182
1812
  }
183
1813
  catch (err) {
184
- log(`Screenshot render failed: ${err.message}`);
1814
+ logError(`Screenshot render failed: ${err?.message ?? err}`);
185
1815
  return;
186
1816
  }
187
1817
  let imageKey;
@@ -189,7 +1819,7 @@ async function captureAndUpload() {
189
1819
  imageKey = await uploadImageBuffer(larkAppIdForUpload, larkAppSecretForUpload, png);
190
1820
  }
191
1821
  catch (err) {
192
- log(`Screenshot upload failed: ${err.message}`);
1822
+ logError(`Screenshot upload failed: ${err?.message ?? err}`);
193
1823
  return;
194
1824
  }
195
1825
  let status = isPromptReady ? 'idle' : 'working';
@@ -377,9 +2007,11 @@ let trustHandled = false;
377
2007
  // ─── Prompt Detection ────────────────────────────────────────────────────────
378
2008
  function onPtyData(data) {
379
2009
  renderer?.write(data);
380
- // In tmux mode, web clients have their own tmux attach — no relay needed.
381
- // In non-tmux mode, broadcast to all WS clients via shared scrollback.
382
- if (!isTmuxMode) {
2010
+ // In tmux-attach mode, each web client has its own tmux attach PTY
2011
+ // no relay needed. In non-tmux mode AND in pipe mode (adopt-bridge),
2012
+ // broadcast through the shared scrollback so all connected web clients
2013
+ // render the same byte stream.
2014
+ if (!isTmuxMode || isPipeMode) {
383
2015
  // Track alt-buffer state so we can restore it in the scrollback prefix.
384
2016
  // Scan for the *last* toggle in this chunk — that's the current state.
385
2017
  let lastToggleIdx = -1;
@@ -453,6 +2085,71 @@ function markPromptReady() {
453
2085
  }
454
2086
  flushPending();
455
2087
  }
2088
+ function persistCliSessionId(cliSessionId) {
2089
+ if (!cliSessionId || !sessionId)
2090
+ return;
2091
+ if (lastInitConfig)
2092
+ lastInitConfig.cliSessionId = cliSessionId;
2093
+ try {
2094
+ const session = sessionStore.getSession(sessionId);
2095
+ if (!session || session.cliSessionId === cliSessionId)
2096
+ return;
2097
+ session.cliSessionId = cliSessionId;
2098
+ sessionStore.updateSession(session);
2099
+ log(`Persisted CLI session id: ${cliSessionId}`);
2100
+ }
2101
+ catch (err) {
2102
+ log(`Failed to persist CLI session id: ${err.message}`);
2103
+ }
2104
+ }
2105
+ /** How long to wait before re-checking whether a submit-not-confirmed message
2106
+ * eventually landed. Cold-start sessions and slow third-party hooks
2107
+ * (UserPromptSubmit, SessionStart — e.g. superpowers' large skill injection)
2108
+ * can defer Claude's jsonl append by 5–15s; a 20s deferred recheck covers
2109
+ * both without being so long that a true failure goes unsurfaced. */
2110
+ const SUBMIT_DEFERRED_RECHECK_MS = 20_000;
2111
+ /** Worker-side handler for `submitted: false`. Defers the user-facing
2112
+ * warning and runs the adapter-supplied `recheck` closure first; if the
2113
+ * message has shown up in the transcript by then (slow path, hook delay),
2114
+ * suppresses the warning entirely. Adapters without a recheck still fall
2115
+ * through to the warning after the same delay so the UX is uniform.
2116
+ *
2117
+ * `bridgeTurnId` is the BridgeTurnQueue mark created right before the
2118
+ * failing writeInput. When the deferred recheck conclusively fails (= no
2119
+ * jsonl line will ever match this fingerprint), we drop the mark — leaving
2120
+ * it would keep `maybeSwitchBridgeJsonl` doing full-directory scans every
2121
+ * poll tick for a fingerprint that's permanently dead, the 99% CPU bug
2122
+ * this whole patch series is fixing. */
2123
+ function scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId) {
2124
+ const preview = msg.length > 60 ? msg.slice(0, 60) + '…' : msg;
2125
+ log(`writeInput: submit not confirmed after retries — deferred ${SUBMIT_DEFERRED_RECHECK_MS}ms recheck queued. preview="${preview}"`);
2126
+ setTimeout(async () => {
2127
+ if (recheck) {
2128
+ try {
2129
+ if (await recheck()) {
2130
+ log(`Deferred recheck found submit in ${transcriptLabel} — suppressing warning. preview="${preview}"`);
2131
+ return;
2132
+ }
2133
+ }
2134
+ catch (err) {
2135
+ log(`Deferred recheck threw (${err?.message ?? err}); falling through to warning.`);
2136
+ }
2137
+ }
2138
+ if (bridgeTurnId) {
2139
+ const dropped = bridgeQueue.dropPendingTurn(bridgeTurnId);
2140
+ if (dropped) {
2141
+ if (dropped.contentFingerprint)
2142
+ bridgeFingerprintScanLastMs.delete(dropped.contentFingerprint);
2143
+ log(`Bridge mark dropped after submit failure (turnId=${bridgeTurnId}) — rotation-fallback scan will stop spinning on this fingerprint.`);
2144
+ }
2145
+ }
2146
+ log(`Deferred recheck still missing — notifying user. preview="${preview}"`);
2147
+ send({
2148
+ type: 'user_notify',
2149
+ message: `⚠️ 刚才那条消息发给 ${cliName()} 后没能确认提交(重试 Enter 后等了 ${Math.round(SUBMIT_DEFERRED_RECHECK_MS / 1000)}s 仍未在${transcriptLabel}里看到新记录)。可能卡在输入框里——请去 Web 终端看一下,手动按 Enter 或重发。\n开头:${preview}`,
2150
+ });
2151
+ }, SUBMIT_DEFERRED_RECHECK_MS);
2152
+ }
456
2153
  /**
457
2154
  * Drain the pending message queue sequentially.
458
2155
  * Async with isFlushing mutex: awaits each writeInput, then immediately
@@ -466,8 +2163,19 @@ async function flushPending() {
466
2163
  return;
467
2164
  if (pendingMessages.length === 0)
468
2165
  return; // nothing to flush — keep isPromptReady
469
- // Type-ahead adapters flush even while the CLI is busy; others wait for idle.
470
- if (!isPromptReady && !cliAdapter.supportsTypeAhead)
2166
+ // Type-ahead adapters flush even while the CLI is busy; others wait for
2167
+ // idle. Claude bridge fallback used to also disable type-ahead because
2168
+ // BridgeTurnQueue.ingest didn't recognise the `attachment(queued_command)`
2169
+ // events Claude writes when it dequeues a queued submit — assistant text
2170
+ // for the type-ahead'd turn was either dropped or attributed to the wrong
2171
+ // Lark message. Now that the queue handles queued_command identically to
2172
+ // role:user (and overrides markTimeMs to the dequeue-time event timestamp
2173
+ // so the gate window is correct), Claude bridge can run with type-ahead
2174
+ // again. Codex bridge stays serial because its queue hasn't been upgraded.
2175
+ const claudeBridgeActive = !!bridgeJsonlPath && !lastInitConfig?.adoptMode;
2176
+ const codexBridgeActive = codexBridgeFallbackActive();
2177
+ const typeAheadAllowed = cliAdapter.supportsTypeAhead && !codexBridgeActive;
2178
+ if (!isPromptReady && !typeAheadAllowed)
471
2179
  return;
472
2180
  isFlushing = true;
473
2181
  if (isPromptReady) {
@@ -477,16 +2185,56 @@ async function flushPending() {
477
2185
  try {
478
2186
  while (pendingMessages.length > 0 && backend && cliAdapter) {
479
2187
  const msg = pendingMessages.shift();
2188
+ // Bridge fallback: mark immediately before writeInput. Doing it here
2189
+ // (instead of at enqueue time) means markTimeMs anchors to the
2190
+ // moment the message actually starts hitting the PTY — so any
2191
+ // `botmux send` whose sentAtMs lands during turn N's processing
2192
+ // falls inside [markTimeMs(N), markTimeMs(N+1)). Marking earlier
2193
+ // (at IPC arrival) would let a slow-finishing turn N's send leak
2194
+ // into turn N+1's window and falsely suppress its emit.
2195
+ let bridgeTurnId;
2196
+ if (claudeBridgeActive) {
2197
+ try {
2198
+ bridgeIngest();
2199
+ }
2200
+ catch { /* best-effort */ }
2201
+ bridgeTurnId = bridgeMarkPendingTurn(msg);
2202
+ }
2203
+ else if (codexBridgeActive) {
2204
+ // Codex mark works even before the rollout path is known: the
2205
+ // queue is path-agnostic, and the late-attach below will start
2206
+ // ingest from offset 0 so the user_message that lands shortly
2207
+ // after still fingerprint-matches this turn.
2208
+ codexBridgeMarkPendingTurn(msg);
2209
+ }
480
2210
  log(`Writing to PTY (flush): "${msg.substring(0, 80)}"`);
481
2211
  const result = await cliAdapter.writeInput(backend, msg);
2212
+ // Persist any sessionId the adapter observed via authoritative sources
2213
+ // (Claude's pid file, Codex's history). Done independently of submit
2214
+ // outcome — the rotation is real even when the current Enter didn't
2215
+ // land, and we want next-resume to use the right id.
2216
+ if (result?.cliSessionId) {
2217
+ persistCliSessionId(result.cliSessionId);
2218
+ // First successful Codex submit also reveals the rollout path.
2219
+ // Late-attach now so subsequent assistant_final events get
2220
+ // attributed to this turn.
2221
+ if (codexBridgeActive)
2222
+ codexBridgeNotifyCliSessionId(result.cliSessionId);
2223
+ }
482
2224
  if (result && result.submitted === false) {
483
- const preview = msg.length > 60 ? msg.slice(0, 60) + '…' : msg;
484
- log(`writeInput: submit not confirmed after retries — notifying user. preview="${preview}"`);
485
- send({
486
- type: 'user_notify',
487
- message: `⚠️ 刚才那条消息发给 ${cliName()} 后没能确认提交(重试 Enter 3 次仍未在会话 JSONL 中看到新记录)。可能卡在输入框里——请去 Web 终端看一下,手动按 Enter 或重发。\n开头:${preview}`,
488
- });
2225
+ scheduleSubmitFailureNotify(msg, result.recheck, '会话 JSONL', bridgeTurnId);
489
2226
  }
2227
+ // Codex bridge: stop after one writeInput per idle cycle. Codex's
2228
+ // bridge queue doesn't yet attribute queued_command-equivalents, so
2229
+ // type-ahead'd submits would have their assistant text dropped or
2230
+ // mis-attributed. We resume on the next idle, by which point Codex
2231
+ // has finished and the next message can be a normal user_message
2232
+ // submit. Claude bridge no longer takes this break — its
2233
+ // BridgeTurnQueue handles `attachment(queued_command)` events
2234
+ // identically to `role:user`, so type-ahead'd turns are correctly
2235
+ // attributed and no longer need the serial-per-idle guard.
2236
+ if (codexBridgeActive && pendingMessages.length > 0)
2237
+ break;
490
2238
  }
491
2239
  }
492
2240
  finally {
@@ -497,11 +2245,26 @@ function sendToPty(content) {
497
2245
  if (!backend || !cliAdapter)
498
2246
  return;
499
2247
  pendingMessages.push(content);
2248
+ // User-override semantics: a fresh Lark message while a TUI prompt is "active"
2249
+ // takes precedence over the AI-detected prompt. The screen analyzer can be
2250
+ // wrong (false positive on a question that has no rendered options) and a
2251
+ // wedged blocking flag silently swallows every subsequent message — without
2252
+ // this override the user has no way to recover from Lark. Mirrors the
2253
+ // web-terminal text-input path (handleTuiTextInput).
500
2254
  if (tuiPromptBlocking) {
501
- log(`Queued message (${pendingMessages.length} pending): "${content.substring(0, 80)}" — TUI prompt active`);
502
- return;
2255
+ log(`User override: incoming Lark message clears tuiPromptBlocking "${content.substring(0, 80)}"`);
2256
+ tuiPromptBlocking = false;
2257
+ screenAnalyzer?.notifySelection('lark-input');
2258
+ // Tear down the prompt card so the user doesn't see stale options.
2259
+ send({ type: 'tui_prompt_resolved', selectedText: 'user-override' });
503
2260
  }
504
- if (isPromptReady || isFlushing || cliAdapter.supportsTypeAhead) {
2261
+ // See flushPending: only Codex bridge still serialises type-ahead.
2262
+ // Claude bridge now attributes `attachment(queued_command)` events
2263
+ // identically to `role:user`, so type-ahead'd submits land in the right
2264
+ // turn and we no longer need to gate the entry path on claudeBridgeActive.
2265
+ const codexBridgeActive = codexBridgeFallbackActive();
2266
+ const typeAheadAllowed = cliAdapter.supportsTypeAhead && !codexBridgeActive;
2267
+ if (isPromptReady || isFlushing || typeAheadAllowed) {
505
2268
  log(`Writing to PTY: "${content.substring(0, 80)}"`);
506
2269
  flushPending(); // fire-and-forget async; no-op if already flushing
507
2270
  }
@@ -511,20 +2274,53 @@ function sendToPty(content) {
511
2274
  }
512
2275
  // ─── Screen Update Timer ─────────────────────────────────────────────────────
513
2276
  function startScreenUpdates() {
514
- renderer = new TerminalRenderer(PTY_COLS, PTY_ROWS);
2277
+ // renderCols / renderRows were set by the init handler from cfg, so
2278
+ // adopt-mode panes (e.g. 270x57) get an xterm-headless of matching
2279
+ // width. With a too-narrow renderer, ANSI meant for the source pane
2280
+ // would wrap and the screenshot would show duplicated / stair-stepped
2281
+ // content (the live failure that prompted this fix).
2282
+ renderer = new TerminalRenderer(renderCols, renderRows);
515
2283
  let lastSentStatus;
2284
+ let lastTextSnapshotHash = '';
516
2285
  screenUpdateTimer = setInterval(() => {
517
- if (!renderer || awaitingFirstPrompt)
2286
+ if (awaitingFirstPrompt)
518
2287
  return;
519
- const { content, changed } = renderer.snapshot();
520
2288
  let status = isPromptReady ? 'idle' : 'working';
521
2289
  if (screenAnalyzer?.isAnalyzing)
522
2290
  status = 'analyzing';
523
- // Send update when content changed OR status changed (e.g. idle → analyzing)
524
- if (changed || status !== lastSentStatus) {
525
- lastSentStatus = status;
526
- send({ type: 'screen_update', content, status });
527
- }
2291
+ void (async () => {
2292
+ let content;
2293
+ let changed;
2294
+ // Preferred path: pipe-pane backends pull a fresh viewport snapshot
2295
+ // from tmux every tick. This eliminates the accumulated-buffer drift
2296
+ // that produced duplicated/staircase text in 'text' display mode.
2297
+ const pipeText = await snapshotToText(backend, renderCols, renderRows, { filter: true });
2298
+ if (pipeText) {
2299
+ content = pipeText.content;
2300
+ const hash = pipeText.ansi;
2301
+ changed = hash !== lastTextSnapshotHash;
2302
+ lastTextSnapshotHash = hash;
2303
+ // Refresh the unfiltered cache that ScreenAnalyzer reads from. Same
2304
+ // tmux call would otherwise need to fire twice per tick.
2305
+ if (changed) {
2306
+ const rawSnap = await snapshotToText(backend, renderCols, renderRows, { filter: false });
2307
+ if (rawSnap)
2308
+ lastAnalyzerSnapshot = rawSnap.content;
2309
+ }
2310
+ }
2311
+ else if (renderer) {
2312
+ const snap = renderer.snapshot();
2313
+ content = snap.content;
2314
+ changed = snap.changed;
2315
+ }
2316
+ else {
2317
+ return;
2318
+ }
2319
+ if (changed || status !== lastSentStatus) {
2320
+ lastSentStatus = status;
2321
+ send({ type: 'screen_update', content, status });
2322
+ }
2323
+ })();
528
2324
  }, SCREEN_UPDATE_INTERVAL_MS);
529
2325
  }
530
2326
  function stopScreenUpdates() {
@@ -536,46 +2332,196 @@ function stopScreenUpdates() {
536
2332
  renderer.dispose();
537
2333
  renderer = null;
538
2334
  }
2335
+ lastAnalyzerSnapshot = '';
539
2336
  }
540
2337
  // ─── PTY Management ──────────────────────────────────────────────────────────
541
2338
  function spawnCli(cfg) {
542
- // ── Adopt mode: attach to an existing tmux pane (no CLI spawn) ──
2339
+ // ── Adopt mode: pipe-pane the user's existing tmux pane (no attach) ──
543
2340
  if (cfg.adoptMode && cfg.adoptTmuxTarget) {
2341
+ // We mark BOTH isTmuxMode and isPipeMode: the former keeps idle/spawn
2342
+ // logic on the tmux track; the latter tells the WS handler to route
2343
+ // updates through the shared scrollback fan-out (because there is no
2344
+ // PTY-per-WS — we don't attach to anything).
544
2345
  isTmuxMode = true;
2346
+ isPipeMode = true;
545
2347
  const cols = cfg.adoptPaneCols ?? PTY_COLS;
546
2348
  const rows = cfg.adoptPaneRows ?? PTY_ROWS;
547
- const tmuxBe = new TmuxBackend('adopt-' + cfg.sessionId.slice(0, 8), { ownsSession: false });
548
- backend = tmuxBe;
549
- tmuxBe.attachToExisting(cfg.adoptTmuxTarget, {
2349
+ const pipeBe = new TmuxPipeBackend(cfg.adoptTmuxTarget);
2350
+ backend = pipeBe;
2351
+ pipeBe.spawn('', [], {
550
2352
  cwd: cfg.workingDir,
551
2353
  cols,
552
2354
  rows,
553
2355
  env: process.env,
554
2356
  });
555
- // Minimal idle detection (output quiescence only)
556
- idleDetector = new IdleDetector({ completionPattern: undefined, readyPattern: undefined });
2357
+ // Seed the shared scrollback with the pane's current screen so any
2358
+ // already-connected (or future) WS clients render meaningful content
2359
+ // immediately, instead of waiting for the next byte tmux pipes through.
2360
+ try {
2361
+ const initial = pipeBe.captureCurrentScreen();
2362
+ if (initial.length > 0)
2363
+ onPtyData(initial);
2364
+ }
2365
+ catch (err) {
2366
+ log(`captureCurrentScreen failed: ${err.message}`);
2367
+ }
2368
+ // Bridge mode: tail the adopted CLI's transcript to harvest assistant
2369
+ // turns out-of-band. Two paths:
2370
+ // - claude-code: cfg.bridgeJsonlPath is set when adopt knew the sid.
2371
+ // - codex: locate rollout via cliSessionId (daemon's discovery probe)
2372
+ // or by reading /proc/<pid>/fd. Both modes enable adopt-only local
2373
+ // turn synthesis so iTerm-typed conversation also reaches Lark.
2374
+ if (cfg.bridgeJsonlPath) {
2375
+ startBridgeWatcher(cfg.bridgeJsonlPath, {
2376
+ cliPid: cfg.adoptCliPid,
2377
+ cliCwd: cfg.adoptCwd,
2378
+ });
2379
+ }
2380
+ else if (cfg.cliId === 'codex') {
2381
+ const adoptStartMs = Date.now();
2382
+ codexAdoptStartMs = adoptStartMs;
2383
+ codexBridgeQueue.setLocalTurns(true, adoptStartMs);
2384
+ let rolloutPath;
2385
+ if (cfg.cliSessionId)
2386
+ rolloutPath = findCodexRolloutBySessionId(cfg.cliSessionId);
2387
+ if (!rolloutPath && cfg.adoptCliPid) {
2388
+ const probed = findCodexRolloutByPid(cfg.adoptCliPid);
2389
+ if (probed)
2390
+ rolloutPath = probed.path;
2391
+ }
2392
+ if (rolloutPath) {
2393
+ // Adopt-time attach: split-live so any iTerm activity that
2394
+ // happened in the brief window between adopt detection and worker
2395
+ // spawn (or between codex's own startup writes and now) lands as
2396
+ // live, not absorbed history.
2397
+ codexBridgeAttach(rolloutPath, 'split-live');
2398
+ }
2399
+ else {
2400
+ // Couldn't locate yet — start poller. The 1s timer keeps trying
2401
+ // both findCodexRolloutBySessionId (if cliSessionId is set) and
2402
+ // findCodexRolloutByPid (passed via the discovery hooks below).
2403
+ if (cfg.cliSessionId)
2404
+ codexBridgePendingSessionId = cfg.cliSessionId;
2405
+ codexAdoptPendingPid = cfg.adoptCliPid;
2406
+ codexBridgeStartTimer();
2407
+ }
2408
+ }
2409
+ else if (cfg.cliId === 'coco') {
2410
+ // CoCo adopt: parallel to codex, but the events.jsonl path is
2411
+ // deterministic from cliSessionId, so once the daemon-side discovery
2412
+ // surfaced an sid we know the path immediately. The file may not
2413
+ // exist yet (CoCo creates it on first event); codexBridgeAttach's
2414
+ // split-live-with-missing-file branch degrades to fresh, and the
2415
+ // late-attach poller catches re-creation.
2416
+ const adoptStartMs = Date.now();
2417
+ codexAdoptStartMs = adoptStartMs;
2418
+ codexBridgeQueue.setLocalTurns(true, adoptStartMs);
2419
+ let eventsPath;
2420
+ if (cfg.cliSessionId)
2421
+ eventsPath = cocoEventsPathForSession(cfg.cliSessionId);
2422
+ if (!eventsPath && cfg.adoptCliPid) {
2423
+ const probed = findCocoSessionByPid(cfg.adoptCliPid);
2424
+ if (probed)
2425
+ eventsPath = probed.eventsPath;
2426
+ }
2427
+ if (eventsPath) {
2428
+ // If the session DIRECTORY is missing (not just events.jsonl), CoCo
2429
+ // is operating on an unlinked inode — common after an e2e test or
2430
+ // manual cleanup wiped the dir while CoCo kept its fds open. The
2431
+ // bridge file will never appear, so warn the user once via Lark
2432
+ // instead of polling forever in silence.
2433
+ const sessionDir = dirname(eventsPath);
2434
+ if (!existsSync(sessionDir)) {
2435
+ send({
2436
+ type: 'final_output',
2437
+ content: '⚠️ 当前 CoCo 进程的会话目录已被删除(可能是 e2e 测试清理或手动 rm),写到 events.jsonl 的内容会落到一个失效 inode 上,桥接读不到。请重启 CoCo 后重新 /adopt。',
2438
+ lastUuid: `coco-adopt-stale-${randomBytes(4).toString('hex')}`,
2439
+ turnId: 'coco-adopt-stale',
2440
+ });
2441
+ log(`CoCo adopt: session dir missing, bridge disabled (${sessionDir})`);
2442
+ }
2443
+ else {
2444
+ codexBridgeAttach(eventsPath, 'split-live');
2445
+ }
2446
+ }
2447
+ else {
2448
+ // No sid known yet — fall back to PID-walk in the late-attach
2449
+ // poller. Reuses codexAdoptPendingPid since the timer dispatches
2450
+ // by cliId at probe time (see codexBridgeStartTimer).
2451
+ codexAdoptPendingPid = cfg.adoptCliPid;
2452
+ }
2453
+ // Always run the bridge poller for CoCo adopt — events.jsonl is created
2454
+ // lazily on first event, so fs.watch typically ENOENTs at attach time.
2455
+ // The 1s timer covers ingest + emit even when the watcher never armed,
2456
+ // and is idempotent (no-op if already started).
2457
+ codexBridgeStartTimer();
2458
+ }
2459
+ // Idle detection. In bridge mode we use the adopted CLI's real
2460
+ // completion/ready patterns (e.g. "Worked for Xs") so tool-execution
2461
+ // pauses don't trigger a premature emit. Other adopt cases keep the
2462
+ // minimal output-quiescence-only detector.
2463
+ const idleAdapter = cfg.bridgeJsonlPath
2464
+ ? createCliAdapterSync('claude-code', undefined)
2465
+ : cfg.cliId === 'codex' || cfg.cliId === 'coco'
2466
+ ? createCliAdapterSync(cfg.cliId, undefined)
2467
+ : { completionPattern: undefined, readyPattern: undefined };
2468
+ idleDetector = new IdleDetector(idleAdapter);
2469
+ // Codex adopt write path: route Lark messages through the codex
2470
+ // adapter's writeInput so they pick up the 200 ms paste-detection
2471
+ // delay + Enter-retry + ~/.codex/history.jsonl verification loop
2472
+ // (see src/adapters/cli/codex.ts:125-178). Without it, Codex TUI's
2473
+ // "\n treated as Enter" handling leaves multi-line submits stuck
2474
+ // in the input box. Other adopt CLIs keep the simpler raw
2475
+ // sendText+Enter path — claude-code adopt has its own bridge
2476
+ // verify path; gemini / coco / opencode / aiden haven't surfaced
2477
+ // this failure mode and we don't want to risk regressing them.
2478
+ if (cfg.cliId === 'codex') {
2479
+ cliAdapter = createCliAdapterSync('codex', cfg.cliPathOverride);
2480
+ }
557
2481
  idleDetector.onIdle(() => {
558
2482
  log('Prompt detected (idle) — adopt mode');
2483
+ try {
2484
+ bridgeDrainAndMaybeEmit();
2485
+ }
2486
+ catch (err) {
2487
+ log(`Bridge emit error: ${err.message}`);
2488
+ }
2489
+ try {
2490
+ codexBridgeDrainAndMaybeEmit();
2491
+ }
2492
+ catch (err) {
2493
+ log(`Codex bridge emit error: ${err.message}`);
2494
+ }
559
2495
  markPromptReady();
560
2496
  });
561
2497
  backend.onData(onPtyData);
562
2498
  backend.onExit((code, signal) => {
563
- log(`Adopted session exited (code: ${code}, signal: ${signal})`);
2499
+ log(`Adopted pipe-pane stream ended (code: ${code}, signal: ${signal})`);
564
2500
  backend = null;
565
2501
  isPromptReady = false;
2502
+ stopBridgeWatcher();
566
2503
  send({ type: 'claude_exit', code, signal });
567
2504
  });
568
- // CLI is already running — unblock screen updates immediately
569
2505
  awaitingFirstPrompt = false;
570
2506
  renderer?.markNewTurn();
571
- log(`Adopt mode: attached to ${cfg.adoptTmuxTarget} (${cols}x${rows})`);
2507
+ log(`Adopt mode (pipe): observing ${cfg.adoptTmuxTarget} (${cols}x${rows})`);
572
2508
  return;
573
2509
  }
574
2510
  cliAdapter = createCliAdapterSync(cfg.cliId, cfg.cliPathOverride);
575
- const useTmux = cfg.backendType === 'tmux';
576
- isTmuxMode = useTmux;
577
- const tmuxBe = useTmux ? new TmuxBackend(TmuxBackend.sessionName(cfg.sessionId)) : null;
578
- backend = tmuxBe ?? new PtyBackend();
2511
+ // backendType=tmux trust-but-verify: an explicit per-bot config (or
2512
+ // BACKEND_TYPE=tmux env override) bypasses config.ts's auto-detect, so
2513
+ // the worker re-probes here. If tmux can't start a server we silently
2514
+ // fall back to PTY rather than letting attach-session / new-session spam
2515
+ // the daemon error log every poll cycle.
2516
+ let useTmux = cfg.backendType === 'tmux';
2517
+ if (useTmux && !TmuxBackend.isAvailable()) {
2518
+ log('tmux backend requested but functional probe failed — falling back to PTY backend');
2519
+ useTmux = false;
2520
+ }
2521
+ const selectedBackend = selectSessionBackend({ sessionId: cfg.sessionId, useTmux });
2522
+ isTmuxMode = selectedBackend.isTmuxMode;
2523
+ isPipeMode = selectedBackend.isPipeMode;
2524
+ backend = selectedBackend.backend;
579
2525
  // Claude Code appends a line to ~/.claude/projects/<cwd-hash>/<sid>.jsonl each
580
2526
  // time the user submits. The adapter uses this file to verify paste+Enter
581
2527
  // actually committed (rather than trusting a fixed sleep), so wire it up now.
@@ -588,6 +2534,7 @@ function spawnCli(cfg) {
588
2534
  const args = cliAdapter.buildArgs({
589
2535
  sessionId: cfg.sessionId,
590
2536
  resume: cfg.resume ?? false,
2537
+ resumeSessionId: cfg.cliSessionId,
591
2538
  initialPrompt: cfg.prompt || undefined,
592
2539
  botName: cfg.botName,
593
2540
  botOpenId: cfg.botOpenId,
@@ -596,15 +2543,29 @@ function spawnCli(cfg) {
596
2543
  const extra = (process.env.CLI_EXTRA_ARGS ?? '').trim();
597
2544
  if (extra)
598
2545
  args.push(...extra.split(/\s+/).filter(Boolean));
2546
+ // Claude Code 在 root/sudo 下会拒绝 --dangerously-skip-permissions 并立即 exit。
2547
+ // botmux 必须带这个 flag(话题里没法弹交互式审批),所以为 root 自动注入
2548
+ // IS_SANDBOX=1 走 Claude Code 的受控环境逃生舱。用户显式设了就尊重不覆盖。
2549
+ const injectClaudeSandbox = cfg.cliId === 'claude-code' &&
2550
+ process.getuid?.() === 0 &&
2551
+ !process.env.IS_SANDBOX;
2552
+ if (injectClaudeSandbox) {
2553
+ log('Detected root user — injecting IS_SANDBOX=1 for Claude Code');
2554
+ }
599
2555
  log(`Spawning: ${cliAdapter.resolvedBin} ${args.join(' ')} (cwd: ${cfg.workingDir})`);
600
2556
  backend.spawn(cliAdapter.resolvedBin, args, {
601
2557
  cwd: cfg.workingDir,
602
2558
  cols: PTY_COLS,
603
2559
  rows: PTY_ROWS,
604
- env: { ...process.env, CLAUDECODE: undefined },
2560
+ env: {
2561
+ ...process.env,
2562
+ CLAUDECODE: undefined,
2563
+ ...(injectClaudeSandbox ? { IS_SANDBOX: '1' } : {}),
2564
+ },
605
2565
  });
606
- // Write CLI PID marker so the MCP server can verify it was spawned by botmux.
607
- // The MCP server checks if process.ppid has a marker in this directory.
2566
+ // Write CLI PID marker so agent-facing subcommands (`botmux send`, etc.)
2567
+ // can verify they were spawned inside a botmux session by walking the
2568
+ // process tree and looking for a matching pid file in this directory.
608
2569
  const cliPid = backend.getChildPid?.();
609
2570
  if (cliPid && process.env.SESSION_DATA_DIR) {
610
2571
  const markersDir = join(process.env.SESSION_DATA_DIR, '.botmux-cli-pids');
@@ -618,16 +2579,86 @@ function spawnCli(cfg) {
618
2579
  log(`Failed to write CLI PID marker: ${err.message}`);
619
2580
  }
620
2581
  }
2582
+ // Wire pid + cwd so the claude-code adapter's writeInput can read
2583
+ // ~/.claude/sessions/<pid>.json — the spawn-time pid-state record. Its
2584
+ // `sessionId` is set ONCE at process start (Claude Code 2.1.123); a
2585
+ // `--resume` lookup will surface here, but in-pane `/clear` won't, so a
2586
+ // 'matching sessionId' answer is "no spawn-time rotation observed", not
2587
+ // "no rotation at all". The pinned claudeJsonlPath above is still the
2588
+ // initial guess; the resolver corrects it on first write when Claude was
2589
+ // started with `--resume`.
2590
+ if (cfg.cliId === 'claude-code' && cliPid) {
2591
+ backend.cliPid = cliPid;
2592
+ backend.cliCwd = cfg.workingDir;
2593
+ }
621
2594
  // On tmux re-attach, keep awaitingFirstPrompt = true so screen updates are
622
2595
  // suppressed until the idle detector fires markNewTurn() — this prevents the
623
2596
  // full tmux scrollback history from leaking into the streaming card.
624
- if (tmuxBe?.isReattach) {
625
- log('Re-attached to existing tmux session');
2597
+ // Bridge fallback: claude-code only. Tail Claude's transcript JSONL so a
2598
+ // turn the model finishes WITHOUT calling `botmux send` still gets its
2599
+ // assistant text forwarded to Lark (the gate in emitReadyTurns suppresses
2600
+ // the emit when a send did happen). Adopt mode wires this up separately
2601
+ // (with baseline-existing); here we use fresh-empty for new sessions so
2602
+ // the file Claude creates on first submit isn't absorbed as history,
2603
+ // and baseline-existing on resume so prior-run turns ARE absorbed (we
2604
+ // don't want to re-emit yesterday's conversation as fresh turns).
2605
+ if (cfg.cliId === 'claude-code' && cfg.sessionId) {
2606
+ const claudeJsonl = claudeJsonlPathForSession(cfg.sessionId, cfg.workingDir);
2607
+ startBridgeWatcher(claudeJsonl, {
2608
+ cliPid: cliPid ?? undefined,
2609
+ cliCwd: cfg.workingDir,
2610
+ mode: cfg.resume ? 'baseline-existing' : 'fresh-empty',
2611
+ });
2612
+ }
2613
+ // Structured transcript bridge fallback: if the model finishes without
2614
+ // calling `botmux send`, harvest the final answer from the CLI transcript
2615
+ // and post it to Lark. Codex needs late attach because its rollout id is
2616
+ // discovered after the first submit; CoCo's events path is deterministic
2617
+ // from botmux sessionId.
2618
+ if (cfg.cliId === 'codex') {
2619
+ if (cfg.cliSessionId) {
2620
+ const rolloutPath = findCodexRolloutBySessionId(cfg.cliSessionId);
2621
+ if (rolloutPath) {
2622
+ codexBridgeAttach(rolloutPath, 'baseline-existing');
2623
+ }
2624
+ else {
2625
+ codexBridgePendingSessionId = cfg.cliSessionId;
2626
+ codexBridgeStartTimer();
2627
+ }
2628
+ }
2629
+ else {
2630
+ codexBridgeStartTimer();
2631
+ }
2632
+ }
2633
+ else if (cfg.cliId === 'coco') {
2634
+ const eventsPath = cocoEventsPathForSession(cfg.sessionId);
2635
+ codexBridgeAttach(eventsPath, cfg.resume ? 'baseline-existing' : 'fresh-empty');
2636
+ codexBridgeStartTimer();
626
2637
  }
627
2638
  // Set up idle detection
628
2639
  idleDetector = new IdleDetector(cliAdapter);
629
2640
  idleDetector.onIdle(() => {
630
2641
  log('Prompt detected (idle)');
2642
+ // Bridge drain MUST run before markPromptReady() — the latter calls
2643
+ // flushPending() which can immediately fire the next queued message
2644
+ // (type-ahead adapters), shifting bridgeQueue's notion of "current
2645
+ // turn" before we've had a chance to emit the previous one.
2646
+ if (bridgeJsonlPath) {
2647
+ try {
2648
+ bridgeDrainAndMaybeEmit();
2649
+ }
2650
+ catch (err) {
2651
+ log(`Bridge emit error: ${err.message}`);
2652
+ }
2653
+ }
2654
+ if (codexBridgeFallbackActive()) {
2655
+ try {
2656
+ codexBridgeDrainAndMaybeEmit();
2657
+ }
2658
+ catch (err) {
2659
+ log(`Codex bridge emit error: ${err.message}`);
2660
+ }
2661
+ }
631
2662
  markPromptReady();
632
2663
  });
633
2664
  backend.onData(onPtyData);
@@ -637,10 +2668,22 @@ function spawnCli(cfg) {
637
2668
  isPromptReady = false;
638
2669
  send({ type: 'claude_exit', code, signal });
639
2670
  });
640
- // Fallback: if the CLI takes too long to show its prompt (e.g. slow MCP
641
- // server init), unblock screen updates so the card doesn't stay at "启动中"
642
- // forever. markNewTurn() sets a clean baseline at the current cursor
643
- // position so only content written *after* this point appears in the card.
2671
+ if (isPipeMode && backend instanceof TmuxPipeBackend && backend.isReattach) {
2672
+ log(`Re-attached to existing tmux session via pipe-pane: ${TmuxBackend.sessionName(cfg.sessionId)}`);
2673
+ try {
2674
+ const initial = backend.captureCurrentScreen();
2675
+ if (initial.length > 0)
2676
+ onPtyData(initial);
2677
+ }
2678
+ catch (err) {
2679
+ log(`captureCurrentScreen failed: ${err.message}`);
2680
+ }
2681
+ }
2682
+ // Fallback: if the CLI takes too long to show its prompt (e.g. slow
2683
+ // plugin init), unblock screen updates so the card doesn't stay at
2684
+ // "启动中" forever. markNewTurn() sets a clean baseline at the current
2685
+ // cursor position so only content written *after* this point appears in
2686
+ // the card.
644
2687
  setTimeout(() => {
645
2688
  if (awaitingFirstPrompt) {
646
2689
  awaitingFirstPrompt = false;
@@ -656,6 +2699,11 @@ function killCli() {
656
2699
  stopScreenUpdates();
657
2700
  backend?.kill();
658
2701
  backend = null;
2702
+ // Tear down the bridge watcher (if any). spawnCli will rebuild it on
2703
+ // restart with the proper mode based on the new cfg. Leaving it running
2704
+ // would dangle a watcher pinned to a stale jsonl path.
2705
+ stopBridgeWatcher();
2706
+ stopCodexBridge();
659
2707
  // Clean up CLI PID marker
660
2708
  if (cliPidMarker) {
661
2709
  try {
@@ -688,8 +2736,8 @@ function startWebServer(host, preferredPort) {
688
2736
  if (hasWrite)
689
2737
  authedClients.add(ws);
690
2738
  log(`WS client connected (total: ${wsClients.size}, write: ${hasWrite})`);
691
- if (isTmuxMode && sessionId) {
692
- // ── Tmux mode: per-client attach ──
2739
+ if (isTmuxMode && !isPipeMode && sessionId) {
2740
+ // ── Tmux-attach mode: per-client attach ──
693
2741
  // Each WS client gets its own `tmux attach-session` PTY.
694
2742
  // Scrollback is handled natively by tmux (history-limit).
695
2743
  // In adopt mode, attach to the user's original pane; otherwise use bmx-* session.
@@ -711,6 +2759,7 @@ function startWebServer(host, preferredPort) {
711
2759
  name: 'xterm-256color',
712
2760
  cols,
713
2761
  rows,
2762
+ env: tmuxEnv(),
714
2763
  });
715
2764
  clientPtys.set(ws, cp);
716
2765
  cp.onData((d) => {
@@ -849,11 +2898,11 @@ body{display:flex;flex-direction:column}
849
2898
  color:#565f89;background:#1a1b26cc;padding:2px 8px;border-radius:4px}
850
2899
  #status.ok{color:#9ece6a}
851
2900
  #status.err{color:#f7768e}
852
- #readonly-banner{display:none;position:fixed;top:0;left:0;right:0;z-index:50;
853
- padding:6px 12px;text-align:center;font:12px monospace;color:#f7768e;
854
- background:rgba(247,118,142,0.12);border-bottom:1px solid rgba(247,118,142,0.35);
855
- backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);pointer-events:none}
856
- #readonly-banner.show{display:block}
2901
+ #readonly-banner{display:none;position:fixed;top:8px;left:50%;transform:translateX(-50%);z-index:50;
2902
+ padding:4px 10px;font:12px monospace;color:#f7768e;white-space:nowrap;cursor:pointer;
2903
+ background:rgba(247,118,142,0.12);border:1px solid rgba(247,118,142,0.35);border-radius:4px;
2904
+ backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px)}
2905
+ #readonly-banner.show{display:inline-block}
857
2906
  </style>
858
2907
  </head>
859
2908
  <body>
@@ -879,7 +2928,7 @@ body{display:flex;flex-direction:column}
879
2928
  var isTouch='ontouchstart'in window||navigator.maxTouchPoints>0;
880
2929
  if(isTouch)document.getElementById('vp').content='width=1100,viewport-fit=cover';
881
2930
  var hasToken=${hasWrite};
882
- if(!hasToken)document.getElementById('readonly-banner').classList.add('show');
2931
+ if(!hasToken){var _rb=document.getElementById('readonly-banner');_rb.classList.add('show');_rb.addEventListener('click',function(){_rb.classList.remove('show')});}
883
2932
 
884
2933
  var term=new Terminal({
885
2934
  theme:{background:'#1a1b26',foreground:'#a9b1d6',cursor:'#c0caf5',
@@ -956,7 +3005,7 @@ window.addEventListener('resize',function(){fit.fit();sendResize()});
956
3005
  })();
957
3006
 
958
3007
  // ── Read-only scroll handling ──
959
- if(!hasToken&&!${isTmuxMode}){
3008
+ if(!hasToken&&!${isTmuxMode && !isPipeMode}){
960
3009
  // Non-tmux read-only: CLI mouse mode blocks local scroll, override with scrollLines
961
3010
  document.getElementById('terminal').addEventListener('wheel',function(e){
962
3011
  e.preventDefault();term.scrollLines(e.deltaY>0?3:-3);
@@ -966,7 +3015,7 @@ if(!hasToken&&!${isTmuxMode}){
966
3015
  // ── Scroll helper (shared by toolbar buttons & two-finger touch) ──
967
3016
  function _sendScroll(up,n){
968
3017
  n=n||3;
969
- if(${isTmuxMode}){
3018
+ if(${isTmuxMode && !isPipeMode}){
970
3019
  // SGR mouse wheel: 64=up 65=down — tmux enters copy-mode and scrolls
971
3020
  var seq='\\x1b[<'+(up?64:65)+';1;1M';
972
3021
  for(var i=0;i<n;i++){if(ws_&&ws_.readyState===1)ws_.send(JSON.stringify({type:'input',data:seq}))}
@@ -1055,7 +3104,15 @@ process.on('message', async (raw) => {
1055
3104
  // Capture credentials for direct image upload from worker
1056
3105
  larkAppIdForUpload = msg.larkAppId;
1057
3106
  larkAppSecretForUpload = msg.larkAppSecret;
1058
- log(`Init: session=${sessionId}, cwd=${msg.workingDir}`);
3107
+ // Resolve render dimensions BEFORE startScreenUpdates() the
3108
+ // headless xterm and PNG canvas need to know the source pane size
3109
+ // up-front. Setting them later (after the renderer was built at
3110
+ // 160x50) wouldn't unwrap content xterm has already buffered, so
3111
+ // adopt-mode wide-pane content would still come out stair-stepped.
3112
+ const dims = resolveRenderDimensions(msg);
3113
+ renderCols = dims.cols;
3114
+ renderRows = dims.rows;
3115
+ log(`Init: session=${sessionId}, cwd=${msg.workingDir}, render=${renderCols}x${renderRows}${msg.adoptMode ? ' (adopt-pane)' : ''}`);
1059
3116
  try {
1060
3117
  const port = await startWebServer('0.0.0.0', msg.webPort);
1061
3118
  startScreenUpdates();
@@ -1064,6 +3121,8 @@ process.on('message', async (raw) => {
1064
3121
  // Queue the initial prompt — flushed when CLI shows idle.
1065
3122
  // Adapters with passesInitialPromptViaArgs (e.g. Gemini -i) bake the
1066
3123
  // prompt into CLI args, so we skip queuing to avoid double-send.
3124
+ // Bridge mark is deferred to flushPending — see flushPending
3125
+ // comment for why marking at enqueue is wrong.
1067
3126
  if (msg.prompt && !cliAdapter?.passesInitialPromptViaArgs) {
1068
3127
  pendingMessages.push(msg.prompt);
1069
3128
  }
@@ -1083,10 +3142,70 @@ process.on('message', async (raw) => {
1083
3142
  exitTmuxScrollMode();
1084
3143
  const content = msg.content;
1085
3144
  if (lastInitConfig?.adoptMode) {
1086
- // Adopt mode: raw write to PTY (no adapter writeInput)
3145
+ // Bridge mode: capture transcript baseline BEFORE writing to the pane,
3146
+ // so any assistant uuids appended after this point are attributed to
3147
+ // *this* Lark turn (not local user activity in the pane). Mark may
3148
+ // return false (baseline not ready) — we still write to the pane;
3149
+ // user just won't get a final_output for this message.
3150
+ if (bridgeJsonlPath) {
3151
+ try {
3152
+ bridgeIngest();
3153
+ }
3154
+ catch { /* best effort */ }
3155
+ bridgeMarkPendingTurn(content);
3156
+ }
3157
+ else if (codexBridgeFallbackActive()) {
3158
+ // Codex adopt: same idea, different bridge. ingest first so any
3159
+ // in-flight events from a local-typed prior turn close before
3160
+ // this Lark turn's fingerprint window opens. Mark works even
3161
+ // pre-attach (queue is path-agnostic).
3162
+ try {
3163
+ codexBridgeIngest();
3164
+ }
3165
+ catch { /* best effort */ }
3166
+ codexBridgeMarkPendingTurn(content);
3167
+ }
3168
+ // Adopt mode write:
3169
+ // - codex routes through cliAdapter.writeInput so the adapter's
3170
+ // paste-detection delay + Enter-retry + history.jsonl verify
3171
+ // loop handles Codex TUI's "\n treated as Enter" submit
3172
+ // behaviour. Without it, Lark messages get stranded in the
3173
+ // input box (user-reported "卡在输入框中").
3174
+ // - everything else keeps the simple raw sendText+Enter — the
3175
+ // claude-code adopt bridge has its own dual-write recovery
3176
+ // path, and the other CLIs' adopt flows haven't surfaced
3177
+ // this submit-detection issue.
1087
3178
  if (backend) {
1088
- if ('sendText' in backend && 'sendSpecialKeys' in backend) {
3179
+ if (lastInitConfig?.cliId === 'codex' && cliAdapter) {
3180
+ // writeInput is async but we're already inside an async
3181
+ // message handler. Errors are best-effort logged; the bridge
3182
+ // ingest path is unaffected because mark already happened
3183
+ // above (codexBridgeMarkPendingTurn / bridgeMarkPendingTurn).
3184
+ try {
3185
+ const result = await cliAdapter.writeInput(backend, content);
3186
+ if (result?.cliSessionId) {
3187
+ persistCliSessionId(result.cliSessionId);
3188
+ codexBridgeNotifyCliSessionId(result.cliSessionId);
3189
+ }
3190
+ if (result && result.submitted === false) {
3191
+ scheduleSubmitFailureNotify(content, result.recheck, 'Codex history');
3192
+ }
3193
+ }
3194
+ catch (err) {
3195
+ log(`Codex adopt writeInput error: ${err.message}`);
3196
+ }
3197
+ }
3198
+ else if ('sendText' in backend && 'sendSpecialKeys' in backend) {
1089
3199
  backend.sendText(content);
3200
+ // Beat between text and Enter so the adopted CLI's input layer
3201
+ // has time to register the typed chars before submit. Without
3202
+ // this, Ink-based TUIs (CoCo, Claude Code) flag the rapid
3203
+ // input+Enter as paste continuation and treat the trailing
3204
+ // Enter as a soft-newline, leaving the message stranded in the
3205
+ // input box. 200ms mirrors the per-adapter writeInput delay
3206
+ // that fresh-spawn mode goes through and matches the slash-
3207
+ // command (raw_input) fix.
3208
+ await new Promise(r => setTimeout(r, 200));
1090
3209
  backend.sendSpecialKeys('Enter');
1091
3210
  }
1092
3211
  else {
@@ -1097,6 +3216,11 @@ process.on('message', async (raw) => {
1097
3216
  }
1098
3217
  }
1099
3218
  else {
3219
+ // Non-adopt: enqueue only. Bridge mark is deferred to flushPending
3220
+ // so markTimeMs anchors to the actual PTY-write moment, not IPC
3221
+ // arrival. Marking now would race with a still-running previous
3222
+ // turn whose `botmux send` could sneak its sentAtMs past this
3223
+ // turn's markTimeMs and falsely suppress its fallback.
1100
3224
  sendToPty(content);
1101
3225
  }
1102
3226
  break;
@@ -1113,6 +3237,14 @@ process.on('message', async (raw) => {
1113
3237
  if (backend) {
1114
3238
  if ('sendText' in backend && 'sendSpecialKeys' in backend) {
1115
3239
  backend.sendText(msg.content);
3240
+ // Beat between text and Enter so the CLI's slash-command picker has
3241
+ // time to register the match before submit. Without this, Codex
3242
+ // (and likely other Ink-based TUIs) fires Enter while the picker
3243
+ // is still building, dismisses the match, and submits the literal
3244
+ // `/clear` as a regular user prompt — visible to the user as
3245
+ // "/clear + 换行" stuck in conversation history. 200ms mirrors the
3246
+ // codex adapter's own writeInput paste-detection delay.
3247
+ await new Promise(r => setTimeout(r, 200));
1116
3248
  backend.sendSpecialKeys('Enter');
1117
3249
  }
1118
3250
  else {
@@ -1177,6 +3309,11 @@ process.on('message', async (raw) => {
1177
3309
  // destroySession kills tmux session permanently; kill() only detaches
1178
3310
  backend?.destroySession?.();
1179
3311
  killCli();
3312
+ // Bridge marker file outlives a single CLI process (we keep it across
3313
+ // restarts so a mid-flight send is still credited), but a real close
3314
+ // tears down the session — purge the file so a future re-use of the
3315
+ // same sessionId starts clean.
3316
+ clearSendMarkers();
1180
3317
  cleanup();
1181
3318
  process.exit(0);
1182
3319
  }
@@ -1207,5 +3344,33 @@ process.on('SIGTERM', () => { stopScreenshotLoop(); killCli(); cleanup(); proces
1207
3344
  process.on('SIGINT', () => { stopScreenshotLoop(); killCli(); cleanup(); process.exit(0); });
1208
3345
  // If parent daemon dies, IPC channel closes — clean up
1209
3346
  process.on('disconnect', () => { log('Daemon disconnected'); stopScreenshotLoop(); killCli(); cleanup(); process.exit(0); });
3347
+ // Watchdog: belt-and-braces parent-death detection. SIGTERM and 'disconnect'
3348
+ // should both reach us when the daemon dies, but if main thread is stuck in
3349
+ // a sync path V8 silently buffers the signal and we end up as a ppid=1
3350
+ // orphan forever (we accumulated 841 such orphans before this guard, eating
3351
+ // ~65GB of RAM). setInterval itself depends on the event loop, so a
3352
+ // permanently-stuck thread would still orphan — but real-world stuck
3353
+ // patterns are periodic (e.g. the v2.9.2 bridge scan was 1s-on / 0.x-off),
3354
+ // so the 30s tick gets many landing windows. `unref()` keeps the timer
3355
+ // from preventing a normal exit. `getppid()` is the read fd from /proc/self
3356
+ // — cheap, sync, no allocation. The daemon-side SIGKILL grace window
3357
+ // (SHUTDOWN_GRACE_MS in daemon.ts) is the harder backstop.
3358
+ const ORIGINAL_PARENT_PID = process.ppid;
3359
+ setInterval(() => {
3360
+ const currentPpid = process.ppid;
3361
+ if (currentPpid !== ORIGINAL_PARENT_PID || currentPpid === 1) {
3362
+ log(`Watchdog: parent pid changed (${ORIGINAL_PARENT_PID} → ${currentPpid}) — daemon died, exiting`);
3363
+ stopScreenshotLoop();
3364
+ try {
3365
+ killCli();
3366
+ }
3367
+ catch { /* best-effort */ }
3368
+ try {
3369
+ cleanup();
3370
+ }
3371
+ catch { /* best-effort */ }
3372
+ process.exit(0);
3373
+ }
3374
+ }, 30_000).unref();
1210
3375
  log('Worker started, waiting for init...');
1211
3376
  //# sourceMappingURL=worker.js.map