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
@@ -22,6 +22,7 @@
22
22
  */
23
23
  import { EventEmitter } from 'node:events';
24
24
  import { collectDescendants } from './proc-tree.js';
25
+ import { foreignViewSessionName, buildForeignDockerViewKillCommand, buildForeignAttachCommand, buildForeignDockerAttachCommand, buildForeignRemoteAttachCommand, } from './foreign-tmux.js';
25
26
  import { execSync, exec, execFile } from 'node:child_process';
26
27
  import { promisify } from 'node:util';
27
28
  const execAsync = promisify(exec);
@@ -315,9 +316,10 @@ export function derivePaneCurrentPaths(rows, previous = new Map()) {
315
316
  *
316
317
  * Mirrors `Session.paneExitApplies`, which is where the rule is enforced. A
317
318
  * remote session's local pane holds the ssh client, a docker case's holds a
318
- * `docker exec` into the container's own tmux, and a record rebuilt from the
319
- * socket carries no provenance at all, so the session end forces all three to
320
- * UNKNOWN whatever tmux reports. A tick that sees only those has nothing to
319
+ * `docker exec` into the container's own tmux, an ADOPTED session's holds a
320
+ * `tmux attach` to a session a human started rather than the agent itself, and
321
+ * a record rebuilt from the socket carries no provenance at all, so the session
322
+ * end forces all four to UNKNOWN whatever tmux reports. A tick that sees only those has nothing to
321
323
  * learn, and `refreshPaneExits()` skips its tmux read rather than paying for
322
324
  * the answer.
323
325
  *
@@ -332,12 +334,27 @@ export function hasObservablePaneSession(sessions) {
332
334
  continue;
333
335
  if (session.docker)
334
336
  continue;
337
+ if (session.adopt)
338
+ continue;
335
339
  if (session.discovered === true)
336
340
  continue;
337
341
  return true;
338
342
  }
339
343
  return false;
340
344
  }
345
+ /**
346
+ * Which adopted wrappers have a dead pane with a tmux client still attached, from rows of
347
+ * `#{pane_dead} #{session_attached} #{session_name}`. Exported for unit testing.
348
+ */
349
+ export function endedAdoptedAttaches(rows, adopted) {
350
+ const ended = [];
351
+ for (const row of rows.split('\n')) {
352
+ const [dead, attached, muxName] = row.trim().split(' ');
353
+ if (dead === '1' && Number(attached) > 0 && adopted.has(muxName))
354
+ ended.push(muxName);
355
+ }
356
+ return ended;
357
+ }
341
358
  /**
342
359
  * Resolve a target pane id from `tmux list-panes -F '#{pane_id}:#{pane_active}'`.
343
360
  * Prefers the active pane and falls back to the first valid pane.
@@ -1026,12 +1043,15 @@ export function remoteTmuxLocation(remote, sessionId) {
1026
1043
  *
1027
1044
  * Emits:
1028
1045
  * ssh -o BatchMode=yes -t [<COD-107 connection opts>] user@host \
1029
- * 'tmux -L codeman-remote new-session -A -s codeman-ssh-<id> -c <path> "cd <path> && exec <cli>" \
1046
+ * 'tmux -L codeman-remote new-session -A -s codeman-ssh-<id> -c <path> /bin/sh -c <pane-command> \
1030
1047
  * \; set -t codeman-ssh-<id> status off \; set -t codeman-ssh-<id> mouse off \
1031
1048
  * \; set -t codeman-ssh-<id> prefix C-q \; set -s escape-time 0 \; \
1032
1049
  * bind-key -T prefix C-q send-prefix \; unbind-key -a -T prefix \; \
1033
1050
  * bind-key -T prefix C-q send-prefix \; set -t codeman-ssh-<id> remain-on-exit failed'
1034
1051
  *
1052
+ * `<pane-command>` is `cd <path> && exec <cli>`, quoted as one token. A per-host `commands.*`
1053
+ * override is that one token alone, with no `/bin/sh -c` in front of it.
1054
+ *
1035
1055
  * COD-107 — the connection options (`-p`, `-i`, `-J`, SOCKS `-o ProxyCommand`,
1036
1056
  * arbitrary `-o`) come from the shared `buildSshConnectionArgs(remote)`, so the
1037
1057
  * prereq tmux probe and this launch connect with identical options.
@@ -1045,10 +1065,12 @@ export function remoteTmuxLocation(remote, sessionId) {
1045
1065
  * - The `set` options are scoped per-session (`set -t <name>` / server-level
1046
1066
  * `set -s`), never `-g`, so they never mutate other sessions' prefix/mouse.
1047
1067
  * - The whole tmux invocation is a SINGLE ssh argument (the remote login shell
1048
- * runs it), so it is shell-quoted as one unit; the `cd && exec` command is in
1049
- * turn a single tmux argument (tmux runs it via `/bin/sh -c`), so the path is
1050
- * shell-quoted inside it too. This keeps escaping correct through every layer
1051
- * even when the remote path contains spaces.
1068
+ * runs it), so it is shell-quoted as one unit. The `cd && exec` command is the
1069
+ * pane command: Codeman's own text goes to `/bin/sh -c` as separate tmux
1070
+ * arguments, and a per-host `commands.*` override stays one argument, which
1071
+ * tmux hands to the remote default-shell (see the comment at `paneCommand`).
1072
+ * Each is shell-quoted, so escaping stays correct through every layer even
1073
+ * when the remote path contains spaces.
1052
1074
  */
