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

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 (307) 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/images-svg.js +33 -0
  16. package/actions/images.js +145 -0
  17. package/actions/navigation.js +190 -0
  18. package/actions/node.js +222 -0
  19. package/actions/notes.js +55 -0
  20. package/actions/plugins.js +36 -0
  21. package/actions/project-make.js +28 -0
  22. package/actions/rag.js +202 -0
  23. package/actions/row-actions.js +262 -0
  24. package/actions/screens.js +270 -0
  25. package/actions/testing.js +121 -0
  26. package/actions/video-edit.js +54 -0
  27. package/ai-inventory.js +182 -0
  28. package/ai-items.js +202 -0
  29. package/assistant-cast.js +176 -0
  30. package/assistant-suggestions.js +91 -0
  31. package/assistant.js +484 -0
  32. package/audio-voice.js +528 -0
  33. package/auth-google.js +260 -0
  34. package/build-ai.js +52 -0
  35. package/build-order-page.js +191 -0
  36. package/build-order.js +313 -0
  37. package/builder-args.js +117 -0
  38. package/changelog.js +302 -0
  39. package/chat-threads.js +134 -0
  40. package/checks.js +105 -0
  41. package/claude-args.js +282 -0
  42. package/claude-sessions.js +129 -0
  43. package/command-author.js +117 -0
  44. package/command-block.js +20 -0
  45. package/commands/defaults.yml +159 -0
  46. package/commands-page.js +460 -0
  47. package/commands.js +568 -0
  48. package/cucumber.js +43 -0
  49. package/ddev-addons-page.js +124 -0
  50. package/ddev-addons.js +154 -0
  51. package/ddev-custom-commands.js +96 -0
  52. package/ddev-domains.js +84 -0
  53. package/ddev-import.js +93 -0
  54. package/ddev-mail-page.js +167 -0
  55. package/ddev-mail.js +110 -0
  56. package/ddev-options.js +145 -0
  57. package/ddev-snapshots.js +126 -0
  58. package/ddev.js +145 -0
  59. package/docs/DEVELOPER-GUIDE.md +224 -0
  60. package/docs/README.md +252 -0
  61. package/docs/REFERENCE.md +684 -0
  62. package/docs/USER-GUIDE.md +697 -0
  63. package/docs/screenshots/assistant/avatar-board.png +0 -0
  64. package/docs/screenshots/assistant/avatar-coffee.png +0 -0
  65. package/docs/screenshots/assistant/avatar-colours.png +0 -0
  66. package/docs/screenshots/assistant/avatar-icons.png +0 -0
  67. package/docs/screenshots/assistant/avatar-moves.png +0 -0
  68. package/docs/screenshots/assistant/avatar-pick-cookieman.png +0 -0
  69. package/docs/screenshots/assistant/avatar-settings-voices.png +0 -0
  70. package/docs/screenshots/assistant/avatar-status.png +0 -0
  71. package/docs/screenshots/create-card/create-card.png +0 -0
  72. package/docs/screenshots/create-card/start-ai.png +0 -0
  73. package/docs/screenshots/guide-clone-address.png +0 -0
  74. package/docs/screenshots/guide-clone-worktree-off.png +0 -0
  75. package/docs/screenshots/guide-clone-worktree-on.png +0 -0
  76. package/docs/screenshots/guide-ddev-menu.png +0 -0
  77. package/docs/screenshots/guide-domains.png +0 -0
  78. package/docs/screenshots/guide-home.png +0 -0
  79. package/docs/screenshots/guide-ide-menu.png +0 -0
  80. package/docs/screenshots/guide-remove-refusal.png +0 -0
  81. package/docs/screenshots/guide-rows-worktree.png +0 -0
  82. package/docs/screenshots/guide-screens.png +0 -0
  83. package/docs/screenshots/guide-settings.png +0 -0
  84. package/docs/screenshots/guide-workspace-page.png +0 -0
  85. package/docs/screenshots/home/card-settings.png +0 -0
  86. package/docs/screenshots/home/drag-reorder.png +0 -0
  87. package/docs/screenshots/home/edit-card.png +0 -0
  88. package/docs/screenshots/images/claude-code-image.png +0 -0
  89. package/docs/screenshots/images/edit-with-ai.png +0 -0
  90. package/docs/screenshots/images/gallery.png +0 -0
  91. package/docs/screenshots/images/generate-description.png +0 -0
  92. package/docs/screenshots/images/generate-plan.png +0 -0
  93. package/docs/screenshots/images/providers.png +0 -0
  94. package/docs/screenshots/images/svg-form.png +0 -0
  95. package/docs/screenshots/images/svg-result.png +0 -0
  96. package/docs/screenshots/images/upload.png +0 -0
  97. package/docs/screenshots/new-folder/2-form.png +0 -0
  98. package/docs/screenshots/new-folder/8-ddev-options.png +0 -0
  99. package/docs/screenshots/new-folder/files-folder.png +0 -0
  100. package/docs/screenshots/projects/ddev-custom-commands.png +0 -0
  101. package/docs/screenshots/projects/state-chip-stop.png +0 -0
  102. package/docs/screenshots/readme/dashboard-dark.png +0 -0
  103. package/docs/screenshots/readme/dashboard-light.png +0 -0
  104. package/docs/screenshots/readme/dev-dark.png +0 -0
  105. package/docs/screenshots/readme/dev-light.png +0 -0
  106. package/docs/screenshots/readme/docs-dark.png +0 -0
  107. package/docs/screenshots/readme/docs-light.png +0 -0
  108. package/docs/screenshots/readme/icons/ai.svg +4 -0
  109. package/docs/screenshots/readme/icons/code.svg +4 -0
  110. package/docs/screenshots/readme/icons/docs.svg +4 -0
  111. package/docs/screenshots/readme/icons/services.svg +4 -0
  112. package/docs/screenshots/readme/icons/sites.svg +4 -0
  113. package/docs/screenshots/readme/test-report-dark.png +0 -0
  114. package/docs/screenshots/readme/test-report-light.png +0 -0
  115. package/docs/screenshots/readme/test-scenarios.png +0 -0
  116. package/docs/screenshots/readme/test-steps.png +0 -0
  117. package/docs/screenshots/readme/videos-dark.png +0 -0
  118. package/docs/screenshots/readme/videos-light.png +0 -0
  119. package/docs/screenshots/screens-menu.png +0 -0
  120. package/docs/screenshots/screens-new.png +0 -0
  121. package/docs/screenshots/settings/auth-google-lists.png +0 -0
  122. package/docs/screenshots/settings/delete-1-row.png +0 -0
  123. package/docs/screenshots/settings/delete-2-confirm.png +0 -0
  124. package/docs/screenshots/settings/delete-3-type-name.png +0 -0
  125. package/docs/screenshots/settings/delete-4-type-yes.png +0 -0
  126. package/event-log.js +59 -0
  127. package/files-browser.js +247 -0
  128. package/folders.js +121 -0
  129. package/format.js +73 -0
  130. package/git-info.js +81 -0
  131. package/github-auth.js +62 -0
  132. package/html.js +10 -0
  133. package/icons.js +83 -0
  134. package/images-ai.js +447 -0
  135. package/images-create.js +226 -0
  136. package/images-page.js +417 -0
  137. package/images-records.js +39 -0
  138. package/images-render.js +36 -0
  139. package/images-svg-core.js +214 -0
  140. package/images-svg-sanitize.js +188 -0
  141. package/images-svg.js +241 -0
  142. package/images.js +270 -0
  143. package/jobs.js +430 -0
  144. package/linger.js +21 -0
  145. package/lists.js +292 -0
  146. package/media-info.js +136 -0
  147. package/navigation-page.js +264 -0
  148. package/navigation.js +394 -0
  149. package/notes.js +0 -0
  150. package/package.json +47 -4
  151. package/palette-index.js +46 -0
  152. package/playwright.config.js +23 -0
  153. package/plugin-install.js +239 -0
  154. package/plugin-loader.js +466 -0
  155. package/plugin-manifest.js +83 -0
  156. package/plugin-settings.js +44 -0
  157. package/plugins/workspace-notes/index.js +59 -0
  158. package/plugins-page.js +103 -0
  159. package/project-make.js +241 -0
  160. package/project-name.js +210 -0
  161. package/public/assistant-avatar-moves.js +646 -0
  162. package/public/assistant-avatar-ui.js +1223 -0
  163. package/public/assistant-avatar.js +530 -0
  164. package/public/avatar/druplicon.vrm +0 -0
  165. package/public/css/base.css +260 -0
  166. package/public/css/components/ai-assistant.css +263 -0
  167. package/public/css/components/assistant-avatar.css +279 -0
  168. package/public/css/components/assistant-views.css +198 -0
  169. package/public/css/components/build-order.css +74 -0
  170. package/public/css/components/clone-as-a-git-worktree.css +30 -0
  171. package/public/css/components/command-palette.css +61 -0
  172. package/public/css/components/commands-center.css +139 -0
  173. package/public/css/components/confirm-dialog.css +40 -0
  174. package/public/css/components/create-card.css +8 -0
  175. package/public/css/components/ddev-menu.css +23 -0
  176. package/public/css/components/deep-chat.css +17 -0
  177. package/public/css/components/density.css +11 -0
  178. package/public/css/components/floating-assistant-workspace-pages.css +55 -0
  179. package/public/css/components/folders.css +5 -0
  180. package/public/css/components/full-height-left-assistant-sidebar-all-pages.css +39 -0
  181. package/public/css/components/full-height-right-actions-rail-pages-that-have-actions.css +267 -0
  182. package/public/css/components/grid-and-list.css +203 -0
  183. package/public/css/components/help-page.css +36 -0
  184. package/public/css/components/hero.css +16 -0
  185. package/public/css/components/images.css +70 -0
  186. package/public/css/components/layout-rhythm-and-spacing-polish.css +139 -0
  187. package/public/css/components/mic-on-any-field-that-wants-typing.css +416 -0
  188. package/public/css/components/modal-fit.css +113 -0
  189. package/public/css/components/page-width.css +23 -0
  190. package/public/css/components/plugins-page.css +9 -0
  191. package/public/css/components/project-cards.css +256 -0
  192. package/public/css/components/project-rows.css +390 -0
  193. package/public/css/components/reordering-the-workspace-cards.css +85 -0
  194. package/public/css/components/responsive-home-and-the-workspace-pages.css +457 -0
  195. package/public/css/components/running-commands-as-toasts.css +327 -0
  196. package/public/css/components/settings-editor-core-config-yml.css +828 -0
  197. package/public/css/components/short-results-as-a-toast-at-the-top.css +203 -0
  198. package/public/css/components/sound-player.css +84 -0
  199. package/public/css/components/suggest-agents-skills-commands.css +112 -0
  200. package/public/css/components/tab-modal.css +142 -0
  201. package/public/css/components/terminals.css +244 -0
  202. package/public/css/components/test-report-media-one-file-per-row-paged.css +144 -0
  203. package/public/css/components/top-toolbar-logo-breadcrumb.css +107 -0
  204. package/public/css/components/video-edit.css +6 -0
  205. package/public/css/components/web-ide-open-with.css +23 -0
  206. package/public/css/components/working-indicators.css +158 -0
  207. package/public/css/components/workspace-cards.css +79 -0
  208. package/public/favicon.ico +0 -0
  209. package/public/favicon.png +0 -0
  210. package/public/htmx.min.js +1 -0
  211. package/public/logo.png +0 -0
  212. package/public/media-player.js +215 -0
  213. package/public/mic.js +255 -0
  214. package/public/narration.js +110 -0
  215. package/public/palette.js +278 -0
  216. package/public/sortable.js +251 -0
  217. package/public/suggest.js +337 -0
  218. package/public/terminal.js +903 -0
  219. package/public/themes/default/theme.css +267 -0
  220. package/public/themes/ember/theme.css +180 -0
  221. package/public/themes/press/theme.css +290 -0
  222. package/public/themes/slate/theme.css +182 -0
  223. package/public/touch-icon.png +0 -0
  224. package/public/ui.js +2721 -0
  225. package/public/vendor/ace/LICENSE.txt +24 -0
  226. package/public/vendor/ace/ace.js +23 -0
  227. package/public/vendor/ace/ext-language_tools.js +8 -0
  228. package/public/vendor/ace/ext-searchbox.js +8 -0
  229. package/public/vendor/ace/mode-css.js +8 -0
  230. package/public/vendor/ace/mode-html.js +8 -0
  231. package/public/vendor/ace/mode-javascript.js +8 -0
  232. package/public/vendor/ace/mode-markdown.js +8 -0
  233. package/public/vendor/ace/mode-sh.js +8 -0
  234. package/public/vendor/ace/mode-yaml.js +8 -0
  235. package/public/vendor/ace/theme-textmate.js +8 -0
  236. package/public/vendor/ace/theme-tomorrow_night.js +8 -0
  237. package/public/vendor/ace/worker-yaml.js +1 -0
  238. package/public/vendor/deep-chat.LICENSE +21 -0
  239. package/public/vendor/deep-chat.bundle.js +1 -0
  240. package/public/vendor/fonts/dm-serif-text-LICENSE.txt +93 -0
  241. package/public/vendor/fonts/dm-serif-text-latin-400-italic.woff2 +0 -0
  242. package/public/vendor/fonts/dm-serif-text-latin-400-normal.woff2 +0 -0
  243. package/public/vendor/fonts/figtree-LICENSE.txt +93 -0
  244. package/public/vendor/fonts/figtree-latin-400-italic.woff2 +0 -0
  245. package/public/vendor/fonts/figtree-latin-400-normal.woff2 +0 -0
  246. package/public/vendor/fonts/figtree-latin-600-italic.woff2 +0 -0
  247. package/public/vendor/fonts/figtree-latin-600-normal.woff2 +0 -0
  248. package/public/vendor/fonts/jetbrains-mono-LICENSE.txt +93 -0
  249. package/public/vendor/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  250. package/public/vendor/fonts/ubuntu-mono-LICENSE.txt +98 -0
  251. package/public/vendor/fonts/ubuntu-mono-latin-wght-normal.woff2 +0 -0
  252. package/public/vendor/tabler-LICENSE.txt +21 -0
  253. package/public/vendor/tabler-sprite.svg +1 -0
  254. package/public/vendor/three/LICENSE +21 -0
  255. package/public/vendor/three/addons/loaders/GLTFLoader.js +4860 -0
  256. package/public/vendor/three/addons/utils/BufferGeometryUtils.js +1501 -0
  257. package/public/vendor/three/addons/utils/SkeletonUtils.js +496 -0
  258. package/public/vendor/three/three.core.min.js +6 -0
  259. package/public/vendor/three/three.module.min.js +6 -0
  260. package/public/vendor/three-vrm/LICENSE +22 -0
  261. package/public/vendor/three-vrm/three-vrm.module.min.js +983 -0
  262. package/public/vendor/uikit-icons.min.js +1 -0
  263. package/public/vendor/uikit.min.css +1 -0
  264. package/public/vendor/uikit.min.js +1 -0
  265. package/public/vendor/webship-icons/ui/bookmark-outlined.svg +1 -0
  266. package/public/vendor/webship-icons/ui/headset.svg +1 -0
  267. package/public/vendor/webship-icons/ui/menu.svg +1 -0
  268. package/public/vendor/webship-icons/ui/pause.svg +1 -0
  269. package/public/vendor/webship-icons/ui/play.svg +1 -0
  270. package/public/vendor/webship-icons/ui/share.svg +1 -0
  271. package/public/vendor/webship-icons/ui/sound-off.svg +1 -0
  272. package/public/vendor/webship-icons/ui/sound-on.svg +1 -0
  273. package/public/vendor/webship-icons/ui/stop.svg +1 -0
  274. package/public/vendor/xterm/addon-fit.js +2 -0
  275. package/public/vendor/xterm/addon-unicode11.js +2 -0
  276. package/public/vendor/xterm/addon-web-links.js +2 -0
  277. package/public/vendor/xterm/xterm.css +218 -0
  278. package/public/vendor/xterm/xterm.js +2 -0
  279. package/rag.js +169 -0
  280. package/reader.js +132 -0
  281. package/repo-blobs.js +66 -0
  282. package/report-pdf.js +337 -0
  283. package/row-layout.js +1439 -0
  284. package/screenrc +37 -0
  285. package/screens.js +1063 -0
  286. package/scripts/code-server-start.sh +190 -0
  287. package/scripts/images-claude-finish.js +107 -0
  288. package/scripts/screen-host.sh +114 -0
  289. package/scripts/screen-runner.js +362 -0
  290. package/scripts/screen-then.js +47 -0
  291. package/scripts/update.sh +350 -0
  292. package/server.js +7923 -0
  293. package/settings-file.js +423 -0
  294. package/speech.js +297 -0
  295. package/tab-modal.js +75 -0
  296. package/test-reports.js +260 -0
  297. package/themes.js +381 -0
  298. package/transcripts.js +168 -0
  299. package/urls.js +13 -0
  300. package/validate.js +18 -0
  301. package/version-check.js +94 -0
  302. package/video-edit.js +588 -0
  303. package/waiting.js +85 -0
  304. package/workspace-card-tools.js +41 -0
  305. package/workspace-delete.js +139 -0
  306. package/workspace-types.js +85 -0
  307. package/workspaces.js +553 -0
