@webship.co/workspace 0.0.0-stage → 1.0.0-alpha1

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 (311) hide show
  1. package/PLUGINS.md +249 -0
  2. package/README.md +379 -2
  3. package/WORKSPACES.md +69 -0
  4. package/actions/ai-items.js +293 -0
  5. package/actions/audio.js +48 -0
  6. package/actions/build-order.js +79 -0
  7. package/actions/checks.js +95 -0
  8. package/actions/claude-session.js +104 -0
  9. package/actions/commands-center.js +258 -0
  10. package/actions/composer.js +267 -0
  11. package/actions/ddev-addons.js +90 -0
  12. package/actions/ddev-mail.js +80 -0
  13. package/actions/ddev.js +237 -0
  14. package/actions/folders.js +165 -0
  15. package/actions/graphs.js +107 -0
  16. package/actions/images-svg.js +33 -0
  17. package/actions/images.js +145 -0
  18. package/actions/navigation.js +190 -0
  19. package/actions/node.js +222 -0
  20. package/actions/notes.js +55 -0
  21. package/actions/obsidian.js +59 -0
  22. package/actions/plugins.js +36 -0
  23. package/actions/project-make.js +28 -0
  24. package/actions/rag.js +202 -0
  25. package/actions/row-actions.js +262 -0
  26. package/actions/screens.js +270 -0
  27. package/actions/testing.js +121 -0
  28. package/actions/video-edit.js +54 -0
  29. package/ai-inventory.js +182 -0
  30. package/ai-items.js +202 -0
  31. package/assistant-cast.js +176 -0
  32. package/assistant-suggestions.js +91 -0
  33. package/assistant.js +484 -0
  34. package/audio-voice.js +528 -0
  35. package/auth-google.js +260 -0
  36. package/build-ai.js +52 -0
  37. package/build-order-page.js +191 -0
  38. package/build-order.js +313 -0
  39. package/builder-args.js +117 -0
  40. package/changelog.js +302 -0
  41. package/chat-threads.js +134 -0
  42. package/checks.js +105 -0
  43. package/claude-args.js +282 -0
  44. package/claude-sessions.js +129 -0
  45. package/command-author.js +117 -0
  46. package/command-block.js +20 -0
  47. package/commands/defaults.yml +159 -0
  48. package/commands-page.js +460 -0
  49. package/commands.js +568 -0
  50. package/cucumber.js +43 -0
  51. package/ddev-addons-page.js +124 -0
  52. package/ddev-addons.js +154 -0
  53. package/ddev-custom-commands.js +96 -0
  54. package/ddev-domains.js +84 -0
  55. package/ddev-import.js +93 -0
  56. package/ddev-mail-page.js +167 -0
  57. package/ddev-mail.js +110 -0
  58. package/ddev-options.js +145 -0
  59. package/ddev-snapshots.js +126 -0
  60. package/ddev.js +145 -0
  61. package/docs/DEVELOPER-GUIDE.md +224 -0
  62. package/docs/README.md +252 -0
  63. package/docs/REFERENCE.md +684 -0
  64. package/docs/USER-GUIDE.md +697 -0
  65. package/docs/screenshots/assistant/avatar-board.png +0 -0
  66. package/docs/screenshots/assistant/avatar-coffee.png +0 -0
  67. package/docs/screenshots/assistant/avatar-colours.png +0 -0
  68. package/docs/screenshots/assistant/avatar-icons.png +0 -0
  69. package/docs/screenshots/assistant/avatar-moves.png +0 -0
  70. package/docs/screenshots/assistant/avatar-pick-cookieman.png +0 -0
  71. package/docs/screenshots/assistant/avatar-settings-voices.png +0 -0
  72. package/docs/screenshots/assistant/avatar-status.png +0 -0
  73. package/docs/screenshots/create-card/create-card.png +0 -0
  74. package/docs/screenshots/create-card/start-ai.png +0 -0
  75. package/docs/screenshots/guide-clone-address.png +0 -0
  76. package/docs/screenshots/guide-clone-worktree-off.png +0 -0
  77. package/docs/screenshots/guide-clone-worktree-on.png +0 -0
  78. package/docs/screenshots/guide-ddev-menu.png +0 -0
  79. package/docs/screenshots/guide-domains.png +0 -0
  80. package/docs/screenshots/guide-home.png +0 -0
  81. package/docs/screenshots/guide-ide-menu.png +0 -0
  82. package/docs/screenshots/guide-remove-refusal.png +0 -0
  83. package/docs/screenshots/guide-rows-worktree.png +0 -0
  84. package/docs/screenshots/guide-screens.png +0 -0
  85. package/docs/screenshots/guide-settings.png +0 -0
  86. package/docs/screenshots/guide-workspace-page.png +0 -0
  87. package/docs/screenshots/home/card-settings.png +0 -0
  88. package/docs/screenshots/home/drag-reorder.png +0 -0
  89. package/docs/screenshots/home/edit-card.png +0 -0
  90. package/docs/screenshots/images/claude-code-image.png +0 -0
  91. package/docs/screenshots/images/edit-with-ai.png +0 -0
  92. package/docs/screenshots/images/gallery.png +0 -0
  93. package/docs/screenshots/images/generate-description.png +0 -0
  94. package/docs/screenshots/images/generate-plan.png +0 -0
  95. package/docs/screenshots/images/providers.png +0 -0
  96. package/docs/screenshots/images/svg-form.png +0 -0
  97. package/docs/screenshots/images/svg-result.png +0 -0
  98. package/docs/screenshots/images/upload.png +0 -0
  99. package/docs/screenshots/new-folder/2-form.png +0 -0
  100. package/docs/screenshots/new-folder/8-ddev-options.png +0 -0
  101. package/docs/screenshots/new-folder/files-folder.png +0 -0
  102. package/docs/screenshots/projects/ddev-custom-commands.png +0 -0
  103. package/docs/screenshots/projects/state-chip-stop.png +0 -0
  104. package/docs/screenshots/readme/dashboard-dark.png +0 -0
  105. package/docs/screenshots/readme/dashboard-light.png +0 -0
  106. package/docs/screenshots/readme/dev-dark.png +0 -0
  107. package/docs/screenshots/readme/dev-light.png +0 -0
  108. package/docs/screenshots/readme/docs-dark.png +0 -0
  109. package/docs/screenshots/readme/docs-light.png +0 -0
  110. package/docs/screenshots/readme/icons/ai.svg +4 -0
  111. package/docs/screenshots/readme/icons/code.svg +4 -0
  112. package/docs/screenshots/readme/icons/docs.svg +4 -0
  113. package/docs/screenshots/readme/icons/services.svg +4 -0
  114. package/docs/screenshots/readme/icons/sites.svg +4 -0
  115. package/docs/screenshots/readme/test-report-dark.png +0 -0
  116. package/docs/screenshots/readme/test-report-light.png +0 -0
  117. package/docs/screenshots/readme/test-scenarios.png +0 -0
  118. package/docs/screenshots/readme/test-steps.png +0 -0
  119. package/docs/screenshots/readme/videos-dark.png +0 -0
  120. package/docs/screenshots/readme/videos-light.png +0 -0
  121. package/docs/screenshots/screens-menu.png +0 -0
  122. package/docs/screenshots/screens-new.png +0 -0
  123. package/docs/screenshots/settings/auth-google-lists.png +0 -0
  124. package/docs/screenshots/settings/delete-1-row.png +0 -0
  125. package/docs/screenshots/settings/delete-2-confirm.png +0 -0
  126. package/docs/screenshots/settings/delete-3-type-name.png +0 -0
  127. package/docs/screenshots/settings/delete-4-type-yes.png +0 -0
  128. package/event-log.js +59 -0
  129. package/files-browser.js +247 -0
  130. package/folders.js +121 -0
  131. package/format.js +73 -0
  132. package/git-info.js +81 -0
  133. package/github-auth.js +62 -0
  134. package/graphs.js +104 -0
  135. package/html.js +10 -0
  136. package/icons.js +83 -0
  137. package/images-ai.js +447 -0
  138. package/images-create.js +226 -0
  139. package/images-page.js +417 -0
  140. package/images-records.js +39 -0
  141. package/images-render.js +36 -0
  142. package/images-svg-core.js +214 -0
  143. package/images-svg-sanitize.js +188 -0
  144. package/images-svg.js +241 -0
  145. package/images.js +270 -0
  146. package/jobs.js +430 -0
  147. package/linger.js +21 -0
  148. package/lists.js +292 -0
  149. package/media-info.js +136 -0
  150. package/navigation-page.js +264 -0
  151. package/navigation.js +394 -0
  152. package/notes.js +0 -0
  153. package/obsidian.js +405 -0
  154. package/package.json +47 -4
  155. package/palette-index.js +46 -0
  156. package/playwright.config.js +23 -0
  157. package/plugin-install.js +239 -0
  158. package/plugin-loader.js +448 -0
  159. package/plugin-manifest.js +83 -0
  160. package/plugin-settings.js +44 -0
  161. package/plugins/workspace-notes/index.js +59 -0
  162. package/plugins-page.js +103 -0
  163. package/project-make.js +241 -0
  164. package/project-name.js +210 -0
  165. package/public/assistant-avatar-moves.js +646 -0
  166. package/public/assistant-avatar-ui.js +1223 -0
  167. package/public/assistant-avatar.js +530 -0
  168. package/public/avatar/druplicon.vrm +0 -0
  169. package/public/css/base.css +260 -0
  170. package/public/css/components/ai-assistant.css +263 -0
  171. package/public/css/components/assistant-avatar.css +279 -0
  172. package/public/css/components/assistant-views.css +198 -0
  173. package/public/css/components/build-order.css +74 -0
  174. package/public/css/components/clone-as-a-git-worktree.css +30 -0
  175. package/public/css/components/command-palette.css +61 -0
  176. package/public/css/components/commands-center.css +139 -0
  177. package/public/css/components/confirm-dialog.css +40 -0
  178. package/public/css/components/create-card.css +8 -0
  179. package/public/css/components/ddev-menu.css +23 -0
  180. package/public/css/components/deep-chat.css +17 -0
  181. package/public/css/components/density.css +11 -0
  182. package/public/css/components/floating-assistant-workspace-pages.css +55 -0
  183. package/public/css/components/folders.css +5 -0
  184. package/public/css/components/full-height-left-assistant-sidebar-all-pages.css +39 -0
  185. package/public/css/components/full-height-right-actions-rail-pages-that-have-actions.css +267 -0
  186. package/public/css/components/grid-and-list.css +203 -0
  187. package/public/css/components/help-page.css +36 -0
  188. package/public/css/components/hero.css +16 -0
  189. package/public/css/components/images.css +70 -0
  190. package/public/css/components/layout-rhythm-and-spacing-polish.css +139 -0
  191. package/public/css/components/mic-on-any-field-that-wants-typing.css +416 -0
  192. package/public/css/components/modal-fit.css +113 -0
  193. package/public/css/components/page-width.css +23 -0
  194. package/public/css/components/plugins-page.css +9 -0
  195. package/public/css/components/project-cards.css +256 -0
  196. package/public/css/components/project-rows.css +390 -0
  197. package/public/css/components/reordering-the-workspace-cards.css +85 -0
  198. package/public/css/components/responsive-home-and-the-workspace-pages.css +457 -0
  199. package/public/css/components/running-commands-as-toasts.css +327 -0
  200. package/public/css/components/settings-editor-core-config-yml.css +828 -0
  201. package/public/css/components/short-results-as-a-toast-at-the-top.css +203 -0
  202. package/public/css/components/sound-player.css +84 -0
  203. package/public/css/components/suggest-agents-skills-commands.css +112 -0
  204. package/public/css/components/tab-modal.css +142 -0
  205. package/public/css/components/terminals.css +244 -0
  206. package/public/css/components/test-report-media-one-file-per-row-paged.css +144 -0
  207. package/public/css/components/top-toolbar-logo-breadcrumb.css +107 -0
  208. package/public/css/components/video-edit.css +6 -0
  209. package/public/css/components/web-ide-open-with.css +23 -0
  210. package/public/css/components/working-indicators.css +158 -0
  211. package/public/css/components/workspace-cards.css +79 -0
  212. package/public/favicon.ico +0 -0
  213. package/public/favicon.png +0 -0
  214. package/public/htmx.min.js +1 -0
  215. package/public/logo.png +0 -0
  216. package/public/media-player.js +215 -0
  217. package/public/mic.js +255 -0
  218. package/public/narration.js +110 -0
  219. package/public/palette.js +278 -0
  220. package/public/sortable.js +251 -0
  221. package/public/suggest.js +337 -0
  222. package/public/terminal.js +903 -0
  223. package/public/themes/default/theme.css +267 -0
  224. package/public/themes/ember/theme.css +180 -0
  225. package/public/themes/press/theme.css +290 -0
  226. package/public/themes/slate/theme.css +182 -0
  227. package/public/touch-icon.png +0 -0
  228. package/public/ui.js +2721 -0
  229. package/public/vendor/ace/LICENSE.txt +24 -0
  230. package/public/vendor/ace/ace.js +23 -0
  231. package/public/vendor/ace/ext-language_tools.js +8 -0
  232. package/public/vendor/ace/ext-searchbox.js +8 -0
  233. package/public/vendor/ace/mode-css.js +8 -0
  234. package/public/vendor/ace/mode-html.js +8 -0
  235. package/public/vendor/ace/mode-javascript.js +8 -0
  236. package/public/vendor/ace/mode-markdown.js +8 -0
  237. package/public/vendor/ace/mode-sh.js +8 -0
  238. package/public/vendor/ace/mode-yaml.js +8 -0
  239. package/public/vendor/ace/theme-textmate.js +8 -0
  240. package/public/vendor/ace/theme-tomorrow_night.js +8 -0
  241. package/public/vendor/ace/worker-yaml.js +1 -0
  242. package/public/vendor/deep-chat.LICENSE +21 -0
  243. package/public/vendor/deep-chat.bundle.js +1 -0
  244. package/public/vendor/fonts/dm-serif-text-LICENSE.txt +93 -0
  245. package/public/vendor/fonts/dm-serif-text-latin-400-italic.woff2 +0 -0
  246. package/public/vendor/fonts/dm-serif-text-latin-400-normal.woff2 +0 -0
  247. package/public/vendor/fonts/figtree-LICENSE.txt +93 -0
  248. package/public/vendor/fonts/figtree-latin-400-italic.woff2 +0 -0
  249. package/public/vendor/fonts/figtree-latin-400-normal.woff2 +0 -0
  250. package/public/vendor/fonts/figtree-latin-600-italic.woff2 +0 -0
  251. package/public/vendor/fonts/figtree-latin-600-normal.woff2 +0 -0
  252. package/public/vendor/fonts/jetbrains-mono-LICENSE.txt +93 -0
  253. package/public/vendor/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  254. package/public/vendor/fonts/ubuntu-mono-LICENSE.txt +98 -0
  255. package/public/vendor/fonts/ubuntu-mono-latin-wght-normal.woff2 +0 -0
  256. package/public/vendor/tabler-LICENSE.txt +21 -0
  257. package/public/vendor/tabler-sprite.svg +1 -0
  258. package/public/vendor/three/LICENSE +21 -0
  259. package/public/vendor/three/addons/loaders/GLTFLoader.js +4860 -0
  260. package/public/vendor/three/addons/utils/BufferGeometryUtils.js +1501 -0
  261. package/public/vendor/three/addons/utils/SkeletonUtils.js +496 -0
  262. package/public/vendor/three/three.core.min.js +6 -0
  263. package/public/vendor/three/three.module.min.js +6 -0
  264. package/public/vendor/three-vrm/LICENSE +22 -0
  265. package/public/vendor/three-vrm/three-vrm.module.min.js +983 -0
  266. package/public/vendor/uikit-icons.min.js +1 -0
  267. package/public/vendor/uikit.min.css +1 -0
  268. package/public/vendor/uikit.min.js +1 -0
  269. package/public/vendor/webship-icons/ui/bookmark-outlined.svg +1 -0
  270. package/public/vendor/webship-icons/ui/headset.svg +1 -0
  271. package/public/vendor/webship-icons/ui/menu.svg +1 -0
  272. package/public/vendor/webship-icons/ui/pause.svg +1 -0
  273. package/public/vendor/webship-icons/ui/play.svg +1 -0
  274. package/public/vendor/webship-icons/ui/share.svg +1 -0
  275. package/public/vendor/webship-icons/ui/sound-off.svg +1 -0
  276. package/public/vendor/webship-icons/ui/sound-on.svg +1 -0
  277. package/public/vendor/webship-icons/ui/stop.svg +1 -0
  278. package/public/vendor/xterm/addon-fit.js +2 -0
  279. package/public/vendor/xterm/addon-unicode11.js +2 -0
  280. package/public/vendor/xterm/addon-web-links.js +2 -0
  281. package/public/vendor/xterm/xterm.css +218 -0
  282. package/public/vendor/xterm/xterm.js +2 -0
  283. package/rag.js +169 -0
  284. package/reader.js +132 -0
  285. package/repo-blobs.js +66 -0
  286. package/report-pdf.js +337 -0
  287. package/row-layout.js +1440 -0
  288. package/screenrc +37 -0
  289. package/screens.js +1063 -0
  290. package/scripts/code-server-start.sh +190 -0
  291. package/scripts/images-claude-finish.js +107 -0
  292. package/scripts/screen-host.sh +114 -0
  293. package/scripts/screen-runner.js +362 -0
  294. package/scripts/screen-then.js +47 -0
  295. package/scripts/update.sh +350 -0
  296. package/server.js +8140 -0
  297. package/settings-file.js +423 -0
  298. package/speech.js +297 -0
  299. package/tab-modal.js +75 -0
  300. package/test-reports.js +260 -0
  301. package/themes.js +381 -0
  302. package/transcripts.js +168 -0
  303. package/urls.js +13 -0
  304. package/validate.js +18 -0
  305. package/version-check.js +94 -0
  306. package/video-edit.js +588 -0
  307. package/waiting.js +85 -0
  308. package/workspace-card-tools.js +41 -0
  309. package/workspace-delete.js +139 -0
  310. package/workspace-types.js +85 -0
  311. package/workspaces.js +557 -0