1053
1075
  export function buildRemoteLaunchCommand(options) {
1054
1076
  const { mode, remote, sessionId, claudeMode, allowedTools, ompConfig, resumeSessionId } = options;
@@ -1056,9 +1078,9 @@ export function buildRemoteLaunchCommand(options) {
1056
1078
  // hardcoding --dangerously-skip-permissions, so a non-granted multi-user user's
1057
1079
  // downgraded 'auto' actually reaches the remote agent (the default command otherwise
1058
1080
  // ignored claudeMode). A per-host `commands.claude` override stays authoritative
1059
- // (admin's explicit choice). Wrapped in `$SHELL -i -l -c` for the same reason as
1060
- // `defaultRemoteCommandForMode`: `claude` lives under a per-user PATH entry that
1061
- // only an interactive login shell resolves (see that function's comment).
1081
+ // (admin's explicit choice). Wrapped in the login shell (`remoteLoginShellCommand`) for
1082
+ // the same reason as `defaultRemoteCommandForMode`: `claude` lives under a per-user PATH
1083
+ // entry that only an interactive login shell resolves (see that function's comment).
1062
1084
  const override = remote.commands?.[mode];
1063
1085
  let modeCommand;
1064
1086
  if (override) {
@@ -1123,16 +1145,24 @@ export function buildRemoteLaunchCommand(options) {
1123
1145
  modeCommand = defaultRemoteCommandForMode(mode);
1124
1146
  }
1125
1147
  const remoteName = remoteTmuxSessionName(sessionId);
1126
- // Innermost: the command tmux runs in the new pane. Run via `/bin/sh -c` by
1127
- // tmux, so the path needs shell-quoting here. `exec` replaces the shell with
1128
- // the CLI so the pane PID is the agent itself.
1148
+ // Innermost: the command tmux runs in the new pane. `exec` replaces the shell
1149
+ // with the CLI so the pane PID is the agent itself.
1150
+ //
1151
+ // tmux runs a ONE-argument command through the remote default-shell, which is
1152
+ // the user's login shell, and bypasses it for two or more arguments (both
1153
+ // measured on tmux 3.4). Codeman's own text is POSIX (`"${SHELL:-/bin/sh}"` is
1154
+ // a parse error in fish, status 127, a dead pane on every new session), so it
1155
+ // goes to `/bin/sh -c` as separate arguments and no login shell has to parse it.
1156
+ // An override is the user's own text for THEIR shell: it stays one argument,
1157
+ // because `source` and `[[` work in bash and zsh and die in a dash `/bin/sh`.
1129
1158
  const paneCommand = `cd ${shellescape(remote.remotePath)} && ${modeCommand}`;
1159
+ const paneArgv = `${override ? '' : '/bin/sh -c '}${shellescape(paneCommand)}`;
1130
1160
  // The tmux command line, with `\;` separating commands so the config `set`s
1131
1161
  // apply on the SAME connection (and are idempotent on reattach). Options are
1132
1162
  // scoped per-session (`set -t <name>` / server `set -s`), NEVER `-g`, so a
1133
1163
  // shared remote tmux server's other sessions keep their own prefix/mouse.
1134
1164
  const tmuxInvocation = [
1135
- `tmux -L ${REMOTE_TMUX_SOCKET} new-session -A -s ${remoteName} -c ${shellescape(remote.remotePath)} ${shellescape(paneCommand)}`,
1165
+ `tmux -L ${REMOTE_TMUX_SOCKET} new-session -A -s ${remoteName} -c ${shellescape(remote.remotePath)} ${paneArgv}`,
1136
1166
  `set -t ${remoteName} status off`,
1137
1167
  `set -t ${remoteName} mouse off`,
1138
1168
  `set -t ${remoteName} prefix ${NESTED_TMUX_PREFIX}`,
@@ -1281,7 +1311,7 @@ function claudeDockerPaneCommand(modeCommand, sessionId, resumeId) {
1281
1311
  * Build the ONE `bash -c` launch string for a docker session: image-check ->
1282
1312
  * ensure (inspect-or-create) -> start -> `exec docker exec -it` into the durable
1283
1313
  * in-container tmux (resume-aware). PURE and unit-testable. The escaping survives
1284
- * four layers: outer `bash -c "…"` (JSON.stringify at respawn-pane) -> the joined
1314
+ * four layers: outer `bash -c '…'` (`bashCArg()` at respawn-pane) -> the joined
1285
1315
  * command -> `docker exec … sh -lc '<tmux>'` -> tmux `'<paneCommand>'`.
1286
1316
  */
1287
1317
  export function buildDockerLaunchCommand(opts) {
@@ -1308,8 +1338,8 @@ export function buildDockerLaunchCommand(opts) {
1308
1338
  else if (resumeSessionId) {
1309
1339
  modeCommand = appendResumeFlag(modeCommand, mode, resumeSessionId);
1310
1340
  }
1311
- // Run by tmux via /bin/sh -c, so the path is shell-quoted here. `exec` makes the
1312
- // pane PID the agent itself.
1341
+ // Run by tmux through the container's default-shell, so the path is shell-quoted
1342
+ // here. `exec` makes the pane PID the agent itself.
1313
1343
  const paneCommand = `cd ${workdir} && ${modeCommand}`;
1314
1344
  // `setenv -g` primes the session id so reattaches / newly-created panes inherit
1315
1345
  // it. `new-session -A` = attach-or-create (idempotent + resume-aware). Options
@@ -1363,11 +1393,10 @@ export function buildDockerLaunchCommand(opts) {
1363
1393
  const ensure = adopted
1364
1394
  ? `${base} inspect ${name} >/dev/null 2>&1 || { echo ${notFoundMsg}; exit 1; }`
1365
1395
  : `${base} inspect ${name} >/dev/null 2>&1 || ${createCommand}`;
1366
- // ⚠️ No double quotes and no `$(…)` in the ADOPTED arms. This whole chain is
1367
- // embedded in an outer `bash -c "…"`, so an unescaped `"` closes that string early,
1368
- // the rest is re-tokenized, and tmux fails to exec with a bare `execvp(3) failed`.
1369
- // A `grep -qx` pipeline reads the same answer using only the single-quoted form
1370
- // every other line in this builder already uses.
1396
+ // A `grep -qx` pipeline, in the single-quoted form every other line in this builder
1397
+ // uses. This chain reaches `bash -c` verbatim now (`bashCArg()`), but it went through
1398
+ // a double-quoted layer for a long time, where a `"` closed that string early and tmux
1399
+ // failed with a bare `execvp(3) failed`, so keep it free of double quotes and `$(…)`.
1371
1400
  const start = adopted
1372
1401
  ? `${base} inspect -f ${shellescape('{{.State.Running}}')} ${name} 2>/dev/null | grep -qx true || { echo ${notRunningMsg}; exit 1; }`
1373
1402
  : `${base} start ${name} >/dev/null 2>&1 || { echo ${startFailMsg}; exit 1; }`;
@@ -1562,6 +1591,44 @@ function buildRemoteSessionCommand(options) {
1562
1591
  }
1563
1592
  return buildRemoteLaunchCommand(options);
1564
1593
  }
1594
+ /**
1595
+ * ADOPTION: the pane command for a wrapper session around a tmux session a
1596
+ * human started. Sibling of `buildRemoteSessionCommand` and
1597
+ * `buildDockerLaunchCommand`, and the same shape of decision: the wrapper is an
1598
+ * ordinary `codeman-<8hex>` session on OUR socket, and only what runs INSIDE its
1599
+ * pane differs per location.
1600
+ *
1601
+ * That indirection is what makes adoption safe by construction rather than by
1602
+ * discipline: `killSession` only ever kills the wrapper on our own socket, so
1603
+ * there is no shape of caller bug that reaches the foreign server with a
1604
+ * `kill-session`. It is the same reasoning that keeps a non-owned REMOTE session
1605
+ * safe (see the detach-not-kill early return in `killSession`).
1606
+ */
1607
+ export function buildAdoptSessionCommand(adopt, sessionId) {
1608
+ const target = {
1609
+ socketPath: adopt.socketPath,
1610
+ targetSession: adopt.targetSession,
1611
+ // Derived rather than required: the view name is a function of the Codeman
1612
+ // session id, so a record that predates it (or one whose creator filled it in
1613
+ // after construction) still produces the right name instead of an empty `-s`.
1614
+ viewSession: adopt.viewSession || foreignViewSessionName(sessionId),
1615
+ };
1616
+ if (adopt.location === 'docker' && adopt.docker) {
1617
+ return buildForeignDockerAttachCommand({
1618
+ ...target,
1619
+ dockerBase: buildDockerBaseArgs(adopt.docker).join(' '),
1620
+ containerName: adopt.docker.containerName,
1621
+ });
1622
+ }
1623
+ if (adopt.location === 'remote' && adopt.remote) {
1624
+ return buildForeignRemoteAttachCommand({
1625
+ ...target,
1626
+ sshArgs: buildSshConnectionArgs(adopt.remote),
1627
+ sshTarget: remoteSshTarget(adopt.remote),
1628
+ });
1629
+ }
1630
+ return buildForeignAttachCommand(target);
1631
+ }
1565
1632
  /**
1566
1633
  * Push one environment variable into a tmux session with `setenv`.
1567
1634
  *
@@ -2113,7 +2180,7 @@ export class TmuxManager extends EventEmitter {
2113
2180
  * In test mode: creates an in-memory session only (no real tmux session).
2114
2181
  */
2115
2182
  async createSession(options) {
2116
- const { sessionId, workingDir, mode, name, cliName, niceConfig, model, claudeMode, allowedTools, openCodeConfig, codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig, copilotConfig, deepSeekConfig, ompConfig, resumeSessionId, envOverrides, effort, advisorModel, keepWarm, spawnedByAgent, historyLimit = DEFAULT_TMUX_HISTORY_LIMIT, remote, docker, owner, } = options;
2183
+ const { sessionId, workingDir, mode, name, cliName, niceConfig, model, claudeMode, allowedTools, openCodeConfig, codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig, copilotConfig, deepSeekConfig, ompConfig, resumeSessionId, envOverrides, effort, advisorModel, keepWarm, spawnedByAgent, historyLimit = DEFAULT_TMUX_HISTORY_LIMIT, remote, docker, adopt, owner, } = options;
2117
2184
  const muxName = `codeman-${sessionId.slice(0, 8)}`;
2118
2185
  if (!isValidMuxName(muxName)) {
2119
2186
  throw new Error('Invalid session name: contains unsafe characters');
@@ -2131,6 +2198,7 @@ export class TmuxManager extends EventEmitter {
2131
2198
  workingDir,
2132
2199
  remote,
2133
2200
  docker,
2201
+ adopt,
2134
2202
  owner,
2135
2203
  mode,
2136
2204
  attached: false,
@@ -2156,14 +2224,22 @@ export class TmuxManager extends EventEmitter {
2156
2224
  // surfacing as a bare `execvp(3) failed: No such file or directory` with nothing
2157
2225
  // pointing at the real cause. The container's own CLIs are verified by the adoption
2158
2226
  // preflight / image gate before launch instead.
2159
- const cliRunsInContainer = !!docker;
2227
+ //
2228
+ // ⚠️ Skipped for an ADOPTED session for the mirror-image reason: its CLI was started by
2229
+ // a human in a process we did not spawn, and our pane only runs `tmux attach`. Requiring
2230
+ // the binary here would reject adopting a claude that lives in a container or on an ssh
2231
+ // host, and (worse) reject a LOCAL adoption whenever the agent is outside the server
2232
+ // process's PATH, which is the usual systemd/launchd situation.
2233
+ const cliRunsInContainer = !!docker || !!adopt;
2160
2234
  if (!cliRunsInContainer && !cliDir) {
2161
2235
  const message = missingCliMessage(mode);
2162
2236
  if (message)
2163
2237
  throw new Error(message);
2164
2238
  }
2165
2239
  const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
2166
- const { command: statusLineCommand, state: statusLine } = await this._resolveStatusLine(mode, workingDir, !!(remote || docker));
2240
+ const { command: statusLineCommand, state: statusLine } = await this._resolveStatusLine(mode, workingDir,
2241
+ // An adopted wrapper launches no agent, so nothing can carry the exporter.
2242
+ !!(remote || docker || adopt));
2167
2243
  const idleCompactionOff = await this._resolveIdleCompactionOptOut(mode, statusLine, keepWarm, spawnedByAgent);
2168
2244
  // The user's own REAL statusLine, if any (walked via Claude Code's own
2169
2245
  // settings precedence) — exported below so the shared exporter script
@@ -2196,11 +2272,21 @@ export class TmuxManager extends EventEmitter {
2196
2272
  try {
2197
2273
  // Build the full command to run inside tmux
2198
2274
  const localFullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
2199
- const fullCmd = docker
2200
- ? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
2201
- : remote
2202
- ? buildRemoteSessionCommand({ mode, remote, sessionId, claudeMode, allowedTools, ompConfig, resumeSessionId })
2203
- : localFullCmd;
2275
+ const fullCmd = adopt
2276
+ ? buildAdoptSessionCommand(adopt, sessionId)
2277
+ : docker
2278
+ ? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
2279
+ : remote
2280
+ ? buildRemoteSessionCommand({
2281
+ mode,
2282
+ remote,
2283
+ sessionId,
2284
+ claudeMode,
2285
+ allowedTools,
2286
+ ompConfig,
2287
+ resumeSessionId,
2288
+ })
2289
+ : localFullCmd;
2204
2290
  // Create tmux session in three steps to handle cold-start (no server running)
2205
2291
  // and avoid the race where the command exits before remain-on-exit is set:
2206
2292
  // 2. Set remain-on-exit (server now exists, session won't vanish on exit)
@@ -2250,8 +2336,10 @@ export class TmuxManager extends EventEmitter {
2250
2336
  this.applyEnvOverrides(muxName, envOverrides);
2251
2337
  // Replace the shell with the actual command (no echo in terminal). Keep
2252
2338
  // pane launch in /tmp, then cd inside bash against the current mount table.
2253
- const launchCmd = remote || docker ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
2254
- execSync(`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`, {
2339
+ // ⚠️ An adopted pane is never `cd`'d: it only attaches, and its `workingDir`
2340
+ // is the neutral wrapper folder, not where the foreign pane is.
2341
+ const launchCmd = remote || docker || adopt ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
2342
+ execSync(`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${bashCArg(launchCmd, !!(remote || docker || adopt))}`, {
2255
2343
  timeout: EXEC_TIMEOUT_MS,
2256
2344
  stdio: 'ignore',
2257
2345
  });
@@ -2305,6 +2393,7 @@ export class TmuxManager extends EventEmitter {
2305
2393
  workingDir,
2306
2394
  remote,
2307
2395
  docker,
2396
+ adopt,
2308
2397
  owner,
2309
2398
  mode,
2310
2399
  attached: false,
@@ -2402,13 +2491,20 @@ export class TmuxManager extends EventEmitter {
2402
2491
  const session = this.sessions.get(sessionId);
2403
2492
  if (!session)
2404
2493
  return null;
2494
+ // Adoption metadata comes from the tracked session, not the caller: a respawn
2495
+ // of an adopted wrapper RE-ATTACHES to the same foreign target. It must never
2496
+ // be able to become "launch a fresh agent here" because a caller forgot to
2497
+ // pass the field through.
2498
+ const adopt = options.adopt ?? session.adopt;
2405
2499
  const muxName = session.muxName;
2406
2500
  if (!isValidMuxName(muxName) || !isValidPath(workingDir))
2407
2501
  return null;
2408
2502
  // Resolve CLI binary directory based on mode
2409
2503
  const { pathExport } = this.buildPathExport(mode);
2410
2504
  const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
2411
- const { command: statusLineCommand, state: statusLine } = await this._resolveStatusLine(mode, workingDir, !!(remote || docker));
2505
+ const { command: statusLineCommand, state: statusLine } = await this._resolveStatusLine(mode, workingDir,
2506
+ // An adopted wrapper launches no agent, so nothing can carry the exporter.
2507
+ !!(remote || docker || adopt));
2412
2508
  const idleCompactionOff = await this._resolveIdleCompactionOptOut(mode, statusLine, keepWarm, spawnedByAgent);
2413
2509
  const userStatusLineCommand = statusLineCommand ? await findEffectiveUserStatusLineCommand(workingDir) : undefined;
2414
2510
  const baseCmd = buildSpawnCommand({
@@ -2436,11 +2532,13 @@ export class TmuxManager extends EventEmitter {
2436
2532
  const config = niceConfig || DEFAULT_NICE_CONFIG;
2437
2533
  const cmd = wrapWithNice(baseCmd, config);
2438
2534
  const localFullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
2439
- const fullCmd = docker
2440
- ? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
2441
- : remote
2442
- ? buildRemoteSessionCommand({ mode, remote, sessionId, claudeMode, allowedTools, ompConfig, resumeSessionId })
2443
- : localFullCmd;
2535
+ const fullCmd = adopt
2536
+ ? buildAdoptSessionCommand(adopt, sessionId)
2537
+ : docker
2538
+ ? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
2539
+ : remote
2540
+ ? buildRemoteSessionCommand({ mode, remote, sessionId, claudeMode, allowedTools, ompConfig, resumeSessionId })
2541
+ : localFullCmd;
2444
2542
  try {
2445
2543
  // Same per-CLI env setup as createSession, re-applied so the respawned pane inherits it.
2446
2544
  this._configureCliEnv(muxName, sessionId, mode, legacyConfigForMode(mode, options));
@@ -2449,8 +2547,10 @@ export class TmuxManager extends EventEmitter {
2449
2547
  // dropping the ones the caller retired first (see applyEnvOverrides).
2450
2548
  this.applyEnvOverrides(muxName, envOverrides, unsetEnvKeys);
2451
2549
  // -c /tmp + cd bounce — see createSession() for rationale (stale FUSE state).
2452
- const launchCmd = remote || docker ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
2453
- await execAsync(`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`, {
2550
+ // ⚠️ An adopted pane is never `cd`'d: it only attaches, and its `workingDir`
2551
+ // is the neutral wrapper folder, not where the foreign pane is.
2552
+ const launchCmd = remote || docker || adopt ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
2553
+ await execAsync(`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${bashCArg(launchCmd, !!(remote || docker || adopt))}`, {
2454
2554
  timeout: EXEC_TIMEOUT_MS,
2455
2555
  });
2456
2556
  // Wait for the respawned process to start
@@ -2650,6 +2750,56 @@ export class TmuxManager extends EventEmitter {
2650
2750
  // for a non-owned session. The only `kill-session` we run is on OUR LOCAL
2651
2751
  // socket (`this.tmux()` = `tmux -L codeman` on THIS host), which kills the
2652
2752
  // local pane — it does NOT reach the REMOTE socket.
2753
+ // ADOPTION: DETACH-NOT-KILL, by construction rather than by discipline.
2754
+ //
2755
+ // An adopted session's wrapper is an ordinary session on OUR socket whose
2756
+ // pane runs `tmux attach` (or `docker exec …`/`ssh …` then attach) against a
2757
+ // FOREIGN server. Killing the wrapper kills our attach client, which is a
2758
+ // DETACH on the foreign side; the human's session keeps running, and our
2759
+ // grouped view session self-destroys via `destroy-unattached on`.
2760
+ //
2761
+ // The guard below is a formality more than a fence: `this.tmux()` only ever
2762
+ // addresses our own socket, so no code path here CAN reach the foreign
2763
+ // server. It is written out anyway so the intent survives future edits, the
2764
+ // same way the non-owned-remote early return does.
2765
+ if (session.adopt) {
2766
+ console.log(`[TmuxManager] DETACH (adopted): tearing down wrapper only for ${session.muxName}`);
2767
+ // A `docker exec` survives its client, so the in-container view would stay
2768
+ // attached forever and leak. Best-effort, and it can only reach OUR view.
2769
+ // See buildForeignDockerViewKillCommand for why local/remote need nothing.
2770
+ // Fire and forget like the owned docker branch below: a daemon that stopped
2771
+ // answering must not hold the event loop for the whole timeout.
2772
+ if (session.adopt.location === 'docker' && session.adopt.docker) {
2773
+ try {
2774
+ exec(buildForeignDockerViewKillCommand({
2775
+ dockerBase: buildDockerBaseArgs(session.adopt.docker).join(' '),
2776
+ containerName: session.adopt.docker.containerName,
2777
+ socketPath: session.adopt.socketPath,
2778
+ viewSession: session.adopt.viewSession || foreignViewSessionName(sessionId),
2779
+ }), { timeout: EXEC_TIMEOUT_MS }, () => { });
2780
+ }
2781
+ catch {
2782
+ // Best-effort: the container may be gone, or the view already collected.
2783
+ }
2784
+ }
2785
+ if (isValidMuxName(session.muxName)) {
2786
+ try {
2787
+ await execAsync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
2788
+ timeout: EXEC_TIMEOUT_MS,
2789
+ });
2790
+ }
2791
+ catch {
2792
+ // Wrapper may already be gone.
2793
+ }
2794
+ }
2795
+ this.lastPaneCount.delete(session.muxName);
2796
+ this.clearPaneExit(session.muxName);
2797
+ this.sessions.delete(sessionId);
2798
+ this.clearRemoteReconnectState(sessionId);
2799
+ this.saveSessions();
2800
+ this.emit('sessionKilled', { sessionId });
2801
+ return true;
2802
+ }
2653
2803
  if (session.remote && session.remote.owned === false) {
2654
2804
  console.log(`[TmuxManager] DETACH (non-owned remote): tearing down local pane only for ${session.muxName}`);
2655
2805
  if (isValidMuxName(session.muxName)) {
@@ -3328,8 +3478,51 @@ export class TmuxManager extends EventEmitter {
3328
3478
  catch (err) {
3329
3479
  console.error('[TmuxManager] Remote reconnect watcher error:', err);
3330
3480
  }
3481
+ void this.detachEndedAdoptedAttaches().catch((err) => {
3482
+ console.error('[TmuxManager] Adopted attach watcher error:', err);
3483
+ });
3331
3484
  }, intervalMs);
3332
3485
  }
3486
+ /**
3487
+ * Let go of a wrapper whose pane died (#510). That pane holds the attach to someone
3488
+ * else's tmux session, so it dies when that session ends, the transport drops or the
3489
+ * view gets detached, and `paneExit` rightly reports none of them. But `remain-on-exit`
3490
+ * keeps Codeman's own tmux client on the dead pane, so the tab kept its pid and `busy`,
3491
+ * input went nowhere and both attach routes refused it.
3492
+ *
3493
+ * Detaching that client is an ordinary PTY exit with status 0: the pid goes null, the tab
3494
+ * stays open, and selecting it starts the wrapper again, which joins the target if it is
3495
+ * back and otherwise prints that it is gone. Only Codeman's own socket is touched.
3496
+ */
3497
+ async detachEndedAdoptedAttaches() {
3498
+ const adopted = new Set();
3499
+ for (const session of this.sessions.values()) {
3500
+ if (session.adopt)
3501
+ adopted.add(session.muxName);
3502
+ }
3503
+ if (adopted.size === 0)
3504
+ return;
3505
+ for (const muxName of endedAdoptedAttaches(await this.readAttachRows(), adopted)) {
3506
+ await this.detachClients(muxName);
3507
+ }
3508
+ }
3509
+ /** Rows for {@link endedAdoptedAttaches}. Empty when tmux did not answer. */
3510
+ async readAttachRows() {
3511
+ if (IS_TEST_MODE)
3512
+ return '';
3513
+ const format = '#{pane_dead} #{session_attached} #{session_name}';
3514
+ const { stdout } = await execAsync(`${this.tmux()} list-panes -a -F '${format}' 2>/dev/null || true`, {
3515
+ encoding: 'utf-8',
3516
+ timeout: EXEC_TIMEOUT_MS,
3517
+ });
3518
+ return stdout;
3519
+ }
3520
+ /** Detach every tmux client from one of Codeman's own sessions. */
3521
+ async detachClients(muxName) {
3522
+ if (IS_TEST_MODE || !isValidMuxName(muxName))
3523
+ return;
3524
+ await execAsync(`${this.tmux()} detach-client -s "${muxName}"`, { timeout: EXEC_TIMEOUT_MS });
3525
+ }
3333
3526
  stopRemoteReconnectWatcher() {
3334
3527
  if (this.remoteReconnectInterval) {
3335
3528
  clearInterval(this.remoteReconnectInterval);
@@ -4099,6 +4292,28 @@ export class TmuxManager extends EventEmitter {
4099
4292
  }
4100
4293
  }
4101
4294
  }
4295
+ /**
4296
+ * The `bash -c` argument of a pane launch (`respawn-pane … bash -c <arg>`, run through the
4297
+ * local sh). A remote, docker or adopted command is already quoted for the shell at the far
4298
+ * end and must reach bash verbatim, so it goes in single quotes. Double quotes
4299
+ * (JSON.stringify) let the local sh expand every `$` first: the PATH bootstrap of
4300
+ * `withRemoteTmuxPath` (`$HOME/.local/bin:…:$PATH`) arrived on the remote host as this
4301
+ * host's home and PATH, so a remote user with another home found no tmux and every new
4302
+ * remote pane exited 127. `${SHELL:-/bin/sh}` in the remote launch was the local shell for
4303
+ * the same reason, and an adopted ssh attach carries the same bootstrap, so a tmux in the
4304
+ * remote `~/.local/bin` was found by discovery and then missed by the attach. Single quotes
4305
+ * also keep a real newline, which JSON escaping left as the two characters `\n`.
4306
+ * An adopted attach goes verbatim at all three locations, the local one included: every
4307
+ * value in it is already quoted by `foreign-tmux.ts`, and nothing in it is for this shell.
4308
+ * An agent launched on this host keeps its double-quoted form.
4309
+ *
4310
+ * ⚠️ So nothing in a verbatim launch is expanded on this host any more, the docker client's
4311
+ * own arguments included: a bare `$VAR` in `extraCreateArgs`/`extraExecArgs` reaches docker
4312
+ * as those literal characters.
4313
+ */
4314
+ export function bashCArg(launchCmd, verbatim) {
4315
+ return verbatim ? shellescape(launchCmd) : JSON.stringify(launchCmd);
4316
+ }
4102
4317
  /**
4103
4318
  * Shell-escape a string for use as a single argument.
4104
4319
  * Wraps in single quotes, escaping any embedded single quotes.