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
Binary file
Binary file
@@ -9,12 +9,14 @@
9
9
  * `GET /api/sessions/:id/edits?sync=1` when it opens and when the active session changes, and,
10
10
  * only while it is open, re-reads (debounced) on the active session's `transcript:tool_end` for a
11
11
  * tool that can change files (the existing transcript watcher's event) and on its `session:idle`
12
- * (the end of a turn). No poll, and nothing at all while the panel is closed.
12
+ * (the end of a turn). No poll, and nothing at all while the panel is closed. A remote session has
13
+ * no transcript watcher (no hooks reach back from its host), so it follows turn ends only.
13
14
  *
14
15
  * - A code entry opens its diff inline (`GET /api/sessions/:id/edits/:entryId`), drawn with the Git
15
16
  * panel's diff classes (`git-diff`, `git-diff-line--hunk|add|del`).
16
17
  * - An image entry opens the shared file preview (panels-ui.js `openFilePreview`), as does a click
17
- * on any path; inside the workspace it also shows a thumbnail. Both go through the existing file
18
+ * on any path; inside a local session's workspace it also shows a thumbnail (a remote one would
19
+ * stream every listed image over ssh). Both go through the existing file
18
20
  * routes only: the thumbnail is the confined `file-raw`, and the preview of a file outside the
19
21
  * workspace registers it on click with `notify: false` (`_registerExternalPreview`, which
20
22
  * enforces the attachment guard and its extension allowlist). A path from the transcript is
