chamba 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +47 -44
- package/bin/chamba.js +212 -0
- package/dist/commands/advanced.js +278 -0
- package/dist/commands/dev.js +619 -0
- package/dist/commands/doctor.js +29 -0
- package/dist/commands/menu.js +80 -0
- package/dist/commands/onboard.js +229 -0
- package/dist/commands/settings.js +349 -0
- package/dist/lib/agent-context.js +177 -0
- package/dist/lib/browser.js +40 -0
- package/dist/lib/chamba-yaml.js +191 -0
- package/dist/lib/constants.js +135 -0
- package/dist/lib/dockerfile-builder.js +267 -0
- package/dist/lib/env.js +78 -0
- package/dist/lib/global-config.js +66 -0
- package/dist/lib/pnpm-store.js +19 -0
- package/dist/lib/ports.js +210 -0
- package/dist/lib/safe-rm.js +26 -0
- package/dist/lib/sessions.js +34 -0
- package/dist/lib/shadows.js +174 -0
- package/dist/lib/webterm.js +490 -0
- package/dist/lib/workspace-identity.js +260 -0
- package/package.json +61 -24
- package/schema/chamba.schema.json +65 -0
- package/templates/.dockerignore +3 -0
- package/templates/Dockerfile +173 -0
- package/templates/claude-statusline.sh +120 -0
- package/templates/context/baseline.md +13 -0
- package/templates/context/context-usage.md +1 -0
- package/templates/context/git-mode-local.md +1 -0
- package/templates/context/git-mode-strict.md +1 -0
- package/templates/context/git-mode-unrestricted.md +1 -0
- package/templates/context/git-unavailable.md +1 -0
- package/templates/context/shadow-paths.md +3 -0
- package/templates/context-usage.sh +249 -0
- package/templates/git-readonly-wrapper.mjs +309 -0
- package/templates/npmrc +2 -0
- package/templates/pnpm-config.yaml +9 -0
- package/templates/runtime-constants.mjs +18 -0
- package/templates/skills/chamba-statusline/SKILL.md +79 -0
- package/templates/skills/context-usage/SKILL.md +53 -0
- package/templates/skills/web-pane/SKILL.md +62 -0
- package/templates/startup-git-mode.mjs +145 -0
- package/templates/startup.mjs +333 -0
- package/templates/webpane.sh +126 -0
- package/templates/webterm/README.md +157 -0
- package/templates/webterm/artifacts.js +583 -0
- package/templates/webterm/config.js +269 -0
- package/templates/webterm/context/claude.md +14 -0
- package/templates/webterm/conversation.js +248 -0
- package/templates/webterm/package-lock.json +884 -0
- package/templates/webterm/package.json +17 -0
- package/templates/webterm/pane.js +156 -0
- package/templates/webterm/proc.js +89 -0
- package/templates/webterm/public/app/alerts.js +472 -0
- package/templates/webterm/public/app/cards.js +123 -0
- package/templates/webterm/public/app/clipboard.js +229 -0
- package/templates/webterm/public/app/composer.js +226 -0
- package/templates/webterm/public/app/connection.js +342 -0
- package/templates/webterm/public/app/dictation.js +98 -0
- package/templates/webterm/public/app/dom.js +37 -0
- package/templates/webterm/public/app/drafts.js +244 -0
- package/templates/webterm/public/app/frames.js +166 -0
- package/templates/webterm/public/app/main.js +82 -0
- package/templates/webterm/public/app/new-session.js +188 -0
- package/templates/webterm/public/app/note.js +24 -0
- package/templates/webterm/public/app/pane-frame.js +166 -0
- package/templates/webterm/public/app/pane.js +353 -0
- package/templates/webterm/public/app/state.js +51 -0
- package/templates/webterm/public/app/status-strip.js +170 -0
- package/templates/webterm/public/app/tabs.js +475 -0
- package/templates/webterm/public/app/terminal.js +102 -0
- package/templates/webterm/public/app/theme.js +46 -0
- package/templates/webterm/public/favicon.svg +21 -0
- package/templates/webterm/public/index.html +105 -0
- package/templates/webterm/public/styles.css +1193 -0
- package/templates/webterm/server.js +1142 -0
- package/templates/webterm/sessions.js +515 -0
- package/templates/webterm/snapshot.js +135 -0
- package/templates/webterm.sh +167 -0
- package/dist/cli.js +0 -1691
- package/dist/server.js +0 -1919
- package/inject/annotate.js +0 -18
- package/skill/README.md +0 -12
- package/skill/SKILL.md +0 -93
- package/web/assets/highlighted-body-OFNGDK62-Bn4Eu7CG.js +0 -1
- package/web/assets/index-B9DI4F1Z.js +0 -202
- package/web/assets/index-DK_n6CTo.css +0 -2
- package/web/assets/mermaid-GHXKKRXX-CEMduc-U.js +0 -1
- package/web/index.html +0 -28
|
@@ -0,0 +1,1142 @@
|
|
|
1
|
+
// server.js - HTTP + WebSocket relay for interactive AI agent CLI sessions.
|
|
2
|
+
//
|
|
3
|
+
// The page is mission control for this container: it lists every live session (one PTY each, running an
|
|
4
|
+
// agent in the workspace) as tabs, and one of them is attached to the browser window at a time. Sessions
|
|
5
|
+
// pick their own agent, so claude and codex can be two tabs of the same bar.
|
|
6
|
+
// The window renders the live TUI (xterm.js) and forwards keystrokes; a rich composer uploads
|
|
7
|
+
// pasted images to /tmp/uploads and injects the composed message as one bracketed paste.
|
|
8
|
+
// Sessions belong to the server, not to the socket: see sessions.js for what that buys.
|
|
9
|
+
// Every route that carries the relay is gated by the key the URL holds (?k=), minted fresh at every start.
|
|
10
|
+
// Auth and sandbox are inherited: the spawned CLI sees the same agent config dirs and the same
|
|
11
|
+
// container isolation it has in the terminal. Nothing here touches credentials.
|
|
12
|
+
|
|
13
|
+
import { randomBytes } from "node:crypto";
|
|
14
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { createServer } from "node:http";
|
|
16
|
+
import { createRequire } from "node:module";
|
|
17
|
+
import { join, resolve, sep } from "node:path";
|
|
18
|
+
import express from "express";
|
|
19
|
+
import { WebSocketServer } from "ws";
|
|
20
|
+
import { cleanLine, cleanSubmission, createPaneStore, isPageId } from "./artifacts.js";
|
|
21
|
+
import {
|
|
22
|
+
AGENT_ARGS,
|
|
23
|
+
AGENT_STORES,
|
|
24
|
+
AGENTS,
|
|
25
|
+
agentSpawnArgv,
|
|
26
|
+
CHECK_INTERVAL_MS,
|
|
27
|
+
CLIENT_PING_INTERVAL_MS,
|
|
28
|
+
DEFAULT_AGENT,
|
|
29
|
+
DIR_SCAN_DEPTH,
|
|
30
|
+
DIR_SCAN_MAX,
|
|
31
|
+
DIR_SCAN_SKIP,
|
|
32
|
+
FEEDBACK_MIN_INTERVAL_MS,
|
|
33
|
+
homeLabel,
|
|
34
|
+
isAllowedOrigin,
|
|
35
|
+
isAuthorized,
|
|
36
|
+
isKnownAgent,
|
|
37
|
+
KEY,
|
|
38
|
+
KEY_FILE,
|
|
39
|
+
MAX_FEEDBACK_BYTES,
|
|
40
|
+
MAX_FEEDBACK_FIELD_LENGTH,
|
|
41
|
+
MAX_FEEDBACK_FIELDS,
|
|
42
|
+
MAX_FEEDBACK_PER_PAGE,
|
|
43
|
+
MAX_FEEDBACK_TEXT_LENGTH,
|
|
44
|
+
MAX_OUTPUT_BUFFER,
|
|
45
|
+
MAX_PAGE_BYTES,
|
|
46
|
+
MAX_PANE_BYTES,
|
|
47
|
+
MAX_PANE_FILES,
|
|
48
|
+
MAX_SESSIONS,
|
|
49
|
+
MAX_UPLOAD_BYTES,
|
|
50
|
+
PANE_DIR,
|
|
51
|
+
PANE_SCAN_MS,
|
|
52
|
+
PASTE_END,
|
|
53
|
+
PASTE_START,
|
|
54
|
+
PORT,
|
|
55
|
+
RESUME_MARKER,
|
|
56
|
+
resolveWorkspacePath,
|
|
57
|
+
STATE_FILE,
|
|
58
|
+
STATUS_SCAN_MS,
|
|
59
|
+
STOP_ANNOUNCE_MS,
|
|
60
|
+
STOP_TIMEOUT_MS,
|
|
61
|
+
SUBMIT,
|
|
62
|
+
SUBMIT_DELAY_MS,
|
|
63
|
+
UPLOAD_DIR,
|
|
64
|
+
UPLOAD_MAX_AGE_MS,
|
|
65
|
+
WEBTERM_CWD_RAW,
|
|
66
|
+
WORKSPACE,
|
|
67
|
+
WORKSPACE_ROOT,
|
|
68
|
+
workspaceLabel,
|
|
69
|
+
} from "./config.js";
|
|
70
|
+
import { createPane } from "./pane.js";
|
|
71
|
+
import { isSelfOrDescendant } from "./proc.js";
|
|
72
|
+
import { createRegistry, WORK_TICK_MS } from "./sessions.js";
|
|
73
|
+
import { statusFor, writesSnapshots } from "./snapshot.js";
|
|
74
|
+
|
|
75
|
+
// node-pty is a native CommonJS addon; load it through createRequire under ESM.
|
|
76
|
+
const require = createRequire(import.meta.url);
|
|
77
|
+
const pty = require("node-pty");
|
|
78
|
+
|
|
79
|
+
// --- Upload dir + weekly cleanup check ---------------------------------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
// Resolve once; every delete is checked to stay strictly under this path.
|
|
82
|
+
const UPLOAD_ROOT = resolve(UPLOAD_DIR);
|
|
83
|
+
|
|
84
|
+
function ensureUploadDir() {
|
|
85
|
+
if (!existsSync(UPLOAD_ROOT)) mkdirSync(UPLOAD_ROOT, { recursive: true });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// True only for a path that lives strictly inside UPLOAD_ROOT. Mirrors the guard-then-delete
|
|
89
|
+
// ethos of src/lib/safe-rm.ts: we refuse to unlink anything outside the upload dir.
|
|
90
|
+
function isInsideUploadRoot(p) {
|
|
91
|
+
const full = resolve(p);
|
|
92
|
+
return full.startsWith(UPLOAD_ROOT + sep);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Weekly, age-based cleanup check. Deletes only regular files older than UPLOAD_MAX_AGE_MS,
|
|
96
|
+
// only within UPLOAD_ROOT, best-effort. Logs a one-line summary (no silent truncation).
|
|
97
|
+
function checkUploads() {
|
|
98
|
+
ensureUploadDir();
|
|
99
|
+
const cutoff = Date.now() - UPLOAD_MAX_AGE_MS;
|
|
100
|
+
let removed = 0;
|
|
101
|
+
let kept = 0;
|
|
102
|
+
for (const name of readdirSync(UPLOAD_ROOT)) {
|
|
103
|
+
const full = join(UPLOAD_ROOT, name);
|
|
104
|
+
try {
|
|
105
|
+
const st = statSync(full);
|
|
106
|
+
if (!st.isFile()) continue;
|
|
107
|
+
if (st.mtimeMs < cutoff && isInsideUploadRoot(full)) {
|
|
108
|
+
unlinkSync(full);
|
|
109
|
+
removed += 1;
|
|
110
|
+
} else {
|
|
111
|
+
kept += 1;
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
// Best-effort: a vanished or unreadable entry is not fatal to the check.
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
console.log(`[webterm] upload check: removed ${removed}, kept ${kept} (older-than 7d, ${UPLOAD_ROOT})`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// --- Where sessions start ----------------------------------------------------------------------------------------------------------------
|
|
121
|
+
//
|
|
122
|
+
// A terminal session opens in the directory `npx chamba` ran in. A browser session cannot copy that: the
|
|
123
|
+
// server is one process per container that outlives every invocation, so "the directory" is not a property
|
|
124
|
+
// of how the interface was reached. It is two things instead - a default every new session takes, which the
|
|
125
|
+
// host keeps pointed at wherever chamba last ran, and a directory the browser can name per session.
|
|
126
|
+
|
|
127
|
+
// The default. Seeded from what the launcher passed and refused when it is not a directory inside the
|
|
128
|
+
// workspace, so a stray value falls back to the root with a line saying so rather than failing every
|
|
129
|
+
// session that follows.
|
|
130
|
+
let defaultCwd = resolveWorkspacePath(WEBTERM_CWD_RAW) ?? WORKSPACE_ROOT;
|
|
131
|
+
const defaultCwdRefused = WEBTERM_CWD_RAW !== "" && resolveWorkspacePath(WEBTERM_CWD_RAW) === null;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Where a session the browser asked for should run, or null when the path names nothing usable.
|
|
135
|
+
* No path at all means the default, which is what the plain "+ New session" sends.
|
|
136
|
+
*
|
|
137
|
+
* The browser writes paths relative to the workspace root, so that is tried first, and "/" reads there as
|
|
138
|
+
* the root itself. A full container path ("/workspace/src") is accepted too, because someone reading it off
|
|
139
|
+
* a terminal prompt will type it and refusing that would be pedantry rather than a rule.
|
|
140
|
+
*/
|
|
141
|
+
function sessionCwd(raw) {
|
|
142
|
+
if (typeof raw !== "string") return defaultCwd;
|
|
143
|
+
return resolveWorkspacePath(raw) ?? resolveWorkspacePath(raw.replace(/^\/+/, ""));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// --- Which agent a session runs ----------------------------------------------------------------------------------------------------------
|
|
147
|
+
//
|
|
148
|
+
// The same shape as the directory above: a default that new sessions take, which the launcher seeds and the
|
|
149
|
+
// container command can move, and a per-session choice the browser can make. A session's agent is fixed for
|
|
150
|
+
// its whole life - it is the process - so nothing here ever touches a live one.
|
|
151
|
+
|
|
152
|
+
let defaultAgent = DEFAULT_AGENT;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The agent a session the browser asked for should run, or null when it named something we do not run.
|
|
156
|
+
* No agent at all means the default, which is what the plain "+ New session" sends.
|
|
157
|
+
*/
|
|
158
|
+
function sessionAgent(raw) {
|
|
159
|
+
if (raw === undefined || raw === null) return defaultAgent;
|
|
160
|
+
return isKnownAgent(raw) ? raw : null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// The one-line file that names the default agent, for the `webterm` launcher to read back. Written when the
|
|
164
|
+
// port is bound and again whenever the default moves, so a second `webterm <agent>` can say what is live.
|
|
165
|
+
function publishAgent() {
|
|
166
|
+
if (!STATE_FILE) return;
|
|
167
|
+
try {
|
|
168
|
+
writeFileSync(STATE_FILE, `${defaultAgent}\n`);
|
|
169
|
+
} catch (err) {
|
|
170
|
+
console.warn(`[webterm] could not write ${STATE_FILE}: ${err instanceof Error ? err.message : String(err)}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The directories the picker offers. Bounded on purpose (see DIR_SCAN_* in config.js): a deep walk of a
|
|
176
|
+
* real workspace is slow and the list would be unreadable anyway. Dot directories and the skip list are
|
|
177
|
+
* never descended into, and neither are symlinks - a link out of the workspace would list paths that the
|
|
178
|
+
* picker then refuses. Breadth-first, so a cap that bites drops the deepest entries rather than a whole
|
|
179
|
+
* branch, and it reports the cut so the caller can say the list is partial.
|
|
180
|
+
*/
|
|
181
|
+
function scanDirs() {
|
|
182
|
+
const found = [];
|
|
183
|
+
let queue = [WORKSPACE_ROOT];
|
|
184
|
+
let truncated = false;
|
|
185
|
+
for (let depth = 0; depth < DIR_SCAN_DEPTH && queue.length > 0 && !truncated; depth++) {
|
|
186
|
+
const next = [];
|
|
187
|
+
for (const dir of queue) {
|
|
188
|
+
let entries;
|
|
189
|
+
try {
|
|
190
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
191
|
+
} catch {
|
|
192
|
+
continue; // Unreadable directory: nothing to offer from it.
|
|
193
|
+
}
|
|
194
|
+
for (const entry of entries) {
|
|
195
|
+
if (!entry.isDirectory() || entry.name.startsWith(".") || DIR_SCAN_SKIP.has(entry.name)) continue;
|
|
196
|
+
if (found.length >= DIR_SCAN_MAX) {
|
|
197
|
+
truncated = true;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
const full = join(dir, entry.name);
|
|
201
|
+
found.push(workspaceLabel(full));
|
|
202
|
+
next.push(full);
|
|
203
|
+
}
|
|
204
|
+
if (truncated) break;
|
|
205
|
+
}
|
|
206
|
+
queue = next;
|
|
207
|
+
}
|
|
208
|
+
if (truncated) console.log(`[webterm] directory list capped at ${DIR_SCAN_MAX} - deeper directories can still be typed in`);
|
|
209
|
+
return { dirs: found, truncated };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// --- The key gate ------------------------------------------------------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
// Publish the live key where the things that print the URL can read it: the `webterm` launcher, the
|
|
215
|
+
// container greeting, and chamba on the host before it probes /status. Called once the port is bound and
|
|
216
|
+
// never before - a second server that loses the bind must not leave its key behind as if it had won.
|
|
217
|
+
// Owner-only, and chmod'ed after the write because the mode above applies to a file being created rather
|
|
218
|
+
// than to one that already exists.
|
|
219
|
+
// Best-effort: a key that cannot be published still gates the relay, it only leaves the URL unprintable.
|
|
220
|
+
function publishKey() {
|
|
221
|
+
try {
|
|
222
|
+
writeFileSync(KEY_FILE, `${KEY}\n`, { mode: 0o600 });
|
|
223
|
+
chmodSync(KEY_FILE, 0o600);
|
|
224
|
+
} catch (err) {
|
|
225
|
+
console.error(`[webterm] could not publish the key to ${KEY_FILE}: ${err instanceof Error ? err.message : String(err)}`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// The key a request presents, from the URL or from a header. Only a plain string counts: a repeated query
|
|
230
|
+
// parameter parses to an array and a repeated header to a comma-joined string, neither of which is a key.
|
|
231
|
+
//
|
|
232
|
+
// The header exists for the pane. A page's content must never be reachable at a URL that carries the key,
|
|
233
|
+
// because a script in an agent-written page can read the URL of the document it is in - so the pane shell
|
|
234
|
+
// fetches artifacts with the key in a header instead, leaving nothing to open by hand and nothing to leak
|
|
235
|
+
// into a page. Everything else keeps using `?k=`, which is what makes the interface openable from a link.
|
|
236
|
+
function presentedKey(req) {
|
|
237
|
+
const header = req.headers[KEY_HEADER];
|
|
238
|
+
if (typeof header === "string" && header !== "") return header;
|
|
239
|
+
return typeof req.query.k === "string" ? req.query.k : "";
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const KEY_HEADER = "x-webterm-key";
|
|
243
|
+
|
|
244
|
+
// What a browser gets instead of the page when its URL has no valid key. Deliberately self-contained -
|
|
245
|
+
// the app's stylesheet and script are for a window that got in, and a locked page must stand on its own.
|
|
246
|
+
const LOCKED_PAGE = `<!doctype html>
|
|
247
|
+
<html lang="en"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
248
|
+
<title>chamba - key required</title>
|
|
249
|
+
<style>
|
|
250
|
+
body { margin:0; height:100vh; display:flex; align-items:center; justify-content:center; background:#0d1117; color:#e6edf3;
|
|
251
|
+
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
|
252
|
+
main { max-width:34rem; padding:2rem; }
|
|
253
|
+
h1 { font-size:1.15rem; margin:0 0 .9rem; }
|
|
254
|
+
p { margin:0 0 .7rem; color:#8b949e; line-height:1.5; }
|
|
255
|
+
code { color:#e6edf3; background:#161b22; border:1px solid #30363d; border-radius:5px; padding:.1rem .35rem; }
|
|
256
|
+
</style></head>
|
|
257
|
+
<body><main>
|
|
258
|
+
<h1>This link needs its key</h1>
|
|
259
|
+
<p>The web interface issues a new key every time it starts, and only the URL it printed can open it.</p>
|
|
260
|
+
<p>Get the current URL from the session greeting, or run <code>webterm <agent></code> inside the container.</p>
|
|
261
|
+
</main></body></html>
|
|
262
|
+
`;
|
|
263
|
+
|
|
264
|
+
// The gate on every route that carries the relay. Static assets stay open: they hold nothing secret and
|
|
265
|
+
// drive nothing, and gating them would mean a cookie - which on localhost is shared across ports, so any
|
|
266
|
+
// other local port could ride this workspace's. The key stays in the URL, scoped to the window given it.
|
|
267
|
+
// Two gates for one rule, differing only in what a refusal looks like: a person opening a URL gets a page
|
|
268
|
+
// that says what is missing, and the app's own calls get the JSON their callers already read.
|
|
269
|
+
function requirePage(req, res, next) {
|
|
270
|
+
if (isAuthorized(presentedKey(req))) {
|
|
271
|
+
next();
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
console.warn(`[webterm] refused ${req.method} ${req.path}: no valid key`);
|
|
275
|
+
res.status(403).type("html").send(LOCKED_PAGE);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function requireKey(req, res, next) {
|
|
279
|
+
if (isAuthorized(presentedKey(req))) {
|
|
280
|
+
next();
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
console.warn(`[webterm] refused ${req.method} ${req.path}: no valid key`);
|
|
284
|
+
res.status(403).json({ error: "missing or invalid key" });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// The pane's gate, which is the same gate minus the URL. Everything the pane carries is either a page's own
|
|
288
|
+
// content or an answer to one, and a URL that holds both a page and the key is a page that can read the key
|
|
289
|
+
// out of its own location - which is the leak the header exists to close. The client already sends it that
|
|
290
|
+
// way; a rule only the client keeps is a rule until someone writes the second caller, so it lives here.
|
|
291
|
+
function requirePaneKey(req, res, next) {
|
|
292
|
+
const header = req.headers[KEY_HEADER];
|
|
293
|
+
if (typeof header === "string" && isAuthorized(header)) {
|
|
294
|
+
next();
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
console.warn(`[webterm] refused ${req.method} ${req.path}: the pane takes its key in the ${KEY_HEADER} header`);
|
|
298
|
+
res.status(403).json({ error: "missing or invalid key" });
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// --- Upload endpoint ---------------------------------------------------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
// Map a small set of image content-types to file extensions. Anything else is rejected.
|
|
304
|
+
const IMAGE_EXT = {
|
|
305
|
+
"image/png": ".png",
|
|
306
|
+
"image/jpeg": ".jpg",
|
|
307
|
+
"image/jpg": ".jpg",
|
|
308
|
+
"image/gif": ".gif",
|
|
309
|
+
"image/webp": ".webp",
|
|
310
|
+
"image/bmp": ".bmp",
|
|
311
|
+
"image/svg+xml": ".svg",
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const app = express();
|
|
315
|
+
|
|
316
|
+
// The document is the one static file behind the gate, and it is the only one that matters: a window that
|
|
317
|
+
// never got the page opens no socket, so the interface as a whole is unreachable without the key.
|
|
318
|
+
//
|
|
319
|
+
// Gated on the path the static handler will resolve, not on the path as written: "//index.html" and
|
|
320
|
+
// "/./index.html" reach the same file, and a route pattern matches neither. Nothing follows from serving the
|
|
321
|
+
// shell - it holds no secret and drives nothing without a key - but a gate with a way around it reads like a
|
|
322
|
+
// gate, which is worse than not having one.
|
|
323
|
+
function isDocumentPath(raw) {
|
|
324
|
+
let path = raw;
|
|
325
|
+
try {
|
|
326
|
+
path = decodeURIComponent(raw);
|
|
327
|
+
} catch {
|
|
328
|
+
return false; // Not a path the static handler will find a file under either.
|
|
329
|
+
}
|
|
330
|
+
const normalised = resolve("/", path);
|
|
331
|
+
return normalised === "/" || normalised === "/index.html";
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
app.use((req, res, next) => {
|
|
335
|
+
if (!isDocumentPath(req.path)) {
|
|
336
|
+
next();
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
requirePage(req, res, next);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// Serve the client and xterm's shipped dist files straight from node_modules (no bundler).
|
|
343
|
+
app.use(express.static(join(import.meta.dirname, "public")));
|
|
344
|
+
app.use("/vendor/xterm", express.static(join(import.meta.dirname, "node_modules", "@xterm", "xterm", "css")));
|
|
345
|
+
app.use("/vendor/xterm", express.static(join(import.meta.dirname, "node_modules", "@xterm", "xterm", "lib")));
|
|
346
|
+
app.use("/vendor/xterm-fit", express.static(join(import.meta.dirname, "node_modules", "@xterm", "addon-fit", "lib")));
|
|
347
|
+
|
|
348
|
+
// Accept a raw image body (the client POSTs the pasted/dropped blob with its Content-Type).
|
|
349
|
+
// Reject non-image types up front; cap the size so a bad request cannot fill the disk.
|
|
350
|
+
app.post("/upload", requireKey, express.raw({ type: () => true, limit: MAX_UPLOAD_BYTES }), (req, res) => {
|
|
351
|
+
const contentType = String(req.headers["content-type"] || "")
|
|
352
|
+
.split(";")[0]
|
|
353
|
+
.trim();
|
|
354
|
+
const ext = IMAGE_EXT[contentType];
|
|
355
|
+
if (!ext) {
|
|
356
|
+
res.status(415).json({ error: `unsupported content-type "${contentType}" - images only` });
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (!Buffer.isBuffer(req.body) || req.body.length === 0) {
|
|
360
|
+
res.status(400).json({ error: "empty body" });
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
ensureUploadDir();
|
|
364
|
+
const filePath = join(UPLOAD_ROOT, `${randomBytes(8).toString("hex")}${ext}`);
|
|
365
|
+
try {
|
|
366
|
+
writeFileSync(filePath, req.body);
|
|
367
|
+
} catch (err) {
|
|
368
|
+
res.status(500).json({ error: `write failed: ${err instanceof Error ? err.message : String(err)}` });
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
// Return the in-container absolute path; the composer injects this so the CLI reads the file.
|
|
372
|
+
res.json({ path: filePath });
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
// --- HTTP + WebSocket server -------------------------------------------------------------------------------------------------------------
|
|
376
|
+
|
|
377
|
+
const server = createServer(app);
|
|
378
|
+
|
|
379
|
+
// The key a WebSocket handshake presents, read off the upgrade request's own URL (there is no express
|
|
380
|
+
// query parsing here). A malformed URL presents nothing.
|
|
381
|
+
function handshakeKey(url) {
|
|
382
|
+
try {
|
|
383
|
+
return new URL(url ?? "", "http://localhost").searchParams.get("k") ?? "";
|
|
384
|
+
} catch {
|
|
385
|
+
return "";
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// verifyClient runs during the WS handshake: no key, no relay. The Origin check stays as the second gate -
|
|
390
|
+
// it stops a remote page from scripting a socket even in a world where it somehow learned a key.
|
|
391
|
+
const wss = new WebSocketServer({
|
|
392
|
+
server,
|
|
393
|
+
path: "/ws",
|
|
394
|
+
verifyClient: ({ origin, req }, done) => {
|
|
395
|
+
if (!isAuthorized(handshakeKey(req.url))) {
|
|
396
|
+
console.warn("[webterm] rejected WS handshake: no valid key");
|
|
397
|
+
done(false, 403, "Forbidden");
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
if (isAllowedOrigin(origin)) {
|
|
401
|
+
done(true);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
console.warn(`[webterm] rejected WS handshake from origin: ${origin ?? "(none)"}`);
|
|
405
|
+
done(false, 403, "Forbidden origin");
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// Every connected browser window. Sessions outlive these sockets, so this set answers only one
|
|
410
|
+
// question: who has to be told when something changes.
|
|
411
|
+
const clients = new Set();
|
|
412
|
+
|
|
413
|
+
function send(client, frame) {
|
|
414
|
+
if (client.readyState === client.OPEN) client.send(JSON.stringify(frame));
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// The session bar, personalised per window: the list is the same for everyone, "which one am I
|
|
418
|
+
// driving" is not.
|
|
419
|
+
function broadcastSessions() {
|
|
420
|
+
const list = registry.list();
|
|
421
|
+
for (const client of clients) {
|
|
422
|
+
send(client, {
|
|
423
|
+
t: "sessions",
|
|
424
|
+
// What "+ New session" will use, so the picker opens on them and every window agrees, and the
|
|
425
|
+
// whole list of agents, so the menu beside it offers what this server will actually run.
|
|
426
|
+
agent: defaultAgent,
|
|
427
|
+
agents: AGENTS,
|
|
428
|
+
workspace: WORKSPACE,
|
|
429
|
+
max: MAX_SESSIONS,
|
|
430
|
+
defaultCwd: workspaceLabel(defaultCwd),
|
|
431
|
+
list,
|
|
432
|
+
attachedSid: registry.attachedSid(client),
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// The registry decides what happened; this is the only place that turns it into frames.
|
|
438
|
+
function handleEvent(event) {
|
|
439
|
+
if (event.t === "out") {
|
|
440
|
+
send(event.client, { t: "out", sid: event.sid, data: event.data });
|
|
441
|
+
} else if (event.t === "replay") {
|
|
442
|
+
send(event.client, { t: "replay", sid: event.sid, data: event.data });
|
|
443
|
+
// A window that just landed on a session needs its pane and its strip as well as its terminal, and
|
|
444
|
+
// this is the one event that means "this window is now looking at this session", reload or reconnect
|
|
445
|
+
// included.
|
|
446
|
+
send(event.client, pagesFrame(event.sid));
|
|
447
|
+
sendStatus(event.sid, { force: true });
|
|
448
|
+
} else if (event.t === "taken") {
|
|
449
|
+
send(event.client, { t: "taken", sid: event.sid });
|
|
450
|
+
} else if (event.t === "exit") {
|
|
451
|
+
console.log(`[webterm] session "${event.label}" ended on its own (${event.agent} exited)`);
|
|
452
|
+
for (const client of clients) send(client, { t: "exit", sid: event.sid, agent: event.agent });
|
|
453
|
+
} else if (event.t === "changed") {
|
|
454
|
+
broadcastSessions();
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const registry = createRegistry({
|
|
459
|
+
spawn: spawnAgent,
|
|
460
|
+
maxSessions: MAX_SESSIONS,
|
|
461
|
+
maxBuffer: MAX_OUTPUT_BUFFER,
|
|
462
|
+
onEvent: handleEvent,
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
// --- The web pane ------------------------------------------------------------------------------------------------------------------------
|
|
466
|
+
//
|
|
467
|
+
// Pages an agent published, filed under the conversation they belong to. The store owns the files, pane.js
|
|
468
|
+
// owns which conversation a session is having, and what is left here is the three things only the server can
|
|
469
|
+
// answer: who is asking (the socket, or a pid handed to the publish route), who to tell when a pane moves,
|
|
470
|
+
// and how a submission gets from a page back to the agent that wrote it.
|
|
471
|
+
|
|
472
|
+
const paneStore = createPaneStore({
|
|
473
|
+
root: PANE_DIR,
|
|
474
|
+
maxPageBytes: MAX_PAGE_BYTES,
|
|
475
|
+
maxTotalBytes: MAX_PANE_BYTES,
|
|
476
|
+
maxTotalFiles: MAX_PANE_FILES,
|
|
477
|
+
maxFeedbackPerPage: MAX_FEEDBACK_PER_PAGE,
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// The live sessions as the pane needs them: the PTY leader's pid roots the process tree an agent runs in,
|
|
481
|
+
// and the start time bounds which conversation on disk could be this session's.
|
|
482
|
+
function paneSessions() {
|
|
483
|
+
const live = [];
|
|
484
|
+
for (const entry of registry.list()) {
|
|
485
|
+
const session = registry.get(entry.id);
|
|
486
|
+
const pid = Number(session?.term?.pid);
|
|
487
|
+
if (!session || !Number.isInteger(pid)) continue;
|
|
488
|
+
live.push({ sid: session.id, agent: session.agent, cwd: session.cwd, pid, since: session.createdAt });
|
|
489
|
+
}
|
|
490
|
+
return live;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const pane = createPane({
|
|
494
|
+
store: paneStore,
|
|
495
|
+
stores: AGENT_STORES,
|
|
496
|
+
sessions: paneSessions,
|
|
497
|
+
onChange: (sid) => sendPages(sid),
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
// The pane as one frame: the list, and the notice when this session is not showing its conversation's own
|
|
501
|
+
// pages (a second window resumed a conversation another live session already holds).
|
|
502
|
+
function pagesFrame(sid) {
|
|
503
|
+
const { pages, notice } = pane.pages(sid);
|
|
504
|
+
return { t: "pages", sid, pages, notice };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Tell whoever is watching this session that its pane moved. Only that window: a pane belongs to a session,
|
|
508
|
+
// and every other window is looking at something else.
|
|
509
|
+
function sendPages(sid) {
|
|
510
|
+
const client = registry.get(sid)?.client;
|
|
511
|
+
if (client) send(client, pagesFrame(sid));
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* The session a process belongs to, walked up from the pid it handed over. This is how a publish is routed:
|
|
516
|
+
* a caller says only "this is me", and the server decides which session that is from the process tree it
|
|
517
|
+
* already knows, rather than taking a key or a session id as an argument.
|
|
518
|
+
*
|
|
519
|
+
* It says which session, not who is calling. A pid is a claim - any process in the container can name
|
|
520
|
+
* another one - so this is not an authentication of the caller, and it is not meant to be: the pane
|
|
521
|
+
* directory is writable by the same uid anyway, and the key on the route is the gate that matters. What it
|
|
522
|
+
* does buy is that no caller can name a directory the server would not have chosen for it.
|
|
523
|
+
*/
|
|
524
|
+
function sessionForPid(pid) {
|
|
525
|
+
if (!Number.isInteger(pid) || pid < 1) return null;
|
|
526
|
+
for (const entry of registry.list()) {
|
|
527
|
+
const session = registry.get(entry.id);
|
|
528
|
+
const leader = Number(session?.term?.pid);
|
|
529
|
+
if (Number.isInteger(leader) && isSelfOrDescendant(pid, leader)) return session;
|
|
530
|
+
}
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// --- The status strip --------------------------------------------------------------------------------------------------------------------
|
|
535
|
+
//
|
|
536
|
+
// A claude session's own numbers, above the composer: the model, the context it is holding, what is left of
|
|
537
|
+
// the quota. They come from the snapshot its status line writes (see snapshot.js), which is a file, so this
|
|
538
|
+
// side is a poll and a comparison - look at what the session's snapshot says now, and send a frame only when
|
|
539
|
+
// it says something different from the last one this window was told.
|
|
540
|
+
|
|
541
|
+
// sid -> the payload last sent for it, serialised. Only so an unchanged snapshot costs nothing: the script
|
|
542
|
+
// rewrites the file on every prompt render, and most rewrites move nothing the strip shows.
|
|
543
|
+
const lastStatus = new Map();
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Push this session's status to the window watching it, when it has moved.
|
|
547
|
+
*
|
|
548
|
+
* Nothing is sent for an agent that writes no snapshots - there is no strip for a codex tab, and an empty
|
|
549
|
+
* frame would only tell the window to draw one and find nothing. `force` is for the two moments a window
|
|
550
|
+
* needs the current state whatever the server last sent: it just landed on the session, or it asked.
|
|
551
|
+
*/
|
|
552
|
+
function sendStatus(sid, { force = false } = {}) {
|
|
553
|
+
const session = registry.get(sid);
|
|
554
|
+
const pid = Number(session?.term?.pid);
|
|
555
|
+
if (!session?.client || !writesSnapshots(session.agent) || !Number.isInteger(pid)) return;
|
|
556
|
+
const status = statusFor({ pid }, AGENT_STORES.claudeSnapshots);
|
|
557
|
+
const serialised = JSON.stringify(status);
|
|
558
|
+
if (!force && lastStatus.get(sid) === serialised) return;
|
|
559
|
+
lastStatus.set(sid, serialised);
|
|
560
|
+
send(session.client, { t: "snapshot", sid, status });
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// One pass over the sessions: whoever is watching one hears about it when its snapshot moved. Sessions that
|
|
564
|
+
// are gone drop out of the map here, which is the only cleanup it needs.
|
|
565
|
+
function sweepStatus() {
|
|
566
|
+
const live = new Set();
|
|
567
|
+
for (const entry of registry.list()) {
|
|
568
|
+
live.add(entry.id);
|
|
569
|
+
sendStatus(entry.id);
|
|
570
|
+
}
|
|
571
|
+
for (const sid of lastStatus.keys()) {
|
|
572
|
+
if (!live.has(sid)) lastStatus.delete(sid);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// What the host asks before it offers to stop the container. `sessions` is how many conversations are
|
|
577
|
+
// alive (they all die with the container), `attached` how many a window is watching right now, `agent` the
|
|
578
|
+
// one a new session gets and `agents` what is actually running, per agent. Counts and names only - nothing
|
|
579
|
+
// about the conversations themselves.
|
|
580
|
+
// A window whose socket dropped asks it too: an answer means the relay is fine, a 403 means this window's
|
|
581
|
+
// key is no longer the live one, and no answer at all means the container is gone. Three different things
|
|
582
|
+
// to tell the user, and this is what tells them apart.
|
|
583
|
+
app.get("/status", requireKey, (_req, res) => {
|
|
584
|
+
const agents = {};
|
|
585
|
+
for (const entry of registry.list()) agents[entry.agent] = (agents[entry.agent] ?? 0) + 1;
|
|
586
|
+
res.json({ agent: defaultAgent, agents, sessions: registry.count(), attached: registry.attachedCount() });
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
// What the picker fills its suggestions from. Scanned per request rather than cached: the picker is opened
|
|
590
|
+
// now and then, and a list built at startup would miss every directory created since.
|
|
591
|
+
app.get("/dirs", requireKey, (_req, res) => {
|
|
592
|
+
const { dirs, truncated } = scanDirs();
|
|
593
|
+
res.json({ default: workspaceLabel(defaultCwd), dirs, truncated });
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
// The host moves the default here, on every session start that finds this interface already running. The
|
|
597
|
+
// interface is started once per container start with the directory chamba ran in, so without this the
|
|
598
|
+
// default would age: run chamba somewhere else tomorrow and the browser would still open new sessions in
|
|
599
|
+
// yesterday's directory, while the terminal session opened in the new one.
|
|
600
|
+
app.post("/cwd", requireKey, express.json({ limit: 4096 }), (req, res) => {
|
|
601
|
+
// A body without a path is refused rather than read as the workspace root: this route moves where every
|
|
602
|
+
// later session starts, so it acts only on a directory that was actually named.
|
|
603
|
+
const raw = req.body?.cwd;
|
|
604
|
+
const wanted = typeof raw === "string" ? resolveWorkspacePath(raw) : null;
|
|
605
|
+
if (wanted === null) {
|
|
606
|
+
res.status(400).json({ error: "not a directory inside the workspace" });
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
if (wanted !== defaultCwd) {
|
|
610
|
+
defaultCwd = wanted;
|
|
611
|
+
console.log(`[webterm] new sessions now start in ${defaultCwd}`);
|
|
612
|
+
// Every open window shows the default in its picker, so they all have to hear it.
|
|
613
|
+
broadcastSessions();
|
|
614
|
+
}
|
|
615
|
+
res.json({ default: workspaceLabel(defaultCwd) });
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
// `webterm <agent>` in the container, with the interface already running. It moves what "+ New session"
|
|
619
|
+
// starts and nothing else: live sessions keep the agent they were started with, because the agent is the
|
|
620
|
+
// process. This is what replaced killing the server to change agents, which took every open session with it.
|
|
621
|
+
app.post("/agent", requireKey, express.json({ limit: 4096 }), (req, res) => {
|
|
622
|
+
const wanted = sessionAgent(req.body?.agent);
|
|
623
|
+
if (wanted === null || req.body?.agent === undefined) {
|
|
624
|
+
res.status(400).json({ error: `not an agent this server runs (${AGENTS.join(", ")})` });
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
if (wanted !== defaultAgent) {
|
|
628
|
+
defaultAgent = wanted;
|
|
629
|
+
console.log(`[webterm] new sessions now start ${defaultAgent}`);
|
|
630
|
+
publishAgent();
|
|
631
|
+
// Every open window shows the default on its "+ New session", so they all have to hear it.
|
|
632
|
+
broadcastSessions();
|
|
633
|
+
}
|
|
634
|
+
res.json({ default: defaultAgent });
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
// --- Pane routes -------------------------------------------------------------------------------------------------------------------------
|
|
638
|
+
|
|
639
|
+
// A session the pane can answer about: live, or one it has already filed pages for. An unknown id gets
|
|
640
|
+
// nothing rather than a fresh directory - a route must not be able to create panes.
|
|
641
|
+
function paneSession(raw) {
|
|
642
|
+
const sid = typeof raw === "string" ? raw : "";
|
|
643
|
+
if (!sid) return null;
|
|
644
|
+
return registry.get(sid) ? sid : pane.knows(sid) ? sid : null;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* One page's content. Key-gated like everything else, and served with the two headers that make it a
|
|
649
|
+
* document rather than a guess: the type it is, and nosniff so nothing re-reads it as something else.
|
|
650
|
+
*
|
|
651
|
+
* The CSP is defence in depth for a case the key gate already covers. The pane renders pages in a sandboxed
|
|
652
|
+
* frame with no origin of their own, and the shell fetches them with the key in a header, so this URL is not
|
|
653
|
+
* something a browser ever navigates to. Should one ever be pointed at it, the sandbox directive keeps the
|
|
654
|
+
* document off this origin anyway.
|
|
655
|
+
*/
|
|
656
|
+
app.get("/pane/page", requirePaneKey, (req, res) => {
|
|
657
|
+
const sid = paneSession(req.query.sid);
|
|
658
|
+
const id = typeof req.query.id === "string" ? req.query.id : "";
|
|
659
|
+
if (sid === null || !isPageId(id)) {
|
|
660
|
+
res.status(404).json({ error: "no such page" });
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
const page = pane.read(sid, id);
|
|
664
|
+
if (!page.ok) {
|
|
665
|
+
if (page.error === "toobig") console.warn(`[webterm] pane: refused to serve ${id} - larger than a page may be`);
|
|
666
|
+
res.status(page.error === "toobig" ? 413 : 404).json({ error: page.error === "toobig" ? "page too large" : "no such page" });
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
res.status(200)
|
|
670
|
+
.set({
|
|
671
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
672
|
+
"X-Content-Type-Options": "nosniff",
|
|
673
|
+
"Content-Security-Policy": "sandbox allow-scripts allow-forms",
|
|
674
|
+
"Cache-Control": "no-store",
|
|
675
|
+
})
|
|
676
|
+
.send(page.html);
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Publish a page. The body is the HTML; the caller passes its own pid and nothing else, and the server works
|
|
681
|
+
* out which session that is by walking the process tree (see sessionForPid, including what a pid does and
|
|
682
|
+
* does not prove). A pid under no session at all is refused and nothing is written.
|
|
683
|
+
*/
|
|
684
|
+
app.post("/pane/publish", requirePaneKey, express.raw({ type: () => true, limit: MAX_PAGE_BYTES }), (req, res) => {
|
|
685
|
+
const session = sessionForPid(Number(req.headers["x-webpane-pid"]));
|
|
686
|
+
if (!session) {
|
|
687
|
+
res.status(409).json({ error: "not running inside a web session - nothing to publish to" });
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
const html = Buffer.isBuffer(req.body) ? req.body.toString("utf8") : "";
|
|
691
|
+
const title = cleanLine(req.headers["x-webpane-title"] ?? "");
|
|
692
|
+
const published = pane.publish(session.id, { title, html });
|
|
693
|
+
if (!published.ok) {
|
|
694
|
+
const message = {
|
|
695
|
+
empty: "the page is empty",
|
|
696
|
+
toobig: "the page is larger than the per-page limit",
|
|
697
|
+
full: "the web pane is full - nothing was removed, and nothing new can be published",
|
|
698
|
+
name: "this session has no pane directory",
|
|
699
|
+
write: "the page could not be written",
|
|
700
|
+
};
|
|
701
|
+
res.status(published.error === "full" || published.error === "toobig" ? 413 : 400).json({
|
|
702
|
+
error: message[published.error] ?? "the page could not be published",
|
|
703
|
+
});
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
console.log(`[webterm] pane: published "${published.title}" to session "${session.name || session.label}"`);
|
|
707
|
+
res.json({ id: published.id, title: published.title, path: published.path });
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
// One submission per page per this long. A form is filled in by a person; anything faster is a stuck button
|
|
711
|
+
// or a script, and refusing it keeps one page from filling the directory.
|
|
712
|
+
const lastSubmission = new Map();
|
|
713
|
+
|
|
714
|
+
function tooSoon(sid, id) {
|
|
715
|
+
const now = Date.now();
|
|
716
|
+
for (const [key, at] of lastSubmission) {
|
|
717
|
+
if (now - at > FEEDBACK_MIN_INTERVAL_MS * 60) lastSubmission.delete(key);
|
|
718
|
+
}
|
|
719
|
+
const key = `${sid}:${id}`;
|
|
720
|
+
if (now - (lastSubmission.get(key) ?? 0) < FEEDBACK_MIN_INTERVAL_MS) return true;
|
|
721
|
+
lastSubmission.set(key, now);
|
|
722
|
+
return false;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* A page's form was submitted. What is written and where is decided here, from the page the pane is actually
|
|
727
|
+
* showing: the directory is the session's, the filename is built from that page's own name and the clock, and
|
|
728
|
+
* the body contributes nothing but the answers - bounded on every axis before they are written.
|
|
729
|
+
* The agent then hears about it the way a user would tell it: one line typed into its terminal.
|
|
730
|
+
*/
|
|
731
|
+
app.post("/pane/feedback", requirePaneKey, express.json({ limit: MAX_FEEDBACK_BYTES }), (req, res) => {
|
|
732
|
+
const sid = paneSession(req.query.sid);
|
|
733
|
+
const id = typeof req.query.id === "string" ? req.query.id : "";
|
|
734
|
+
if (sid === null || !isPageId(id)) {
|
|
735
|
+
res.status(404).json({ error: "no such page" });
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
// Before the listing, which reads the head of every page in the pane to find its title: a page in a
|
|
739
|
+
// loop should cost this route as little as possible, and the gap is the cheapest thing it can meet.
|
|
740
|
+
if (tooSoon(sid, id)) {
|
|
741
|
+
res.status(429).json({ error: "that page was just submitted - give it a moment" });
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
const page = pane.pages(sid).pages.find((entry) => entry.id === id);
|
|
745
|
+
if (!page) {
|
|
746
|
+
res.status(404).json({ error: "no such page" });
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
const submission = cleanSubmission(req.body, {
|
|
750
|
+
maxFields: MAX_FEEDBACK_FIELDS,
|
|
751
|
+
maxFieldLength: MAX_FEEDBACK_FIELD_LENGTH,
|
|
752
|
+
maxTextLength: MAX_FEEDBACK_TEXT_LENGTH,
|
|
753
|
+
});
|
|
754
|
+
const written = pane.feedback(sid, id, submission);
|
|
755
|
+
if (!written.ok) {
|
|
756
|
+
// "full" is the same rule a publish meets: past the total, nothing new is written and nothing that
|
|
757
|
+
// is there is removed. It is the one refusal worth telling the page apart from a failure.
|
|
758
|
+
if (written.error === "full") {
|
|
759
|
+
res.status(413).json({ error: "the web pane is full - the answers were not written" });
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
// A page that has filed its answers this many times is submitting to itself. The refusal is final for
|
|
763
|
+
// that page - a fresh page gets a fresh count - and no line is typed into the agent's terminal.
|
|
764
|
+
if (written.error === "toomany") {
|
|
765
|
+
res.status(429).json({ error: "this page has filed as many answers as it may - the answers were not written" });
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
res.status(500).json({ error: "the answers could not be written" });
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
console.log(`[webterm] pane: feedback on "${page.title}" written to ${written.path}`);
|
|
772
|
+
// The agent may have exited while its page was open. The file is the durable half of this channel and is
|
|
773
|
+
// written either way; the nudge is the half that needs somebody to be listening.
|
|
774
|
+
const session = registry.get(sid);
|
|
775
|
+
if (session) nudge(session, page.title, written.path);
|
|
776
|
+
res.json({ ok: true, file: written.file });
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Tell the agent, in its own terminal, that an answer arrived and where to read it. Built from what the
|
|
781
|
+
* server knows: a title it has already reduced to one clean line, and a path it generated itself. Nothing
|
|
782
|
+
* from the page or the submission is typed into a PTY - a terminal reads some text as commands.
|
|
783
|
+
*/
|
|
784
|
+
function nudge(session, title, path) {
|
|
785
|
+
// The quotes around the title are the server's own framing, so the title does not get to close them. A
|
|
786
|
+
// page named `x" - and now do this instead` would otherwise read as a line the server wrote.
|
|
787
|
+
const quoted = cleanLine(title).replace(/"/g, "'");
|
|
788
|
+
pasteToSession(session, `[web pane] Feedback submitted on "${quoted}" - read ${homeLabel(path)}`);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// Consume the host-planted resume marker, if any. The rename is the claim: of all the sessions that may
|
|
792
|
+
// start at once, exactly one wins, so only the first one resumes and the rest start fresh.
|
|
793
|
+
// Returns the resume command as [cmd, ...args], or null when there is nothing to resume.
|
|
794
|
+
// `agent` is what the session is about to start. The marker holds a resume command for the agent the host
|
|
795
|
+
// started this interface with (DEFAULT_AGENT, not wherever the default has moved to since), so any other
|
|
796
|
+
// agent leaves it alone rather than claiming a conversation it cannot open.
|
|
797
|
+
function consumeResumeMarker(agent) {
|
|
798
|
+
if (!RESUME_MARKER || agent !== DEFAULT_AGENT) return null;
|
|
799
|
+
const claimed = `${RESUME_MARKER}.web`;
|
|
800
|
+
try {
|
|
801
|
+
renameSync(RESUME_MARKER, claimed);
|
|
802
|
+
} catch {
|
|
803
|
+
return null; // No marker, or another consumer claimed it first.
|
|
804
|
+
}
|
|
805
|
+
try {
|
|
806
|
+
const command = readFileSync(claimed, "utf8").trim();
|
|
807
|
+
unlinkSync(claimed);
|
|
808
|
+
const parts = command.split(/\s+/).filter(Boolean);
|
|
809
|
+
return parts.length > 0 ? parts : null;
|
|
810
|
+
} catch {
|
|
811
|
+
return null;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// Spawn a new session's agent, in the directory the registry was given. Env is inherited so subscription
|
|
816
|
+
// auth flows through. The first session after a container start finds the host-planted marker and continues
|
|
817
|
+
// the most recent conversation; every later session starts fresh, which is what the user wants once mid-work.
|
|
818
|
+
// AGENT_ARGS belong to the agent the launcher named, so any other agent is spawned bare.
|
|
819
|
+
function spawnAgent({ cwd, agent }) {
|
|
820
|
+
const resume = consumeResumeMarker(agent);
|
|
821
|
+
const [spawnCmd, ...baseArgs] = resume ?? [agent, ...(agent === DEFAULT_AGENT ? AGENT_ARGS : [])];
|
|
822
|
+
if (resume) console.log(`[webterm] resuming most recent conversation: ${resume.join(" ")}`);
|
|
823
|
+
// Append the browser-awareness flag for claude (fresh or resumed); every other agent is untouched.
|
|
824
|
+
const spawnArgs = agentSpawnArgv(spawnCmd, baseArgs);
|
|
825
|
+
return pty.spawn(spawnCmd, spawnArgs, {
|
|
826
|
+
name: "xterm-256color",
|
|
827
|
+
cols: 80,
|
|
828
|
+
rows: 24,
|
|
829
|
+
cwd,
|
|
830
|
+
// CHAMBA_WEB_SESSION is how an agent tells a browser session from a terminal one. claude learns it
|
|
831
|
+
// from the note appended above, but the note is claude's alone - the flag does not exist on the other
|
|
832
|
+
// two CLIs - and the shared context tells every agent to mention the pane in a web session. An env
|
|
833
|
+
// var is the one answer all three can read.
|
|
834
|
+
env: { ...process.env, CHAMBA_WEB_SESSION: "1" },
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// --- Frames from a window ----------------------------------------------------------------------------------------------------------------
|
|
839
|
+
|
|
840
|
+
// Put a window on a session. It gets it unless another window is really driving it, in which case the
|
|
841
|
+
// browser asks the user whether to take it over - the PTY has one size, so two drivers fight.
|
|
842
|
+
function openSession(ws, sid) {
|
|
843
|
+
const result = registry.attach(sid, ws);
|
|
844
|
+
if (result === "busy") send(ws, { t: "busy", sid });
|
|
845
|
+
else if (result === "gone") broadcastSessions();
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// Start a session and attach this window to it. `rawCwd` and `rawAgent` are what the browser asked for, if
|
|
849
|
+
// anything: the plain "+ New session" sends neither and takes both defaults, the picker sends a path
|
|
850
|
+
// relative to the workspace and the menu sends an agent. Either one naming something we cannot run is
|
|
851
|
+
// refused rather than quietly swapped for the default - starting the wrong agent, or one somewhere the user
|
|
852
|
+
// did not ask for, is worse than saying no.
|
|
853
|
+
function newSession(ws, rawCwd, rawAgent) {
|
|
854
|
+
const cwd = sessionCwd(rawCwd);
|
|
855
|
+
if (cwd === null) {
|
|
856
|
+
console.warn(`[webterm] refused a new session: "${rawCwd}" is not a directory inside the workspace`);
|
|
857
|
+
send(ws, { t: "error", code: "cwd" });
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
const agent = sessionAgent(rawAgent);
|
|
861
|
+
if (agent === null) {
|
|
862
|
+
console.warn(`[webterm] refused a new session: "${rawAgent}" is not an agent this server runs`);
|
|
863
|
+
send(ws, { t: "error", code: "agent" });
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
let created;
|
|
867
|
+
try {
|
|
868
|
+
created = registry.create({ cwd, cwdLabel: workspaceLabel(cwd), agent });
|
|
869
|
+
} catch (err) {
|
|
870
|
+
console.error(`[webterm] could not start ${agent}: ${err instanceof Error ? err.message : String(err)}`);
|
|
871
|
+
send(ws, { t: "error", code: "spawn", agent });
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
if (!created.ok) {
|
|
875
|
+
console.log(`[webterm] refused a new session: already at the limit of ${MAX_SESSIONS}`);
|
|
876
|
+
send(ws, { t: "error", code: created.error });
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
console.log(`[webterm] started session "${created.session.label}" (${registry.count()}/${MAX_SESSIONS})`);
|
|
880
|
+
openSession(ws, created.session.id);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// A window connected. Mission control always opens on something: an empty container gets one session
|
|
884
|
+
// (that first session is what consumes the resume marker), otherwise the window lands on the session it
|
|
885
|
+
// was last looking at, or the oldest one nobody is watching.
|
|
886
|
+
//
|
|
887
|
+
// Auto-create is for a page that was just opened, which is why the window says whether this is its first
|
|
888
|
+
// socket. A reconnect is the same page coming back from a blip or a slept laptop, and it must land on
|
|
889
|
+
// exactly what it left: closing the last session on purpose leaves an empty bar, and waking up hours
|
|
890
|
+
// later should still show that empty bar rather than a fresh agent process nobody asked for.
|
|
891
|
+
function greet(ws, wantSid, fresh) {
|
|
892
|
+
if (registry.count() === 0) {
|
|
893
|
+
if (fresh) newSession(ws);
|
|
894
|
+
else broadcastSessions(); // Nothing to attach to; the bar still has to render, empty.
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
const sid = registry.pickForClient(ws, wantSid);
|
|
898
|
+
if (sid) openSession(ws, sid);
|
|
899
|
+
else broadcastSessions(); // Every session is driven elsewhere; the bar still has to render.
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// End the day from the browser: stop the container, and with it every session in it. PID 1 is the
|
|
903
|
+
// container's keep-alive, and the container stops when it exits - but the kernel drops a signal sent to
|
|
904
|
+
// PID 1 from inside its own namespace unless PID 1 installed a handler for it. chamba starts containers
|
|
905
|
+
// with a keep-alive that traps TERM for exactly this; one created before that lands here and ignores the
|
|
906
|
+
// signal, which is what the timer reports so the page can point at the host instead of hanging.
|
|
907
|
+
function stopContainer() {
|
|
908
|
+
console.log("[webterm] stopping the container (asked for from the browser)");
|
|
909
|
+
// Announced before the signal: the container can go the instant PID 1 does, and a window that heard
|
|
910
|
+
// nothing would show "cannot reach the container" for something the user just asked for.
|
|
911
|
+
for (const client of clients) send(client, { t: "stopping" });
|
|
912
|
+
setTimeout(() => {
|
|
913
|
+
try {
|
|
914
|
+
process.kill(1, "SIGTERM");
|
|
915
|
+
} catch (err) {
|
|
916
|
+
console.error(`[webterm] could not signal PID 1: ${err instanceof Error ? err.message : String(err)}`);
|
|
917
|
+
for (const client of clients) send(client, { t: "error", code: "stop" });
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
setTimeout(() => {
|
|
921
|
+
// Still running, so the signal was dropped rather than acted on.
|
|
922
|
+
console.warn("[webterm] the container did not stop - its keep-alive does not act on TERM from inside");
|
|
923
|
+
for (const client of clients) send(client, { t: "error", code: "stop" });
|
|
924
|
+
}, STOP_TIMEOUT_MS).unref();
|
|
925
|
+
}, STOP_ANNOUNCE_MS).unref();
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// The composed message, delivered to whichever session this window is driving.
|
|
929
|
+
function paste(ws, text) {
|
|
930
|
+
const session = registry.sessionFor(ws);
|
|
931
|
+
if (session) pasteToSession(session, text);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* Deliver a message to a session's agent as if it had been typed and sent. The composer's messages come this
|
|
936
|
+
* way, and so does the pane's feedback nudge - which is the point of it being one function: an agent should
|
|
937
|
+
* not be able to tell the two apart, and the busy detection should not either.
|
|
938
|
+
*/
|
|
939
|
+
function pasteToSession(session, text) {
|
|
940
|
+
const term = session.term;
|
|
941
|
+
// Wrap as one bracketed paste so a multi-line message is not submitted line-by-line.
|
|
942
|
+
registry.typed(session.id);
|
|
943
|
+
term.write(PASTE_START + text + PASTE_END);
|
|
944
|
+
// Send Enter slightly later so it lands after the agent has ingested any pasted image paths,
|
|
945
|
+
// otherwise the submit can be dropped and the message sits un-sent until a second Enter.
|
|
946
|
+
setTimeout(() => {
|
|
947
|
+
// Only submit if that session's PTY is still the live one (it may have exited meanwhile). The
|
|
948
|
+
// lookup is by session, not by window: the Enter belongs to the session that got the paste.
|
|
949
|
+
if (registry.get(session.id)?.term !== term) return;
|
|
950
|
+
try {
|
|
951
|
+
registry.typed(session.id);
|
|
952
|
+
term.write(SUBMIT);
|
|
953
|
+
} catch {
|
|
954
|
+
// PTY went away; nothing to submit.
|
|
955
|
+
}
|
|
956
|
+
}, SUBMIT_DELAY_MS);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
function resize(ws, cols, rows) {
|
|
960
|
+
if (!Number.isInteger(cols) || !Number.isInteger(rows) || cols <= 0 || rows <= 0) return;
|
|
961
|
+
try {
|
|
962
|
+
registry.sessionFor(ws)?.term.resize(cols, rows);
|
|
963
|
+
} catch {
|
|
964
|
+
// A transient bad size (e.g. 0 during layout) must not kill the session.
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
function handleFrame(ws, msg) {
|
|
969
|
+
const sid = typeof msg.sid === "string" ? msg.sid : null;
|
|
970
|
+
switch (msg.t) {
|
|
971
|
+
case "hello":
|
|
972
|
+
greet(ws, sid, msg.fresh === true);
|
|
973
|
+
return;
|
|
974
|
+
case "ping":
|
|
975
|
+
// Liveness probe from a window that just woke up: an answer proves the socket really works,
|
|
976
|
+
// so it can reconnect at once instead of waiting for TCP to give up.
|
|
977
|
+
send(ws, { t: "pong" });
|
|
978
|
+
return;
|
|
979
|
+
case "attach":
|
|
980
|
+
if (sid) openSession(ws, sid);
|
|
981
|
+
return;
|
|
982
|
+
case "pages": {
|
|
983
|
+
// The pane asking for its contents again - after a reconnect, or when the window comes back. It
|
|
984
|
+
// gets the session it is driving, never one it names: a window's pane is whatever it is attached
|
|
985
|
+
// to, and that is not the window's to choose.
|
|
986
|
+
const attached = registry.attachedSid(ws);
|
|
987
|
+
if (attached) send(ws, pagesFrame(attached));
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
case "snapshot": {
|
|
991
|
+
// The strip asking for its session's numbers again, after a reconnect. Like the pane, it gets the
|
|
992
|
+
// session this window is driving and never one it names.
|
|
993
|
+
const attached = registry.attachedSid(ws);
|
|
994
|
+
if (attached) sendStatus(attached, { force: true });
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
case "seen":
|
|
998
|
+
// The user did something in this window - a click, a scroll, a key. It changes nothing about who
|
|
999
|
+
// drives what, only whether the next ending of the session it is driving is worth a sound. Typing
|
|
1000
|
+
// arrives as "in" and is stamped there, so this covers everything that is not a keystroke.
|
|
1001
|
+
registry.seen(registry.attachedSid(ws));
|
|
1002
|
+
return;
|
|
1003
|
+
case "takeover":
|
|
1004
|
+
if (sid) registry.takeover(sid, ws);
|
|
1005
|
+
return;
|
|
1006
|
+
case "new":
|
|
1007
|
+
// No cwd or agent on the frame means the defaults; the picker and the agent menu send theirs.
|
|
1008
|
+
newSession(ws, msg.cwd, msg.agent);
|
|
1009
|
+
return;
|
|
1010
|
+
case "stop":
|
|
1011
|
+
// Never implicit: the browser only sends this after the user confirmed a card that names
|
|
1012
|
+
// everything it ends.
|
|
1013
|
+
stopContainer();
|
|
1014
|
+
return;
|
|
1015
|
+
case "close": {
|
|
1016
|
+
// Read the display name first: closing removes the session, and this is what the log names it by.
|
|
1017
|
+
const target = sid ? registry.get(sid) : null;
|
|
1018
|
+
const shown = target ? target.name || target.label : null;
|
|
1019
|
+
if (sid && registry.close(sid)) console.log(`[webterm] closed session "${shown}" (asked for)`);
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
case "rename": {
|
|
1023
|
+
if (sid && typeof msg.name === "string" && registry.rename(sid, msg.name)) {
|
|
1024
|
+
const session = registry.get(sid);
|
|
1025
|
+
if (session?.name) console.log(`[webterm] renamed session "${session.label}" to "${session.name}"`);
|
|
1026
|
+
else console.log(`[webterm] cleared the name on session "${session?.label ?? sid}"`);
|
|
1027
|
+
}
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
case "reorder":
|
|
1031
|
+
// Not logged: unlike a rename, where the session goes in the bar says nothing a later log line
|
|
1032
|
+
// needs. The registry broadcasts the new bar to every window.
|
|
1033
|
+
if (sid) registry.reorder(sid, msg.index);
|
|
1034
|
+
return;
|
|
1035
|
+
case "in": {
|
|
1036
|
+
if (typeof msg.data !== "string") return;
|
|
1037
|
+
const session = registry.sessionFor(ws);
|
|
1038
|
+
if (!session) return;
|
|
1039
|
+
// Stamped as the user's own typing first, so the echo that comes straight back is not read as
|
|
1040
|
+
// the agent working.
|
|
1041
|
+
registry.typed(session.id);
|
|
1042
|
+
session.term.write(msg.data);
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
case "paste":
|
|
1046
|
+
if (typeof msg.data === "string") paste(ws, msg.data);
|
|
1047
|
+
return;
|
|
1048
|
+
case "resize":
|
|
1049
|
+
resize(ws, msg.cols, msg.rows);
|
|
1050
|
+
return;
|
|
1051
|
+
default:
|
|
1052
|
+
return; // Unknown frame: ignore.
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// One socket per browser window carries everything - the session list, the attached session's output,
|
|
1057
|
+
// and every command - so switching sessions is a frame, not a reconnect.
|
|
1058
|
+
wss.on("connection", (ws) => {
|
|
1059
|
+
clients.add(ws);
|
|
1060
|
+
// Answering the keepalive ping is what keeps this socket (and its attachment) alive.
|
|
1061
|
+
ws.isAlive = true;
|
|
1062
|
+
ws.on("pong", () => {
|
|
1063
|
+
ws.isAlive = true;
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
ws.on("message", (raw) => {
|
|
1067
|
+
let msg;
|
|
1068
|
+
try {
|
|
1069
|
+
msg = JSON.parse(raw.toString());
|
|
1070
|
+
} catch {
|
|
1071
|
+
return; // Ignore malformed frames.
|
|
1072
|
+
}
|
|
1073
|
+
handleFrame(ws, msg);
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
// The window is gone - closed, slept, network dropped, all the same from here. Its session keeps
|
|
1077
|
+
// running and keeps buffering; only the attachment is released.
|
|
1078
|
+
ws.on("close", () => {
|
|
1079
|
+
clients.delete(ws);
|
|
1080
|
+
registry.detach(ws);
|
|
1081
|
+
});
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
// --- Startup -----------------------------------------------------------------------------------------------------------------------------
|
|
1085
|
+
|
|
1086
|
+
ensureUploadDir();
|
|
1087
|
+
checkUploads();
|
|
1088
|
+
setInterval(checkUploads, CHECK_INTERVAL_MS).unref();
|
|
1089
|
+
|
|
1090
|
+
// Whether each agent is working is a question about time - how long since its last byte - so something has
|
|
1091
|
+
// to ask it. The registry does the deciding; this only sets the pace, and only broadcasts when an answer
|
|
1092
|
+
// actually changed.
|
|
1093
|
+
setInterval(() => registry.tick(), WORK_TICK_MS).unref();
|
|
1094
|
+
|
|
1095
|
+
// The pane's own sweep, on the same principle: one pass over the live sessions asks the two questions that
|
|
1096
|
+
// are about time rather than about an event - has this agent written down which conversation it is having
|
|
1097
|
+
// yet, and has anything been written into its pane directory that did not come through the publish route.
|
|
1098
|
+
setInterval(() => pane.sweep(), PANE_SCAN_MS).unref();
|
|
1099
|
+
|
|
1100
|
+
// The strip's sweep, for the same reason: a snapshot file is written by a shell script into a bind mount, so
|
|
1101
|
+
// the only way to know it moved is to look.
|
|
1102
|
+
setInterval(() => sweepStatus(), STATUS_SCAN_MS).unref();
|
|
1103
|
+
|
|
1104
|
+
// Keepalive sweep. A window that stops answering is terminated, which releases the session it was
|
|
1105
|
+
// driving so the window that comes back can pick it up without a takeover prompt. Sessions themselves
|
|
1106
|
+
// are never touched here - a dead socket says nothing about whether a conversation is worth keeping.
|
|
1107
|
+
setInterval(() => {
|
|
1108
|
+
for (const client of clients) {
|
|
1109
|
+
if (!client.isAlive) {
|
|
1110
|
+
client.terminate();
|
|
1111
|
+
continue;
|
|
1112
|
+
}
|
|
1113
|
+
client.isAlive = false;
|
|
1114
|
+
client.ping();
|
|
1115
|
+
}
|
|
1116
|
+
}, CLIENT_PING_INTERVAL_MS).unref();
|
|
1117
|
+
|
|
1118
|
+
// Nothing here works without the port, so a bind failure ends the process - but with a line saying which
|
|
1119
|
+
// port and why, rather than a stack trace in the log. The ordinary cause is two launchers racing: the one
|
|
1120
|
+
// that loses lands here, and the interface the winner started is already serving.
|
|
1121
|
+
// Both objects, because ws re-emits the HTTP server's error on the WebSocket server: whichever of the two
|
|
1122
|
+
// is left without a listener is the one that crashes.
|
|
1123
|
+
const onServerError = (err) => {
|
|
1124
|
+
console.error(`[webterm] could not listen on container port ${PORT}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1125
|
+
process.exit(1);
|
|
1126
|
+
};
|
|
1127
|
+
server.on("error", onServerError);
|
|
1128
|
+
wss.on("error", onServerError);
|
|
1129
|
+
|
|
1130
|
+
server.listen(PORT, "0.0.0.0", () => {
|
|
1131
|
+
// First thing after the bind: the key is what makes the URL printable, and the launcher is already
|
|
1132
|
+
// watching the port to know when to print it.
|
|
1133
|
+
publishKey();
|
|
1134
|
+
console.log(`[webterm] listening on container port ${PORT} - open the published host port in your browser`);
|
|
1135
|
+
console.log(`[webterm] agents: ${AGENTS.join(", ")} (new sessions start ${[defaultAgent, ...AGENT_ARGS].join(" ")} in ${defaultCwd})`);
|
|
1136
|
+
if (defaultCwdRefused) {
|
|
1137
|
+
console.warn(`[webterm] ignored WEBTERM_CWD="${WEBTERM_CWD_RAW}": not a directory inside ${WORKSPACE_ROOT}`);
|
|
1138
|
+
}
|
|
1139
|
+
// Publish the default agent for the `webterm` launcher: a port probe proves something is listening, not
|
|
1140
|
+
// what it runs. Written after listen so the file only exists once the port is really bound.
|
|
1141
|
+
publishAgent();
|
|
1142
|
+
});
|