@@ -0,0 +1,270 @@
1
+ /**
2
+ * The screen-session actions: watch a session's log live, or stop it. The
3
+ * domain lives in ../screens, shared with the modal and the Screens page.
4
+ */
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+
9
+ const { esc } = require('../html');
10
+ const { run, startJob, stopJob, jobFragment, runningJobKey, hostRunnerUp, hostRunnerRequest } = require('../jobs');
11
+ const { screenLogFor, screensGridHtml, screenTargetDir, defaultSessionName } = require('../screens');
12
+ const claudeSessions = require('../claude-sessions');
13
+ const claudeArgs = require('../claude-args');
14
+ const { ROOT } = require('../workspaces');
15
+
16
+ // A new session: named by the user, started detached in a directory the
17
+ // server resolves from a target token (never a path from the browser), with
18
+ // its output teed to the workspace's own log store.
19
+ const shq = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`;
20
+
21
+ /** The prompt of a session, kept beside its log with this user's permissions only. */
22
+ function writePromptFile(session, text) {
23
+ const dir = path.join(ROOT, '.screens', 'prompts');
24
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
25
+ const file = path.join(dir, `${session}.txt`);
26
+ fs.writeFileSync(file, text, { mode: 0o600 });
27
+ return file;
28
+ }
29
+
30
+ async function screen_new({ form, send, res }) {
31
+ // An empty name is not a mistake: it means "call it after where it runs", which is what the form
32
+ // pre-fills anyway. Refusing it made the one field nobody wants to think about the one that fails.
33
+ const session = String(form.session || '').trim() || defaultSessionName(form.target);
34
+ if (!/^[a-z0-9][a-zA-Z0-9_.-]*$/.test(session) || session.length > 60) {
35
+ return send('<div class="msg error">A session name starts with a lowercase letter or digit and holds only letters, digits, - _ and . (60 max).</div>', 400);
36
+ }
37
+ const dir = screenTargetDir(form.target);
38
+ if (!dir) return send('<div class="msg error">Unknown place to run in.</div>', 400);
39
+ // The name says where it runs, exactly like the sessions the row's commands
40
+ // start: <workspace>--<project>--<name>. A target of the workspace root or a
41
+ // folder contributes only what it has.
42
+ const target = String(form.target || '');
43
+ const pm = target.match(/^p:([a-z0-9_-]+):(.+)$/);
44
+ const wm = target.match(/^ws:([a-z0-9_-]+)$/);
45
+ const prefix = pm ? `${pm[1]}--${pm[2]}--` : wm ? `${wm[1]}--` : '';
46
+ // The form pre-fills the name with where it runs, so a name that already says it is not said twice:
47
+ // `dev--ha1x00b1` must not become `dev--ha1x00b1--dev--ha1x00b1`.
48
+ const said = prefix && (`${session}--` === prefix || session.startsWith(prefix));
49
+ const fullSession = `${said ? '' : prefix}${session}`.toLowerCase()
50
+ .replace(/[^a-z0-9._-]+/g, '-').replace(/-{3,}/g, '--').slice(0, 60);
51
+ const logDir = path.join(ROOT, '.screens');
52
+ try { fs.mkdirSync(logDir, { recursive: true }); } catch (_) { /* exists */ }
53
+ const logFile = path.join(logDir, `${fullSession}.log`);
54
+ const command = String(form.command || '').trim();
55
+ const kind = String(form.kind || (command ? 'command' : 'shell'));
56
+ let inner;
57
+ if (kind === 'claude') {
58
+ // Claude Code interactively, in the real PTY screen provides. The field is
59
+ // the prompt it opens with; a prompt template brings its own flags along,
60
+ // so the session runs the same command that prompt file documents
61
+ // (typically --permission-mode bypassPermissions).
62
+ // The flags are the CLI's own, read from `claude --help` (claude-args.js) rather than typed into a
63
+ // free-text box: every one that arrives has to be an option that help printed, and every value has
64
+ // to fit the shape that option documented. A value that does not is REFUSED by name — a flag that
65
+ // silently did nothing is worse than one that says why.
66
+ const { options, error } = await claudeArgs.claudeOptions();
67
+ if (error) {
68
+ return send(`<div class="msg error">The Claude Code options could not be read on this machine: ${esc(error.slice(0, 200))}</div>`, 503);
69
+ }
70
+ const { words, problems } = claudeArgs.flagsFromForm(options, form);
71
+ if (problems.length) {
72
+ return send(`<div class="msg error">${problems.map((x) => esc(x)).join('<br>')}</div>`, 400);
73
+ }
74
+ const flags = words.join(' ');
75
+ // Resuming an existing conversation, by id: `claude --resume <id>` from the same directory. The id
76
+ // is checked against the uuid shape before it reaches a shell line, and only a session that really
77
+ // exists for THIS directory is accepted — an id from another project would resume nothing and say
78
+ // so far from here.
79
+ // The checkbox is what turns it on; the select only says which one. Without the checkbox a select
80
+ // that was left on its default would resume a session nobody asked to resume.
81
+ const wanted = form.resume_on ? String(form.resume || '').trim() : '';
82
+ const resumable = wanted && claudeSessions.isSessionId(wanted)
83
+ && claudeSessions.sessionsFor(dir, { limit: 200 }).some((x) => x.id === wanted)
84
+ ? wanted : '';
85
+ if (wanted && !resumable) {
86
+ return send(`<div class="msg error">No Claude session <code>${esc(wanted.slice(0, 40))}</code> in <code>${esc(dir)}</code> to resume.</div>`, 400);
87
+ }
88
+ const resumeFlag = resumable ? `--resume ${resumable}` : '';
89
+ // The opening prompt is free text, so it reaches claude through a private file and `$(cat …)`, never as
90
+ // a double-quoted word in the line: there `$(…)` and backticks in it would be RUN by the shell.
91
+ const promptFile = command ? writePromptFile(fullSession, command) : '';
92
+ inner = (command
93
+ ? `exec claude ${resumeFlag} ${flags} "$(cat ${shq(promptFile)})"`
94
+ : `exec claude ${resumeFlag} ${flags}`).replace(/ {2,}/g, ' ').trim();
95
+ } else if (kind === 'shell' || !command) {
96
+ inner = 'exec bash -l';
97
+ } else {
98
+ // Braces group the WHOLE command before the pipe: without them only the
99
+ // last segment of `a; b` is logged.
100
+ inner = `{ ${command} ; } 2>&1 | tee -a ${JSON.stringify(logFile)}`;
101
+ }
102
+ // The session knows where it was started without being told: the workspace, the project and the folder
103
+ // are in its environment, so a shell, a command or the agent never has to ask which workspace this is.
104
+ const sq = shq;
105
+ const known = { WEBSHIP_WORKSPACE: pm ? pm[1] : wm ? wm[1] : '', WEBSHIP_PROJECT: pm ? pm[2] : '', WEBSHIP_WORKSPACE_DIR: dir };
106
+ const vars = Object.entries(known).map(([k, v]) => `${k}=${sq(v)}`).join(' ');
107
+ // The runner keeps a command that starts with `exec ` interactive and pipes anything else through tee,
108
+ // which turns claude into --print and a shell into a pipe, so the variables go INSIDE the exec.
109
+ inner = /^exec /.test(inner) ? `exec env ${vars} ${inner.slice(5)}` : `export ${vars}; ${inner}`;
110
+ // Sessions run on the machine, not in this container — so `screen -r` in the
111
+ // user's own terminal reaches them, and a browser an agent opens is a window
112
+ // on their desktop.
113
+ if (!(await hostRunnerUp())) return send(runnerDownHtml(), 503);
114
+ const answer = await hostRunnerRequest('start', { session: fullSession, dir, owner: dir, cmd: inner });
115
+ if (!answer) return send(runnerDownHtml(), 503);
116
+ if (answer.already) return send(`<div class="msg error">A session called <strong>${esc(fullSession)}</strong> is already running.</div>`, 409);
117
+ if (!answer.ok) return send(`<div class="msg error">Could not start it: <pre>${esc(String(answer.error || '').slice(-250))}</pre></div>`, 500);
118
+
119
+ // Started for a work item (the form's `work_key`): the plugins note the run and say so on the item
120
+ // if that was asked for. The dashboard itself keeps no record of what a key means.
121
+ const workKey = String(form.work_key || '').toUpperCase();
122
+ let posted = '';
123
+ if (/^[A-Z][A-Z0-9]+-[0-9]+$/.test(workKey)) {
124
+ const wm = String(form.target || '').match(/^p:([a-z0-9_-]+):(.+)$/);
125
+ posted = await require('../plugin-loader').pluginScreenStarted({ session: fullSession, workKey,
126
+ workspace: wm ? wm[1] : '', project: wm ? wm[2] : '', dir, form });
127
+ }
128
+ // A session that exists now should show up now: the badge in the topbar, the row's SCREENS menu and
129
+ // the screens page all listen for this, so none of them waits out its own poll to admit that the
130
+ // thing you just started is running.
131
+ try { res.setHeader('HX-Trigger', JSON.stringify({ 'screens-changed': { session: fullSession } })); } catch (_) { /* headers already sent */ }
132
+ return send(posted + await screensGridHtml());
133
+ }
134
+
135
+ // The rest of the screen(1) verbs the page offers, each one command:
136
+ // detach screen -S <s> -X detach — drop every other client
137
+ // interrupt screen -S <s> -X stuff ^C — interrupt what runs inside
138
+ // wipe screen -wipe — forget dead sessions
139
+ // The dashboard's container has no screen sessions of its own any more, so a
140
+ // screen(1) verb is asked of the machine — falling back to the container only
141
+ // for the sessions started before the move.
142
+ async function onHostScreen(session, args) {
143
+ const live = await require('../screens').listScreens();
144
+ const row = live.find((s) => s.session === session);
145
+ // Stopping goes through the host runner when the runner owns the session: it started the process
146
+ // group and is the one that can take the whole thing down.
147
+ if (row && row.where === 'host' && args.includes('quit')) {
148
+ const answer = await hostRunnerRequest('stop', { session });
149
+ if (answer && answer.ok) return { ok: true, stdout: '', stderr: '' };
150
+ }
151
+ // Everything else is screen's own, and screen IN HERE can reach it: `/run/screen` is shared with
152
+ // this container, which is the same reason `screen -x` attaches at all.
153
+ //
154
+ // This used to refuse every verb but quit for any session marked `host` — which is every session,
155
+ // since that is what listScreens labels them — and answer with "run it in your terminal" instead. So
156
+ // the terminal window's Ctrl-C and Detach buttons did nothing but print a command to go and type,
157
+ // for a session this process could drive all along. Verified from the container: `screen -S <s> -X
158
+ // stuff` is accepted.
159
+ const out = await run('screen', args, process.env.HOME, { timeoutMs: 10 * 1000 });
160
+ if (out.ok) return out;
161
+ // Only when it really fails is there something for a person to do, and then the command is the one
162
+ // that was actually attempted rather than a reconstruction of it.
163
+ const verb = args.slice(3).join(' ');
164
+ return { ok: false,
165
+ stdout: out.stdout,
166
+ stderr: `${String(out.stderr || out.stdout || '').trim()}\nIf this keeps failing, run it in your terminal: screen -S ${session} -X ${verb}` };
167
+ }
168
+
169
+ function runnerDownHtml() {
170
+ return '<div class="msg error">Sessions run on your machine, not in this container, and its runner is not up. '
171
+ + 'Start it once with: <code>bash ~/workspace/core/workspace-app/scripts/screen-host.sh --start</code></div>';
172
+ }
173
+
174
+ // The modes a program turns on and does not always turn off again.
175
+ //
176
+ // A TUI asks the terminal to report the mouse (1000/1002/1003, SGR 1006/1015), to bracket pastes
177
+ // (2004) and for its own screen (1049). screen remembers the window's state and REPLAYS it to every
178
+ // display that attaches afterwards — so when the program dies without putting them back, the next
179
+ // terminal to attach has mouse reporting on with nothing consuming it, and the wheel writes
180
+ // coordinates like `<35;80;10M` into the shell instead of scrolling. It looks like a broken terminal
181
+ // and it is a mode nobody can see.
182
+ //
183
+ // There is no screen command that writes to a window's terminal: `-X echo` goes to the status line,
184
+ // and `-X stuff` goes to the program's INPUT. So the reset is what a person would type — a printf of
185
+ // the disable sequences — which is why this asks first and says so: it types a line into the session,
186
+ // and that is only harmless at a shell prompt.
187
+ const MODE_RESETS = ['?1000l', '?1001l', '?1002l', '?1003l', '?1005l', '?1006l', '?1015l', '?2004l']
188
+ .map((m) => `\\\\033[${m}`).join('');
189
+
190
+ async function screen_verb({ form, send, pathname }) {
191
+ const session = String(form.session || '');
192
+ if (pathname === '/actions/screen-wipe') {
193
+ await run('screen', ['-wipe'], process.env.HOME, { timeoutMs: 10 * 1000 });
194
+ return send(await screensGridHtml());
195
+ }
196
+ if (!/^[a-z0-9][a-zA-Z0-9_.-]*$/.test(session)) return send('<div class="msg error">Not a screen session name.</div>', 400);
197
+ if (pathname === '/actions/screen-reset-modes') {
198
+ if (form.confirm !== 'yes') return send('<div class="msg error">Resetting types a command into the session, so it asks first.</div>', 400);
199
+ const out = await onHostScreen(session, ['-S', session, '-X', 'stuff', `printf "${MODE_RESETS}"\n`]);
200
+ return send(out.ok
201
+ ? `<div class="msg assistant">Cleared the mouse and paste modes on <strong>${esc(session)}</strong>.
202
+ Every terminal attached to it, here and in a real terminal, scrolls normally again.
203
+ <p class="uk-text-meta">It typed <code>printf</code> at the session's prompt — if a full-screen program was running
204
+ rather than a shell, that line went to the program instead and nothing was reset.</p></div>`
205
+ : `<div class="msg error">Could not reach ${esc(session)}: ${esc(String(out.stderr || out.stdout).slice(-200))}</div>`, out.ok ? 200 : 500);
206
+ }
207
+ const args = pathname === '/actions/screen-detach'
208
+ ? ['-S', session, '-X', 'detach']
209
+ : ['-S', session, '-X', 'stuff', '\x03'];
210
+ const out = await onHostScreen(session, args);
211
+ const what = pathname === '/actions/screen-detach' ? 'Detached every other client from' : 'Sent Ctrl-C to';
212
+ return send(out.ok
213
+ ? `<div class="msg assistant">${what} <strong>${esc(session)}</strong>.</div>`
214
+ : `<div class="msg error">Could not reach ${esc(session)}: ${esc(String(out.stderr || out.stdout).slice(-200))}</div>`, out.ok ? 200 : 500);
215
+ }
216
+
217
+ async function screen_watch_or_stop({ form, send, pathname, res }) {
218
+ const session = String(form.session || '');
219
+ if (!/^[a-z0-9][a-zA-Z0-9_.-]*$/.test(session)) return send('<div class="msg error">Not a screen session name.</div>', 400);
220
+ if (pathname === '/actions/screen-stop') {
221
+ if (form.confirm !== 'yes') return send('<div class="msg error">Stopping requires confirmation.</div>', 400);
222
+ const out = await onHostScreen(session, ['-S', session, '-X', 'quit']);
223
+ if (!out.ok) {
224
+ // A dead socket has no process to quit — `screen -wipe` is what clears it.
225
+ await run('screen', ['-wipe'], process.env.HOME, { timeoutMs: 10 * 1000 });
226
+ const still = (await require('../screens').listScreens()).some((s) => s.session === session);
227
+ if (still) return send(`<div class="msg error">Could not stop ${esc(session)}: ${esc(String(out.stderr || out.stdout).slice(-200))}</div>`, 500);
228
+ }
229
+ // Answer with the refreshed manager so the killed row disappears at once — and say which session
230
+ // went, so a terminal open on it closes itself. A pane left open on a dead session is a terminal
231
+ // you can still type into, and nothing on the other end.
232
+ try { res.setHeader('HX-Trigger', JSON.stringify({ 'screen-killed': { session }, 'screens-changed': { session } })); } catch (_) { /* headers already sent */ }
233
+ return send(await screensGridHtml());
234
+ }
235
+ const log = screenLogFor(session);
236
+ if (!log) return send('<div class="msg error">No known log for this session — reattach in a terminal instead.</div>', 404);
237
+ const shellLine = [
238
+ `tail -n +1 -F ${JSON.stringify(log)} 2>/dev/null & TP=$!`,
239
+ `while screen -ls 2>/dev/null | grep -q "[.]${session}[[:space:]]"; do sleep 3; done`,
240
+ `sleep 1; kill $TP 2>/dev/null`,
241
+ `echo; echo "--- the session ended; full log: ${path.basename(log)} ---"`,
242
+ ].join('; ');
243
+ // Keyed, so clicking Watch twice re-shows the running tail instead of
244
+ // stacking another one against the same log.
245
+ const key = `watch:${session}`;
246
+ const existing = runningJobKey(key);
247
+ if (existing) return send(jobFragment(existing).html);
248
+ const id = startJob(`📺 Watch <strong>${esc(session)}</strong>`, 'bash', ['-c', shellLine], process.env.HOME,
249
+ { timeoutMs: 12 * 60 * 60 * 1000, inline: true, key });
250
+ return send(jobFragment(id).html);
251
+ }
252
+
253
+ async function job_stop({ form, send }) {
254
+ const id = String(form.id || '');
255
+ if (!/^[0-9]+-[a-z0-9]+$/.test(id)) return send('<div class="msg error">Not a job id.</div>', 400);
256
+ stopJob(id);
257
+ return send(jobFragment(id).html);
258
+ }
259
+
260
+ module.exports = {
261
+ '/actions/job-stop': job_stop,
262
+ '/actions/screen-new': screen_new,
263
+ '/actions/screen-detach': screen_verb,
264
+ '/actions/screen-reset-modes': screen_verb,
265
+ '/actions/screen-interrupt': screen_verb,
266
+ '/actions/screen-wipe': screen_verb,
267
+ '/actions/screen-watch': screen_watch_or_stop,
268
+ '/actions/screen-stop': screen_watch_or_stop,
269
+ onHostScreen,
270
+ };
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Setting a project's testing environment up, running its suite, and deleting what a run left.
3
+ *
4
+ * One module per area of the dashboard, each exporting the handlers it owns keyed by the path that
5
+ * reaches them. server.js merges the maps and dispatches; nothing here knows about routing, and
6
+ * adding an action is adding an entry rather than another branch in a 1,250-line function.
7
+ *
8
+ * Every handler takes the same context: the parsed form, the response, a `send` that writes an HTML
9
+ * fragment, and the workspace the request named.
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ const { esc } = require('../html');
16
+ const { humanSize, humanTime } = require('../format');
17
+ const { ROOT, loadWorkspaces, workspaceDir } = require('../workspaces');
18
+ const {
19
+ run, jobFragment, runningJobKey, startJob, ddevProjectName, listProjects,
20
+ startInScreen, screenStartedHtml,
21
+ } = require('../jobs');
22
+ const { graphStoreDir } = require('../graphs');
23
+ const {
24
+ milvusProjectName, ragInstances, ragBindings, ragInstanceFor, ragWriteBinding,
25
+ ragCollectionName, milvusPost, ragCollections, ragInvalidate,
26
+ } = require('../rag');
27
+ const {
28
+ findTestReports, testReportFor, invalidateTestReports, testMediaHtml, testingStackOf,
29
+ } = require('../test-reports');
30
+ const { NAME_RE, SCRIPT_RE } = require('../validate');
31
+ const { commandBlock } = require('../command-block');
32
+ const { wsUrl } = require('../urls');
33
+ const { findGraphifyScript, findRagifyScript } = require('../workspaces');
34
+ const { DDEV_ACTIONS } = require('../ddev');
35
+
36
+ // Delete one screenshot/recording, or all of a report's.
37
+ //
38
+ // Evidence from a finished run, and the next run writes its own — so it is deletable, unlike
39
+ // anything else in a project. Three things keep it safe:
40
+ // * no path ever comes from the request. The name is looked up in the report's OWN file list,
41
+ // the same rule the viewer uses, so a crafted name resolves to nothing.
42
+ // * every resolved file is realpath-checked to be inside the project directory.
43
+ // * a screenshot takes its DOM dump with it: the dump is written per failed step and is
44
+ // meaningless once the picture is gone.
45
+ async function test_media_delete({ form, res, send, workspace, pathname }) {
46
+ const dir = workspaceDir(workspace);
47
+ const projectName = String(form.projectName || '');
48
+ const kind = String(form.kind || '') === 'videos' ? 'videos' : 'shots';
49
+ if (form.confirm !== 'yes') return send('<div class="msg error">Deleting requires confirmation.</div>', 400);
50
+ if (!NAME_RE.test(projectName) || !fs.existsSync(path.join(dir, projectName))) {
51
+ return send('<div class="msg error">Unknown project.</div>', 400);
52
+ }
53
+ const t = testReportFor(dir, projectName);
54
+ if (!t) return send('<div class="msg error">No report for this project.</div>', 404);
55
+
56
+ const listed = kind === 'videos' ? t.report.videos : t.report.shots;
57
+ const wanted = String(form.name || '');
58
+ const picked = form.all === 'yes' ? listed : listed.filter((f) => f.name === wanted);
59
+ if (!picked.length) return send('<div class="msg error">That file is not in the report.</div>', 404);
60
+
61
+ // A screenshot's DOM dump shares its stem.
62
+ const stem = (n) => n.replace(/\.(png|jpe?g|html?)$/i, '');
63
+ const doms = kind === 'shots'
64
+ ? t.report.doms.filter((d) => picked.some((f) => stem(d.name) === stem(f.name)))
65
+ : [];
66
+
67
+ const root = fs.realpathSync(path.join(dir, projectName));
68
+ let removed = 0;
69
+ let bytes = 0;
70
+ for (const f of [...picked, ...doms]) {
71
+ const full = path.join(dir, projectName, f.file);
72
+ let real;
73
+ try {
74
+ real = fs.realpathSync(full);
75
+ if (!real.startsWith(root + path.sep)) continue; // outside the project: not ours to delete
76
+ } catch (_) { continue; } // already gone
77
+ try {
78
+ bytes += fs.statSync(real).size;
79
+ fs.unlinkSync(real);
80
+ removed += 1;
81
+ } catch (_) { /* raced with something else */ }
82
+ }
83
+ invalidateTestReports();
84
+ res.setHeader('HX-Trigger', 'refresh-projects');
85
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
86
+ return res.end(`<div class="msg assistant test-media-flash">🗑️ Deleted ${removed} file${removed === 1 ? '' : 's'} (${esc(humanSize(bytes))}).</div>`
87
+ + testMediaHtml(workspace, projectName, kind, 1));
88
+ }
89
+
90
+ // Set up a project's testing environment, and run its suite. Both are the workspace command,
91
+ // run as a job — they take minutes (an add-on install pulls browsers; a Webship suite is
92
+ // hundreds of scenarios) and both must not overlap on one project: two configures race on the
93
+ // same scaffold, and two runs write the same report.
94
+ async function testing_configure({ form, res, send, workspace, pathname }) {
95
+ const dir = workspaceDir(workspace);
96
+ const projectName = String(form.projectName || '');
97
+ const script = 'cmd-tools-testing.sh';
98
+ const isRun = pathname.endsWith('-run');
99
+ if (!fs.existsSync(path.join(dir, script))) return send('<div class="msg error">No testing script in this workspace.</div>', 400);
100
+ if (!NAME_RE.test(projectName) || !listProjects(dir).includes(projectName)) {
101
+ return send('<div class="msg error">Unknown project.</div>', 400);
102
+ }
103
+ if (isRun && testingStackOf(dir, projectName) === 'none') {
104
+ return send('<div class="msg error">No testing stack in this project yet — set the environment up first.</div>', 409);
105
+ }
106
+ const jobKey = `testing:${workspace}/${projectName}`;
107
+ if (runningJobKey(jobKey)) {
108
+ return send('<div class="msg error">A testing job is already running for this project.</div>', 409);
109
+ }
110
+ const args = isRun ? [script, projectName, '--run'] : [script, projectName];
111
+ const started = await startInScreen({ workspace, project: projectName, projectDir: dir, ownerDir: path.join(dir, projectName),
112
+ verb: isRun ? 'tests' : 'tests-setup', cmdLine: `bash ${JSON.stringify(script)} ${JSON.stringify(projectName)}${isRun ? ' --run' : ''}`,
113
+ label: isRun ? 'run the tests' : 'set up testing' });
114
+ return send(screenStartedHtml(started, isRun ? 'run the tests' : 'set up testing', projectName), started.ok ? 200 : (started.already ? 409 : 500));
115
+ }
116
+
117
+ module.exports = {
118
+ '/actions/test-media-delete': test_media_delete,
119
+ '/actions/testing-configure': testing_configure,
120
+ '/actions/testing-run': testing_configure,
121
+ };
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Edit a video into a new version: the tabs of the Edit modal post here.
3
+ *
4
+ * The form is `video-edit.js`; the work is `videos/cmd-tools-video-edit.sh`, run on the host in a screen
5
+ * session like every other long action. Only a new section spoken with ElevenLabs spends credits, and that
6
+ * needs `confirm=yes`, which only the confirm dialog's button sends.
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ const { esc } = require('../html');
13
+ const { workspaceDir } = require('../workspaces');
14
+ const { startInScreen, screenStartedHtml } = require('../jobs');
15
+ const voice = require('../audio-voice');
16
+ const videoEdit = require('../video-edit');
17
+
18
+ async function video_edit({ form, send, workspace }) {
19
+ if (workspace !== 'videos') return send('<div class="msg error">Videos are edited in the videos workspace.</div>', 400);
20
+ const found = form.mode === 'sections' ? await voice.loadEngines() : { engines: [] };
21
+ const p = videoEdit.plan(form, found.engines);
22
+ if (p.error) return send(`<div class="msg error">${esc(p.error)}</div>`, 400);
23
+ if (p.spends && form.confirm !== 'yes') return send(videoEdit.spendHtml(p));
24
+ const stringsFile = Object.keys(p.strings).length ? videoEdit.writeStrings(p.strings) : null;
25
+ const opts = { workspace, project: path.basename(p.out, '.mp4'), projectDir: workspaceDir('videos'), verb: 'edit',
26
+ cmdLine: videoEdit.commandLine(p, stringsFile, true), label: `video edit ${p.out}` };
27
+ const started = await startInScreen(opts);
28
+ if (!started.ok) {
29
+ if (stringsFile) fs.rmSync(path.dirname(stringsFile), { recursive: true, force: true });
30
+ return send(screenStartedHtml(started, opts.label, opts.project), started.already ? 409 : 500);
31
+ }
32
+ return send(voice.progressHtml({ kind: 'video', file: p.out, session: started.session, t0: Date.now(), engine: '', edit: '1' }, null));
33
+ }
34
+
35
+ // Ask the AI to make the change: a Claude Code screen in the videos folder, briefed with the video, its script,
36
+ // the tools, the rules, the settings already chosen on the tab, and the person's own words.
37
+ async function video_edit_ai({ form, send, res }) {
38
+ const brief = videoEdit.askBrief(form);
39
+ if (brief.error) return send(`<div class="msg error">${esc(brief.error)}</div>`, 400);
40
+ let out = null;
41
+ await require('./screens')['/actions/screen-new']({
42
+ form: { target: brief.target, kind: 'claude', session: brief.session, command: brief.prompt },
43
+ send: (html, status = 200) => { out = { html, status }; },
44
+ res,
45
+ });
46
+ if (!out || out.status >= 400) return send((out && out.html) || '<div class="msg error">The session did not start.</div>', (out && out.status) || 500);
47
+ const sessionName = `${brief.target.startsWith('p:') ? brief.target.slice(2).replace(':', '--') : 'videos'}--${brief.session}`.toLowerCase();
48
+ return send(`<div class="msg assistant">
49
+ <p>The AI (Claude Code) is working on <strong>${esc(brief.file)}</strong> in the session <code>${esc(sessionName)}</code>.</p>
50
+ <p class="uk-text-meta">Watch it, answer it and approve its steps on <a href="/screens">Screens</a>, or <code>screen -r ${esc(sessionName)}</code> in your own terminal.
51
+ The new version appears in the list when it is done; the original is not touched.</p></div>`);
52
+ }
53
+
54
+ module.exports = { '/actions/video-edit': video_edit, '/actions/video-edit-ai': video_edit_ai };
@@ -0,0 +1,182 @@
1
+ /**
2
+ * What "@" and "/" can offer, and what the AI Assistant is told it has.
3
+ *
4
+ * ONE inventory, read by three callers that used to disagree or not know at all:
5
+ *
6
+ * - the suggestion popup in any prompt field (@ for agents and skills, / for commands),
7
+ * - the Ace completer on the New screen form,
8
+ * - the assistant's own system prompt, which never listed these at all — so it could
9
+ * not use a skill it had, and would improvise instead of invoking one.
10
+ *
11
+ * The DESCRIPTION is why this is not just a directory listing. Claude Code chooses an
12
+ * agent or a skill by its frontmatter `description`, and a picker that shows only names
13
+ * makes the reader guess between `webship-workspace-build` and `webship-workspace-backups`.
14
+ * The same line is what lets the assistant pick correctly, so both read it from the file
15
+ * rather than from a hand-kept table that would drift.
16
+ *
17
+ * Three places hold items, and precedence runs the same way Claude Code resolves them:
18
+ * a project's own .claude/ wins over ~/.claude, which wins over the workspace draft
19
+ * folders (ai-agents/, ai-skills/, ai-commands/, ai-prompts/). `where` says which, so
20
+ * the list can show it and the assistant knows whether an item is actually installed.
21
+ */
22
+
23
+ const fs = require('fs');
24
+ const path = require('path');
25
+
26
+ const { ROOT } = require('./workspaces');
27
+
28
+ /**
29
+ * The workspace tooling itself: `webship-workspace` and its skills (-build, -backups,
30
+ * -graphify, -dashboard, -authoring, -site-templates, -upsun, -linux-install).
31
+ *
32
+ * These are BUILT IN to the AI Assistant — it already operates this workspace, and its
33
+ * system prompt carries these rules whether anybody names them or not. So offering them
34
+ * in the picker is noise at best and misleading at worst: it implies that building a
35
+ * project needs you to remember `@webship-workspace-build` first, when asking for a build
36
+ * is enough. Nine rows out of 135, and they crowd the top of the list because they share
37
+ * a prefix with the product's own name.
38
+ *
39
+ * They stay in the inventory — the assistant is still told it has them, so it can say
40
+ * which one it is following — and are filtered out of the SUGGESTIONS only.
41
+ */
42
+ const BUILT_IN = /^webship-workspace(-|$)/;
43
+
44
+ // Kind -> which trigger character offers it.
45
+ //
46
+ // This mirrors the CLI rather than inventing a scheme: in Claude Code a slash command is
47
+ // typed as `/name`, and an agent is named in prose. Skills appear under BOTH here on
48
+ // purpose — a skill is a capability you name (@) and also the thing you most often want
49
+ // to invoke directly (/) — and prompts under neither, because a prompt is a whole
50
+ // `claude …` launch command for a NEW session, so it cannot be dropped into a message
51
+ // that is already running. The New screen form offers those in its own template picker.
52
+ const TRIGGERS = {
53
+ agent: ['@'],
54
+ skill: ['@', '/'],
55
+ command: ['/'],
56
+ };
57
+
58
+ // A frontmatter description, as one line.
59
+ //
60
+ // Read from the head of the file only: a skill can be hundreds of lines and none of the
61
+ // rest is frontmatter. The value may be folded over several lines in YAML, so a
62
+ // continuation (indented, no `key:` of its own) is joined back on.
63
+ const HEAD_BYTES = 4096;
64
+ function descriptionOf(file) {
65
+ let head = '';
66
+ try {
67
+ const fd = fs.openSync(file, 'r');
68
+ const buf = Buffer.alloc(HEAD_BYTES);
69
+ const read = fs.readSync(fd, buf, 0, HEAD_BYTES, 0);
70
+ fs.closeSync(fd);
71
+ head = buf.slice(0, read).toString('utf8');
72
+ } catch (_) { return ''; }
73
+ if (!/^---\s*$/m.test(head.split('\n')[0] || '')) return '';
74
+ const lines = head.split('\n').slice(1);
75
+ let out = '';
76
+ for (let i = 0; i < lines.length; i += 1) {
77
+ const line = lines[i];
78
+ if (/^---\s*$/.test(line)) break;
79
+ const m = line.match(/^description:\s*(.*)$/);
80
+ if (!m) continue;
81
+ out = m[1].trim().replace(/^["']|["']$/g, '');
82
+ // A folded value: following lines that are indented and are not themselves a key.
83
+ for (let j = i + 1; j < lines.length; j += 1) {
84
+ if (/^---\s*$/.test(lines[j])) break;
85
+ if (/^\s+\S/.test(lines[j]) && !/^\s*[a-zA-Z_-]+:/.test(lines[j])) {
86
+ out += ` ${lines[j].trim()}`;
87
+ } else break;
88
+ }
89
+ break;
90
+ }
91
+ return out.replace(/\s+/g, ' ').trim();
92
+ }
93
+
94
+ /** Items of one kind in one directory. Skills are directories with a SKILL.md. */
95
+ function readKind(dir, kind, where, out) {
96
+ let entries = [];
97
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; }
98
+ for (const e of entries) {
99
+ let name = '';
100
+ let file = '';
101
+ if (kind === 'skill') {
102
+ if (!e.isDirectory()) continue;
103
+ file = path.join(dir, e.name, 'SKILL.md');
104
+ if (!fs.existsSync(file)) continue;
105
+ name = e.name;
106
+ } else {
107
+ if (!e.isFile() || !e.name.endsWith('.md')) continue;
108
+ // A folder's own documentation is not an item in it.
109
+ if (['README.md', 'AGENTS.md', 'CLAUDE.md'].includes(e.name)) continue;
110
+ name = e.name.replace(/\.md$/, '');
111
+ file = path.join(dir, e.name);
112
+ }
113
+ const key = `${kind}:${name}`;
114
+ // First writer wins, and callers add in precedence order: a project's own copy of an
115
+ // item is the one that would actually run there, so it must not be overwritten by
116
+ // the global one found later.
117
+ if (out.has(key)) continue;
118
+ out.set(key, { name, kind, where, file, description: descriptionOf(file),
119
+ triggers: TRIGGERS[kind] || [], builtIn: BUILT_IN.test(name) });
120
+ }
121
+ }
122
+
123
+ // The filesystem is walked per request otherwise, and a keystroke in the composer asks
124
+ // for this list. Short enough that installing a skill shows up while you are looking.
125
+ const TTL = 5000;
126
+ const _cache = new Map();
127
+
128
+ /**
129
+ * Everything "@" and "/" can offer here.
130
+ * @param {string} [workspace] the workspace key, when a page is scoped to one
131
+ * @param {string} [project] a project inside it, whose own .claude/ takes precedence
132
+ */
133
+ function aiInventory(workspace, project) {
134
+ const ck = `${workspace || ''}/${project || ''}`;
135
+ const hit = _cache.get(ck);
136
+ if (hit && Date.now() - hit.at < TTL) return hit.items;
137
+
138
+ const home = process.env.HOME || '';
139
+ const items = new Map();
140
+
141
+ // Precedence, highest first.
142
+ if (workspace && project) {
143
+ const pDir = path.join(ROOT, workspace, project, '.claude');
144
+ readKind(path.join(pDir, 'agents'), 'agent', 'this project', items);
145
+ readKind(path.join(pDir, 'skills'), 'skill', 'this project', items);
146
+ readKind(path.join(pDir, 'commands'), 'command', 'this project', items);
147
+ }
148
+ readKind(path.join(home, '.claude', 'agents'), 'agent', 'installed', items);
149
+ readKind(path.join(home, '.claude', 'skills'), 'skill', 'installed', items);
150
+ readKind(path.join(home, '.claude', 'commands'), 'command', 'installed', items);
151
+ // The workspace drafts. Anything only here is NOT loaded by a session yet, which the
152
+ // label has to say — otherwise a draft reads as available and silently does nothing.
153
+ readKind(path.join(ROOT, 'ai-agents'), 'agent', 'workspace draft', items);
154
+ readKind(path.join(ROOT, 'ai-skills'), 'skill', 'workspace draft', items);
155
+ readKind(path.join(ROOT, 'ai-commands'), 'command', 'workspace draft', items);
156
+
157
+ const list = [...items.values()].sort((a, b) => a.name.localeCompare(b.name));
158
+ _cache.set(ck, { at: Date.now(), items: list });
159
+ return list;
160
+ }
161
+
162
+ /** Just the items a trigger character offers, optionally filtered by what is typed. */
163
+ function suggestFor(trigger, typed, workspace, project) {
164
+ const q = String(typed || '').toLowerCase();
165
+ return aiInventory(workspace, project)
166
+ // The assistant's own tooling is not something to pick — see BUILT_IN above.
167
+ .filter((i) => !i.builtIn)
168
+ .filter((i) => i.triggers.includes(trigger))
169
+ .filter((i) => !q || i.name.toLowerCase().includes(q)
170
+ || (i.description || '').toLowerCase().includes(q))
171
+ .sort((a, b) => {
172
+ // What the user is typing, first: a name that STARTS with it beats one that
173
+ // merely contains it, and a description match comes last.
174
+ const rank = (i) => (i.name.toLowerCase().startsWith(q) ? 0
175
+ : i.name.toLowerCase().includes(q) ? 1 : 2)
176
+ // then by how close to hand it is
177
+ + (i.where === 'this project' ? 0 : i.where === 'installed' ? 0.1 : 0.2);
178
+ return rank(a) - rank(b) || a.name.localeCompare(b.name);
179
+ });
180
+ }
181
+
182
+ module.exports = { aiInventory, suggestFor, descriptionOf, TRIGGERS, BUILT_IN };