@@ -318,10 +320,12 @@ Object.assign(CodemanApp.prototype, {
318
320
  * (`POST …/attachments`, `notify: false`), and minting one per rendered row would push the
319
321
  * session's real attachments out of its capped registry. Clicking it opens the file preview,
320
322
  * which registers it like any other external file. SVGs get none either (`file-raw` serves
321
- * them as a download).
323
+ * them as a download), nor does a remote session's image: its `file-raw` streams over ssh, and
324
+ * a list of generated images would open one ssh read per row; a click still previews it.
322
325
  */
323
326
  _editHistoryThumbSrc(path, sid) {
324
- if (!path || /\.svg$/i.test(path) || this._isExternalPreviewPath?.(path, sid) !== false) return null;
327
+ if (!path || /\.svg$/i.test(path) || this.sessions?.get?.(sid)?.remote) return null;
328
+ if (this._isExternalPreviewPath?.(path, sid) !== false) return null;
325
329
  return this._editHistoryUrl(`/api/sessions/${encodeURIComponent(sid)}/file-raw?path=${encodeURIComponent(path)}`);
326
330
  },
327
331
 
@@ -353,21 +357,30 @@ Object.assign(CodemanApp.prototype, {
353
357
  }
354
358
  const data = h.data;
355
359
  if (data.state === 'unsupported') {
356
- body.append(el('div', 'edit-history-empty', t('Edit history is only available for local Claude sessions.')));
357
- return;
358
- }
359
- if (data.state === 'unavailable') {
360
360
  body.append(
361
361
  el(
362
362
  'div',
363
- 'edit-history-note edit-history-note--warn',
364
- data.reason === 'no-transcript'
365
- ? t(
366
- 'No transcript found for this session yet. It is picked up with the next prompt; until then a CLAUDE_CONFIG_DIR set outside Codeman cannot be found.'
367
- )
368
- : t('Showing saved entries only: {reason}', { reason: data.reason || '' })
363
+ 'edit-history-empty',
364
+ data.reason === 'adopted'
365
+ ? t('Edit history is not available for a tmux session opened outside Codeman.')
366
+ : t('Edit history is only available for Claude sessions outside Docker.')
369
367
  )
370
368
  );
369
+ return;
370
+ }
371
+ if (data.state === 'unavailable') {
372
+ // A remote host sends no hooks: its transcript is only ever found by the lookup over ssh.
373
+ const note =
374
+ data.reason !== 'no-transcript'
375
+ ? t('Showing saved entries only: {reason}', { reason: data.reason || '' })
376
+ : this.sessions?.get?.(h.sessionId)?.remote
377
+ ? t(
378
+ 'No transcript found on the remote host yet (Claude writes it with the first prompt). A CLAUDE_CONFIG_DIR set only in a shell profile (.profile, .bash_profile, .zshrc) cannot be found.'
379
+ )
380
+ : t(
381
+ 'No transcript found for this session yet. It is picked up with the next prompt; until then a CLAUDE_CONFIG_DIR set outside Codeman cannot be found.'
382
+ );
383
+ body.append(el('div', 'edit-history-note edit-history-note--warn', note));
371
384
  }
372
385
 
373
386
  const all = Array.isArray(data.entries) ? data.entries : [];
@@ -0,0 +1,476 @@
1
+ /**
2
+ * @fileoverview The "sessions someone opened by hand" block on the home screen.
3
+ *
4
+ * A tmux session a human started (`tmux new -s work`, then `claude`, or just a
5
+ * shell) is invisible to Codeman: it lives on the DEFAULT socket, not the
6
+ * instance-scoped one Codeman owns. `GET /api/mux/foreign` finds those, and one
7
+ * click wraps one in a Codeman tab (`POST /api/sessions/adopt`).
8
+ *
9
+ * ## One renderer, two containers
10
+ *
11
+ * The welcome screen and the phone overview both show this list. They call the
12
+ * SAME `renderForeignSessions(container)` rather than each building rows, because
13
+ * two renderers are how one surface ends up calling a session "shell" while the
14
+ * other calls it "bash". Same reason `CodemanSessionOrder` is shared.
15
+ *
16
+ * ## Polling only while the home screen is up
17
+ *
18
+ * The list is a poll, not an SSE stream: the server caches a local scan on a TTL,
19
+ * so N tabs polling costs one scan per TTL no matter how many are open. But an
20
+ * open tab that has LEFT the home screen must stop, otherwise every background
21
+ * tab keeps sweeping tmux sockets forever. `startForeignPolling` /
22
+ * `stopForeignPolling` are called from `showWelcome`/`hideWelcome`.
23
+ *
24
+ * A tab hidden while it shows the home screen skips its ticks as well. The TTL sits
25
+ * under the poll interval, so one tab never hits the cache: every tick is a full
26
+ * sweep, one fork or more per socket, for a list nobody is reading. It catches up
27
+ * on `visibilitychange`, and the cache is what bounds a flurry of tab switches.
28
+ *
29
+ * ⚠️ Docker and remote locations are NOT in that poll. Each costs a `docker exec`
30
+ * or a full ssh handshake per target, each with a ~12s timeout, so riding them
31
+ * along on an 8s tick stacks connections on a slow host, the one cost the
32
+ * server-side design explicitly refuses. The wide scan is therefore ONE-SHOT:
33
+ * turning the toggle on fetches once, and after that it is the user's Rescan
34
+ * button. The periodic poll stays local-only forever.
35
+ *
36
+ * The wide rows are kept separately (`_foreignWideSessions`) and merged into
37
+ * every local render, because a local-only poll's payload does not contain them
38
+ * and would otherwise wipe the container/remote rows off the list one tick after
39
+ * they appeared. Turning the toggle off discards them.
40
+ *
41
+ * ⚠️ Adoption creates a REAL session, so the button locks while in flight and the
42
+ * result goes through the app's normal idempotent create path
43
+ * (`_onSessionCreated` then `selectSession`), never by inserting a tab here.
44
+ *
45
+ * @mixin Extends CodemanApp.prototype via Object.assign
46
+ * @dependency app.js (this.sessions, _onSessionCreated, selectSession)
47
+ * @dependency api-client.js (_api)
48
+ * @dependency ralph-panel.js (formatRelativeTime)
49
+ * @dependency i18n.js (codemanT, optional)
50
+ * @loadorder 12.565 of 16, after home-sessions.js, before git-status-ui.js
51
+ */
52
+
53
+ /** Fallback poll cadence; the server sends its own in `pollIntervalMs`. */
54
+ const FOREIGN_POLL_FALLBACK_MS = 8000;
55
+
56
+ /** i18n.js's translator, or the English source where it has not loaded. */
57
+ function foreignT(source) {
58
+ return typeof codemanT === 'function' ? codemanT(source) : source;
59
+ }
60
+
61
+ /** A row's mode, in the registry's own word for it. Falls back to the id, never a list of ids here. */
62
+ function foreignModeLabel(mode) {
63
+ const catalog =
64
+ typeof window !== 'undefined' && Array.isArray(window.__codemanCliCatalog) ? window.__codemanCliCatalog : [];
65
+ return catalog.find((entry) => entry.id === mode)?.label || mode;
66
+ }
67
+
68
+ const FOREIGN_LOCATION_LABEL = {
69
+ local: 'this machine',
70
+ docker: 'container',
71
+ remote: 'remote',
72
+ };
73
+
74
+ Object.assign(CodemanApp.prototype, {
75
+ // ═══════════════════════════════════════════════════════════════
76
+ // Lifecycle
77
+ // ═══════════════════════════════════════════════════════════════
78
+
79
+ /**
80
+ * Begin polling. Idempotent: the home screen re-shows on several paths
81
+ * (boot, closing the last tab, a breakpoint change) and each would otherwise
82
+ * stack another interval on top of the last.
83
+ */
84
+ startForeignPolling() {
85
+ if (this._foreignPollTimer || this._foreignRefused) return;
86
+ this._foreignPollTick();
87
+ this._foreignPollTimer = setInterval(
88
+ () => this._foreignPollTick(),
89
+ this._foreignPollMs || FOREIGN_POLL_FALLBACK_MS
90
+ );
91
+ // Stored, unlike a poller made once: this runs on every trip to the home screen.
92
+ this._foreignOnVisible = () => this._foreignPollTick();
93
+ document.addEventListener('visibilitychange', this._foreignOnVisible);
94
+ },
95
+
96
+ /** Stop polling. MUST run on leaving the home screen, see @fileoverview. */
97
+ stopForeignPolling() {
98
+ if (!this._foreignPollTimer) return;
99
+ clearInterval(this._foreignPollTimer);
100
+ this._foreignPollTimer = null;
101
+ document.removeEventListener('visibilitychange', this._foreignOnVisible);
102
+ this._foreignOnVisible = null;
103
+ },
104
+
105
+ /** One poll, and the one gate every poll passes: a hidden tab skips it, see @fileoverview. */
106
+ _foreignPollTick() {
107
+ if (document.hidden) return;
108
+ // Local only: see @fileoverview. The wide scan never rides the timer.
109
+ void this.loadForeignSessions();
110
+ },
111
+
112
+ /** Whether the user asked for the expensive docker/remote scan too. */
113
+ foreignScanRemote() {
114
+ return this._foreignScanRemote === true;
115
+ },
116
+
117
+ /**
118
+ * Flip the wide scan. ON fetches containers and hosts ONCE; OFF discards what
119
+ * that fetch found, because those rows are not re-confirmed by any later poll
120
+ * and leaving them up would show a container session that may be long gone.
121
+ */
122
+ toggleForeignScanRemote() {
123
+ this._foreignScanRemote = !this._foreignScanRemote;
124
+ if (!this._foreignScanRemote) {
125
+ this._foreignWideSessions = [];
126
+ this._foreignWideNotes = [];
127
+ this.renderAllForeignSessions();
128
+ return;
129
+ }
130
+ void this.loadForeignSessions({ wide: true });
131
+ },
132
+
133
+ /** Re-run the expensive scan on demand. The only way wide rows ever refresh. */
134
+ rescanForeignWide() {
135
+ if (!this.foreignScanRemote()) return;
136
+ void this.loadForeignSessions({ wide: true });
137
+ },
138
+
139
+ // ═══════════════════════════════════════════════════════════════
140
+ // Data
141
+ // ═══════════════════════════════════════════════════════════════
142
+
143
+ /**
144
+ * Fetch the list. `wide` asks the server for containers and ssh hosts too, and
145
+ * ONLY an explicit user action ever passes it, never the interval.
146
+ */
147
+ async loadForeignSessions({ wide = false } = {}) {
148
+ if (this._foreignRefused) return;
149
+ const qs = wide ? '?docker=1&remote=1' : '';
150
+ if (wide) this._foreignWideLoading = true;
151
+ this.renderAllForeignSessions();
152
+ const res = await this._api(`/api/mux/foreign${qs}`);
153
+ if (wide) this._foreignWideLoading = false;
154
+ // ⚠️ A 403 is an answer, not a failed poll. The list is admin-only in multi-user
155
+ // mode (it shows other people's processes), so a non-admin has nothing to see here
156
+ // and nothing later on this page changes that: stop asking and show no block,
157
+ // instead of "Could not reach the server" under a heading they cannot use.
158
+ if (res?.status === 403) {
159
+ this._foreignRefused = true;
160
+ this.stopForeignPolling();
161
+ this.renderAllForeignSessions();
162
+ return;
163
+ }
164
+ const body = res?.ok ? await this._readJsonBody(res) : null;
165
+ const data = body?.success === true ? body.data : body?.success === false ? null : body;
166
+ if (!data) {
167
+ // A failed poll must not blank a list the user is looking at: keep the
168
+ // last good result and let the next tick recover.
169
+ this._foreignError = true;
170
+ this.renderAllForeignSessions();
171
+ return;
172
+ }
173
+ this._foreignError = false;
174
+ const rows = Array.isArray(data.sessions) ? data.sessions : [];
175
+ const notes = Array.isArray(data.notes) ? data.notes : [];
176
+ // Every note opens with its location (foreign-tmux-discovery.ts). A local one is
177
+ // re-read on each poll; a container or host one only by the next explicit scan,
178
+ // so it has to outlive the local polls in between.
179
+ const isLocalNote = (note) => String(note).startsWith('local:');
180
+ this._foreignNotes = notes.filter(isLocalNote);
181
+ if (wide) {
182
+ // Split by location: the local rows refresh on every tick, the rest only
183
+ // here, so they have to be stored apart or the next local-only payload
184
+ // (which cannot contain them) would read as "those sessions are gone".
185
+ this._foreignSessions = rows.filter((r) => (r.location || 'local') === 'local');
186
+ this._foreignWideSessions = rows.filter((r) => (r.location || 'local') !== 'local');
187
+ this._foreignWideNotes = notes.filter((note) => !isLocalNote(note));
188
+ this._foreignWideAt = Date.now();
189
+ } else {
190
+ this._foreignSessions = rows;
191
+ }
192
+ this._foreignCanScanWide = data.canScanWide === true;
193
+ if (Number.isFinite(data.pollIntervalMs)) this._foreignPollMs = data.pollIntervalMs;
194
+ this.renderAllForeignSessions();
195
+ },
196
+
197
+ /** Local rows plus whatever the last wide scan found. The render's one input. */
198
+ _allForeignRows() {
199
+ const local = Array.isArray(this._foreignSessions) ? this._foreignSessions : [];
200
+ if (!this.foreignScanRemote()) return local;
201
+ const wide = Array.isArray(this._foreignWideSessions) ? this._foreignWideSessions : [];
202
+ return wide.length ? local.concat(wide) : local;
203
+ },
204
+
205
+ /**
206
+ * Adopt one candidate.
207
+ *
208
+ * The in-flight lock is global, one adoption at a time from this page: it only
209
+ * stops a double-click. The server holds the real one-wrapper-per-target
210
+ * guarantee.
211
+ */
212
+ async adoptForeignSession(id, buttonEl) {
213
+ if (this._foreignAdoptInFlight) return;
214
+ this._foreignAdoptInFlight = true;
215
+ if (buttonEl) {
216
+ buttonEl.disabled = true;
217
+ buttonEl.textContent = foreignT('Opening…');
218
+ }
219
+ try {
220
+ const body = { id };
221
+ if (this.foreignScanRemote()) {
222
+ body.docker = true;
223
+ body.remote = true;
224
+ }
225
+ // ⚠️ `_api`, not `_apiJson`: the latter folds a network failure, a non-2xx
226
+ // and `success:false` all into null, and the server deliberately tells
227
+ // two different stories here: "that tmux session is no longer there" vs
228
+ // "could not reach it just now (…), try again". Collapsing them showed a
229
+ // live remote session as deleted every time the link flickered.
230
+ const res = await this._api('/api/sessions/adopt', { method: 'POST', body });
231
+ const payload = await this._readJsonBody(res);
232
+ const session = payload && payload.success !== false ? payload.data?.session : null;
233
+ if (!session) {
234
+ const fallback = foreignT('Could not open that session. Refreshing the list.');
235
+ this.showToast?.(payload?.error || fallback, 'error');
236
+ await this.loadForeignSessions();
237
+ return;
238
+ }
239
+ const data = { session };
240
+ // Go through the app's normal create path so tab order, lineage lines and
241
+ // SSE-vs-POST ordering behave exactly as they do for a Run.
242
+ this._onSessionCreated?.(data.session);
243
+ await this.selectSession(data.session.id);
244
+ // Still one explicit user action, so a wide row's "Open" refreshes the
245
+ // wide list too, otherwise the row just adopted keeps reading as free.
246
+ await this.loadForeignSessions({ wide: this.foreignScanRemote() });
247
+ } finally {
248
+ this._foreignAdoptInFlight = false;
249
+ if (buttonEl) {
250
+ buttonEl.disabled = false;
251
+ buttonEl.textContent = foreignT('Open');
252
+ }
253
+ }
254
+ },
255
+
256
+ /** The error envelope, or null when there was no readable body at all. */
257
+ async _readJsonBody(res) {
258
+ if (!res) return null;
259
+ try {
260
+ return await res.json();
261
+ } catch {
262
+ return null;
263
+ }
264
+ },
265
+
266
+ // ═══════════════════════════════════════════════════════════════
267
+ // Render
268
+ // ═══════════════════════════════════════════════════════════════
269
+
270
+ /** Paint every mounted container (welcome + phone overview). */
271
+ renderAllForeignSessions() {
272
+ for (const id of ['foreignSessions', 'mobileForeignSessions']) {
273
+ const el = document.getElementById(id);
274
+ if (el) this.renderForeignSessions(el);
275
+ }
276
+ },
277
+
278
+ /**
279
+ * Render into one container. The ONLY row builder, see @fileoverview.
280
+ *
281
+ * Everything is built with DOM APIs and `textContent`: a session name and a
282
+ * command line come from another user's process, so they are never allowed
283
+ * near `innerHTML`.
284
+ */
285
+ renderForeignSessions(container) {
286
+ const rows = this._allForeignRows();
287
+ container.innerHTML = '';
288
+ if (this._foreignRefused) {
289
+ container.hidden = true;
290
+ return;
291
+ }
292
+
293
+ if (!rows.length) {
294
+ // Hidden rather than shown-empty: on a first run there is usually nothing,
295
+ // and an empty block on the welcome screen reads as a broken feature.
296
+ // A note is a REASON the list is empty, so a block carrying one must stay
297
+ // visible: hiding it is exactly how "where did my session go" becomes
298
+ // unanswerable.
299
+ const hasNotes =
300
+ (Array.isArray(this._foreignNotes) && this._foreignNotes.length > 0) ||
301
+ (Array.isArray(this._foreignWideNotes) && this._foreignWideNotes.length > 0);
302
+ // ⚠️ `canScanWide` must keep the block visible even with nothing to show.
303
+ // The scan toggle lives in the header, so hiding an empty block also hides
304
+ // the only control that could fill it: on a host with containers but no
305
+ // local tmux sessions that made the whole feature unreachable.
306
+ container.hidden = !this.foreignScanRemote() && !this._foreignError && !hasNotes && !this._foreignCanScanWide;
307
+ if (!container.hidden) {
308
+ container.appendChild(this._foreignHeader(0));
309
+ const empty = document.createElement('div');
310
+ empty.className = 'foreign-empty';
311
+ this._appendForeignNotes(container);
312
+ empty.textContent = this._foreignError
313
+ ? foreignT('Could not reach the server.')
314
+ : foreignT('No sessions found outside Codeman.');
315
+ container.appendChild(empty);
316
+ }
317
+ return;
318
+ }
319
+
320
+ container.hidden = false;
321
+ container.appendChild(this._foreignHeader(rows.length));
322
+
323
+ this._appendForeignNotes(container);
324
+
325
+ const list = document.createElement('div');
326
+ list.className = 'foreign-list';
327
+ // Adoptable first: an already-open one is not an action, it is a reminder.
328
+ const sorted = rows.slice().sort((a, b) => {
329
+ const aOpen = a.adoptedBy ? 1 : 0;
330
+ const bOpen = b.adoptedBy ? 1 : 0;
331
+ if (aOpen !== bOpen) return aOpen - bOpen;
332
+ return (b.createdAt || 0) - (a.createdAt || 0);
333
+ });
334
+ for (const row of sorted) list.appendChild(this._foreignRow(row));
335
+ container.appendChild(list);
336
+ },
337
+
338
+ /**
339
+ * Why a scan produced less than the user expected.
340
+ *
341
+ * Without this, a session skipped for an unsafe name or a host that could not
342
+ * be reached is simply ABSENT, and "my tmux session does not show up" has no
343
+ * answer anywhere in the product. Notes are server-authored strings, rendered
344
+ * as text.
345
+ */
346
+ _appendForeignNotes(container) {
347
+ const local = Array.isArray(this._foreignNotes) ? this._foreignNotes : [];
348
+ const wide = this.foreignScanRemote() && Array.isArray(this._foreignWideNotes) ? this._foreignWideNotes : [];
349
+ const notes = wide.length ? local.concat(wide) : local;
350
+ if (!notes.length) return;
351
+ const box = document.createElement('div');
352
+ box.className = 'foreign-notes';
353
+ box.setAttribute('data-i18n-skip', '');
354
+ for (const n of notes) {
355
+ const line = document.createElement('div');
356
+ line.className = 'foreign-note';
357
+ line.textContent = n;
358
+ box.appendChild(line);
359
+ }
360
+ container.appendChild(box);
361
+ },
362
+
363
+ _foreignHeader(count) {
364
+ const header = document.createElement('div');
365
+ header.className = 'foreign-header';
366
+
367
+ const title = document.createElement('h3');
368
+ title.className = 'foreign-title';
369
+ title.textContent = foreignT('Opened outside Codeman');
370
+ header.appendChild(title);
371
+
372
+ if (count) {
373
+ const badge = document.createElement('span');
374
+ badge.className = 'foreign-count';
375
+ badge.setAttribute('data-i18n-skip', '');
376
+ badge.textContent = String(count);
377
+ header.appendChild(badge);
378
+ }
379
+
380
+ const scan = document.createElement('button');
381
+ scan.type = 'button';
382
+ scan.className = 'foreign-scan-toggle';
383
+ scan.dataset.foreignAction = 'toggle-scan';
384
+ scan.setAttribute('aria-pressed', String(this.foreignScanRemote()));
385
+ scan.title = foreignT('Also scan containers and remote hosts (slower)');
386
+ scan.textContent = this.foreignScanRemote() ? '⟳ all' : '⟳ local';
387
+ header.appendChild(scan);
388
+
389
+ // The wide scan is one-shot, so without this button its rows could only
390
+ // ever be refreshed by toggling off and on again.
391
+ if (this.foreignScanRemote()) {
392
+ const rescan = document.createElement('button');
393
+ rescan.type = 'button';
394
+ rescan.className = 'foreign-scan-toggle';
395
+ rescan.dataset.foreignAction = 'rescan-wide';
396
+ rescan.disabled = this._foreignWideLoading === true;
397
+ rescan.title = foreignT('Scan containers and remote hosts again');
398
+ rescan.textContent = this._foreignWideLoading ? foreignT('Scanning…') : foreignT('Rescan');
399
+ header.appendChild(rescan);
400
+ }
401
+
402
+ return header;
403
+ },
404
+
405
+ _foreignRow(row) {
406
+ const item = document.createElement('div');
407
+ item.className = 'foreign-row';
408
+ if (row.adoptedBy) item.classList.add('foreign-row--open');
409
+
410
+ const dot = document.createElement('span');
411
+ dot.className = `foreign-dot foreign-dot--${row.mode || 'shell'}`;
412
+ item.appendChild(dot);
413
+
414
+ const body = document.createElement('span');
415
+ body.className = 'foreign-row-body';
416
+
417
+ const line1 = document.createElement('span');
418
+ line1.className = 'foreign-row-name';
419
+ line1.setAttribute('data-i18n-skip', '');
420
+ line1.textContent = row.sessionName || '(unnamed)';
421
+ body.appendChild(line1);
422
+
423
+ const line2 = document.createElement('span');
424
+ line2.className = 'foreign-row-sub';
425
+ line2.setAttribute('data-i18n-skip', '');
426
+ const bits = [foreignModeLabel(row.mode)];
427
+ const where = row.hostLabel || foreignT(FOREIGN_LOCATION_LABEL[row.location] || row.location);
428
+ if (where) bits.push(where);
429
+ if (row.workingDir) bits.push(row.workingDir);
430
+ line2.textContent = bits.join(' · ');
431
+ line2.title = row.command || '';
432
+ body.appendChild(line2);
433
+
434
+ item.appendChild(body);
435
+
436
+ const action = document.createElement('button');
437
+ action.type = 'button';
438
+ action.className = 'foreign-open-btn';
439
+ if (row.adoptedBy) {
440
+ action.dataset.foreignAction = 'select';
441
+ action.dataset.foreignSession = row.adoptedBy;
442
+ action.textContent = foreignT('Go to tab');
443
+ } else {
444
+ action.dataset.foreignAction = 'adopt';
445
+ action.dataset.foreignId = row.id;
446
+ action.textContent = foreignT('Open');
447
+ }
448
+ item.appendChild(action);
449
+
450
+ return item;
451
+ },
452
+
453
+ /**
454
+ * One delegated listener per container, wired once. Delegation matters here
455
+ * because the list is fully rebuilt on every poll: per-button listeners would
456
+ * leak one set per tick.
457
+ */
458
+ wireForeignSessions() {
459
+ if (this._foreignWired) return;
460
+ this._foreignWired = true;
461
+ document.addEventListener('click', (event) => {
462
+ const el = event.target?.closest?.('[data-foreign-action]');
463
+ if (!el) return;
464
+ const action = el.dataset.foreignAction;
465
+ if (action === 'adopt') {
466
+ void this.adoptForeignSession(el.dataset.foreignId, el);
467
+ } else if (action === 'select') {
468
+ void this.selectSession(el.dataset.foreignSession);
469
+ } else if (action === 'toggle-scan') {
470
+ this.toggleForeignScanRemote();
471
+ } else if (action === 'rescan-wide') {
472
+ this.rescanForeignWide();
473
+ }
474
+ });
475
+ },
476
+ });
@@ -425,9 +425,11 @@ Object.assign(CodemanApp.prototype, {
425
425
  overview.state === 'not-a-repo'
426
426
  ? 'No git repository here: this session’s folder is not one, and none was found inside it (up to two levels down).'
427
427
  : overview.state === 'unsupported'
428
- ? overview.reason === 'docker'
429
- ? 'Git status is not available for Docker sessions, or for folders inside a Docker case workspace.'
430
- : 'Git status is not available for remote (SSH) sessions.'
428
+ ? overview.reason === 'adopted'
429
+ ? 'Git status is not available for a tmux session opened outside Codeman.'
430
+ : overview.reason === 'docker'
431
+ ? 'Git status is not available for Docker sessions, or for folders inside a Docker case workspace.'
432
+ : 'Git status is not available for remote (SSH) sessions.'
431
433
  : `Could not read the repository: ${overview.error || 'git failed'}`;
432
434
  body.append(el('div', 'git-status-empty', why));
433
435
  clearChrome();
Binary file
Binary file
Binary file
Binary file