@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.
- package/PLUGINS.md +249 -0
- package/README.md +379 -2
- package/WORKSPACES.md +69 -0
- package/actions/ai-items.js +293 -0
- package/actions/audio.js +48 -0
- package/actions/build-order.js +79 -0
- package/actions/checks.js +95 -0
- package/actions/claude-session.js +104 -0
- package/actions/commands-center.js +258 -0
- package/actions/composer.js +267 -0
- package/actions/ddev-addons.js +90 -0
- package/actions/ddev-mail.js +80 -0
- package/actions/ddev.js +237 -0
- package/actions/folders.js +165 -0
- package/actions/graphs.js +107 -0
- package/actions/images-svg.js +33 -0
- package/actions/images.js +145 -0
- package/actions/navigation.js +190 -0
- package/actions/node.js +222 -0
- package/actions/notes.js +55 -0
- package/actions/obsidian.js +59 -0
- package/actions/plugins.js +36 -0
- package/actions/project-make.js +28 -0
- package/actions/rag.js +202 -0
- package/actions/row-actions.js +262 -0
- package/actions/screens.js +270 -0
- package/actions/testing.js +121 -0
- package/actions/video-edit.js +54 -0
- package/ai-inventory.js +182 -0
- package/ai-items.js +202 -0
- package/assistant-cast.js +176 -0
- package/assistant-suggestions.js +91 -0
- package/assistant.js +484 -0
- package/audio-voice.js +528 -0
- package/auth-google.js +260 -0
- package/build-ai.js +52 -0
- package/build-order-page.js +191 -0
- package/build-order.js +313 -0
- package/builder-args.js +117 -0
- package/changelog.js +302 -0
- package/chat-threads.js +134 -0
- package/checks.js +105 -0
- package/claude-args.js +282 -0
- package/claude-sessions.js +129 -0
- package/command-author.js +117 -0
- package/command-block.js +20 -0
- package/commands/defaults.yml +159 -0
- package/commands-page.js +460 -0
- package/commands.js +568 -0
- package/cucumber.js +43 -0
- package/ddev-addons-page.js +124 -0
- package/ddev-addons.js +154 -0
- package/ddev-custom-commands.js +96 -0
- package/ddev-domains.js +84 -0
- package/ddev-import.js +93 -0
- package/ddev-mail-page.js +167 -0
- package/ddev-mail.js +110 -0
- package/ddev-options.js +145 -0
- package/ddev-snapshots.js +126 -0
- package/ddev.js +145 -0
- package/docs/DEVELOPER-GUIDE.md +224 -0
- package/docs/README.md +252 -0
- package/docs/REFERENCE.md +684 -0
- package/docs/USER-GUIDE.md +697 -0
- package/docs/screenshots/assistant/avatar-board.png +0 -0
- package/docs/screenshots/assistant/avatar-coffee.png +0 -0
- package/docs/screenshots/assistant/avatar-colours.png +0 -0
- package/docs/screenshots/assistant/avatar-icons.png +0 -0
- package/docs/screenshots/assistant/avatar-moves.png +0 -0
- package/docs/screenshots/assistant/avatar-pick-cookieman.png +0 -0
- package/docs/screenshots/assistant/avatar-settings-voices.png +0 -0
- package/docs/screenshots/assistant/avatar-status.png +0 -0
- package/docs/screenshots/create-card/create-card.png +0 -0
- package/docs/screenshots/create-card/start-ai.png +0 -0
- package/docs/screenshots/guide-clone-address.png +0 -0
- package/docs/screenshots/guide-clone-worktree-off.png +0 -0
- package/docs/screenshots/guide-clone-worktree-on.png +0 -0
- package/docs/screenshots/guide-ddev-menu.png +0 -0
- package/docs/screenshots/guide-domains.png +0 -0
- package/docs/screenshots/guide-home.png +0 -0
- package/docs/screenshots/guide-ide-menu.png +0 -0
- package/docs/screenshots/guide-remove-refusal.png +0 -0
- package/docs/screenshots/guide-rows-worktree.png +0 -0
- package/docs/screenshots/guide-screens.png +0 -0
- package/docs/screenshots/guide-settings.png +0 -0
- package/docs/screenshots/guide-workspace-page.png +0 -0
- package/docs/screenshots/home/card-settings.png +0 -0
- package/docs/screenshots/home/drag-reorder.png +0 -0
- package/docs/screenshots/home/edit-card.png +0 -0
- package/docs/screenshots/images/claude-code-image.png +0 -0
- package/docs/screenshots/images/edit-with-ai.png +0 -0
- package/docs/screenshots/images/gallery.png +0 -0
- package/docs/screenshots/images/generate-description.png +0 -0
- package/docs/screenshots/images/generate-plan.png +0 -0
- package/docs/screenshots/images/providers.png +0 -0
- package/docs/screenshots/images/svg-form.png +0 -0
- package/docs/screenshots/images/svg-result.png +0 -0
- package/docs/screenshots/images/upload.png +0 -0
- package/docs/screenshots/new-folder/2-form.png +0 -0
- package/docs/screenshots/new-folder/8-ddev-options.png +0 -0
- package/docs/screenshots/new-folder/files-folder.png +0 -0
- package/docs/screenshots/projects/ddev-custom-commands.png +0 -0
- package/docs/screenshots/projects/state-chip-stop.png +0 -0
- package/docs/screenshots/readme/dashboard-dark.png +0 -0
- package/docs/screenshots/readme/dashboard-light.png +0 -0
- package/docs/screenshots/readme/dev-dark.png +0 -0
- package/docs/screenshots/readme/dev-light.png +0 -0
- package/docs/screenshots/readme/docs-dark.png +0 -0
- package/docs/screenshots/readme/docs-light.png +0 -0
- package/docs/screenshots/readme/icons/ai.svg +4 -0
- package/docs/screenshots/readme/icons/code.svg +4 -0
- package/docs/screenshots/readme/icons/docs.svg +4 -0
- package/docs/screenshots/readme/icons/services.svg +4 -0
- package/docs/screenshots/readme/icons/sites.svg +4 -0
- package/docs/screenshots/readme/test-report-dark.png +0 -0
- package/docs/screenshots/readme/test-report-light.png +0 -0
- package/docs/screenshots/readme/test-scenarios.png +0 -0
- package/docs/screenshots/readme/test-steps.png +0 -0
- package/docs/screenshots/readme/videos-dark.png +0 -0
- package/docs/screenshots/readme/videos-light.png +0 -0
- package/docs/screenshots/screens-menu.png +0 -0
- package/docs/screenshots/screens-new.png +0 -0
- package/docs/screenshots/settings/auth-google-lists.png +0 -0
- package/docs/screenshots/settings/delete-1-row.png +0 -0
- package/docs/screenshots/settings/delete-2-confirm.png +0 -0
- package/docs/screenshots/settings/delete-3-type-name.png +0 -0
- package/docs/screenshots/settings/delete-4-type-yes.png +0 -0
- package/event-log.js +59 -0
- package/files-browser.js +247 -0
- package/folders.js +121 -0
- package/format.js +73 -0
- package/git-info.js +81 -0
- package/github-auth.js +62 -0
- package/graphs.js +104 -0
- package/html.js +10 -0
- package/icons.js +83 -0
- package/images-ai.js +447 -0
- package/images-create.js +226 -0
- package/images-page.js +417 -0
- package/images-records.js +39 -0
- package/images-render.js +36 -0
- package/images-svg-core.js +214 -0
- package/images-svg-sanitize.js +188 -0
- package/images-svg.js +241 -0
- package/images.js +270 -0
- package/jobs.js +430 -0
- package/linger.js +21 -0
- package/lists.js +292 -0
- package/media-info.js +136 -0
- package/navigation-page.js +264 -0
- package/navigation.js +394 -0
- package/notes.js +0 -0
- package/obsidian.js +405 -0
- package/package.json +47 -4
- package/palette-index.js +46 -0
- package/playwright.config.js +23 -0
- package/plugin-install.js +239 -0
- package/plugin-loader.js +448 -0
- package/plugin-manifest.js +83 -0
- package/plugin-settings.js +44 -0
- package/plugins/workspace-notes/index.js +59 -0
- package/plugins-page.js +103 -0
- package/project-make.js +241 -0
- package/project-name.js +210 -0
- package/public/assistant-avatar-moves.js +646 -0
- package/public/assistant-avatar-ui.js +1223 -0
- package/public/assistant-avatar.js +530 -0
- package/public/avatar/druplicon.vrm +0 -0
- package/public/css/base.css +260 -0
- package/public/css/components/ai-assistant.css +263 -0
- package/public/css/components/assistant-avatar.css +279 -0
- package/public/css/components/assistant-views.css +198 -0
- package/public/css/components/build-order.css +74 -0
- package/public/css/components/clone-as-a-git-worktree.css +30 -0
- package/public/css/components/command-palette.css +61 -0
- package/public/css/components/commands-center.css +139 -0
- package/public/css/components/confirm-dialog.css +40 -0
- package/public/css/components/create-card.css +8 -0
- package/public/css/components/ddev-menu.css +23 -0
- package/public/css/components/deep-chat.css +17 -0
- package/public/css/components/density.css +11 -0
- package/public/css/components/floating-assistant-workspace-pages.css +55 -0
- package/public/css/components/folders.css +5 -0
- package/public/css/components/full-height-left-assistant-sidebar-all-pages.css +39 -0
- package/public/css/components/full-height-right-actions-rail-pages-that-have-actions.css +267 -0
- package/public/css/components/grid-and-list.css +203 -0
- package/public/css/components/help-page.css +36 -0
- package/public/css/components/hero.css +16 -0
- package/public/css/components/images.css +70 -0
- package/public/css/components/layout-rhythm-and-spacing-polish.css +139 -0
- package/public/css/components/mic-on-any-field-that-wants-typing.css +416 -0
- package/public/css/components/modal-fit.css +113 -0
- package/public/css/components/page-width.css +23 -0
- package/public/css/components/plugins-page.css +9 -0
- package/public/css/components/project-cards.css +256 -0
- package/public/css/components/project-rows.css +390 -0
- package/public/css/components/reordering-the-workspace-cards.css +85 -0
- package/public/css/components/responsive-home-and-the-workspace-pages.css +457 -0
- package/public/css/components/running-commands-as-toasts.css +327 -0
- package/public/css/components/settings-editor-core-config-yml.css +828 -0
- package/public/css/components/short-results-as-a-toast-at-the-top.css +203 -0
- package/public/css/components/sound-player.css +84 -0
- package/public/css/components/suggest-agents-skills-commands.css +112 -0
- package/public/css/components/tab-modal.css +142 -0
- package/public/css/components/terminals.css +244 -0
- package/public/css/components/test-report-media-one-file-per-row-paged.css +144 -0
- package/public/css/components/top-toolbar-logo-breadcrumb.css +107 -0
- package/public/css/components/video-edit.css +6 -0
- package/public/css/components/web-ide-open-with.css +23 -0
- package/public/css/components/working-indicators.css +158 -0
- package/public/css/components/workspace-cards.css +79 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.png +0 -0
- package/public/htmx.min.js +1 -0
- package/public/logo.png +0 -0
- package/public/media-player.js +215 -0
- package/public/mic.js +255 -0
- package/public/narration.js +110 -0
- package/public/palette.js +278 -0
- package/public/sortable.js +251 -0
- package/public/suggest.js +337 -0
- package/public/terminal.js +903 -0
- package/public/themes/default/theme.css +267 -0
- package/public/themes/ember/theme.css +180 -0
- package/public/themes/press/theme.css +290 -0
- package/public/themes/slate/theme.css +182 -0
- package/public/touch-icon.png +0 -0
- package/public/ui.js +2721 -0
- package/public/vendor/ace/LICENSE.txt +24 -0
- package/public/vendor/ace/ace.js +23 -0
- package/public/vendor/ace/ext-language_tools.js +8 -0
- package/public/vendor/ace/ext-searchbox.js +8 -0
- package/public/vendor/ace/mode-css.js +8 -0
- package/public/vendor/ace/mode-html.js +8 -0
- package/public/vendor/ace/mode-javascript.js +8 -0
- package/public/vendor/ace/mode-markdown.js +8 -0
- package/public/vendor/ace/mode-sh.js +8 -0
- package/public/vendor/ace/mode-yaml.js +8 -0
- package/public/vendor/ace/theme-textmate.js +8 -0
- package/public/vendor/ace/theme-tomorrow_night.js +8 -0
- package/public/vendor/ace/worker-yaml.js +1 -0
- package/public/vendor/deep-chat.LICENSE +21 -0
- package/public/vendor/deep-chat.bundle.js +1 -0
- package/public/vendor/fonts/dm-serif-text-LICENSE.txt +93 -0
- package/public/vendor/fonts/dm-serif-text-latin-400-italic.woff2 +0 -0
- package/public/vendor/fonts/dm-serif-text-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/figtree-LICENSE.txt +93 -0
- package/public/vendor/fonts/figtree-latin-400-italic.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-600-italic.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-600-normal.woff2 +0 -0
- package/public/vendor/fonts/jetbrains-mono-LICENSE.txt +93 -0
- package/public/vendor/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/ubuntu-mono-LICENSE.txt +98 -0
- package/public/vendor/fonts/ubuntu-mono-latin-wght-normal.woff2 +0 -0
- package/public/vendor/tabler-LICENSE.txt +21 -0
- package/public/vendor/tabler-sprite.svg +1 -0
- package/public/vendor/three/LICENSE +21 -0
- package/public/vendor/three/addons/loaders/GLTFLoader.js +4860 -0
- package/public/vendor/three/addons/utils/BufferGeometryUtils.js +1501 -0
- package/public/vendor/three/addons/utils/SkeletonUtils.js +496 -0
- package/public/vendor/three/three.core.min.js +6 -0
- package/public/vendor/three/three.module.min.js +6 -0
- package/public/vendor/three-vrm/LICENSE +22 -0
- package/public/vendor/three-vrm/three-vrm.module.min.js +983 -0
- package/public/vendor/uikit-icons.min.js +1 -0
- package/public/vendor/uikit.min.css +1 -0
- package/public/vendor/uikit.min.js +1 -0
- package/public/vendor/webship-icons/ui/bookmark-outlined.svg +1 -0
- package/public/vendor/webship-icons/ui/headset.svg +1 -0
- package/public/vendor/webship-icons/ui/menu.svg +1 -0
- package/public/vendor/webship-icons/ui/pause.svg +1 -0
- package/public/vendor/webship-icons/ui/play.svg +1 -0
- package/public/vendor/webship-icons/ui/share.svg +1 -0
- package/public/vendor/webship-icons/ui/sound-off.svg +1 -0
- package/public/vendor/webship-icons/ui/sound-on.svg +1 -0
- package/public/vendor/webship-icons/ui/stop.svg +1 -0
- package/public/vendor/xterm/addon-fit.js +2 -0
- package/public/vendor/xterm/addon-unicode11.js +2 -0
- package/public/vendor/xterm/addon-web-links.js +2 -0
- package/public/vendor/xterm/xterm.css +218 -0
- package/public/vendor/xterm/xterm.js +2 -0
- package/rag.js +169 -0
- package/reader.js +132 -0
- package/repo-blobs.js +66 -0
- package/report-pdf.js +337 -0
- package/row-layout.js +1440 -0
- package/screenrc +37 -0
- package/screens.js +1063 -0
- package/scripts/code-server-start.sh +190 -0
- package/scripts/images-claude-finish.js +107 -0
- package/scripts/screen-host.sh +114 -0
- package/scripts/screen-runner.js +362 -0
- package/scripts/screen-then.js +47 -0
- package/scripts/update.sh +350 -0
- package/server.js +8140 -0
- package/settings-file.js +423 -0
- package/speech.js +297 -0
- package/tab-modal.js +75 -0
- package/test-reports.js +260 -0
- package/themes.js +381 -0
- package/transcripts.js +168 -0
- package/urls.js +13 -0
- package/validate.js +18 -0
- package/version-check.js +94 -0
- package/video-edit.js +588 -0
- package/waiting.js +85 -0
- package/workspace-card-tools.js +41 -0
- package/workspace-delete.js +139 -0
- package/workspace-types.js +85 -0
- package/workspaces.js +557 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The host screen runner.
|
|
4
|
+
*
|
|
5
|
+
* Screen sessions belong on the machine, not inside the dashboard's container:
|
|
6
|
+
* that is where the display is, where ~/.claude lives, and where a browser an
|
|
7
|
+
* agent drives can actually be watched. But the dashboard runs INSIDE the
|
|
8
|
+
* container and cannot start or attach a host process — so this small server
|
|
9
|
+
* stands on the host and does it on the dashboard's behalf.
|
|
10
|
+
*
|
|
11
|
+
* start POST /start {session, dir, cmd} -> screen -dmS on this machine
|
|
12
|
+
* list GET /list -> what `screen -ls` says here
|
|
13
|
+
* stop POST /stop {session}
|
|
14
|
+
* attach WS /attach/<session> -> a PTY sharing that session
|
|
15
|
+
*
|
|
16
|
+
* It binds to 127.0.0.1 only and requires a token that lives in the workspace
|
|
17
|
+
* (which the container already mounts), so the dashboard can read it and other
|
|
18
|
+
* machines cannot reach it at all.
|
|
19
|
+
*
|
|
20
|
+
* Run it with: bash <workspace>/core/workspace-app/scripts/screen-host.sh --start
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const http = require('http');
|
|
24
|
+
const fs = require('fs');
|
|
25
|
+
const path = require('path');
|
|
26
|
+
const { spawn, execFile } = require('child_process');
|
|
27
|
+
const crypto = require('crypto');
|
|
28
|
+
// One copy of the working-vs-waiting rules, shared with the dashboard. No dependencies.
|
|
29
|
+
const { waitingState } = require(path.join(__dirname, '..', 'waiting'));
|
|
30
|
+
|
|
31
|
+
// The workspace is two levels up from this script: core/workspace-app/scripts.
|
|
32
|
+
const ROOT = process.env.WORKSPACE_ROOT || path.resolve(__dirname, '..', '..', '..');
|
|
33
|
+
const STORE = path.join(ROOT, '.screens');
|
|
34
|
+
const PORT = Number(process.env.SCREEN_RUNNER_PORT || 7681);
|
|
35
|
+
const NAME_RE = /^[a-z0-9][a-zA-Z0-9._-]*$/;
|
|
36
|
+
|
|
37
|
+
fs.mkdirSync(STORE, { recursive: true });
|
|
38
|
+
|
|
39
|
+
// The token is the whole access control: readable by this user (and so by the
|
|
40
|
+
// container, which mounts this tree), unreadable by anything else.
|
|
41
|
+
const tokenFile = path.join(STORE, '.runner-token');
|
|
42
|
+
let TOKEN;
|
|
43
|
+
try {
|
|
44
|
+
TOKEN = fs.readFileSync(tokenFile, 'utf8').trim();
|
|
45
|
+
if (!TOKEN) throw new Error('empty');
|
|
46
|
+
} catch (_) {
|
|
47
|
+
TOKEN = crypto.randomBytes(24).toString('hex');
|
|
48
|
+
fs.writeFileSync(tokenFile, `${TOKEN}\n`, { mode: 0o600 });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Who may reach the runner: this machine, and the container on the docker
|
|
52
|
+
// bridge — nothing from the network beyond. The socket has to listen past
|
|
53
|
+
// loopback for the container to arrive at all, so the peer is checked here.
|
|
54
|
+
const localPeer = (socket) => {
|
|
55
|
+
const a = String(socket.remoteAddress || '').replace(/^::ffff:/, '');
|
|
56
|
+
return a === '127.0.0.1' || a === '::1'
|
|
57
|
+
|| /^172\.(1[6-9]|2\d|3[01])\./.test(a) // docker's default bridge ranges
|
|
58
|
+
|| /^10\./.test(a) || /^192\.168\./.test(a);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const ok = (req) => {
|
|
62
|
+
if (!localPeer(req.socket)) return false;
|
|
63
|
+
const given = req.headers['x-runner-token'] || '';
|
|
64
|
+
// Constant-time compare: this is a local secret, but a token check that leaks
|
|
65
|
+
// its answer by timing is not a token check.
|
|
66
|
+
const a = Buffer.from(String(given));
|
|
67
|
+
const b = Buffer.from(TOKEN);
|
|
68
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const json = (res, code, body) => {
|
|
72
|
+
res.writeHead(code, { 'Content-Type': 'application/json' });
|
|
73
|
+
res.end(JSON.stringify(body));
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
function listSessions(cb) {
|
|
77
|
+
execFile('screen', ['-ls'], { timeout: 10000 }, (_err, stdout) => {
|
|
78
|
+
const rows = [];
|
|
79
|
+
for (const line of String(stdout || '').split('\n')) {
|
|
80
|
+
const m = line.match(/^\s+(\d+)\.(\S+)\s+\(([^)]*)\)(?:\s+\(([^)]*)\))?/);
|
|
81
|
+
if (m) rows.push({ pid: m[1], session: m[2], started: m[3], state: m[4] || '' });
|
|
82
|
+
}
|
|
83
|
+
cb(rows);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The environment every screen session is created with.
|
|
88
|
+
//
|
|
89
|
+
// A full-screen TUI in a screen window needs TWO things fixed, and either one alone
|
|
90
|
+
// leaves it broken — this is the "I see colors" report: raw escape codes as text.
|
|
91
|
+
//
|
|
92
|
+
// 1. TERM. screen replaces $TERM in its windows with its own default, `screen`, which
|
|
93
|
+
// terminfo says has EIGHT colours, however capable the real client is:
|
|
94
|
+
// TERM=screen -> tput colors = 8; TERM=xterm-256color -> 256
|
|
95
|
+
// So the CLI renders for 8 colours while the far end is xterm.js.
|
|
96
|
+
// 2. COLORTERM is inherited UNCHANGED, so the window still claims `truecolor` and the
|
|
97
|
+
// CLI emits 24-bit \e[38;2;R;G;Bm sequences. GNU screen 4.09 cannot pass those
|
|
98
|
+
// through — `truecolor on` arrived in screen 5 — so it mangles them and the codes
|
|
99
|
+
// reach the browser as visible text. Removing COLORTERM makes the CLI fall back to
|
|
100
|
+
// 256-colour codes, which screen does pass and xterm.js draws correctly.
|
|
101
|
+
//
|
|
102
|
+
// Set here rather than in a ~/.screenrc, so a machine needs no dotfile for the
|
|
103
|
+
// dashboard's terminals to be right. `screen -T` states the same TERM for the window.
|
|
104
|
+
const SESSION_ENV = { ...process.env, TERM: 'xterm-256color' };
|
|
105
|
+
|
|
106
|
+
// The screen configuration every session started here runs under. It sits beside the dashboard rather
|
|
107
|
+
// than in ~/.screenrc, because it belongs to these sessions and must not change what a person's own
|
|
108
|
+
// `screen` does. This runner lives in core/workspace-app/scripts, so the file is one level up.
|
|
109
|
+
const SCREENRC = path.join(__dirname, '..', 'screenrc');
|
|
110
|
+
delete SESSION_ENV.COLORTERM;
|
|
111
|
+
|
|
112
|
+
// The waiting flag, computed HERE because this process is on the machine with the
|
|
113
|
+
// sessions. The dashboard cannot do it: `screen` in its container knows nothing about
|
|
114
|
+
// host sessions, and — the reason this exists at all — the log file is useless for the
|
|
115
|
+
// sessions that matter most. A Claude Code session is started with `exec claude`, no
|
|
116
|
+
// `tee`, because piping a full-screen TUI produces garbage instead of an interface. So
|
|
117
|
+
// its .log is ZERO BYTES, and a log-reading check can never fire for an agent.
|
|
118
|
+
//
|
|
119
|
+
// `screen -X hardcopy` reads the visible window instead. Throttled per session: the
|
|
120
|
+
// published list is rewritten every two seconds and a subprocess per session per tick
|
|
121
|
+
// would be pure waste, while a question on a screen keeps for six seconds.
|
|
122
|
+
const WAIT_TTL = 6000;
|
|
123
|
+
const _waitCache = new Map();
|
|
124
|
+
|
|
125
|
+
function waitingFor(session, cb) {
|
|
126
|
+
const hit = _waitCache.get(session);
|
|
127
|
+
if (hit && Date.now() - hit.at < WAIT_TTL) return cb(hit.state);
|
|
128
|
+
const out = path.join(STORE, `.peek-${session}`);
|
|
129
|
+
execFile('screen', ['-S', session, '-X', 'hardcopy', out], { timeout: 8000 }, () => {
|
|
130
|
+
let text = '';
|
|
131
|
+
try { text = fs.readFileSync(out, 'utf8'); } catch (_) { text = ''; }
|
|
132
|
+
try { fs.unlinkSync(out); } catch (_) { /* best effort */ }
|
|
133
|
+
const lines = text.split('\n').filter((l) => l.trim());
|
|
134
|
+
const state = waitingState(lines.slice(-14).join('\n'));
|
|
135
|
+
_waitCache.set(session, { at: Date.now(), state });
|
|
136
|
+
cb(state);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** listSessions, plus the waiting flag for each row. */
|
|
141
|
+
function listSessionsWithState(cb) {
|
|
142
|
+
listSessions((rows) => {
|
|
143
|
+
if (!rows.length) return cb(rows);
|
|
144
|
+
let left = rows.length;
|
|
145
|
+
rows.forEach((r) => waitingFor(r.session, (st) => {
|
|
146
|
+
r.waiting = !!st.waiting;
|
|
147
|
+
r.waitingWhy = st.why || '';
|
|
148
|
+
if (--left === 0) cb(rows);
|
|
149
|
+
}));
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const server = http.createServer((req, res) => {
|
|
154
|
+
if (!ok(req)) return json(res, 403, { error: 'bad token' });
|
|
155
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
156
|
+
|
|
157
|
+
if (req.method === 'GET' && url.pathname === '/list') {
|
|
158
|
+
return listSessionsWithState((rows) => json(res, 200, { sessions: rows }));
|
|
159
|
+
}
|
|
160
|
+
// What is on a session's screen right now, so the dashboard can tell a session that
|
|
161
|
+
// is WORKING from one that is WAITING FOR A PERSON. A long agent run that has stopped
|
|
162
|
+
// to ask "which of these do you want?" looks identical to one still thinking, from
|
|
163
|
+
// the outside — and it will sit there until somebody notices.
|
|
164
|
+
//
|
|
165
|
+
// `screen -X hardcopy` writes the visible window to a file. It is read-only in every
|
|
166
|
+
// sense that matters: it sends no keys and no signals, and it does not attach, so it
|
|
167
|
+
// cannot steal a session from a terminal that has it open.
|
|
168
|
+
if (req.method === 'GET' && url.pathname === '/peek') {
|
|
169
|
+
const session = String(url.searchParams.get('session') || '');
|
|
170
|
+
if (!NAME_RE.test(session)) return json(res, 400, { error: 'bad session name' });
|
|
171
|
+
const out = path.join(STORE, `.peek-${session}`);
|
|
172
|
+
return execFile('screen', ['-S', session, '-X', 'hardcopy', out], { timeout: 10000 }, () => {
|
|
173
|
+
let text = '';
|
|
174
|
+
try { text = fs.readFileSync(out, 'utf8'); } catch (_) { text = ''; }
|
|
175
|
+
try { fs.unlinkSync(out); } catch (_) { /* best effort */ }
|
|
176
|
+
// Only the tail is of interest: a question is at the bottom of a screen, never
|
|
177
|
+
// at the top, and the whole window is 50+ lines of scrollback nobody needs here.
|
|
178
|
+
const lines = text.split('\n').filter((l) => l.trim());
|
|
179
|
+
return json(res, 200, { session, tail: lines.slice(-14).join('\n') });
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
if (req.method === 'GET' && url.pathname === '/health') {
|
|
183
|
+
return json(res, 200, { ok: true, host: require('os').hostname(), screen: !!process.env.PATH });
|
|
184
|
+
}
|
|
185
|
+
if (req.method === 'POST' && (url.pathname === '/start' || url.pathname === '/stop')) {
|
|
186
|
+
let body = '';
|
|
187
|
+
req.on('data', (d) => { body += d; if (body.length > 1e6) req.destroy(); });
|
|
188
|
+
req.on('end', () => {
|
|
189
|
+
let payload = {};
|
|
190
|
+
try { payload = JSON.parse(body || '{}'); } catch (_) { return json(res, 400, { error: 'bad json' }); }
|
|
191
|
+
const session = String(payload.session || '');
|
|
192
|
+
if (!NAME_RE.test(session)) return json(res, 400, { error: 'bad session name' });
|
|
193
|
+
|
|
194
|
+
if (url.pathname === '/stop') {
|
|
195
|
+
return execFile('screen', ['-S', session, '-X', 'quit'], { timeout: 10000 },
|
|
196
|
+
(err) => json(res, 200, { ok: !err }));
|
|
197
|
+
}
|
|
198
|
+
// start: the directory must be inside the workspace, and the command runs
|
|
199
|
+
// through a login shell so the user's PATH, ~/.claude and agents apply.
|
|
200
|
+
const dir = path.resolve(String(payload.dir || ROOT));
|
|
201
|
+
if (!dir.startsWith(ROOT)) return json(res, 400, { error: 'directory outside the workspace' });
|
|
202
|
+
if (!fs.existsSync(dir)) return json(res, 400, { error: 'no such directory' });
|
|
203
|
+
const cmd = String(payload.cmd || '').trim() || 'exec bash -l';
|
|
204
|
+
const logFile = path.join(STORE, `${session}.log`);
|
|
205
|
+
try { fs.writeFileSync(path.join(STORE, `${session}.cwd`), `${payload.owner || dir}\n`); } catch (_) { /* best effort */ }
|
|
206
|
+
listSessions((rows) => {
|
|
207
|
+
if (rows.some((r) => r.session === session)) return json(res, 409, { error: 'already running' });
|
|
208
|
+
const inner = /^exec /.test(cmd) ? cmd : `{ ${cmd} ; } 2>&1 | tee -a ${JSON.stringify(logFile)}`;
|
|
209
|
+
startScreen(session, inner, logFile, dir, (err, _o, stderr) => {
|
|
210
|
+
if (err) return json(res, 500, { error: String(stderr || err.message).slice(-200) });
|
|
211
|
+
listSessions((after) => json(res, 200, {
|
|
212
|
+
ok: after.some((r) => r.session === session), session, log: logFile,
|
|
213
|
+
}));
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
return json(res, 404, { error: 'no such endpoint' });
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// The attach socket: a PTY sharing the session, exactly like the container's own
|
|
223
|
+
// bridge, so the dashboard can proxy a browser terminal to a host session.
|
|
224
|
+
let WebSocketServer;
|
|
225
|
+
try { ({ WebSocketServer } = require(path.join(__dirname, '..', 'node_modules', 'ws'))); } catch (_) { /* reported below */ }
|
|
226
|
+
if (WebSocketServer) {
|
|
227
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
228
|
+
wss.on('error', () => { /* a bad frame must not stop the runner */ });
|
|
229
|
+
const children = new Set();
|
|
230
|
+
for (const sig of ['exit', 'SIGTERM', 'SIGINT']) {
|
|
231
|
+
process.on(sig, () => {
|
|
232
|
+
for (const c of children) { try { c.kill('SIGHUP'); } catch (_) { /* gone */ } }
|
|
233
|
+
if (sig !== 'exit') process.exit(0);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
server.on('upgrade', (req, socket, head) => {
|
|
237
|
+
const u = new URL(req.url, 'http://127.0.0.1');
|
|
238
|
+
const m = u.pathname.match(/^\/attach\/([a-z0-9][a-zA-Z0-9._-]*)$/);
|
|
239
|
+
if (!m || !localPeer(socket) || (u.searchParams.get('token') || '') !== TOKEN) { socket.destroy(); return; }
|
|
240
|
+
const session = m[1];
|
|
241
|
+
const cols = Math.min(500, Math.max(20, parseInt(u.searchParams.get('cols'), 10) || 120));
|
|
242
|
+
const rows = Math.min(200, Math.max(5, parseInt(u.searchParams.get('rows'), 10) || 30));
|
|
243
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
244
|
+
const child = spawn('script', ['-qfc',
|
|
245
|
+
`stty cols ${cols} rows ${rows} 2>/dev/null; exec screen -x ${session}`, '/dev/null'],
|
|
246
|
+
{ cwd: process.env.HOME, env: { ...process.env, TERM: 'xterm-256color' } });
|
|
247
|
+
children.add(child);
|
|
248
|
+
const reap = () => { children.delete(child); try { child.kill('SIGHUP'); } catch (_) { /* gone */ } };
|
|
249
|
+
child.stdout.on('data', (d) => { try { ws.send(d); } catch (_) { /* closing */ } });
|
|
250
|
+
child.stderr.on('data', (d) => { try { ws.send(d); } catch (_) { /* closing */ } });
|
|
251
|
+
child.on('exit', () => { children.delete(child); try { ws.close(); } catch (_) { /* closed */ } });
|
|
252
|
+
ws.on('message', (d) => { try { child.stdin.write(d); } catch (_) { /* gone */ } });
|
|
253
|
+
ws.on('close', reap);
|
|
254
|
+
ws.on('error', reap);
|
|
255
|
+
setTimeout(() => { try { execFile('screen', ['-S', session, '-X', 'redisplay']); } catch (_) { /* gone */ } }, 400);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Bound past loopback ONLY so the dashboard's container can reach it; every
|
|
261
|
+
// request still has to come from this machine or its docker bridge AND carry
|
|
262
|
+
// the token from a file only this user can read.
|
|
263
|
+
// ---------------------------------------------------------- the mailbox --
|
|
264
|
+
// The dashboard's container cannot open a socket to this machine on some hosts
|
|
265
|
+
// (docker's bridge is firewalled), but both sides already share the workspace
|
|
266
|
+
// tree — so the real channel is that folder. The dashboard drops a request
|
|
267
|
+
// file; this picks it up, does the work, and writes the answer back. The HTTP
|
|
268
|
+
// server above stays for anything on this machine.
|
|
269
|
+
const QUEUE = path.join(STORE, 'queue');
|
|
270
|
+
const RESULTS = path.join(STORE, 'results');
|
|
271
|
+
fs.mkdirSync(QUEUE, { recursive: true });
|
|
272
|
+
fs.mkdirSync(RESULTS, { recursive: true });
|
|
273
|
+
|
|
274
|
+
function answer(id, body) {
|
|
275
|
+
try { fs.writeFileSync(path.join(RESULTS, `${id}.json`), JSON.stringify(body)); } catch (_) { /* the caller times out */ }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Sessions this runner is starting right now: a second request for the same name, arriving before the first
|
|
279
|
+
// one's session exists, is answered "already" instead of starting it a second time.
|
|
280
|
+
// Start a detached session whose login shell runs `inner`. No shell sits between this runner and screen: `inner`
|
|
281
|
+
// reaches the session's bash as one argument and is parsed once, when the session runs it. It used to go through
|
|
282
|
+
// `bash -lc "…"` here, where JSON quoting left $, $(…) and backticks live, so this runner's own shell expanded them
|
|
283
|
+
// on the host before the session existed: a backtick in a prompt ran, and a $(…) meant for the session ran here.
|
|
284
|
+
function startScreen(session, inner, logFile, dir, done) {
|
|
285
|
+
try { fs.writeFileSync(logFile, ''); } catch (err) { return done(err, '', String(err.message)); }
|
|
286
|
+
return execFile('screen', ['-c', SCREENRC, '-T', 'xterm-256color', '-dmS', session, 'bash', '-lc', inner],
|
|
287
|
+
{ cwd: dir, timeout: 30000, env: SESSION_ENV }, done);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const starting = new Set();
|
|
291
|
+
|
|
292
|
+
function handleRequest(file) {
|
|
293
|
+
// Claim the request before reading it. The queue is drained on a poll and on every folder-watch event, and
|
|
294
|
+
// one new file fires the watch more than once: two drains read the same request before its (asynchronous)
|
|
295
|
+
// unlink landed, and a burst of 12 starts made 14 sessions. A rename is atomic, so exactly one drain wins.
|
|
296
|
+
const claimed = `${file}.taken`;
|
|
297
|
+
try { fs.renameSync(file, claimed); } catch (_) { return; }
|
|
298
|
+
let req;
|
|
299
|
+
try { req = JSON.parse(fs.readFileSync(claimed, 'utf8')); } catch (_) { return fs.unlink(claimed, () => {}); }
|
|
300
|
+
fs.unlink(claimed, () => {});
|
|
301
|
+
const id = String(req.id || '').replace(/[^a-zA-Z0-9._-]/g, '');
|
|
302
|
+
const session = String(req.session || '');
|
|
303
|
+
if (!id) return;
|
|
304
|
+
if (!NAME_RE.test(session)) return answer(id, { ok: false, error: 'bad session name' });
|
|
305
|
+
|
|
306
|
+
if (req.action === 'stop') {
|
|
307
|
+
return execFile('screen', ['-S', session, '-X', 'quit'], { timeout: 10000 },
|
|
308
|
+
(err) => answer(id, { ok: !err }));
|
|
309
|
+
}
|
|
310
|
+
if (req.action === 'start') {
|
|
311
|
+
const dir = path.resolve(String(req.dir || ROOT));
|
|
312
|
+
if (!dir.startsWith(ROOT)) return answer(id, { ok: false, error: 'directory outside the workspace' });
|
|
313
|
+
if (!fs.existsSync(dir)) return answer(id, { ok: false, error: 'no such directory' });
|
|
314
|
+
const cmd = String(req.cmd || '').trim() || 'exec bash -l';
|
|
315
|
+
const logFile = path.join(STORE, `${session}.log`);
|
|
316
|
+
try { fs.writeFileSync(path.join(STORE, `${session}.cwd`), `${req.owner || dir}\n`); } catch (_) { /* best effort */ }
|
|
317
|
+
if (starting.has(session)) return answer(id, { ok: false, already: true });
|
|
318
|
+
starting.add(session);
|
|
319
|
+
return listSessions((rows) => {
|
|
320
|
+
if (rows.some((r) => r.session === session)) { starting.delete(session); return answer(id, { ok: false, already: true }); }
|
|
321
|
+
const inner = /^exec /.test(cmd) ? cmd : `{ ${cmd} ; } 2>&1 | tee -a ${JSON.stringify(logFile)}`;
|
|
322
|
+
startScreen(session, inner, logFile, dir, (err, _o, stderr) => {
|
|
323
|
+
if (err) { starting.delete(session); return answer(id, { ok: false, error: String(stderr || err.message).slice(-200) }); }
|
|
324
|
+
listSessions((after) => { starting.delete(session); answer(id, { ok: after.some((r) => r.session === session), session }); });
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
return answer(id, { ok: false, error: 'unknown action' });
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function drainQueue() {
|
|
332
|
+
let files = [];
|
|
333
|
+
try { files = fs.readdirSync(QUEUE).filter((f) => f.endsWith('.json')); } catch (_) { return; }
|
|
334
|
+
for (const f of files) handleRequest(path.join(QUEUE, f));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// The published truth: what is running here, refreshed so the dashboard can
|
|
338
|
+
// read it without asking anything. Its mtime is also how the dashboard knows
|
|
339
|
+
// this runner is alive.
|
|
340
|
+
function publish() {
|
|
341
|
+
// With the waiting flag, so the dashboard's list shows it without asking anything:
|
|
342
|
+
// the file the page already reads is the one that carries it.
|
|
343
|
+
listSessionsWithState((rows) => {
|
|
344
|
+
try {
|
|
345
|
+
fs.writeFileSync(path.join(STORE, 'host-sessions.json'),
|
|
346
|
+
JSON.stringify({ at: Date.now(), host: require('os').hostname(), sessions: rows }));
|
|
347
|
+
} catch (_) { /* the dashboard will see a stale file and say so */ }
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
publish();
|
|
352
|
+
setInterval(publish, 2000).unref?.();
|
|
353
|
+
setInterval(drainQueue, 500).unref?.();
|
|
354
|
+
try { fs.watch(QUEUE, () => drainQueue()); } catch (_) { /* the poll above covers it */ }
|
|
355
|
+
|
|
356
|
+
server.listen(PORT, process.env.SCREEN_RUNNER_BIND || '0.0.0.0', () => {
|
|
357
|
+
// The port file is how the container finds it without being told.
|
|
358
|
+
fs.writeFileSync(path.join(STORE, '.runner-port'), `${PORT}\n`);
|
|
359
|
+
console.log(`Screen runner on ${process.env.SCREEN_RUNNER_BIND || '0.0.0.0'}:${PORT} — sessions run on ${require('os').hostname()}, not in the container.`);
|
|
360
|
+
console.log(`Token: ${path.join(STORE, '.runner-token')} (the dashboard reads it through the workspace mount)`);
|
|
361
|
+
if (!WebSocketServer) console.log('NOTE: ws was not found, so browser terminals will not attach. Run npm install in core/workspace-app.');
|
|
362
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Start a screen session through the host runner, from a shell line that runs on the host.
|
|
4
|
+
*
|
|
5
|
+
* A build that should hand over to Claude Code ends with `&& node screen-then.js <payload.json>`, so the
|
|
6
|
+
* session starts only when the build succeeded, and as its own interactive session: the runner keeps a
|
|
7
|
+
* command that starts with `exec` interactive, which a step inside the build's own piped session could not be.
|
|
8
|
+
*
|
|
9
|
+
* The payload is {session, dir, owner, cmd}, written by the dashboard into .screens/prompts/.
|
|
10
|
+
*/
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const http = require('http');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
const ROOT = process.env.WORKSPACE_ROOT || path.resolve(__dirname, '..', '..', '..');
|
|
16
|
+
const PORT = Number(process.env.SCREEN_RUNNER_PORT || 7681);
|
|
17
|
+
|
|
18
|
+
const file = process.argv[2];
|
|
19
|
+
if (!file) { console.error('usage: screen-then.js <payload.json>'); process.exit(2); }
|
|
20
|
+
|
|
21
|
+
let payload;
|
|
22
|
+
let token;
|
|
23
|
+
try {
|
|
24
|
+
payload = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
25
|
+
token = fs.readFileSync(path.join(ROOT, '.screens', '.runner-token'), 'utf8').trim();
|
|
26
|
+
} catch (e) {
|
|
27
|
+
console.error(`Could not start the session: ${e.message}`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const body = JSON.stringify(payload);
|
|
32
|
+
const req = http.request({ host: '127.0.0.1', port: PORT, path: '/start', method: 'POST',
|
|
33
|
+
headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body), 'x-runner-token': token } }, (res) => {
|
|
34
|
+
let out = '';
|
|
35
|
+
res.on('data', (d) => { out += d; });
|
|
36
|
+
res.on('end', () => {
|
|
37
|
+
if (res.statusCode === 200) {
|
|
38
|
+
console.log(`Claude Code is starting in ${payload.dir} as the session ${payload.session}. Open it from Screens.`);
|
|
39
|
+
try { fs.unlinkSync(file); } catch (_) { /* already gone */ }
|
|
40
|
+
return process.exit(0);
|
|
41
|
+
}
|
|
42
|
+
console.error(`The runner did not start ${payload.session}: ${out.slice(0, 200)}`);
|
|
43
|
+
return process.exit(1);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
req.on('error', (e) => { console.error(`The screen runner is not reachable: ${e.message}`); process.exit(1); });
|
|
47
|
+
req.end(body);
|