package/screens.js ADDED
@@ -0,0 +1,1063 @@
1
+ /**
2
+ * The screen-sessions domain: what is running detached on this machine, and
3
+ * where a session's log lives when the dashboard knows the convention. The
4
+ * watch/stop actions live in actions/screens.js; the Screens modal, the
5
+ * /settings/screens page and the rail badge all read the same list.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const crypto = require('crypto');
11
+
12
+ const { run } = require('./jobs');
13
+ const { ROOT, loadWorkspaces } = require('./workspaces');
14
+ const { esc } = require('./html');
15
+ const { tildePath } = require('./format');
16
+ const { waitingState } = require('./waiting');
17
+ const claudeSessions = require('./claude-sessions');
18
+ const claudeArgs = require('./claude-args');
19
+
20
+ /** Live screen sessions: the host's when its runner is up, this container's
21
+ * otherwise. Each row says where it lives, because `screen -r` in a terminal
22
+ * only reaches the host ones. */
23
+ /** Every session runs on the machine. Two ways to see them, and both are used:
24
+ * screen(1) itself, through the socket directory mounted in here — which is
25
+ * also how the browser terminal attaches — and the runner's published list,
26
+ * which still answers when that mount is missing. */
27
+ async function listScreens() {
28
+ const { hostSessionsFile } = require('./jobs');
29
+ const rows = (await listContainerScreens()).map((s) => ({ ...s, where: 'host' }));
30
+ const seen = new Set(rows.map((s) => s.session));
31
+ const published = hostSessionsFile();
32
+ const pubRows = published && Array.isArray(published.sessions) ? published.sessions : [];
33
+ // The waiting flag is computed by the HOST runner (it reads each session's visible
34
+ // screen), so it only ever arrives on the published rows. It has to be MERGED onto the
35
+ // rows this side already knows about rather than used only for the ones it does not:
36
+ // `screen -ls` here lists the same sessions, those rows won, and the flag was dropped
37
+ // every time — the badge could not appear for any session that was already listed,
38
+ // which is all of them.
39
+ const pubBy = new Map(pubRows.map((s) => [s.session, s]));
40
+ for (const r of rows) {
41
+ const p = pubBy.get(r.session);
42
+ if (p && p.waiting !== undefined) { r.waiting = p.waiting; r.waitingWhy = p.waitingWhy; }
43
+ }
44
+ const extra = pubRows.filter((s) => !seen.has(s.session)).map((s) => ({ ...s, where: 'host' }));
45
+ return rows.concat(extra);
46
+ }
47
+
48
+ /** The sessions inside this container. */
49
+ async function listContainerScreens() {
50
+ const out = String((await run('screen', ['-ls'], process.env.HOME, { timeoutMs: 10 * 1000 })).stdout || '');
51
+ const rows = [];
52
+ for (const line of out.split('\n')) {
53
+ // `screen -ls` prints: \t<pid>.<name>\t(<date>)\t(<state>)
54
+ const m = line.match(/^\s+(\d+)\.(\S+)\s+\(([^)]*)\)(?:\s+\(([^)]*)\))?/);
55
+ if (m) rows.push({ pid: m[1], session: m[2], started: m[3], state: m[4] || '' });
56
+ }
57
+ return rows;
58
+ }
59
+
60
+ // Where a session's log lives, when the dashboard knows the convention:
61
+ // a plugin's sessions log where the plugin says; generic runs log
62
+ // under ~/.workspace-screens/<session>.log. Unknown sessions return null —
63
+ // they can still be stopped or reattached, just not tailed.
64
+ function screenLogFor(session) {
65
+ // This workspace's own log store first; the old shared home location stays
66
+ // readable so sessions started before the move keep their Watch.
67
+ for (const dir of [path.join(ROOT, '.screens'), path.join(process.env.HOME, '.workspace-screens')]) {
68
+ const f = path.join(dir, `${session}.log`);
69
+ if (fs.existsSync(f)) return f;
70
+ }
71
+ // A plugin's convention (a jira-<key> session logs inside the project it works): asked of every
72
+ // project folder, so a session started by hand is found the same as one the dashboard started.
73
+ {
74
+ const dirs = [];
75
+ for (const ws of Object.values(loadWorkspaces())) {
76
+ if (ws.kind === 'files') continue;
77
+ try { for (const p of fs.readdirSync(ws.dir)) dirs.push(path.join(ws.dir, p)); } catch (_) { /* skip */ }
78
+ }
79
+ const f = require('./plugin-loader').pluginSessionLog(session, dirs);
80
+ if (f) return f;
81
+ }
82
+ return null;
83
+ }
84
+
85
+ /** The session list, shared by the Screens modal and /settings/screens. */
86
+ // A session that has stopped to ask something. There is no API for "the program
87
+ // is waiting" — the truth is in what it last printed, so this reads the tail and
88
+ // looks for the shapes a question takes. It is a hint, never a claim: false
89
+ // positives cost a badge, false negatives cost nothing that was not already so.
90
+ const WAITING_PATTERNS = [
91
+ /\?\s*$/, // a question, still on the last line
92
+ /\((?:y\/n|yes\/no|Y\/n|y\/N)\)\s*[:?]?\s*$/i,
93
+ /\b(?:do you want|would you like|proceed\?|continue\?|are you sure)\b/i,
94
+ /\b(?:press|type)\b[^\n]{0,40}\b(?:enter|any key|to continue)\b/i,
95
+ /\b(?:waiting for|awaiting)\b[^\n]{0,30}\b(?:input|confirmation|approval|response)\b/i,
96
+ /^\s*[❯>]\s*\d+\.\s/m, // a numbered choice list, Claude Code style
97
+ /\b(?:allow|approve|permission)\b[^\n]{0,40}\?/i,
98
+ /passphrase|password\s*[:?]\s*$/i,
99
+ ];
100
+
101
+ function sessionNeedsInput(session) {
102
+ const log = screenLogFor(session);
103
+ if (!log) return false;
104
+ let tail = '';
105
+ try {
106
+ const fd = fs.openSync(log, 'r');
107
+ const size = fs.fstatSync(fd).size;
108
+ const start = Math.max(0, size - 3000);
109
+ const buf = Buffer.alloc(size - start);
110
+ fs.readSync(fd, buf, 0, buf.length, start);
111
+ fs.closeSync(fd);
112
+ tail = buf.toString('utf8');
113
+ } catch (_) { return false; }
114
+ // Only what it printed LAST matters: a question answered ten minutes ago is
115
+ // not a question now.
116
+ const lines = tail.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '').split('\n').filter((l) => l.trim());
117
+ const recent = lines.slice(-6).join('\n');
118
+ return WAITING_PATTERNS.some((re) => re.test(recent));
119
+ }
120
+
121
+ /**
122
+ * Which sessions have an AGENT running in them — Claude Code, rather than a shell or a command.
123
+ *
124
+ * Asked of the process table, not of the session's name and not of a note written when it started:
125
+ * a session can be started from the dashboard, from `cmd-tools-screen.sh`, or by hand in a terminal,
126
+ * and only the first of those could ever have left a marker behind. What is actually running inside it
127
+ * is the same question however it was started.
128
+ *
129
+ * One `ps` for the whole page — a workspace page renders twenty rows and each asks — memoised for a
130
+ * few seconds, which is far shorter than the fifteen the rows poll on. The SNAPSHOT is what is kept,
131
+ * never the answer: see processTable.
132
+ *
133
+ * It walks DOWN from each session's own screen process: `screen` forks the command, and a Claude Code
134
+ * session is `exec claude …`, so the agent is a descendant rather than the screen process itself.
135
+ */
136
+ let psMemo = { at: 0, kids: new Map(), args: new Map() };
137
+
138
+ /**
139
+ * The process table, as a parent → children map and a pid → command line map.
140
+ *
141
+ * The SNAPSHOT is what is memoised, never the answer computed from it. Caching the answer was wrong in
142
+ * a way that looked right on the page that found it: the first project to ask filled the cache, and
143
+ * every other row within the next few seconds got that project's set back — so on a workspace of
144
+ * eleven projects, ten of them claimed an agent was running that belonged to the first one.
145
+ */
146
+ function processTable() {
147
+ const now = Date.now();
148
+ if (now - psMemo.at < 5000) return psMemo;
149
+ const kids = new Map();
150
+ const args = new Map();
151
+ try {
152
+ const out = require('child_process').execFileSync('ps', ['-eo', 'pid=,ppid=,args='],
153
+ { encoding: 'utf8', timeout: 5000, maxBuffer: 8 * 1024 * 1024 });
154
+ for (const line of out.split('\n')) {
155
+ const m = line.match(/^\s*(\d+)\s+(\d+)\s+(.*)$/);
156
+ if (!m) continue;
157
+ const [, pid, ppid, cmd] = m;
158
+ if (!kids.has(ppid)) kids.set(ppid, []);
159
+ kids.get(ppid).push(pid);
160
+ args.set(pid, cmd);
161
+ }
162
+ } catch (_) { /* no ps, no claim */ }
163
+ psMemo = { at: now, kids, args };
164
+ return psMemo;
165
+ }
166
+
167
+ // `claude` the CLI, never a path that merely mentions it: a shell sitting in a folder called
168
+ // claude-something is not an agent, and neither is `tail -F .../claude.log`.
169
+ const isClaudeCmd = (cmd) => /(^|\/)claude(\s|$)/.test(String(cmd || '').trim());
170
+
171
+ function agentSessions(sessions) {
172
+ const { kids, args } = processTable();
173
+ const found = new Set();
174
+ for (const s of sessions || []) {
175
+ const seen = new Set();
176
+ // Never walk from 0: every top-level process has ppid 0, so a row with no pid — a session the
177
+ // runner published that `screen -ls` has not listed yet — would walk the whole machine and claim
178
+ // every agent on it as its own.
179
+ const root = String(s.pid || '');
180
+ if (!root || root === '0') continue;
181
+ const stack = [root];
182
+ while (stack.length) {
183
+ const pid = stack.pop();
184
+ if (!pid || seen.has(pid)) continue;
185
+ seen.add(pid);
186
+ if (isClaudeCmd(args.get(pid))) { found.add(s.session); break; }
187
+ (kids.get(pid) || []).forEach((k) => stack.push(k));
188
+ }
189
+ }
190
+ return found;
191
+ }
192
+
193
+ /**
194
+ * Is this session waiting for a person, and what for?
195
+ *
196
+ * The runner's answer FIRST, and the log's only after it. The runner publishes what it read from the
197
+ * session's own VISIBLE SCREEN, which is the only thing that works for an agent: a Claude Code session
198
+ * runs `exec claude` with no `tee` — piping a full-screen TUI gives garbage — so its `.log` is zero
199
+ * bytes and the log check below can never fire for exactly the sessions where "it stopped to ask you
200
+ * something" matters most.
201
+ *
202
+ * One function because three places ask: the Screens page, the project row on a workspace page, and the
203
+ * count in the top bar. Three copies of this rule would be three answers to one question.
204
+ */
205
+ function sessionWaitingWhy(s) {
206
+ if (!s) return '';
207
+ if (s.waiting) return s.waitingWhy || 'waiting for you';
208
+ return sessionNeedsInput(s.session) ? 'waiting for you' : '';
209
+ }
210
+
211
+ /** The last stretch of a session's log, as a terminal pane body. */
212
+ function screenTailHtml(session) {
213
+ const log = screenLogFor(session);
214
+ if (!log) return '<span class="uk-text-meta">No known log for this session — reattach in a terminal.</span>';
215
+ let text = '';
216
+ try {
217
+ const fd = fs.openSync(log, 'r');
218
+ const size = fs.fstatSync(fd).size;
219
+ const want = 4000;
220
+ const start = Math.max(0, size - want);
221
+ const buf = Buffer.alloc(size - start);
222
+ fs.readSync(fd, buf, 0, buf.length, start);
223
+ fs.closeSync(fd);
224
+ text = buf.toString('utf8');
225
+ if (start > 0) text = text.slice(text.indexOf('\n') + 1);
226
+ } catch (_) { return '<span class="uk-text-meta">The log is not readable yet.</span>'; }
227
+ return esc(text || '(no output yet)');
228
+ }
229
+
230
+ // Where a new session can run: the workspace root, any workspace folder, and
231
+ // any project inside one. The value is a token the action resolves back to a
232
+ // directory — never a path from the browser.
233
+ function screenTargets() {
234
+ const targets = [{ value: 'root', label: 'Workspace root', dir: ROOT }];
235
+ for (const [key, meta] of Object.entries(loadWorkspaces())) {
236
+ targets.push({ value: `ws:${key}`, label: `${key}/`, dir: meta.dir, group: 'Workspace folders' });
237
+ if (meta.kind === 'files') continue;
238
+ let entries = [];
239
+ try { entries = fs.readdirSync(meta.dir, { withFileTypes: true }); } catch (_) { continue; }
240
+ for (const e of entries) {
241
+ if (!e.isDirectory() || e.name.startsWith('.')) continue;
242
+ targets.push({ value: `p:${key}:${e.name}`, label: `${key}/${e.name}`, dir: path.join(meta.dir, e.name), group: 'Projects' });
243
+ }
244
+ }
245
+ return targets;
246
+ }
247
+
248
+ /** The directory a target token means, or null when it names nothing real. */
249
+ function screenTargetDir(value) {
250
+ const t = screenTargets().find((x) => x.value === String(value || ''));
251
+ return t && fs.existsSync(t.dir) ? t.dir : null;
252
+ }
253
+
254
+ /**
255
+ * The sessions a target directory can resume, as a select. Empty when there are none — a first session
256
+ * in a project has nothing to resume, and an empty control there would be a question with no answers.
257
+ */
258
+ function claudeResumeHtml(target) {
259
+ const dir = screenTargetDir(target);
260
+ // Only what --resume can actually open. An id with no transcript answers "No conversation found with
261
+ // session ID" and the screen dies in the same second — offering one is offering a dead end.
262
+ const sessions = dir ? claudeSessions.sessionsFor(dir, { limit: 25, resumableOnly: true }) : [];
263
+ const openOnes = dir ? claudeSessions.sessionsFor(dir).filter((x) => !x.resumable).length : 0;
264
+ if (!sessions.length) {
265
+ return `<div class="uk-text-meta">No Claude session to resume in ${dir ? `<code>${esc(tildePath(dir))}</code>` : 'that place'} yet — this starts a new one.${
266
+ openOnes ? ` (${openOnes} session${openOnes === 1 ? ' has' : 's have'} no transcript written yet, so <code>--resume</code> cannot open ${openOnes === 1 ? 'it' : 'them'}.)` : ''}</div>`;
267
+ }
268
+ const when = (ms) => {
269
+ const mins = Math.round((Date.now() - ms) / 60000);
270
+ if (mins < 60) return `${mins}m ago`;
271
+ if (mins < 60 * 24) return `${Math.round(mins / 60)}h ago`;
272
+ return `${Math.round(mins / 1440)}d ago`;
273
+ };
274
+ // A checkbox, because resuming is the exception and starting fresh is the default — and because the
275
+ // answer to "which one" is almost always the newest, which is what the list opens on.
276
+ return `<div class="claude-resume">
277
+ <label class="uk-text-meta claude-resume-on">
278
+ <input class="uk-checkbox" type="checkbox" name="resume_on" value="1"
279
+ onchange="var s=this.closest('.claude-resume').querySelector('select[name=resume]');s.disabled=!this.checked;">
280
+ Resume a Claude session in <code>${esc(tildePath(dir))}</code>
281
+ <span class="uk-text-meta">· ${sessions.length} to pick from${openOnes ? `, ${openOnes} with no transcript yet` : ''}</span>
282
+ </label>
283
+ <select class="uk-select uk-form-small" name="resume" disabled aria-label="Claude conversation to resume"
284
+ title="claude --resume &lt;id&gt; — picks that exact conversation back up, in this directory">
285
+ ${sessions.map((x, i) => `<option value="${esc(x.id)}"${i === 0 ? ' selected' : ''}>${esc(when(x.at))} · ${esc(x.title || (x.transcript ? 'no first message' : 'no transcript written yet'))} · ${esc(x.id.slice(0, 8))}</option>`).join('')}
286
+ </select>
287
+ </div>`;
288
+ }
289
+
290
+ // The reusable prompts in the prompts workspace, offered as starting text.
291
+ // ITEM_RESERVED_FILES-style folder docs (AGENTS.md, CLAUDE.md, README.md) are
292
+ // not prompts.
293
+ function promptTemplates() {
294
+ const dir = path.join(ROOT, 'ai-prompts');
295
+ try {
296
+ return fs.readdirSync(dir)
297
+ .filter((f) => f.endsWith('.md') && !/^(AGENTS|CLAUDE|README)\.md$/.test(f))
298
+ .sort();
299
+ } catch (_) { return []; }
300
+ }
301
+
302
+ /** The sessions running in a directory: the .cwd note each start leaves, plus
303
+ * whatever convention a plugin's sessions follow (a log inside the project). */
304
+ async function screensForDir(dir) {
305
+ const live = await listScreens();
306
+ const store = path.join(ROOT, '.screens');
307
+ return live.filter((s) => {
308
+ try {
309
+ if (fs.readFileSync(path.join(store, `${s.session}.cwd`), 'utf8').trim() === dir) return true;
310
+ } catch (_) { /* no note */ }
311
+ return !!require('./plugin-loader').pluginSessionLog(s.session, [dir]);
312
+ });
313
+ }
314
+
315
+ /**
316
+ * What is running in each project: how many screen sessions, how many of those have an agent inside,
317
+ * and how many are waiting on a person.
318
+ *
319
+ * One answer for a whole table of rows, asked once — the project list filters and sorts on it, and a
320
+ * per-row `screensForDir` would be one `screen -ls` per project. A session belongs to a project by the
321
+ * same two rules `screensForDir` uses (the .cwd note a start leaves, then the jira log inside the
322
+ * project), and the agent test is `agentSessions`, so a project that passes the filter is one whose
323
+ * row badges say so. A dead session (screen -ls still lists it) is not running anything.
324
+ *
325
+ * `activityFromSessions` is the pure half, so the counting can be tested without screen or ps.
326
+ */
327
+ function activityFromSessions(sessions, { agents, dirOf }) {
328
+ const out = new Map();
329
+ for (const s of sessions || []) {
330
+ if (/dead/i.test(s.state || '')) continue;
331
+ const dir = dirOf(s.session);
332
+ if (!dir) continue;
333
+ const a = out.get(dir) || { screens: 0, agents: 0, waiting: 0 };
334
+ a.screens += 1;
335
+ if (agents.has(s.session)) a.agents += 1;
336
+ if (sessionWaitingWhy(s)) a.waiting += 1;
337
+ out.set(dir, a);
338
+ }
339
+ return out;
340
+ }
341
+
342
+ async function activityByDir(dirs) {
343
+ const sessions = await listScreens();
344
+ if (!sessions.length) return new Map();
345
+ const store = path.join(ROOT, '.screens');
346
+ const known = new Set(dirs);
347
+ const dirOf = (session) => {
348
+ try {
349
+ const d = fs.readFileSync(path.join(store, `${session}.cwd`), 'utf8').trim();
350
+ if (known.has(d)) return d;
351
+ } catch (_) { /* no note */ }
352
+ const f = require('./plugin-loader').pluginSessionLog(session, dirs);
353
+ return f ? (dirs.find((d) => f.startsWith(d + path.sep)) || '') : '';
354
+ };
355
+ return activityFromSessions(sessions, { agents: agentSessions(sessions), dirOf });
356
+ }
357
+
358
+ /** The claude flags a template's own command carries (kept, not invented). */
359
+ function promptTemplateFlags(file) {
360
+ if (!promptTemplates().includes(file)) return '';
361
+ let raw;
362
+ try { raw = fs.readFileSync(path.join(ROOT, 'ai-prompts', file), 'utf8'); } catch (_) { return ''; }
363
+ const m = raw.match(/^\s*claude\s+([^\n]*?)\s*"""\s*$/m);
364
+ return m ? m[1].trim() : '';
365
+ }
366
+
367
+ // One template, as the text Claude Code should actually open with. Every
368
+ // prompt in that folder is written as the whole launch command —
369
+ // `claude --permission-mode bypassPermissions """<prompt>"""` inside a fence —
370
+ // so the wrapper is peeled off here: the form runs claude itself, and the
371
+ // user should see the prompt, not the command that would start it.
372
+ function promptTemplateText(file) {
373
+ if (!promptTemplates().includes(file)) return null;
374
+ let raw;
375
+ try { raw = fs.readFileSync(path.join(ROOT, 'ai-prompts', file), 'utf8'); } catch (_) { return null; }
376
+ const m = raw.match(/claude[^\n]*"""\n([\s\S]*?)\n"""/);
377
+ if (m) return m[1];
378
+ // No wrapper: drop YAML frontmatter and the description line, keep the body.
379
+ const noFm = raw.replace(/^---\n[\s\S]*?\n---\n/, '');
380
+ return noFm.trim();
381
+ }
382
+
383
+ function newScreenButtonHtml() {
384
+ return `<button class="uk-button uk-button-primary uk-button-small"
385
+ hx-get="/fragments/screen-new-form" hx-target="#editor-modal-body" hx-swap="innerHTML"
386
+ hx-on::after:request="UIkit.modal('#editor-modal').show()">
387
+ <span uk-icon="icon: plus-circle; ratio: .8"></span> New screen</button>`;
388
+ }
389
+
390
+ /**
391
+ * Where a session can start, as two lists instead of one.
392
+ *
393
+ * It used to be a single select of 309 entries — every workspace folder and every project in the tree,
394
+ * one flat list to scroll. Choosing the workspace first turns that into a list of 22 and then a list of
395
+ * the projects in it, which is also how somebody thinks about where they are going.
396
+ */
397
+ function workspaceOptionsHtml(chosen) {
398
+ const rows = [['root', 'Workspace root']];
399
+ for (const [key, meta] of Object.entries(loadWorkspaces())) rows.push([key, `${key}/`, meta.label]);
400
+ return rows.map(([value, label, title]) =>
401
+ `<option value="${esc(value)}"${value === chosen ? ' selected' : ''} title="${esc(title || label)}">${esc(label)}</option>`).join('');
402
+ }
403
+
404
+ /** Which workspace a target token belongs to, so reopening the form lands on the list it was picked from. */
405
+ function workspaceOfTarget(target) {
406
+ const t = String(target || '');
407
+ const m = t.match(/^p:([a-z0-9_-]+):/) || t.match(/^ws:([a-z0-9_-]+)$/);
408
+ return m ? m[1] : 'root';
409
+ }
410
+
411
+ /**
412
+ * The places inside one workspace: the folder itself first, then its projects. The folder is a place a
413
+ * session runs in its own right — a whole workspace is what a build, a sweep or a review is about — so
414
+ * it is the first option rather than something to notice is missing.
415
+ */
416
+ function targetOptionsHtml(workspace, chosen) {
417
+ const key = String(workspace || 'root');
418
+ if (key === 'root') {
419
+ return `<option value="root" data-path="${esc(tildePath(ROOT))}" selected>the workspace root</option>`;
420
+ }
421
+ const inside = screenTargets().filter((t) => t.value === `ws:${key}` || t.value.startsWith(`p:${key}:`));
422
+ return inside.map((t) => {
423
+ const folder = t.value === `ws:${key}`;
424
+ const label = folder ? `the ${key}/ folder itself` : t.label.slice(key.length + 1);
425
+ // Each option carries where it is, so the line under the select follows the choice with no request.
426
+ return `<option value="${esc(t.value)}" data-path="${esc(tildePath(t.dir))}"${t.value === chosen ? ' selected' : ''}>${esc(label)}</option>`;
427
+ }).join('');
428
+ }
429
+
430
+ /**
431
+ * The name a session gets by default: where it runs, and a short id so it is its own.
432
+ *
433
+ * The place is what `screen -ls` shows and what `screen -r` takes, so the answer is almost always
434
+ * `dev--ha1x00b1--`; typing that out for every session was busywork with one right answer. The id on the
435
+ * end is what makes it startable twice — a second session in the same project used to be refused with
436
+ * "a session called that is already running", which is a naming collision presented as a rule.
437
+ *
438
+ * Four hex characters: enough that two sessions in one place do not collide, short enough to type after
439
+ * `screen -r`. The caller may pass its own id, which is what makes this testable.
440
+ */
441
+ function shortId() {
442
+ return crypto.randomUUID().replace(/-/g, '').slice(0, 4);
443
+ }
444
+
445
+ function defaultSessionName(target, id) {
446
+ const tail = id || shortId();
447
+ const t = String(target || '');
448
+ const pm = t.match(/^p:([a-z0-9_-]+):(.+)$/);
449
+ // The same shape the session gets when it starts, so the workspace and the project are not said twice.
450
+ if (pm) return `${pm[1]}--${pm[2]}--${tail}`.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').slice(0, 60);
451
+ const wm = t.match(/^ws:([a-z0-9_-]+)$/);
452
+ if (wm) return `${wm[1]}--${tail}`;
453
+ // The whole tree, which is a place but not a word anyone would name a session: `screen -ls` showing
454
+ // "root" reads as the user it runs as, which it is not.
455
+ return `workspace-${tail}`;
456
+ }
457
+
458
+ /** The "where it runs" half of the form, reloaded whole when the workspace changes. */
459
+ function screenWhereHtml(workspace, target) {
460
+ const ws = String(workspace || 'root');
461
+ const options = targetOptionsHtml(ws, target);
462
+ const first = (options.match(/value="([^"]*)"/) || [])[1] || 'root';
463
+ const picked = options.includes(`value="${target}"`) ? target : first;
464
+ const dir = screenTargetDir(picked);
465
+ return `<select class="uk-select uk-form-small" name="target" id="screen-target"
466
+ title="The directory the session starts in"
467
+ hx-get="/fragments/claude-sessions" hx-trigger="change" hx-include="closest form"
468
+ hx-target="#claude-resume-row" hx-swap="innerHTML"
469
+ hx-on::after:request="event.stopPropagation()"
470
+ onchange="window.screenNameFollowsTarget && window.screenNameFollowsTarget(this.form)">${options}</select>
471
+ <div class="uk-text-meta screen-where-path"><code>${dir ? esc(tildePath(dir)) : 'nothing there'}</code></div>`;
472
+ }
473
+
474
+ /** One Claude Code option, as the control its own shape asks for. */
475
+ function claudeArgFieldHtml(opt, value, suggestions, asSelect = false) {
476
+ const field = claudeArgs.fieldName(opt.name);
477
+ const id = `arg-${field}`;
478
+ const hint = esc(opt.description);
479
+ if (opt.takes === 'none') {
480
+ return `<label class="claude-arg claude-arg-flag" title="${hint}">
481
+ <input class="uk-checkbox" type="checkbox" name="${field}" value="1"${value ? ' checked' : ''}>
482
+ <code>${esc(opt.name)}</code>
483
+ <span class="claude-arg-help">${hint}</span>
484
+ </label>`;
485
+ }
486
+ // Three shapes, one per kind of answer the option has:
487
+ //
488
+ // - a CLOSED list (--effort: low, medium, high, xhigh, max) is a select. The CLI names all five.
489
+ // - an OPEN list (--model, --agent) is an input with a datalist: a dropdown you can also type
490
+ // into. A select here would refuse every model released after this help text was read, and
491
+ // every agent that comes from a plugin or a directory this does not walk.
492
+ // - anything else is a plain field, as before.
493
+ const list = suggestions && suggestions.length ? `${id}-list` : '';
494
+ // A plain list of what is installed, for a form that wants a pick rather than a type-ahead.
495
+ const pickList = asSelect && suggestions && suggestions.length;
496
+ const control = pickList
497
+ ? `<select class="uk-select uk-form-small" name="${field}" id="${id}">
498
+ <option value="">None</option>
499
+ ${suggestions.map((c) => `<option value="${esc(c)}"${String(value) === c ? ' selected' : ''}>${esc(c)}</option>`).join('')}
500
+ </select>`
501
+ : opt.choices.length
502
+ ? `<select class="uk-select uk-form-small" name="${field}" id="${id}">
503
+ <option value="">—</option>
504
+ ${opt.choices.map((c) => `<option value="${esc(c)}"${String(value) === c ? ' selected' : ''}>${esc(c)}</option>`).join('')}
505
+ </select>`
506
+ : `<input class="uk-input uk-form-small" name="${field}" id="${id}" value="${esc(value || '')}"
507
+ ${list ? `list="${list}"` : ''} placeholder="${esc(opt.placeholder || '')}${opt.repeatable ? ' — space separated' : ''}">${
508
+ list ? `<datalist id="${list}">${suggestions.map((c) => `<option value="${esc(c)}"></option>`).join('')}</datalist>` : ''}`;
509
+ return `<div class="claude-arg" title="${hint}">
510
+ <label class="claude-arg-name" for="${id}"><code>${esc(opt.name)}</code></label>
511
+ ${control}
512
+ <span class="claude-arg-help">${hint}</span>
513
+ </div>`;
514
+ }
515
+
516
+ /**
517
+ * The Claude Code arguments, from the CLI's own --help rather than from a list kept here by hand.
518
+ *
519
+ * The four reached for every time are laid out first; the rest are behind a disclosure, so the form
520
+ * opens as a few controls rather than as forty. The free-text "more claude options" box this replaces
521
+ * meant knowing the flags by heart and typing them with no check at all until the screen either started
522
+ * or died.
523
+ */
524
+ /*
525
+ * The Jira checkbox, explained here rather than as an HTML comment inside the markup.
526
+ *
527
+ * A comment that starts on the same line as the backtick opening a nested template literal is exactly
528
+ * where a stray backtick later would be invisible — the repo has a test for the shape, and it caught
529
+ * this one. The rule it enforces is worth keeping: prose about markup belongs in JS, not in the HTML.
530
+ *
531
+ * What the checkbox is: a run started FROM a workitem can say so ON the workitem. Unticked when the
532
+ * form opens, because a Jira comment is visible to everyone on it and a run meant to stay quiet must
533
+ * not become public as a side effect of starting it. The label names exactly what is posted, because
534
+ * "comment the session" could be read as the transcript, and it never is — only where the session is
535
+ * and how to reach it.
536
+ */
537
+
538
+ async function claudeArgsHtml(pre) {
539
+ const p = pre || {};
540
+ const { options, error } = await claudeArgs.claudeOptions();
541
+ if (error) {
542
+ return `<div class="uk-text-meta">The Claude Code options could not be read from <code>claude --help</code> on this machine (${esc(error.slice(0, 120))}), so only the prompt and the resume list are offered.</div>`;
543
+ }
544
+ const byName = new Map(options.map((o) => [o.name, o]));
545
+ const valueOf = (opt) => {
546
+ const posted = p[claudeArgs.fieldName(opt.name)];
547
+ if (posted !== undefined) return posted;
548
+ // The permission mode used to be its own field, and a prompt template still names one.
549
+ if (opt.name === '--permission-mode') return p.permission_mode || 'bypassPermissions';
550
+ return '';
551
+ };
552
+ // What to suggest for the two options whose values are a list of real things rather than a fixed set:
553
+ // the agents this machine (and this project) actually have, and the model aliases the CLI's own help
554
+ // names. `--agent`'s parsed suggestion is the word "agent" — the quoted setting name in its
555
+ // description — which is why the agents come from the files instead.
556
+ const agents = claudeArgs.installedAgents(screenTargetDir(p.target));
557
+ const suggestionsFor = (opt) => (opt.name === '--agent' ? agents : (opt.suggestions || []));
558
+ const common = claudeArgs.COMMON.map((n) => byName.get(n)).filter(Boolean);
559
+ const rest = options.filter((o) => !claudeArgs.COMMON.includes(o.name));
560
+ return `<div class="claude-args">
561
+ <div class="claude-args-common">${common.map((o) => claudeArgFieldHtml(o, valueOf(o), suggestionsFor(o), !!p.agentSelect && o.name === '--agent')).join('')}</div>
562
+ <details class="claude-args-all">
563
+ <summary>All Claude Code options (${rest.length})</summary>
564
+ <p class="uk-text-meta">Read from <code>claude --help</code> on this machine, so this list is whatever
565
+ the installed CLI actually accepts. The ones that only work with <code>--print</code>, the ones that
566
+ start the session somewhere else, and the ones this form already asks about are not here.</p>
567
+ <div class="claude-args-grid">${rest.map((o) => claudeArgFieldHtml(o, valueOf(o), suggestionsFor(o))).join('')}</div>
568
+ </details>
569
+ </div>`;
570
+ }
571
+
572
+ async function newScreenFormHtml(pre) {
573
+ const p = pre || {};
574
+ const ws = p.workspace || workspaceOfTarget(p.target);
575
+ // Opened on a workspace's own address, the workspace is not a question: asking it again is how a screen
576
+ // started on dev.workspace.ddev.site ended up in the root.
577
+ const locked = !!p.hostWorkspace && p.hostWorkspace === ws;
578
+ const isClaude = p.kind === 'claude';
579
+ const prefilled = defaultSessionName(p.target);
580
+ const kind = (value, label, help) => `<label class="screen-kind${(p.kind || 'shell') === value ? ' is-on' : ''}">
581
+ <input type="radio" name="kind" value="${value}"${(p.kind || 'shell') === value ? ' checked' : ''}
582
+ onchange="window.screenKindChanged && window.screenKindChanged(this.form)">
583
+ <strong>${label}</strong><span class="uk-text-meta">${help}</span>
584
+ </label>`;
585
+ return `
586
+ <div class="editor-card new-screen-card">
587
+ <div class="modal-head">
588
+ <h3 class="uk-margin-small-bottom">New screen</h3>
589
+ <p class="uk-text-meta uk-margin-small-bottom">It runs detached on your machine and survives this
590
+ dashboard restarting; <code>screen -r &lt;name&gt;</code> in your own terminal reattaches to it.</p>
591
+ </div>
592
+ <form hx-post="/actions/screen-new" hx-target="#webship-workspace-projects" hx-swap="innerHTML"
593
+ hx-on::after:request="UIkit.modal('#editor-modal').hide()">
594
+
595
+ <fieldset class="screen-step">
596
+ <legend>Where it runs${locked ? ` <span class="uk-text-meta">in the <strong>${esc(ws)}/</strong> workspace</span>` : ''}</legend>
597
+ <div class="uk-grid-small uk-child-width-1-2@s" uk-grid>
598
+ ${locked ? `<input type="hidden" name="workspace" value="${esc(ws)}">` : `<div>
599
+ <label class="uk-form-label" for="screen-workspace">Workspace</label>
600
+ <select class="uk-select uk-form-small" name="workspace" id="screen-workspace"
601
+ hx-get="/fragments/screen-where" hx-include="closest form"
602
+ hx-target="#screen-where" hx-swap="innerHTML"
603
+ hx-on::after:request="event.stopPropagation()">
604
+ ${workspaceOptionsHtml(ws)}
605
+ </select>
606
+ </div>`}
607
+ <div id="screen-where"${locked ? ' class="uk-width-1-1"' : ''}>
608
+ <label class="uk-form-label" for="screen-target">Folder or project</label>
609
+ ${screenWhereHtml(ws, p.target)}
610
+ </div>
611
+ </div>
612
+ </fieldset>
613
+
614
+ <fieldset class="screen-step">
615
+ <legend>What it starts</legend>
616
+ <div class="screen-kinds">
617
+ ${kind('shell', 'Shell', 'a plain login shell there')}
618
+ ${kind('claude', 'Claude Code', 'the claude CLI, with the field below as its opening prompt')}
619
+ ${kind('command', 'Command', 'runs what you type, and logs it')}
620
+ </div>
621
+ <div class="uk-margin-small-top screen-name-row">
622
+ <label class="uk-form-label" for="screen-session">Session name</label>
623
+ <input class="uk-input uk-form-small" name="session" id="screen-session"
624
+ value="${esc(p.session || prefilled)}"
625
+ data-derived="${esc(prefilled)}" data-tail="${esc(prefilled.slice(-4))}"
626
+ pattern="[a-z0-9][a-zA-Z0-9_.\-]*" title="lowercase start; letters, digits, - _ . after">
627
+ <span class="uk-text-meta">Where it runs, and a short id so a second session here is not the
628
+ same name. <code>screen -r</code> takes it; leave it empty and it is filled in for you.</span>
629
+ </div>
630
+ </fieldset>
631
+
632
+ <fieldset class="screen-step" data-claude-only${isClaude ? '' : ' hidden'}>
633
+ <legend>Claude Code</legend>
634
+ ${p.work ? `
635
+ <input type="hidden" name="work_key" value="${esc(p.work)}">
636
+ <label class="claude-arg claude-arg-flag jira-comment-opt" title="Posts one comment on ${esc(p.work)}">
637
+ <input class="uk-checkbox" type="checkbox" name="comment_session" value="1">
638
+ <span>Comment on <strong>${esc(p.work)}</strong> that this session is running</span>
639
+ <span class="claude-arg-help">Posts the session name, a link to its terminal on this hub, and
640
+ how to resume the conversation on this machine — never the transcript or anything the agent
641
+ says. A Claude Code CLI session has no page on claude.ai; it lives on this machine.</span>
642
+ </label>` : ''}
643
+ <!-- Resume rather than start over. A screen session that died — a reboot, a closed terminal,
644
+ a wipe of the dead ones — did not take the conversation with it: the CLI keeps every
645
+ session's transcript, and claude --resume <id> from the same directory picks it back up.
646
+ By id, not --continue, which takes the most recent session for the directory and may not
647
+ be the one meant. It is per DIRECTORY, so a workspace folder has its own sessions exactly
648
+ as a project does, and the list reloads whenever the place above changes.
649
+ No backticks in this comment: the form is a template literal. -->
650
+ <div id="claude-resume-row">${claudeResumeHtml(p.target)}</div>
651
+ ${await claudeArgsHtml(p)}
652
+ <div class="uk-margin-small-top">
653
+ <label class="uk-form-label" for="screen-template">Prompt template</label>
654
+ <select class="uk-select uk-form-small" id="screen-template"
655
+ title="Fill the prompt below with one of the prompts in the ai-prompts workspace, and set the flags that prompt documents"
656
+ onchange="window.screenPromptTemplate && window.screenPromptTemplate(this)">
657
+ <option value="">— write it yourself —</option>
658
+ ${promptTemplates().map((f) => `<option value="${esc(f)}" data-flags="${esc(promptTemplateFlags(f))}">${esc(f.replace(/\.md$/, ''))}</option>`).join('')}
659
+ </select>
660
+ </div>
661
+ </fieldset>
662
+
663
+ <fieldset class="screen-step">
664
+ <legend><span data-claude-only${isClaude ? '' : ' hidden'}>The prompt it opens with</span><span data-not-claude${isClaude ? ' hidden' : ''}>The command it runs</span></legend>
665
+ <textarea class="uk-textarea uk-form-small" name="command" rows="12" data-ace="sh"
666
+ data-mentions="/fragments/ai-suggest${p.target && p.target.startsWith('p:') ? `/${esc(p.target.split(':')[1])}/${esc(p.target.split(':').slice(2).join(':'))}` : ''}"
667
+ placeholder="a command to run, or the prompt Claude Code opens with — leave it empty for a plain session">${esc(p.command || '')}</textarea>
668
+ </fieldset>
669
+
670
+ <p class="modal-actions"><button class="uk-button uk-button-primary" type="submit">Start it</button></p>
671
+ </form>
672
+ </div>`;
673
+ }
674
+
675
+ // The page: the New-screen form, a toolbar, then one row per live session —
676
+ // a terminal glyph, what it is, and the real screen(1) verbs behind icons.
677
+ const TERM_SVG = `
678
+ <svg class="term-glyph" viewBox="0 0 96 72" aria-hidden="true" focusable="false">
679
+ <rect x="1" y="1" width="94" height="70" rx="8" fill="#300a24" stroke="#5e2750" stroke-width="1.5"/>
680
+ <path d="M1 9a8 8 0 0 1 8-8h78a8 8 0 0 1 8 8v8H1z" fill="#2c001e"/>
681
+ <circle cx="70" cy="9" r="3.1" fill="#77216f"/><circle cx="79" cy="9" r="3.1" fill="#77216f"/><circle cx="88" cy="9" r="3.4" fill="#e95420"/>
682
+ <rect x="8" y="6" width="22" height="6" rx="3" fill="#aea79f" opacity=".55"/>
683
+ <rect x="8" y="26" width="30" height="5" rx="2.5" fill="#8ae234"/><rect x="40" y="26" width="3" height="5" rx="1.5" fill="#ffffff"/>
684
+ <rect x="45" y="26" width="8" height="5" rx="2.5" fill="#729fcf"/><rect x="55" y="26" width="3" height="5" rx="1.5" fill="#ffffff"/>
685
+ <rect x="62" y="24" width="8" height="9" rx="1" fill="#ffffff" class="term-glyph-cursor"/>
686
+ <rect x="8" y="40" width="52" height="4" rx="2" fill="#d3d7cf" opacity=".6"/>
687
+ <rect x="8" y="49" width="38" height="4" rx="2" fill="#d3d7cf" opacity=".4"/>
688
+ <rect x="8" y="58" width="24" height="4" rx="2" fill="#d3d7cf" opacity=".25"/>
689
+ </svg>`;
690
+
691
+ /** The styles a terminal can wear, in the order the window's menu lists them. terminal.js owns the palettes. */
692
+ const TERM_STYLES = [
693
+ ['ubuntu', 'Ubuntu'],
694
+ ['tango', 'Tango light'],
695
+ ['solarized', 'Solarized dark'],
696
+ ['dracula', 'Dracula'],
697
+ ['dashboard', 'Dashboard style'],
698
+ ];
699
+
700
+ /** What one `ps` line says about an automation browser. Split out from the
701
+ * listing so it can be tested against real command lines rather than only
702
+ * against whatever happens to be running. */
703
+ function parseBrowserLine(line) {
704
+ const m = line.trim().match(/^(\d+)\s+(\S+)\s+(.*)$/);
705
+ if (!m) return null;
706
+ const args = m[3];
707
+ // The program itself has to BE a browser. Matching the arguments alone listed
708
+ // `ddev exec … BROWSER=chromium`, its docker-compose and the bash that runs
709
+ // the suite as three separate "browsers".
710
+ const prog = args.split(/\s+/)[0] || '';
711
+ const exe = prog.split('/').filter(Boolean).pop() || '';
712
+ // A browser in ANOTHER container is listed with its path only: this process
713
+ // cannot read that one's cmdline, and `ps` then prints the executable, often
714
+ // cut at a directory (…/chromium_headless_shell). So the whole path counts as
715
+ // evidence, not just the last segment.
716
+ const isBrowser = /^(google-)?chrome(-headless-shell)?$|^chromium(-browser)?$|^headless_shell$|^firefox$|^msedge$|webkit/i.test(exe)
717
+ || /ms-playwright|headless[-_]shell/i.test(prog);
718
+ if (!isBrowser) return null;
719
+ if (/ --type=/.test(args)) return null; // a renderer, not the browser
720
+ if (!/remote-debugging-(port|pipe)|ms-playwright|mcp-chrome|playwright/i.test(args)) return null;
721
+ const dir = (args.match(/--user-data-dir=(\S+)/) || [])[1] || '';
722
+ return {
723
+ pid: m[1],
724
+ uptime: m[2],
725
+ profile: (dir.split('/').filter(Boolean).pop() || (/headless[-_]shell/i.test(prog) ? 'test browser' : 'playwright')).slice(0, 40),
726
+ // A headless shell has no window whatever its flags say, so the binary
727
+ // counts as much as --headless does.
728
+ headless: /--headless/.test(args) || /headless[-_]shell/i.test(prog),
729
+ port: (args.match(/--remote-debugging-port=(\d+)/) || [])[1] || '',
730
+ pipe: /--remote-debugging-pipe/.test(args),
731
+ };
732
+ }
733
+
734
+ /** Is this process inside a container? The dashboard shares the machine's pid
735
+ * namespace, so a suite's browser in a project's web container shows up in
736
+ * `ps` here exactly like one on the desktop — and it is not on the desktop. */
737
+ function processInContainer(pid) {
738
+ try {
739
+ const cgroup = fs.readFileSync(`/proc/${pid}/cgroup`, 'utf8');
740
+ return /docker|containerd|kubepods|libpod/.test(cgroup);
741
+ } catch (_) { return false; }
742
+ }
743
+
744
+ /** The Playwright MCP browsers running on this machine, as tiles of their own. */
745
+ async function listPlaywrightBrowsers() {
746
+ // An automation browser, not any browser: one driven over a debugging pipe or
747
+ // port, or running from a playwright/mcp profile. The docs tooling's
748
+ // screenshot chromium is short-lived and matches none of those.
749
+ const out = await run('bash', ['-c',
750
+ "ps -eo pid,etime,args | grep -iE 'chrome|chromium|firefox|webkit' | grep -v grep | grep -v ' --type=' "
751
+ + "| grep -E 'remote-debugging-(port|pipe)|ms-playwright|mcp-chrome|playwright' | head -10"],
752
+ process.env.HOME, { timeoutMs: 10 * 1000 });
753
+ const rows = String(out.stdout || '').split('\n').map(parseBrowserLine).filter(Boolean)
754
+ .map((b) => ({ ...b, inContainer: processInContainer(b.pid) }));
755
+ // One browser is several processes, and the helpers of a browser in another
756
+ // container cannot be told apart from it (their arguments are unreadable, so
757
+ // the usual `--type=` filter never sees them). Group them into the one thing
758
+ // they are, oldest first — that is the browser itself.
759
+ const groups = new Map();
760
+ for (const b of rows) {
761
+ const key = `${b.profile}|${b.headless}|${b.inContainer}`;
762
+ const seen = groups.get(key);
763
+ if (!seen) groups.set(key, { ...b, count: 1 });
764
+ else { seen.count += 1; if (b.uptime > seen.uptime) Object.assign(seen, b, { count: seen.count }); }
765
+ }
766
+ return [...groups.values()];
767
+ }
768
+
769
+ async function screensGridHtml() {
770
+ const sessions = await listScreens();
771
+ const toolbar = `
772
+ <div class="uk-flex uk-flex-between uk-flex-middle uk-margin-small screens-toolbar">
773
+ <span class="uk-text-meta">${sessions.length} session${sessions.length === 1 ? '' : 's'}</span>
774
+ <div>
775
+ <!-- One under another by default: a session row carries its name, its state, its log path and
776
+ five verbs, and two of those side by side on a 1400px screen leaves each one narrower than
777
+ the buttons it holds. Two and three across are for a machine with a dozen sessions on it,
778
+ where the question is "which of these is waiting" rather than "what does this one say". -->
779
+ <span class="screens-cols" role="group" aria-label="How many sessions across">
780
+ ${[['1', 'list', 'One under another'], ['2', 'grid', 'Two across'], ['3', 'grid', 'Three across']]
781
+ .map(([n, icon, title]) => `<button type="button" class="uk-button uk-button-default uk-button-small"
782
+ data-screens-cols="${n}" title="${esc(title)}" aria-label="${esc(title)}"
783
+ aria-pressed="false">${n === '1' ? '<span uk-icon="icon: list; ratio: .7"></span>' : `<span uk-icon="icon: ${icon}; ratio: .7"></span> ${n}`}</button>`).join('')}
784
+ </span>
785
+ ${newScreenButtonHtml()}
786
+ <button class="uk-button uk-button-default uk-button-small"
787
+ hx-get="/fragments/screens-grid" hx-target="#webship-workspace-projects" hx-swap="innerHTML"
788
+ title="Refresh the list (screen -ls)"><span uk-icon="icon: refresh; ratio: .8"></span></button>
789
+ <button class="uk-button uk-button-default uk-button-small"
790
+ hx-post="/actions/screen-wipe" hx-target="#webship-workspace-projects" hx-swap="innerHTML"
791
+ title="Remove dead sessions (screen -wipe)"><span uk-icon="icon: trash; ratio: .8"></span> Remove ended sessions</button>
792
+ </div>
793
+ </div>`;
794
+ const claudeHere = agentSessions(sessions);
795
+ const rows = sessions.map((s) => {
796
+ const log = screenLogFor(s.session);
797
+ const dead = /dead/i.test(s.state);
798
+ const attached = /attached/i.test(s.state);
799
+ const verb = (icon, title, attrs) => `
800
+ <button class="uk-button uk-button-default uk-button-small" title="${esc(title)}" ${attrs}>
801
+ <span uk-icon="icon: ${icon}; ratio: .8"></span></button>`;
802
+ return `
803
+ <div class="uk-card uk-card-default uk-card-small uk-card-body uk-margin-small screen-tile${dead ? ' is-dead' : ''}${sessionWaitingWhy(s) ? ' is-waiting' : ''}">
804
+ <div class="uk-flex uk-flex-middle" style="gap:16px">
805
+ ${TERM_SVG}
806
+ <div class="uk-flex-1">
807
+ <div><strong>${esc(s.session)}</strong>
808
+ <span class="uk-label ${dead ? 'uk-label-danger' : attached ? 'screen-state-attached' : 'uk-label-success'}">${esc((s.state || 'detached').toLowerCase())}</span>
809
+ ${(() => {
810
+ // The one rule, in sessionWaitingWhy — the runner's reading of the visible screen
811
+ // first, the log only as the fallback for sessions that write one.
812
+ const why = sessionWaitingWhy(s);
813
+ if (!why) return '';
814
+ // "waiting on yes/no" after "Waiting for you" says waiting twice: keep what it is waiting for.
815
+ const ask = String(why).replace(/^waiting (on|for) /, '');
816
+ return `<span class="uk-label uk-label-warning needs-input" title="Read from its screen just now — open the terminal and answer it">✋ Waiting for you · ${esc(ask)}</span>`;
817
+ })()}
818
+ <span class="uk-text-meta">pid ${esc(s.pid || '?')} · since ${esc(s.started)}</span></div>
819
+ <div class="uk-text-meta">${log ? `log: <code>${esc(tildePath(log))}</code>` : 'no known log — the terminal still works'}
820
+ · on this machine — <code>screen -r ${esc(s.session)}</code> in your own terminal</div>
821
+ <div class="uk-margin-small-top uk-button-group-spaced">
822
+ <button class="uk-button uk-button-primary uk-button-small" type="button"
823
+ data-open-terminal="${esc(s.session)}"
824
+ title="Attach an interactive terminal (screen -x)"><span uk-icon="icon: laptop; ratio: .8"></span> Open terminal</button>
825
+ ${log ? verb('play-circle', 'Follow the log (tail -F)',
826
+ `hx-post="/actions/screen-watch" hx-target="#editor-modal-body" hx-swap="innerHTML"
827
+ hx-vals='{"session":"${esc(s.session)}"}' hx-on::after:request="UIkit.modal('#editor-modal').show()"`) : ''}
828
+ ${verb('sign-out', 'Detach every other client (screen -X detach)',
829
+ `hx-post="/actions/screen-detach" hx-target="#webship-workspace-output" hx-swap="innerHTML"
830
+ hx-vals='{"session":"${esc(s.session)}"}'`)}
831
+ ${verb('bolt', 'Interrupt what runs inside it (Ctrl-C)',
832
+ `hx-post="/actions/screen-interrupt" hx-target="#webship-workspace-output" hx-swap="innerHTML"
833
+ hx-vals='{"session":"${esc(s.session)}"}'
834
+ hx-confirm="Send Ctrl-C to ${esc(s.session)}? Whatever runs inside it is interrupted."`)}
835
+ ${claudeHere.has(s.session) && !dead ? `<button class="uk-button uk-button-default uk-button-small arm-step" type="button"
836
+ title="Exit Claude Code in ${esc(s.session)} (/exit)" hx-trigger="confirmed-remove"
837
+ hx-post="/actions/claude-exit" hx-target="#webship-workspace-output" hx-swap="innerHTML"
838
+ hx-vals='${JSON.stringify({ session: s.session, confirm: 'yes' })}'><span uk-icon="icon: sign-out; ratio: .8"></span> /exit</button>` : ''}
839
+ ${verb('ban', 'Kill the session (screen -X quit)',
840
+ `hx-post="/actions/screen-stop" hx-target="#webship-workspace-projects" hx-swap="innerHTML"
841
+ hx-vals='{"session":"${esc(s.session)}","confirm":"yes"}'
842
+ hx-confirm="Kill the screen session ${esc(s.session)}? Whatever runs inside it is killed."`)}
843
+ </div>
844
+ </div>
845
+ </div>
846
+ </div>`;
847
+ }).join('');
848
+ // The browsers, listed the same way: what is open, how long, and how to see it.
849
+ const browsers = await listPlaywrightBrowsers();
850
+ const browserRows = !browsers.length ? '' : `
851
+ <h2 class="uk-h4 uk-margin-medium-top uk-margin-small-bottom" id="screens-browsers-title">Playwright browsers</h2>
852
+ <div class="screens-list" data-cols="1" role="group" aria-labelledby="screens-browsers-title">
853
+ ${browsers.map((b) => `
854
+ <div class="uk-card uk-card-default uk-card-small uk-card-body uk-margin-small screen-tile">
855
+ <div class="uk-flex uk-flex-middle" style="gap:16px">
856
+ <span uk-icon="icon: world; ratio: 1.6"></span>
857
+ <div class="uk-flex-1">
858
+ <div><strong>${esc(b.profile)}</strong>
859
+ <span class="uk-label uk-label-${b.headless || b.inContainer ? 'warning' : 'success'}">${b.headless ? 'headless' : b.inContainer ? 'in a container' : 'on your desktop'}</span>
860
+ <span class="uk-text-meta">pid ${esc(b.pid)} · up ${esc(b.uptime)}${b.count > 1 ? ` · ${b.count} processes` : ''}</span></div>
861
+ <div class="uk-text-meta">${b.port
862
+ ? `DevTools on <a href="http://127.0.0.1:${esc(b.port)}" target="_blank">127.0.0.1:${esc(b.port)}</a>`
863
+ : 'Driven over a pipe, so it has no DevTools url'}${b.headless
864
+ ? ' · no window at all; it writes its screenshots to a file'
865
+ : b.inContainer
866
+ ? ' · it runs inside a container, so its window is not on the desktop'
867
+ : ' · its window is already open on this machine'}</div>
868
+ </div>
869
+ </div>
870
+ </div>`).join('')}
871
+ </div>`;
872
+ return toolbar
873
+ + (sessions.length
874
+ ? `<div class="screens-list" data-cols="1">${rows}</div>`
875
+ : '<div class="msg assistant">No screen sessions right now — start one above, or anything from cmd-tools-screen.sh and Jira "Work on it" appears here.</div>')
876
+ + browserRows;
877
+ }
878
+
879
+ async function screensListHtml() {
880
+ const sessions = await listScreens();
881
+ const rows = sessions.map((s) => {
882
+ const log = screenLogFor(s.session);
883
+ return `
884
+ <div class="uk-card uk-card-default uk-card-small uk-card-body uk-margin-small">
885
+ <div>📺 <strong>${esc(s.session)}</strong> <span class="uk-label uk-label-success">running</span>
886
+ <span class="uk-text-meta">since ${esc(s.started)}</span></div>
887
+ <div class="uk-margin-small-top">
888
+ ${log ? `<button class="uk-button uk-button-primary uk-button-small"
889
+ hx-post="/actions/screen-watch" hx-target="#editor-modal-body" hx-swap="innerHTML"
890
+ hx-vals='{"session":"${esc(s.session)}"}'><span uk-icon="icon: play-circle; ratio: .6"></span> Watch live</button>` : ''}
891
+ <button class="uk-button uk-button-danger uk-button-small"
892
+ hx-post="/actions/screen-stop" hx-target="#webship-workspace-output" hx-swap="innerHTML"
893
+ hx-vals='{"session":"${esc(s.session)}","confirm":"yes"}'
894
+ hx-confirm="Stop the screen session ${esc(s.session)}? Whatever runs inside it is killed."><span uk-icon="icon: close; ratio: .6"></span> Stop</button>
895
+ <span class="uk-text-meta">· reattach in a terminal: <code>ddev ssh</code> then <code>screen -r ${esc(s.session)}</code> (Ctrl-A D detaches, it keeps running)</span>
896
+ </div>
897
+ </div>`;
898
+ }).join('');
899
+ return sessions.length ? rows : '<div class="msg assistant">No screen sessions right now.</div>';
900
+ }
901
+
902
+
903
+ /* ------------------------------------------------------------------ *
904
+ * The terminal panes, and the dock that holds them
905
+ * ------------------------------------------------------------------ */
906
+
907
+ /**
908
+ * The commands that reach THIS session in a real terminal.
909
+ *
910
+ * The session runs on the machine, so these are the plain ones — and the follow command needs the real
911
+ * workspace rather than a placeholder nobody can paste.
912
+ */
913
+ function termCliHintHtml(session) {
914
+ let ws = '';
915
+ try {
916
+ const dir = fs.readFileSync(path.join(ROOT, '.screens', `${session}.cwd`), 'utf8').trim();
917
+ const rel = path.relative(ROOT, dir);
918
+ if (rel && !rel.startsWith('..')) ws = rel.split(path.sep)[0];
919
+ } catch (_) { /* an unknown owner falls back below */ }
920
+ if (!ws) ws = (session.match(/^([a-z0-9_-]+)--/) || [])[1] || '';
921
+ const followDir = ws ? path.join(ROOT, ws) : ROOT;
922
+ return `<details class="uk-margin-small-bottom term-cli-hint">
923
+ <summary class="uk-text-meta">Same session in a real terminal</summary>
924
+ <div class="uk-text-meta">
925
+ <div><code>screen -r ${esc(session)}</code> <span>— attach it (<code>Ctrl-A D</code> detaches again, it keeps running)</span></div>
926
+ <div><code>screen -x ${esc(session)}</code> <span>— share it: the browser and the terminal see the same screen</span></div>
927
+ ${ws ? `<div><code>cd ${esc(tildePath(followDir))} &amp;&amp; bash cmd-tools-screen.sh ${esc(session)} --follow</code> <span>— follow its log instead</span></div>` : ''}
928
+ ${(() => {
929
+ // The Claude Code session this folder is having, by id.
930
+ //
931
+ // It is LOCAL, and there is deliberately no link on it. A CLI session is a transcript in
932
+ // ~/.claude/projects on this machine: it has no page on claude.ai, and the claude.ai/code
933
+ // session ids that turn up in transcripts belong to cloud sessions, not to these. A link
934
+ // would either go nowhere or suggest the conversation is on the web when it is not — and
935
+ // these are client projects, so the id is something to resume with, never to publish.
936
+ //
937
+ // The NEWEST session for the folder, said as that rather than as "this session's": a screen
938
+ // started with `exec claude` does not record which conversation it ended up in, and claiming
939
+ // an id that might belong to another window would be worse than showing none.
940
+ let dir = '';
941
+ try { dir = fs.readFileSync(path.join(ROOT, '.screens', `${session}.cwd`), 'utf8').trim(); } catch (_) { return ''; }
942
+ let latest;
943
+ try { [latest] = require('./claude-sessions').sessionsFor(dir, { limit: 1 }); } catch (_) { return ''; }
944
+ if (!latest) return '';
945
+ return `<div><code class="claude-session-id">claude --resume ${esc(latest.id)}</code> <span>— the newest
946
+ Claude Code session in this folder, reopened here on this machine. A CLI session is a local
947
+ transcript; it has no page on claude.ai.</span></div>`;
948
+ })()}
949
+ </div>
950
+ </details>`;
951
+ }
952
+
953
+ /**
954
+ * One terminal, as a window of its own.
955
+ *
956
+ * It carries the session's OWN verbs — interrupt, detach, kill — and not only the window's. They were
957
+ * on the screen row alone, which meant that acting on the session you were watching started by closing
958
+ * the terminal you were watching it in. Each goes through the dashboard's one confirm dialog, the way
959
+ * the row's do, and the two destructive ones say what they will end.
960
+ *
961
+ * Every id in here is a CLASS instead: several terminals are open at once, and ids made the second one
962
+ * drive the first one's status line and buttons — which is precisely why only one could ever be open.
963
+ *
964
+ * The geometry is not here. Where a window sits and how big it is belongs to the browser that put it
965
+ * there (public/terminal.js, localStorage), so the server renders the same markup whether this is the
966
+ * first terminal or the fifth.
967
+ */
968
+ function termPaneHtml(session) {
969
+ const verb = (cls, icon, title, attrs = '', extra = '') => `<button type="button"
970
+ class="uk-button uk-button-default uk-button-small ${cls}" title="${esc(title)}"
971
+ aria-label="${esc(title)}"${attrs ? ` ${attrs}` : ''}>${
972
+ icon ? `<span uk-icon="icon: ${icon}; ratio: .7"></span>` : ''}${extra}</button>`;
973
+ const vals = (extra) => esc(JSON.stringify({ session, ...extra }));
974
+ return `
975
+ <section class="term-window" data-session="${esc(session)}" role="dialog" aria-label="Terminal — ${esc(session)}">
976
+ <header class="term-window-head">
977
+ <span class="term-title" title="${esc(session)}"><span uk-icon="icon: laptop; ratio: .7"></span> ${esc(session)}</span>
978
+ <span class="term-status uk-text-meta">connecting…</span>
979
+ <span class="term-verbs">
980
+ <!-- A button as well as the wheel: scrolling back is screen's copy mode, and nobody should
981
+ have to know that, let alone the chord for it. Pressing q leaves it, which the status
982
+ line says while it is open. No backticks in this comment: the function is one template
983
+ literal, and the repo has a test for exactly this. -->
984
+ ${verb('term-scrollback', 'arrow-up', `Scroll back through what ${session} printed (q leaves it)`)}
985
+ <!-- The size, in columns and rows, which is how a terminal is measured: the same four the
986
+ GNOME/Ubuntu terminal offers. Reset — the menu entry and a double-click on the title bar
987
+ — goes to 132x43, which is also what a window opens at, clamped to the display if it is
988
+ too small to hold it. -->
989
+ <span class="uk-inline term-size-pick">
990
+ ${verb('term-fit', 'cog', 'Size, text size and style of this window')}
991
+ <div uk-dropdown="mode: click; pos: bottom-right" class="term-look-menu"><ul class="uk-nav uk-dropdown-nav">
992
+ <li class="uk-nav-header">Size</li>
993
+ ${[[80, 24], [80, 43], [132, 24], [132, 43]].map(([c, r]) =>
994
+ `<li><a href data-term-size="${c}x${r}">${c} &times; ${r}</a></li>`).join('')}
995
+ <li><a href data-term-size="fit">Reset to 132 &times; 43</a></li>
996
+ <li class="uk-nav-divider"></li>
997
+ <li class="uk-nav-header">Text size</li>
998
+ <li class="term-font-row">
999
+ <button type="button" class="uk-button uk-button-default uk-button-small" data-term-font="down" aria-label="Smaller text" title="Smaller (Ctrl and minus)"><span uk-icon="icon: minus; ratio: .7"></span></button>
1000
+ <span class="term-font-size" aria-live="polite">17 px</span>
1001
+ <button type="button" class="uk-button uk-button-default uk-button-small" data-term-font="up" aria-label="Larger text" title="Larger (Ctrl and plus)"><span uk-icon="icon: plus; ratio: .7"></span></button>
1002
+ <button type="button" class="uk-button uk-button-default uk-button-small" data-term-font="reset" title="Back to the default (Ctrl and 0)">Reset</button>
1003
+ </li>
1004
+ <li class="uk-nav-divider"></li>
1005
+ <li class="uk-nav-header">Style</li>
1006
+ ${TERM_STYLES.map(([id, label]) =>
1007
+ `<li><a href role="menuitemradio" aria-checked="false" data-term-style="${id}"><span class="term-swatch" aria-hidden="true"></span> ${label}</a></li>`).join('')}
1008
+ </ul></div>
1009
+ </span>
1010
+ ${verb('term-reconnect', 'refresh', 'Reconnect this terminal',
1011
+ `hidden hx-get="/fragments/screen-term/${encodeURIComponent(session)}" hx-target="closest .term-window" hx-swap="outerHTML"`)}
1012
+ <!-- Ctrl-C goes down THIS window's own socket, not to /actions/screen-interrupt.
1013
+ onHostScreen says it plainly — "detach and Ctrl-C are screen's own; the runner does not
1014
+ proxy them yet" — so that endpoint answers 500 for a session on the host, which is every
1015
+ session here. A window is already attached to the session, and sending the byte is
1016
+ exactly what typing it would do. terminal.js does it, after the same confirm. -->
1017
+ ${verb('term-interrupt arm-step', 'bolt', `Interrupt what runs in ${session} (Ctrl-C)`)}
1018
+ <!-- The wheel writing mouse coordinates into the shell instead of scrolling is a mode a
1019
+ program turned on and did not turn off: screen replays the window's state to every
1020
+ display that attaches, so it follows the session into a real terminal too. Nothing on
1021
+ screen can say so, which is why there is a button for it. It types a printf at the
1022
+ prompt (the only way to reach a window's terminal), so it asks first, like the other
1023
+ two verbs that do. No backticks in this comment: the function is one template literal,
1024
+ and the repo has a test for exactly this. -->
1025
+ ${verb('term-reset-modes arm-step', 'crosshairs', `Clear the mouse and paste modes in ${session} — the fix when the wheel writes characters instead of scrolling`,
1026
+ `hx-post="/actions/screen-reset-modes" hx-vals='${vals({ confirm: 'yes' })}' hx-target="#webship-workspace-output" hx-swap="innerHTML" hx-trigger="confirmed-remove"`)}
1027
+ ${verb('term-detach arm-step', 'sign-out', `Detach every other client from ${session}`,
1028
+ `hx-post="/actions/screen-detach" hx-vals='${vals({})}' hx-target="#webship-workspace-output" hx-swap="innerHTML" hx-trigger="confirmed-remove"`)}
1029
+ ${verb('term-kill arm-step', 'ban', `Kill the session ${session}`,
1030
+ `hx-post="/actions/screen-stop" hx-vals='${vals({ confirm: 'yes' })}' hx-target="#webship-workspace-output" hx-swap="innerHTML" hx-trigger="confirmed-remove"`)}
1031
+ <span class="term-verbs-sep" aria-hidden="true"></span>
1032
+ ${verb('term-fullscreen', 'expand', 'Fill the screen (Esc to come back)')}
1033
+ <a class="uk-button uk-button-default uk-button-small term-newtab" title="Open this session in its own tab"
1034
+ aria-label="Open this session in its own tab"
1035
+ href="/terminal/${encodeURIComponent(session)}" target="_blank"><span uk-icon="icon: link-external; ratio: .7"></span></a>
1036
+ ${verb('term-close', 'close', `Close this terminal (${session} keeps running)`)}
1037
+ </span>
1038
+ </header>
1039
+ <div class="term-screen"></div>
1040
+ ${termCliHintHtml(session)}
1041
+ <!-- The corner grip. The CSS resize property needs a scroll container and drags the element's own
1042
+ box, which fights an absolutely placed window, so the drag is done in terminal.js and this is
1043
+ its handle. No backticks in this comment: the function is one template literal. -->
1044
+ <span class="term-resize" title="Drag to resize" aria-hidden="true"></span>
1045
+ </section>`;
1046
+ }
1047
+
1048
+ /**
1049
+ * The layer every terminal window floats in.
1050
+ *
1051
+ * Empty in the page shell: `public/terminal.js` fills it as terminals are opened, and the bar that
1052
+ * counts them and arranges them is drawn there too — it exists only while something is open, and a
1053
+ * server-rendered one would be an empty strip on every page of the dashboard.
1054
+ */
1055
+ function termLayerHtml(sessions = []) {
1056
+ return `<div id="term-windows">${sessions.map(termPaneHtml).join('')}</div>`;
1057
+ }
1058
+
1059
+ module.exports = {
1060
+ TERM_STYLES,
1061
+ termPaneHtml, termLayerHtml, termCliHintHtml,
1062
+ claudeResumeHtml,
1063
+ waitingState, sessionWaitingWhy, agentSessions, activityByDir, activityFromSessions, listScreens, listContainerScreens, listPlaywrightBrowsers, parseBrowserLine, processInContainer, sessionNeedsInput, screensForDir, screenLogFor, screensListHtml, screensGridHtml, screenTailHtml, screenTargets, screenTargetDir, newScreenFormHtml, newScreenButtonHtml, screenWhereHtml, workspaceOptionsHtml, workspaceOfTarget, targetOptionsHtml, defaultSessionName, claudeArgsHtml, promptTemplates, promptTemplateText };