chamba 0.6.0 → 0.6.1
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/dist/commands/dev.js +11 -11
- package/dist/commands/settings.js +1 -1
- package/dist/lib/chamba-yaml.js +1 -1
- package/dist/lib/constants.js +4 -4
- package/dist/lib/ports.js +4 -4
- package/dist/lib/webterm.js +7 -7
- package/package.json +1 -3
- package/templates/context/web-pane-craft.md +1 -1
- package/templates/skills/chamba-statusline/SKILL.md +1 -1
- package/templates/webterm/README.md +10 -10
- package/templates/webterm/artifacts.js +11 -11
- package/templates/webterm/config.js +9 -9
- package/templates/webterm/conversation.js +3 -3
- package/templates/webterm/pane.js +14 -3
- package/templates/webterm/proc.js +1 -1
- package/templates/webterm/public/app/alerts.js +5 -5
- package/templates/webterm/public/app/composer.js +1 -1
- package/templates/webterm/public/app/connection.js +2 -2
- package/templates/webterm/public/app/dictation.js +1 -1
- package/templates/webterm/public/app/frames.js +1 -1
- package/templates/webterm/public/app/main.js +1 -1
- package/templates/webterm/public/app/new-session.js +1 -1
- package/templates/webterm/public/app/state.js +1 -1
- package/templates/webterm/public/app/tabs.js +1 -1
- package/templates/webterm/server.js +27 -11
- package/templates/webterm/sessions.js +7 -7
- package/templates/webterm/snapshot.js +2 -2
package/dist/commands/dev.js
CHANGED
|
@@ -116,7 +116,7 @@ export async function startContainer(opts) {
|
|
|
116
116
|
const stdio = quiet ? "pipe" : ["ignore", "ignore", "inherit"];
|
|
117
117
|
// --- Published ports fingerprint (static config, no host I/O here) ----------------------------------------------------------------------
|
|
118
118
|
// Ports are declared, not resolved, so the label is a pure function of the config. Host availability is
|
|
119
|
-
//
|
|
119
|
+
// checked just-in-time on the create path (assertHostPortsAvailable), never on resume/connect.
|
|
120
120
|
const currentPortsLabel = portsLabel(portMappings);
|
|
121
121
|
// --- Env fingerprint (inline vars + resolved file contents) -----------------------------------------------------------------------------
|
|
122
122
|
// Covers inline vars and the contents of every existing env file, so editing either recreates the container.
|
|
@@ -243,8 +243,8 @@ export async function startContainer(opts) {
|
|
|
243
243
|
outro("Failed to build container image.");
|
|
244
244
|
process.exit(buildResult.status);
|
|
245
245
|
}
|
|
246
|
-
// Minutes can pass between run()
|
|
247
|
-
// recreate confirm, the image build), so re-
|
|
246
|
+
// Minutes can pass between run() checking the web port and this create path (profile selection, a
|
|
247
|
+
// recreate confirm, the image build), so re-check it here. A port taken in the meantime drops the
|
|
248
248
|
// mapping and the session continues without the interface. Only chamba.yaml ports are
|
|
249
249
|
// allowed to fail a session, which is what assertHostPortsAvailable below is for.
|
|
250
250
|
let published = portMappings;
|
|
@@ -255,8 +255,8 @@ export async function startContainer(opts) {
|
|
|
255
255
|
published = portMappings.filter((m) => m.host !== webPort);
|
|
256
256
|
publishedWebPort = undefined;
|
|
257
257
|
}
|
|
258
|
-
// Ports are static config, so
|
|
259
|
-
// every path that reaches here, so we never
|
|
258
|
+
// Ports are static config, so check host availability up front. The old container was already removed on
|
|
259
|
+
// every path that reaches here, so we never check our own live port. A taken host port fails clearly and
|
|
260
260
|
// names the entry, before docker run - so no doomed `created` container is left behind on a clash.
|
|
261
261
|
try {
|
|
262
262
|
await assertHostPortsAvailable(published, publishedWebPort);
|
|
@@ -268,7 +268,7 @@ export async function startContainer(opts) {
|
|
|
268
268
|
}
|
|
269
269
|
if (!quiet)
|
|
270
270
|
log.info("Starting dev container...");
|
|
271
|
-
// Only set when the web port survived the re-
|
|
271
|
+
// Only set when the web port survived the re-check above: this gates the greeting's URL line and the
|
|
272
272
|
// webterm launcher. Built here rather than with the other runtime env so a dropped mapping cannot
|
|
273
273
|
// leave a URL behind that nothing is listening on.
|
|
274
274
|
// Never stale either way: the web mapping is part of the ports label written just below, so a moved or
|
|
@@ -298,7 +298,7 @@ export async function startContainer(opts) {
|
|
|
298
298
|
containerName,
|
|
299
299
|
...CONTAINER_KEEP_ALIVE,
|
|
300
300
|
];
|
|
301
|
-
// Capture stderr so the real docker error is re-emitted on failure. A single run - the pre-flight
|
|
301
|
+
// Capture stderr so the real docker error is re-emitted on failure. A single run - the pre-flight check
|
|
302
302
|
// above already rejected any taken host port, so there is no port-race retry loop.
|
|
303
303
|
const runStdio = quiet ? "pipe" : ["ignore", "ignore", "pipe"];
|
|
304
304
|
const runResult = spawnSync("docker", runArgs, { stdio: runStdio });
|
|
@@ -387,7 +387,7 @@ export async function run(packageDir, ctx, options) {
|
|
|
387
387
|
}
|
|
388
388
|
// --- Web agent interface port (sticky per-workspace assignment) ----------------------------------------------------------------------
|
|
389
389
|
// Resolved on every session: the interface has no off switch, it is where the agent runs.
|
|
390
|
-
// Pushing the mapping into portMappings is the whole wiring: publish args, the availability
|
|
390
|
+
// Pushing the mapping into portMappings is the whole wiring: publish args, the availability check,
|
|
391
391
|
// and the LABEL_PORTS recreate fingerprint all pick it up with no arg-builder changes.
|
|
392
392
|
// A session still opens without it, so every problem below drops it for this session and says why - it
|
|
393
393
|
// must never fail a session the way a chamba.yaml port does. The sticky assignment is left alone in all
|
|
@@ -467,7 +467,7 @@ export async function run(packageDir, ctx, options) {
|
|
|
467
467
|
...(options?.noCache !== undefined && { noCache: options.noCache }),
|
|
468
468
|
};
|
|
469
469
|
let startResult = await startContainer(containerOpts);
|
|
470
|
-
// The create path re-
|
|
470
|
+
// The create path re-checks the web port and drops it when something took it while the image built, so
|
|
471
471
|
// from here on the published port is the only one worth talking to.
|
|
472
472
|
webPort = startResult.webPort;
|
|
473
473
|
// --- Stale image check - prompt user to rebuild if image is outdated ------------------------------------------------------------------
|
|
@@ -496,7 +496,7 @@ export async function run(packageDir, ctx, options) {
|
|
|
496
496
|
// Ports are static config, so the notice derives straight from the mappings - no .lock lookup needed.
|
|
497
497
|
// The web mapping is skipped here: its URL is announced by the container greeting, where it is actionable.
|
|
498
498
|
// Matched on the container port, which validatePortsConfig reserves - so it can only ever be chamba's own
|
|
499
|
-
// web mapping, and it is skipped whether or not the port survived the create path's re-
|
|
499
|
+
// web mapping, and it is skipped whether or not the port survived the create path's re-check.
|
|
500
500
|
for (const m of portMappings) {
|
|
501
501
|
if (m.container === WEB_CONTAINER_PORT)
|
|
502
502
|
continue;
|
|
@@ -589,7 +589,7 @@ export async function run(packageDir, ctx, options) {
|
|
|
589
589
|
// is still offered, because those sessions may be finished ones nobody has closed yet.
|
|
590
590
|
if (containerSessionCount(containerName) === 0) {
|
|
591
591
|
// The counts are behind the interface's key, so it is read from the container first - fresh, because
|
|
592
|
-
// a new one is
|
|
592
|
+
// a new one is created at every server start and nothing on the host keeps one.
|
|
593
593
|
const webSessions = (webPort === null ? null : await webSessionInfo(webPort, readWebKey(containerName)))?.sessions ?? 0;
|
|
594
594
|
if (webSessions > 0) {
|
|
595
595
|
const count = webSessions === 1 ? "1 agent session is" : `${webSessions} agent sessions are`;
|
|
@@ -171,7 +171,7 @@ async function gitModeMenu(ctx) {
|
|
|
171
171
|
async function portsMenu(ctx) {
|
|
172
172
|
const range = readWebRange();
|
|
173
173
|
const webPort = readWebPort(ctx.workspaceId);
|
|
174
|
-
// The URL only opens the interface with the key the running server
|
|
174
|
+
// The URL only opens the interface with the key the running server created, so the live one is shown
|
|
175
175
|
// whenever it can be read. With nothing running there is no key to show, and saying where it comes
|
|
176
176
|
// from beats printing half a URL.
|
|
177
177
|
const liveKey = webPort === null ? null : readWebKey(ctx.containerName);
|
package/dist/lib/chamba-yaml.js
CHANGED
|
@@ -141,7 +141,7 @@ const KNOWN_KEYS = new Set(["workspace_id", "env", "shadow_paths", "profiles", "
|
|
|
141
141
|
/**
|
|
142
142
|
* Attempt to repair an invalid chamba.yaml on disk.
|
|
143
143
|
* Strips unknown fields, fills missing required/optional fields from defaults,
|
|
144
|
-
* and rewrites with
|
|
144
|
+
* and rewrites with standard formatting. Returns a result describing what happened.
|
|
145
145
|
*/
|
|
146
146
|
export function repairChambaYaml(dir) {
|
|
147
147
|
const filePath = join(dir, CHAMBA_YAML);
|
package/dist/lib/constants.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// =========================================================================================================================================
|
|
2
|
-
// src/lib/constants.ts -
|
|
2
|
+
// src/lib/constants.ts - Shared constants used across the chamba codebase
|
|
3
3
|
// =========================================================================================================================================
|
|
4
4
|
import { dirname } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
@@ -100,10 +100,10 @@ export const DEFAULT_WEB_AGENT = "claude";
|
|
|
100
100
|
// loopback-only to a sticky per-workspace host port taken from web_range (host-global setting).
|
|
101
101
|
export const WEB_CONTAINER_PORT = 3899;
|
|
102
102
|
export const WEB_RANGE_DEFAULT = "3900-3999";
|
|
103
|
-
// Where the webterm server publishes the key its URL carries (`/?k=<key>`). A new key is
|
|
103
|
+
// Where the webterm server publishes the key its URL carries (`/?k=<key>`). A new key is created every time
|
|
104
104
|
// that server starts and written here as soon as it has the port, so whoever prints the URL reads it back
|
|
105
105
|
// from this file rather than storing one: the container greeting, the `webterm` launcher, and chamba on the
|
|
106
|
-
// host before it
|
|
106
|
+
// host before it checks /status. Container-side path - the host only ever reads it through `docker exec`.
|
|
107
107
|
export const WEB_KEY_FILE_PATH = "/tmp/webterm.key";
|
|
108
108
|
// Where the container sees this workspace's webterm state dir, bind-mounted from the cache dir above. The
|
|
109
109
|
// one thing in it is the agent file: which agent new web sessions start with, written by the server when
|
|
@@ -116,7 +116,7 @@ export const WEBTERM_AGENT_FILE_PATH = `${CONTAINER_WEBTERM_STATE_DIR}/${WEBTERM
|
|
|
116
116
|
// conversation's pane and the feedback submitted on them. On the host it sits under the cache dir's
|
|
117
117
|
// agents/ dir, which is exactly what Advanced > Clear agent memory removes, so that menu item is the one
|
|
118
118
|
// thing that clears the pane. It is bind-mounted into the container, so the pages outlive the container
|
|
119
|
-
// and a resumed conversation finds its own history again. Nothing
|
|
119
|
+
// and a resumed conversation finds its own history again. Nothing clears it by age.
|
|
120
120
|
export const WEBPANE_DIR = "webpane";
|
|
121
121
|
export const CONTAINER_WEBPANE_DIR = `${CONTAINER_HOME}/.webpane`;
|
|
122
122
|
// Reopening the last conversation is the container's own job: templates/webterm/resume.js reads the agent
|
package/dist/lib/ports.js
CHANGED
|
@@ -8,10 +8,10 @@ import { createHash } from "node:crypto";
|
|
|
8
8
|
import { createServer } from "node:net";
|
|
9
9
|
import { WEB_CONTAINER_PORT } from "./constants.js";
|
|
10
10
|
// --- Constants ---------------------------------------------------------------------------------------------------------------------------
|
|
11
|
-
// The CLI runs as a non-root user, so the availability
|
|
11
|
+
// The CLI runs as a non-root user, so the availability check (net bind) cannot bind ports below 1024.
|
|
12
12
|
export const PORT_MIN = 1024;
|
|
13
13
|
export const PORT_MAX = 65535;
|
|
14
|
-
// Publish and
|
|
14
|
+
// Publish and check on loopback only - never 0.0.0.0. Nothing on the LAN may reach these sessions.
|
|
15
15
|
export const PORT_LOOPBACK_HOST = "127.0.0.1";
|
|
16
16
|
// --- Config validation and normalization -------------------------------------------------------------------------------------------------
|
|
17
17
|
/** A single port number is a usable, unprivileged host/container port. */
|
|
@@ -127,7 +127,7 @@ export function parsePublishedPorts(psOutput) {
|
|
|
127
127
|
/**
|
|
128
128
|
* Host ports currently published by any running container. On native Linux with userland-proxy=false no host
|
|
129
129
|
* socket exists (DNAT), so canBind alone would wrongly report a published port as free - this covers that case.
|
|
130
|
-
* Returns an empty set when docker is unavailable (the bind
|
|
130
|
+
* Returns an empty set when docker is unavailable (the bind check still applies).
|
|
131
131
|
*/
|
|
132
132
|
export function dockerPublishedPorts() {
|
|
133
133
|
const result = spawnSync("docker", ["ps", "--format", "{{.Ports}}"], { encoding: "utf8", stdio: "pipe" });
|
|
@@ -151,7 +151,7 @@ export function containerPublishedPorts(containerName) {
|
|
|
151
151
|
}
|
|
152
152
|
/**
|
|
153
153
|
* Check that every mapped host port is free before we create the container. Called only on the create path - the
|
|
154
|
-
* old container has already been removed, so we never
|
|
154
|
+
* old container has already been removed, so we never check our own live port. Throws a clear error naming the taken
|
|
155
155
|
* host port and its entry on the first collision. Doing this here, rather than letting `docker run` fail, buys two
|
|
156
156
|
* things: the error names the offending entry (docker's raw message does not), and no doomed `created` container is
|
|
157
157
|
* left behind on a clash.
|
package/dist/lib/webterm.js
CHANGED
|
@@ -34,7 +34,7 @@ export function nextFreeWebPort(range, taken) {
|
|
|
34
34
|
* Return a workspace's sticky web port, assigning the lowest free port in the range when it has none.
|
|
35
35
|
* An already-assigned port is returned as-is even when it falls outside the range - stickiness wins;
|
|
36
36
|
* out-of-range ports move only when the user changes the range (reassignOutOfRangeWebPorts).
|
|
37
|
-
* This is pure over the .lock files: it never
|
|
37
|
+
* This is pure over the .lock files: it never checks the host, so it can say a port is assigned but not
|
|
38
38
|
* that it can be published. resolveWebPort adds that.
|
|
39
39
|
*/
|
|
40
40
|
export function ensureWebPort(workspaceId, range) {
|
|
@@ -54,7 +54,7 @@ export function ensureWebPort(workspaceId, range) {
|
|
|
54
54
|
* The host port this workspace's web interface should publish this session.
|
|
55
55
|
*
|
|
56
56
|
* `usable(port)` is the caller's test for "can this port carry the interface right now" - it covers both
|
|
57
|
-
* the host-side
|
|
57
|
+
* the host-side check and any host port the workspace already declares in chamba.yaml. When the sticky
|
|
58
58
|
* assignment fails that test the port MOVES and the new one is persisted: sticky means it stays where it
|
|
59
59
|
* was last put, not that it stays somewhere unusable and warns about the same conflict at every start.
|
|
60
60
|
* When nothing in the range qualifies the old assignment is left alone rather than cleared, so the
|
|
@@ -162,10 +162,10 @@ export async function webPortUsable(webPort, containerName) {
|
|
|
162
162
|
}
|
|
163
163
|
/**
|
|
164
164
|
* The key this container's web interface is currently demanding, read from the file its server publishes
|
|
165
|
-
* before binding the port. A new key is
|
|
165
|
+
* before binding the port. A new key is created at every server start and nothing on the host stores one,
|
|
166
166
|
* so it is always read fresh, right before it is used.
|
|
167
167
|
* Returns null when there is nothing to read: no container, no interface running, or a container built
|
|
168
|
-
* before the key existed. Callers then
|
|
168
|
+
* before the key existed. Callers then check without a key, which is exactly what such an older server
|
|
169
169
|
* (which ignores the query parameter) expects.
|
|
170
170
|
*/
|
|
171
171
|
export function readWebKey(containerName) {
|
|
@@ -175,12 +175,12 @@ export function readWebKey(containerName) {
|
|
|
175
175
|
const key = result.stdout?.toString().trim();
|
|
176
176
|
return key ? key : null;
|
|
177
177
|
}
|
|
178
|
-
// The shape the server
|
|
178
|
+
// The shape the server creates: 16 random bytes, hex. The key file is writable by every process in the
|
|
179
179
|
// container, so anything read back from it is checked against this before the host builds a URL out of it
|
|
180
180
|
// and hands that URL to a program. Argv-style spawning already keeps a strange value from being a command;
|
|
181
181
|
// this keeps it from being a URL at all.
|
|
182
182
|
const WEB_KEY_PATTERN = /^[0-9a-f]{32}$/;
|
|
183
|
-
/** Whether a key read back from the container has the shape the server
|
|
183
|
+
/** Whether a key read back from the container has the shape the server creates. */
|
|
184
184
|
export function isWebKey(key) {
|
|
185
185
|
return key !== null && WEB_KEY_PATTERN.test(key);
|
|
186
186
|
}
|
|
@@ -247,7 +247,7 @@ export async function setWebDefaultCwd(webPort, key, workdir) {
|
|
|
247
247
|
// The interface is on loopback in the same machine, so a slow answer means something is wrong, not far.
|
|
248
248
|
const WEB_STATUS_TIMEOUT_MS = 1500;
|
|
249
249
|
/**
|
|
250
|
-
* Whether the interface is serving at all. Any HTTP answer counts, including the 403 a keyless
|
|
250
|
+
* Whether the interface is serving at all. Any HTTP answer counts, including the 403 a keyless check gets:
|
|
251
251
|
* refusing a caller without a key is still proof that the server is up. Used where only liveness matters
|
|
252
252
|
* (did the launch work, is the interface still there), so those paths need no key and no docker exec.
|
|
253
253
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chamba",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Run AI coding agents in a container, from your browser",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -23,7 +23,6 @@
|
|
|
23
23
|
"start": "node bin/chamba.js",
|
|
24
24
|
"build": "tsx scripts/build.ts",
|
|
25
25
|
"re:build": "rm -rf dist && pnpm build",
|
|
26
|
-
"prepare": "git config core.hooksPath .githooks",
|
|
27
26
|
"typecheck": "tsc --noEmit",
|
|
28
27
|
"lint": "biome check .",
|
|
29
28
|
"lint:fix": "biome check --write .",
|
|
@@ -40,7 +39,6 @@
|
|
|
40
39
|
},
|
|
41
40
|
"license": "MIT",
|
|
42
41
|
"author": "Asaf Ratzon",
|
|
43
|
-
"packageManager": "pnpm@11.9.0",
|
|
44
42
|
"keywords": [
|
|
45
43
|
"ai",
|
|
46
44
|
"ai-agent",
|
|
@@ -26,7 +26,7 @@ One file, standalone, dark.
|
|
|
26
26
|
|
|
27
27
|
- **Inline everything.** No external stylesheets, scripts or fonts. The page renders in a sandboxed frame with no origin of its own: `localStorage`, cookies and same-origin requests do not work in it, and an external asset only makes the page slower and chattier. Inline `<style>` and `<script>` both work.
|
|
28
28
|
- **Match the interface.** Background `#0d1117`, panels and table headers `#161b22`, borders `#30363d`, text `#e6edf3`, muted text `#8b949e`, and a green accent `#2fe58a` for links, headings you want to lift, and the submit button. System font stack, ~14px, generous line height.
|
|
29
|
-
- **Write it as a document**, not a dashboard: a title, sections, tables where a table is clearer than
|
|
29
|
+
- **Write it as a document**, not a dashboard: a title, sections, tables where a table is clearer than text, and short paragraphs. Keep it small - a couple of hundred kilobytes is plenty. The per-page limit is 2 MB, and a page past it is refused rather than trimmed.
|
|
30
30
|
- Images have to be inline too (a data URI, or an SVG written into the page). A path into the container is not something the browser can fetch.
|
|
31
31
|
|
|
32
32
|
### Asking with a form
|
|
@@ -55,7 +55,7 @@ A user who works in the terminal and wants a visible line back can have one - by
|
|
|
55
55
|
|
|
56
56
|
Every action below involves editing `~/.claude/settings.json` and/or writing scripts under `~/.claude/`. **You** make those edits directly with the `Edit` and `Write` tools - do not tell the user to run the change themselves.
|
|
57
57
|
|
|
58
|
-
If write access to those paths is not pre-approved, Claude Code will
|
|
58
|
+
If write access to those paths is not pre-approved, Claude Code will show its standard permission prompt. **That prompt is the expected approval flow, not a refusal signal** - go ahead and call the tool, and the user will grant permission when they see it. Do not apologise for "lacking permission" and do not redirect the work back to the user.
|
|
59
59
|
|
|
60
60
|
### Inspect what is configured now
|
|
61
61
|
|
|
@@ -9,7 +9,7 @@ On top of the terminal it adds a rich composer so you can paste images, drop or
|
|
|
9
9
|
## How it works
|
|
10
10
|
|
|
11
11
|
- `server.js` serves the page, exposes `POST /upload`, and runs a WebSocket relay at `/ws`.
|
|
12
|
-
- The URL carries a key (`/?k=<key>`) and every route that carries the relay demands it. The server
|
|
12
|
+
- The URL carries a key (`/?k=<key>`) and every route that carries the relay demands it. The server creates a new one at each start, so the key lives exactly as long as the process that issued it.
|
|
13
13
|
- `sessions.js` is the session registry: what a session is, who drives it, and what ends it. The PTY is injected, so the rules are unit-tested without `node-pty`.
|
|
14
14
|
- A session is one `node-pty` process running its own agent in a directory inside `/workspace`. Both are fixed for the life of the session - they are the process. Sessions belong to the container, not to the browser.
|
|
15
15
|
- The page (`public/`) lists every live session as a tab, renders the attached one's TUI with xterm.js, and forwards keystrokes.
|
|
@@ -60,7 +60,7 @@ That is why a session showing as working never also shows as waiting for you.
|
|
|
60
60
|
- **A light travels round a tab** while that session's agent is working.
|
|
61
61
|
- **A tab flashes and then stays lit** when its agent finishes. Every ending, whoever is watching: it costs nothing to say a thing twice on screen, and one rule for every session is easier to trust than one for the tab you have open and another for the rest. Touching the session, or arriving at it, is what puts it out.
|
|
62
62
|
- **A tab takes its session's colour and grows a small page** beside its age when a page is published into that session's pane while you are looking at another one, and flashes once as it arrives. It holds until you get to that session, where the newest page opens itself; anything older that piled up behind it keeps its chip badge, so the tab marks itself again - without a second flash - once you leave. Its own state, not the one above: that one is an agent finishing, and this one is a page waiting to be read.
|
|
63
|
-
- **The browser tab speaks too**, because that is all a window behind something else can do. The title counts the sessions waiting for you, and the favicon carries one mark: a white bar across its bottom edge, with a lit segment
|
|
63
|
+
- **The browser tab speaks too**, because that is all a window behind something else can do. The title counts the sessions waiting for you, and the favicon carries one mark: a white bar across its bottom edge, with a lit segment moving along it, while an agent is working, and a green dot in its top corner - pulsing until you go and look - when one is waiting. Two shapes at opposite ends of the icon rather than two colours in one place, because at 16px a hue is the first thing to go. White for working because every hue here belongs to a workspace or a session, and the frame around the icon is one of them.
|
|
64
64
|
- **And ten seconds later it says so out loud** - one soft chime, once, for an ending nobody came back to. A sound cannot be taken back and it reaches you in the next room, so it is the one thing here that asks whether you were there. Several sessions finishing together are one chime, and so are two windows open on the same container. The bell beside the power button mutes it, and remembers.
|
|
65
65
|
|
|
66
66
|
Whether you were there is answered by whether that session gets touched - a keystroke, a click, a scroll - in the ten seconds after it finishes, not by whether the browser had focus.
|
|
@@ -115,7 +115,7 @@ Beside the terminal, each session has a pane of pages the agent published - a pl
|
|
|
115
115
|
- **Feedback.** A page may carry `<form data-feedback>`. Submitting it writes `~/.webpane/<conversation-id>/feedback/<page>-<epoch-ms>.json` - `{ page, submittedAt, fields, text }`, where a field named `text` becomes the free-text box - and types one line into the agent's terminal saying where to read it. An agent that has already exited still gets the file; only the line is skipped.
|
|
116
116
|
- **What you see.** The pane is beside the terminal from the start, as the vertical "Web pane" spine in a session that has published nothing - a pane that only appeared once an agent had used it was one nobody knew to ask for. Opening that empty pane says what it is for and gives three things to say to get a page. Once there are pages, the history is the chips bar above them: one chip per page with its title and age, newest at the end. A page that arrives opens itself and pulses the pane's edge once, and takes nothing else: the caret stays where it was mid-sentence, so you keep typing and click into the page when you want it. "Arrives" is the server's own unread flag rather than "new to this window", so a reload opens nothing that was already read, and a page waiting in a session you have not visited still opens when you get there. A pane you put away comes back for it, at no less than a readable width - the one place this interface overrides a choice you made. A pane nobody has touched yet opens the same way, since collapsed has three answers (yes, no, and nobody has said) and only a click or a drag makes it one of the first two. A page published in a session you are *not* looking at changes nothing where you are: its tab takes that session's colour and a small page beside its age, flashes once, and holds until you get there. Drag the divider to set the width. The minus and plus beside the counter step the shown page through five text sizes, 80% to 150% with 100% the default, and nothing reloads on a press, so a scroll position and a half-filled form both survive it. That control is the browser's own furniture: the size is applied from outside the frame, no page has to account for it, and nothing an agent reads mentions it. The button at the end of the chips bar puts the pane away into the vertical "Web pane" spine, which carries the unread count and reopens on a click, and dragging the divider past the collapse threshold does the same. Reopening gives back the width the pane had, but never less than a third of the window - a pane that comes back as a sliver may as well have stayed shut. The width, the collapse and the text size belong to the window and survive switching tabs; the pages, the selection and the badges belong to the session and switch with it.
|
|
117
117
|
- **How an agent comes to use it.** Two layers, so it happens without being asked each time. Every agent's injected context carries the standing rule - prefer a page when the answer is longer or more structured than a terminal reply carries well, and when a question has more options or structure than the agent's own question tooling holds - plus the palette to match and the form contract. Every agent also gets the craft in full - a standalone dark page, inline everything, how to ask with a form, and what a submitted form does and does not prove - from one file, `templates/context/web-pane-craft.md`: claude reads it as the body of a `web-pane` skill, whose description is the phrases a user actually says ("show me", "as a page", "in the pane", "publish"), and codex and opencode read it as a section of the `AGENTS.md` chamba writes them, since a skill is a Claude Code mechanism and neither has one. The frontmatter and the title are claude's alone; the advice is written once. The session greeting names the pane too, so a user who has never heard of it learns it exists in the first sentence.
|
|
118
|
-
- **Nothing is deleted.** No age
|
|
118
|
+
- **Nothing is deleted.** No removal by age, nothing removed when a session closes. Growth is bounded instead: a page over the per-page limit is refused, and once the whole directory reaches its total - in bytes or in number of files - new publishes are refused and what is there stays. Advanced > Clear agent memory in chamba is what clears it.
|
|
119
119
|
|
|
120
120
|
## The status strip
|
|
121
121
|
|
|
@@ -132,19 +132,19 @@ A window the account does not have takes its whole meter off the strip rather th
|
|
|
132
132
|
- The server binds container port **3899**; chamba publishes it loopback-only (`127.0.0.1:<port>:3899`), so nothing on the LAN can reach it.
|
|
133
133
|
That container port is reserved: a `chamba.yaml` entry publishing it is rejected, so nothing else can front the web URL.
|
|
134
134
|
- Inside the container it listens on all interfaces - required for a published port to reach it. So any process on the host, and any container on the same Docker network, can open the port. Reaching the port is not the same as getting in: the key is.
|
|
135
|
-
- **The URL carries a key, and the relay refuses anything without it.** A new one (16 random bytes, hex) is
|
|
135
|
+
- **The URL carries a key, and the relay refuses anything without it.** A new one (16 random bytes, hex) is created at every server start and published to `/tmp/webterm.key` the moment the port is bound; the launcher and the container greeting read it back, which is how the printed URL is always the live one. Gated: the page itself, `POST /upload`, `GET /status`, `GET /dirs`, `POST /cwd`, `POST /agent`, the three `/pane/*` routes, and the WebSocket handshake. Not gated: the page's modules under `/app/`, the stylesheet, the icon and the xterm files under `/vendor` - they hold nothing secret and drive nothing, and a window that never got the page opens no socket.
|
|
136
136
|
- The key stays in the URL rather than in a cookie on purpose. Cookies on `localhost` are shared across ports, so a page served by any other local port could ride this workspace's; a key in the URL is scoped to the window that was handed it.
|
|
137
137
|
- Comparison is constant time, so a caller cannot learn the key one character at a time from how long a refusal takes.
|
|
138
138
|
- The WebSocket handshake also rejects any non-loopback `Origin`, checked alongside the key. On its own that is a browser-behavior gate - a non-browser client sets any Origin it likes - so it is the second lock, not the first.
|
|
139
139
|
- A key dies with the server that issued it. Restarting the interface (or the container) invalidates every open window, which is why a spent URL gets a page that says where the current one is rather than a silent refusal.
|
|
140
|
-
- Uploads are images only, size-
|
|
140
|
+
- Uploads are images only, size-limited, and written only under `/tmp/uploads/`.
|
|
141
141
|
- **A published page never runs as part of this app.** The pane fetches its content over the key-gated route and renders it into an iframe with `sandbox="allow-scripts allow-forms"` and no `allow-same-origin`, so the page has an opaque origin: its scripts run, and there is nothing of the interface for them to reach. A form talks to the pane by `postMessage`, and only the pane - which is interface code - holds the key and calls the endpoint.
|
|
142
142
|
- **A page cannot read the window's URL, which is where the key is.** A frame rendered from `srcdoc` has no URL of its own and falls back to the base URL of the document holding it, which a script inside the page can read as `document.baseURI` - an opaque origin does not cover this, because `baseURI` is a plain string on the page's own document rather than a cross-origin read. Two things close it. The shell declares `<base href="/">`, so the value a frame falls back to is the bare origin instead of a URL with a key on it; that is the half a page cannot affect. The rendered document is also given `<base href="about:srcdoc">` as its first element, so nothing is inherited in the first place; a page can delete that one, which is why the first exists. The frame is `referrerpolicy="no-referrer"` as well, since a srcdoc document otherwise inherits the parent's referrer and sends it with everything it loads.
|
|
143
143
|
- **A feedback file says a page was submitted, not that a person answered.** The pane cannot tell a form a user filled in from a script in the same page posting the message the form would have. The file and the nudge are the channel, and the answers are bounded and filed either way; an agent should read one as data from its page, not as proof of consent.
|
|
144
144
|
- **The pane's routes take their key in a header** (`X-Webterm-Key`) and refuse it in the URL. A script inside a page can read the URL of the document it is in, so no URL that carries a page's content may carry the key; there is nothing to open by hand and nothing to leak. The refusal is the server's, not a habit of the client's, so a second caller written later cannot reopen it. Every other route keeps `?k=`, which is what makes the interface openable from a link.
|
|
145
|
-
- **A page bigger than a page is never read whole.** Anything can write into the pane directory, so a file there is not held to the publish
|
|
146
|
-
- **Pages are read without leaving the pane directory.** Keys and filenames are checked against a narrow pattern before they become a path, symlinks and anything that is not a regular file are refused rather than followed, and the resolved path is checked to still be inside the root. A file over the per-page
|
|
147
|
-
- **Feedback is bounded and bound.** The body has a size
|
|
145
|
+
- **A page bigger than a page is never read whole.** Anything can write into the pane directory, so a file there is not held to the publish limit. Listing a page reads only the first few kilobytes, looking for its title, and serving one is refused outright past the per-page limit - one huge file cannot stall the interface for every session in the container. The conversation-id discovery reads its stores the same way.
|
|
146
|
+
- **Pages are read without leaving the pane directory.** Keys and filenames are checked against a narrow pattern before they become a path, symlinks and anything that is not a regular file are refused rather than followed, and the resolved path is checked to still be inside the root. A file over the per-page limit is refused rather than read into memory.
|
|
147
|
+
- **Feedback is bounded and bound.** The body has a size limit, a page accepts one submission per second and only so many in its life (past that the page is refused, and no line is typed into the agent's terminal - answers are small, so a byte limit alone would let a page in a loop drive an agent for days), the answers count against the directory's total and its file count like pages do (past either, they are refused, and nothing is removed), and the directory, the page and the filename all come from the artifact the pane is showing - the request body contributes nothing but the answers, which are themselves limited in count and length. The line typed into the agent's terminal is built only from text the server controls: a title reduced to a single clean line, and a path the server generated.
|
|
148
148
|
- **The key gate confines the browser, not the container.** `/tmp/webterm.key` is readable by `devuser`, because the `webpane` helper has to read it, and everything in the container is `devuser`. So a script an agent writes can do anything a browser window can: attach to another session, type into it, publish into its pane. That is the same boundary the container has always had - one agent session can already reach another's files - and it is why the isolation that matters is the container's, not the key's.
|
|
149
149
|
- Runs as the non-root `devuser`; the agent inherits the same sandbox and auth it has in the terminal.
|
|
150
150
|
|
|
@@ -154,9 +154,9 @@ A window the account does not have takes its whole meter off the strip rather th
|
|
|
154
154
|
|
|
155
155
|
## Config
|
|
156
156
|
|
|
157
|
-
`config.js` holds the knobs (port, agent list and default agent, key and key file, upload dir, size
|
|
157
|
+
`config.js` holds the knobs (port, agent list and default agent, key and key file, upload dir, size limit, cleanup age/interval, paste framing, resume stamp, state file, session limit, keepalive interval, stop timings, claude context file, workspace root and directory-scan limits, the pane's directory, limits, scan interval, feedback bounds and agent stores, and the status strip's scan interval).
|
|
158
158
|
Env overrides: `WEBTERM_PORT`, `WEBTERM_CWD`, `WEBTERM_AGENT`, `WEBTERM_AGENT_ARGS`, `WEBTERM_KEY`, `WEBTERM_KEY_FILE`, `WEBTERM_RESUME_STAMP`, `WEBTERM_STATE_FILE`, `WEBTERM_MAX_SESSIONS`, `WEBTERM_PING_INTERVAL_MS`, `WEBTERM_WORKSPACE`, `WEBTERM_CONTEXT_FILE`, `WEBTERM_PANE_DIR`, `WEBTERM_MAX_PAGE_BYTES`, `WEBTERM_MAX_PANE_BYTES`, `WEBTERM_MAX_PANE_FILES`, `WEBTERM_PANE_SCAN_MS`, `WEBTERM_FEEDBACK_MIN_INTERVAL_MS`, `WEBTERM_MAX_FEEDBACK_PER_PAGE`, `WEBTERM_STATUS_SCAN_MS`, `WEBTERM_SUBMIT_DELAY_MS`, and the four `WEBTERM_*_DIR` agent-store paths, plus `WEBTERM_PROC_ROOT`, which belongs to `proc.js` rather than to `config.js`.
|
|
159
|
-
`WEBTERM_KEY` pins the key instead of
|
|
159
|
+
`WEBTERM_KEY` pins the key instead of creating one, which is for tests and hand-run debugging - there is no way to turn the gate off.
|
|
160
160
|
`WEBTERM_CWD` is where new sessions start, not where they must stay: the browser can name another directory per session, and `POST /cwd` moves the default.
|
|
161
161
|
`WEBTERM_AGENT` is the same shape: the agent new sessions start with, which the browser can override per session and `POST /agent` moves. A value that is not one of the three falls back to the first, so nothing arbitrary can be spawned through it.
|
|
162
162
|
`WEBTERM_AGENT_ARGS` belongs to `WEBTERM_AGENT` alone - it comes from the same launcher run - so any other agent is spawned bare.
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// without ever leaving the root. The server owns who is asking and what to do about it.
|
|
11
11
|
//
|
|
12
12
|
// Two rules run through all of it. Nothing is ever deleted - not by age, not on session close, not when the
|
|
13
|
-
//
|
|
13
|
+
// limit is reached; past the limit a publish is refused and what exists stays. And nothing outside the root is
|
|
14
14
|
// ever read: names are checked against a narrow pattern, symlinks are refused rather than followed, and the
|
|
15
15
|
// resolved path is checked to still be inside the root before a byte is read.
|
|
16
16
|
|
|
@@ -33,7 +33,7 @@ import { join } from "node:path";
|
|
|
33
33
|
|
|
34
34
|
// A conversation key is a directory name, so it is checked like one: it may not be "." or "..", may not hold
|
|
35
35
|
// a separator, and must start with something ordinary. Agent conversation ids (uuids, "ses_" ids) pass; the
|
|
36
|
-
// server's own provisional keys are
|
|
36
|
+
// server's own provisional keys are created to match.
|
|
37
37
|
const KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
38
38
|
|
|
39
39
|
// A page is one HTML file directly inside a conversation's directory. Same reasoning as the key: no
|
|
@@ -91,7 +91,7 @@ export function cleanLine(raw, max = MAX_TITLE_LENGTH) {
|
|
|
91
91
|
/**
|
|
92
92
|
* The same cleaning for text that is allowed to be more than one line: a form's free-text box, a long answer
|
|
93
93
|
* typed into a textarea. Line breaks survive, every other control character does not, and the length is
|
|
94
|
-
*
|
|
94
|
+
* limited. Nothing typed into a terminal ever comes from here - this is what gets written to a file.
|
|
95
95
|
*/
|
|
96
96
|
export function cleanText(raw, max) {
|
|
97
97
|
if (typeof raw !== "string") return "";
|
|
@@ -120,7 +120,7 @@ export function slugify(raw) {
|
|
|
120
120
|
* The first `limit` bytes of a file, as text, or "" when it cannot be read.
|
|
121
121
|
*
|
|
122
122
|
* Bounded at the read rather than after it: a page put into the directory by hand is not held to the publish
|
|
123
|
-
*
|
|
123
|
+
* limit, so a listing that read whole files would stall the whole interface on one huge one - and a listing
|
|
124
124
|
* happens on a timer, for every page, in every pane.
|
|
125
125
|
*/
|
|
126
126
|
function headOf(path, limit) {
|
|
@@ -167,7 +167,7 @@ function titleFor(path, id) {
|
|
|
167
167
|
/**
|
|
168
168
|
* The pane store over one artifacts root.
|
|
169
169
|
*
|
|
170
|
-
* `maxPageBytes`
|
|
170
|
+
* `maxPageBytes` limits a single page, `maxTotalBytes` and `maxTotalFiles` the whole root, and
|
|
171
171
|
* `maxFeedbackPerPage` how many answers one page may file. All of them are enforced here rather than in the
|
|
172
172
|
* helper, because the helper is one way in and a file copied by hand is another.
|
|
173
173
|
*/
|
|
@@ -243,7 +243,7 @@ export function createPaneStore({ root, maxPageBytes, maxTotalBytes, maxTotalFil
|
|
|
243
243
|
|
|
244
244
|
/**
|
|
245
245
|
* What the whole root holds, pages and feedback together: bytes and file count. Walked per write, which
|
|
246
|
-
* is affordable only because the file count is
|
|
246
|
+
* is affordable only because the file count is limited too - both numbers come out of this one walk, and
|
|
247
247
|
* the count is what keeps the walk itself small.
|
|
248
248
|
*/
|
|
249
249
|
function totals() {
|
|
@@ -279,12 +279,12 @@ export function createPaneStore({ root, maxPageBytes, maxTotalBytes, maxTotalFil
|
|
|
279
279
|
return { bytes: total, files };
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
-
/** Bytes held by the whole root. The count that goes with it is a
|
|
282
|
+
/** Bytes held by the whole root. The count that goes with it is a limit, not something a caller asks for. */
|
|
283
283
|
function totalBytes() {
|
|
284
284
|
return totals().bytes;
|
|
285
285
|
}
|
|
286
286
|
|
|
287
|
-
/** Room for one more file of this size, or the refusal that says which
|
|
287
|
+
/** Room for one more file of this size, or the refusal that says which limit it met. */
|
|
288
288
|
function roomFor(bytes) {
|
|
289
289
|
const held = totals();
|
|
290
290
|
if (held.bytes + bytes > maxTotalBytes) return "full";
|
|
@@ -359,7 +359,7 @@ export function createPaneStore({ root, maxPageBytes, maxTotalBytes, maxTotalFil
|
|
|
359
359
|
* - "missing": there is no such page, or it is not a regular file - a directory, a device, a symlink.
|
|
360
360
|
* Symlinks are refused rather than followed, which is what stops a link inside the pane from serving
|
|
361
361
|
* the key file or anything else in the container.
|
|
362
|
-
* - "toobig": bigger than a page may be. A file put there by hand is not bound by the publish
|
|
362
|
+
* - "toobig": bigger than a page may be. A file put there by hand is not bound by the publish limit, and
|
|
363
363
|
* reading an arbitrarily large file into memory to answer a request is not something to allow.
|
|
364
364
|
*/
|
|
365
365
|
function read(key, id) {
|
|
@@ -392,7 +392,7 @@ export function createPaneStore({ root, maxPageBytes, maxTotalBytes, maxTotalFil
|
|
|
392
392
|
* the file); the number in front of the name counts up per conversation, so the pane reads in the order
|
|
393
393
|
* the pages were published however they are sorted elsewhere.
|
|
394
394
|
*
|
|
395
|
-
* Refusals: "name" for a key that is not one, "toobig" for a page over the per-page
|
|
395
|
+
* Refusals: "name" for a key that is not one, "toobig" for a page over the per-page limit, "full" when the
|
|
396
396
|
* root has reached its total - nothing is deleted to make room, because a page the user has not read yet
|
|
397
397
|
* is not the server's to throw away - and "write" when the filesystem said no.
|
|
398
398
|
*/
|
|
@@ -451,7 +451,7 @@ export function createPaneStore({ root, maxPageBytes, maxTotalBytes, maxTotalFil
|
|
|
451
451
|
};
|
|
452
452
|
const contents = `${JSON.stringify(body, null, 2)}\n`;
|
|
453
453
|
// A form is submitted by whatever is inside the page - a person clicking, or a script in it - so an
|
|
454
|
-
// endless stream of them is a thing that can happen. Two
|
|
454
|
+
// endless stream of them is a thing that can happen. Two limits stand in the way, and the count is the
|
|
455
455
|
// one that matters: answers are small, so bytes alone would allow a very large number of them, and
|
|
456
456
|
// every one of them also types a line into the agent's terminal.
|
|
457
457
|
if (feedbackFiles(key, stem).length + 1 > maxFeedbackPerPage) return { ok: false, error: "toomany" };
|
|
@@ -57,14 +57,14 @@ export const HOME = process.env.HOME || "/home/devuser";
|
|
|
57
57
|
|
|
58
58
|
export const PANE_DIR = process.env.WEBTERM_PANE_DIR || `${HOME}/.webpane`;
|
|
59
59
|
|
|
60
|
-
//
|
|
60
|
+
// Limits, enforced by the server rather than by the helper: a page is refused above the first, and a publish
|
|
61
61
|
// is refused once the whole directory has reached the second. Nothing is ever deleted to make room - a page
|
|
62
|
-
// the user has not read yet is not the server's to throw away - so reaching the
|
|
62
|
+
// the user has not read yet is not the server's to throw away - so reaching the limit is a message, not a
|
|
63
63
|
// cleanup. A page is a document, so a couple of megabytes is already generous; the total is the backstop.
|
|
64
64
|
export const MAX_PAGE_BYTES = Number(process.env.WEBTERM_MAX_PAGE_BYTES) || 2 * 1024 * 1024;
|
|
65
65
|
export const MAX_PANE_BYTES = Number(process.env.WEBTERM_MAX_PANE_BYTES) || 128 * 1024 * 1024;
|
|
66
66
|
|
|
67
|
-
// And a
|
|
67
|
+
// And a limit on how many files, not only how many bytes. Answers are small, so the byte limit alone would let a
|
|
68
68
|
// page that submits to itself put hundreds of thousands of them in the directory - and every write walks the
|
|
69
69
|
// whole tree to add the bytes up, so that walk is what would slow down. A personal pane holds tens of pages.
|
|
70
70
|
export const MAX_PANE_FILES = Number(process.env.WEBTERM_MAX_PANE_FILES) || 5_000;
|
|
@@ -83,7 +83,7 @@ export const PANE_SCAN_MS = Number(process.env.WEBTERM_PANE_SCAN_MS) || 1_500;
|
|
|
83
83
|
// catches up", and a frame goes out only when something in it actually changed.
|
|
84
84
|
export const STATUS_SCAN_MS = Number(process.env.WEBTERM_STATUS_SCAN_MS) || 2_000;
|
|
85
85
|
|
|
86
|
-
// What a feedback submission may be. The body
|
|
86
|
+
// What a feedback submission may be. The body limit is what the endpoint accepts at all; the rest bound what
|
|
87
87
|
// is written, so a page with a runaway form produces a file an agent can still read.
|
|
88
88
|
export const MAX_FEEDBACK_BYTES = 64 * 1024;
|
|
89
89
|
export const MAX_FEEDBACK_FIELDS = 40;
|
|
@@ -96,7 +96,7 @@ export const FEEDBACK_MIN_INTERVAL_MS = Number(process.env.WEBTERM_FEEDBACK_MIN_
|
|
|
96
96
|
|
|
97
97
|
// How many answers one page may file at all. A person submits a form once, or a few times after changing
|
|
98
98
|
// their mind. Past that it is a script in the page submitting to itself, and each submission also types a
|
|
99
|
-
// line into the agent's terminal - so this, not the byte
|
|
99
|
+
// line into the agent's terminal - so this, not the byte limit, is what bounds how often a page can drive an
|
|
100
100
|
// agent. The gap above only slows that down; this stops it.
|
|
101
101
|
export const MAX_FEEDBACK_PER_PAGE = Number(process.env.WEBTERM_MAX_FEEDBACK_PER_PAGE) || 20;
|
|
102
102
|
|
|
@@ -195,7 +195,7 @@ export const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
|
|
195
195
|
export const UPLOAD_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
196
196
|
export const CHECK_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
197
197
|
|
|
198
|
-
// The key that the URL carries (`/?k=<key>`) and the relay demands. A fresh one is
|
|
198
|
+
// The key that the URL carries (`/?k=<key>`) and the relay demands. A fresh one is created every time
|
|
199
199
|
// this server starts, so a key never outlives the process that issued it and nothing has to store it:
|
|
200
200
|
// whoever prints the URL reads it back from KEY_FILE. WEBTERM_KEY pins it (tests, hand-run debugging).
|
|
201
201
|
// There is deliberately no unauthenticated mode - without the gate the relay would drive an agent for
|
|
@@ -224,7 +224,7 @@ export function isAuthorized(candidate) {
|
|
|
224
224
|
// relay even if it somehow holds a key. We allow any localhost / 127.0.0.1 origin regardless of port,
|
|
225
225
|
// because the published host port can differ from the container port (e.g. 3900:3899) so the browser's
|
|
226
226
|
// Origin varies per workspace. The publish is loopback-only, so "any local port" is the right
|
|
227
|
-
// granularity: it still rejects remote origins like http://
|
|
227
|
+
// granularity: it still rejects remote origins like http://attacker.com.
|
|
228
228
|
export function isAllowedOrigin(origin) {
|
|
229
229
|
if (!origin) return false;
|
|
230
230
|
try {
|
|
@@ -253,9 +253,9 @@ export const SUBMIT_DELAY_MS = Number(process.env.WEBTERM_SUBMIT_DELAY_MS) || 15
|
|
|
253
253
|
export const STOP_ANNOUNCE_MS = 150;
|
|
254
254
|
export const STOP_TIMEOUT_MS = 3_000;
|
|
255
255
|
|
|
256
|
-
//
|
|
256
|
+
// Limit on the replayed session buffer (bytes), per session. A session's PTY outlives every socket, so
|
|
257
257
|
// on attach we replay up to this much recent output and the window lands back in the live conversation.
|
|
258
|
-
// Older output beyond the
|
|
258
|
+
// Older output beyond the limit is dropped from the replay (the agent's own history is unaffected).
|
|
259
259
|
export const MAX_OUTPUT_BUFFER = 1_000_000;
|
|
260
260
|
|
|
261
261
|
// How many sessions (live agent processes) may exist at once. Sessions are never closed for the user,
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// matches it to the session by the two facts the server holds: the process tree and the directory.
|
|
12
12
|
//
|
|
13
13
|
// A miss is normal and cheap. An agent that has not written its first record yet has no id to find, so the
|
|
14
|
-
// answer is null, the pane keeps its provisional directory, and the next
|
|
14
|
+
// answer is null, the pane keeps its provisional directory, and the next pass asks again.
|
|
15
15
|
|
|
16
16
|
import { closeSync, openSync, readdirSync, readFileSync, readSync, statSync } from "node:fs";
|
|
17
17
|
import { basename, join } from "node:path";
|
|
@@ -26,7 +26,7 @@ const HEAD_BYTES = 64 * 1024;
|
|
|
26
26
|
const MAX_CANDIDATES = 40;
|
|
27
27
|
|
|
28
28
|
// The largest a snapshot or a session record can be and still be read whole. The files these stores write
|
|
29
|
-
// are a few hundred bytes; the
|
|
29
|
+
// are a few hundred bytes; the limit is only there so an unrelated file that happens to be in the directory
|
|
30
30
|
// cannot be pulled into memory.
|
|
31
31
|
const MAX_JSON_BYTES = 1024 * 1024;
|
|
32
32
|
|
|
@@ -65,7 +65,7 @@ function walkStore(dir, pattern, depth, onFile) {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
/**
|
|
68
|
-
* Files under `dir` matching `pattern`, newest first,
|
|
68
|
+
* Files under `dir` matching `pattern`, newest first, limited in number.
|
|
69
69
|
* Exported for resume.js, which reads the same stores to answer a different question.
|
|
70
70
|
*/
|
|
71
71
|
export function filesNewestFirst(dir, pattern, depth = MAX_STORE_DEPTH) {
|
|
@@ -22,7 +22,7 @@ import { conversationId } from "./conversation.js";
|
|
|
22
22
|
const PROVISIONAL_PREFIX = "pending-";
|
|
23
23
|
|
|
24
24
|
// How many sessions the key map remembers. Entries outlive their session on purpose: feedback submitted after
|
|
25
|
-
// an agent exited still has to land in the right directory. The
|
|
25
|
+
// an agent exited still has to land in the right directory. The limit only keeps a container that has been up
|
|
26
26
|
// for weeks from growing the map without end.
|
|
27
27
|
const MAX_REMEMBERED = 64;
|
|
28
28
|
|
|
@@ -36,8 +36,10 @@ export function isProvisional(key) {
|
|
|
36
36
|
*
|
|
37
37
|
* - `store` is the artifacts store.
|
|
38
38
|
* - `stores` is where each agent writes its conversation id (config's AGENT_STORES).
|
|
39
|
-
* - `sessions()` returns the live sessions as `{ sid, agent, cwd, pid, since }` - everything
|
|
40
|
-
* needs, handed in rather than reached for, so this file needs no registry and no PTYs to be
|
|
39
|
+
* - `sessions()` returns the live sessions as `{ sid, agent, cwd, pid, since, resumedFrom }` - everything
|
|
40
|
+
* the discovery needs, handed in rather than reached for, so this file needs no registry and no PTYs to be
|
|
41
|
+
* tested. `resumedFrom` is the conversation id the server resumed this session with, or null: the one fact
|
|
42
|
+
* only the server has, because claude gives a resumed conversation a fresh id and records no link back.
|
|
41
43
|
* - `onChange(sid)` is called whenever that session's page list moved, for whatever reason.
|
|
42
44
|
*/
|
|
43
45
|
export function createPane({ store, stores, sessions, onChange }) {
|
|
@@ -100,6 +102,15 @@ export function createPane({ store, stores, sessions, onChange }) {
|
|
|
100
102
|
);
|
|
101
103
|
return true;
|
|
102
104
|
}
|
|
105
|
+
// Claude names a fresh id on every resume, so a resumed conversation arrives here under a new id
|
|
106
|
+
// while its pages still sit under the id it was resumed from. The server witnessed the resume - it
|
|
107
|
+
// built the command - so those pages are carried into the new id first, ahead of the provisional
|
|
108
|
+
// directory, keeping their numbers and their order. An old id another live session still holds stays
|
|
109
|
+
// where it is: those pages are not this session's to take.
|
|
110
|
+
const from = session.resumedFrom;
|
|
111
|
+
if (typeof from === "string" && from !== id && !liveKeys(session.sid).has(from)) {
|
|
112
|
+
store.adopt(from, id);
|
|
113
|
+
}
|
|
103
114
|
if (!store.adopt(entry.key, id)) return false;
|
|
104
115
|
entry.key = id;
|
|
105
116
|
entry.adopted = true;
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { readFileSync } from "node:fs";
|
|
15
15
|
|
|
16
|
-
// A process tree in a container is a handful of levels deep. The
|
|
16
|
+
// A process tree in a container is a handful of levels deep. The limit is what keeps a malformed /proc, or a
|
|
17
17
|
// cycle that should not exist, from spinning here.
|
|
18
18
|
const MAX_ANCESTRY_DEPTH = 64;
|
|
19
19
|
|
|
@@ -18,7 +18,7 @@ import { WORKSPACE_COLOR } from "./theme.js";
|
|
|
18
18
|
// state of the container in one mark:
|
|
19
19
|
//
|
|
20
20
|
// nothing - nothing is happening in here
|
|
21
|
-
// white bar across the bottom - an agent is working, with a lit segment
|
|
21
|
+
// white bar across the bottom - an agent is working, with a lit segment moving along it
|
|
22
22
|
// green dot in the top corner - an agent finished and is waiting for you, pulsing until you go and look
|
|
23
23
|
//
|
|
24
24
|
// One mark at a time, and the two are told apart by where they sit before any colour is read: working owns the
|
|
@@ -46,7 +46,7 @@ const ICON_UNITS = 32;
|
|
|
46
46
|
const ICON_TICK_MS = 240;
|
|
47
47
|
// The waiting dot's bright-to-dim step, in ticks. Slow enough to read as a pulse rather than a flicker.
|
|
48
48
|
const PULSE_TICKS = 3;
|
|
49
|
-
// One out-and-back of the working segment, in ticks - about two seconds each way,
|
|
49
|
+
// One out-and-back of the working segment, in ticks - about two seconds each way, a glide and not a dash.
|
|
50
50
|
const SWEEP_TICKS = 16;
|
|
51
51
|
const DONE_COLOR = "#2fe58a";
|
|
52
52
|
const BUSY_COLOR = "#e6edf3";
|
|
@@ -69,7 +69,7 @@ function pulseDim() {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
// Where the working segment is on its track - 0 at one end, 1 at the other, and back down again. A triangle
|
|
72
|
-
// rather than a saw, so the segment
|
|
72
|
+
// rather than a saw, so the segment travels back instead of jumping to the start.
|
|
73
73
|
function sweepAt() {
|
|
74
74
|
const half = SWEEP_TICKS / 2;
|
|
75
75
|
const step = iconFrame % SWEEP_TICKS;
|
|
@@ -132,7 +132,7 @@ function drawIcon(ctx, badgeColor, dim, sweep) {
|
|
|
132
132
|
ctx.fill();
|
|
133
133
|
ctx.stroke();
|
|
134
134
|
|
|
135
|
-
// Working: a bar across the bottom, with a lit segment
|
|
135
|
+
// Working: a bar across the bottom, with a lit segment moving from end to end. The bottom edge is the far
|
|
136
136
|
// side of the icon from the corner waiting owns, which is what tells the two states apart at 16px - a thin
|
|
137
137
|
// line down the right edge, which is what this used to be, reads as a scrollbar rather than as anything the
|
|
138
138
|
// container is doing. The track is 20 wide and the segment 8, so the segment travels the 12 between them.
|
|
@@ -321,7 +321,7 @@ function playChime() {
|
|
|
321
321
|
const ctx = audio();
|
|
322
322
|
// Blocked until this page has been clicked or typed into. Nearly never true here - you click the terminal to type -
|
|
323
323
|
// and the tab, the title and the icon are all still saying it either way.
|
|
324
|
-
if (
|
|
324
|
+
if (ctx?.state !== "running") return;
|
|
325
325
|
const at = ctx.currentTime + 0.02;
|
|
326
326
|
for (const [ratio, level, decay] of CHIME_PARTIALS) {
|
|
327
327
|
const osc = ctx.createOscillator();
|
|
@@ -45,7 +45,7 @@ export function refreshComposer() {
|
|
|
45
45
|
|
|
46
46
|
export function autoGrow() {
|
|
47
47
|
input.style.height = "auto";
|
|
48
|
-
// The floor keeps the box off the button block behind it and the ceiling
|
|
48
|
+
// The floor keeps the box off the button block behind it and the ceiling limits it to seven lines. Both are
|
|
49
49
|
// read from the box itself rather than written here as well: the same two numbers in two files is how the
|
|
50
50
|
// box comes to spring back to a height the stylesheet no longer says.
|
|
51
51
|
const box = getComputedStyle(input);
|
|
@@ -23,7 +23,7 @@ export let everConnected = false;
|
|
|
23
23
|
//
|
|
24
24
|
// Four reasons a window ends up here, and they are not the same to the user:
|
|
25
25
|
// down - nothing answers. The container is stopped or gone; keeps reconnecting, so it heals itself.
|
|
26
|
-
// locked - the relay answered and refused this window's key. The interface restarted and
|
|
26
|
+
// locked - the relay answered and refused this window's key. The interface restarted and created a new
|
|
27
27
|
// one, so this URL is spent; reconnecting is pointless and stops.
|
|
28
28
|
// stopping - the user just stopped the container from here. Same end state as "down", but it is not a
|
|
29
29
|
// failure and must not read like one.
|
|
@@ -181,7 +181,7 @@ export function sendFrame(frame) {
|
|
|
181
181
|
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(frame));
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
-
// Any frame at all proves the socket works, which is what a wake
|
|
184
|
+
// Any frame at all proves the socket works, which is what a wake check is waiting to hear. Called by the frame
|
|
185
185
|
// router, since every frame counts and only it sees them all.
|
|
186
186
|
export function noteSocketAlive() {
|
|
187
187
|
if (probeTimer) {
|
|
@@ -64,7 +64,7 @@ if (!SpeechRecognition) {
|
|
|
64
64
|
|
|
65
65
|
// Prime the mic permission via getUserMedia first: SpeechRecognition on its own often fails
|
|
66
66
|
// straight to "not-allowed" without prompting. In an embedded webview getUserMedia is missing
|
|
67
|
-
// or blocked, so we
|
|
67
|
+
// or blocked, so we show the "use a real browser" hint.
|
|
68
68
|
async function startDictation() {
|
|
69
69
|
try {
|
|
70
70
|
if (!navigator.mediaDevices?.getUserMedia) {
|
|
@@ -28,7 +28,7 @@ let shownSid = null;
|
|
|
28
28
|
let shownMessage = null;
|
|
29
29
|
|
|
30
30
|
export function onFrame(msg) {
|
|
31
|
-
// Any frame at all proves the socket works, which is what a wake
|
|
31
|
+
// Any frame at all proves the socket works, which is what a wake check is waiting to hear.
|
|
32
32
|
noteSocketAlive();
|
|
33
33
|
if (msg.t === "sessions") {
|
|
34
34
|
applySessions(msg);
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// goes as one {t:"paste"} frame the server wraps as a bracketed paste.
|
|
14
14
|
//
|
|
15
15
|
// Everything here rides on the key this window was handed in its URL: the socket, the uploads and the
|
|
16
|
-
// status
|
|
16
|
+
// status check all carry it, and without a valid one the page is never served in the first place.
|
|
17
17
|
//
|
|
18
18
|
// The page is plain ES modules with no build step, one per subject:
|
|
19
19
|
//
|
|
@@ -78,7 +78,7 @@ async function fillDirList(list) {
|
|
|
78
78
|
if (dir === "/") option.label = "workspace root";
|
|
79
79
|
list.append(option);
|
|
80
80
|
}
|
|
81
|
-
// The scan is
|
|
81
|
+
// The scan is limited, so say when the list is partial instead of letting it look complete.
|
|
82
82
|
if (payload?.truncated) noteMsg("the directory list is partial - deeper paths can still be typed in");
|
|
83
83
|
}
|
|
84
84
|
|
|
@@ -40,7 +40,7 @@ export function labelOf(sid, fallback) {
|
|
|
40
40
|
return sessions.find((entry) => entry.id === sid)?.label ?? fallback;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
// The key that came with the URL. The server
|
|
43
|
+
// The key that came with the URL. The server creates a new one every time it starts and refuses everything
|
|
44
44
|
// without it, so this is also what goes stale: a window left open across a restart still holds the old key,
|
|
45
45
|
// which is the case the curtain explains rather than reconnecting forever.
|
|
46
46
|
export const WEB_KEY = new URLSearchParams(location.search).get("k") ?? "";
|
|
@@ -171,7 +171,7 @@ function renameEditor(entry) {
|
|
|
171
171
|
editor.className = "rename";
|
|
172
172
|
editor.value = editingValue;
|
|
173
173
|
editor.spellcheck = false;
|
|
174
|
-
// A soft
|
|
174
|
+
// A soft limit for the field; the server trims to its own limit and strips control characters.
|
|
175
175
|
editor.maxLength = 60;
|
|
176
176
|
editor.addEventListener("click", (event) => event.stopPropagation());
|
|
177
177
|
editor.addEventListener("dblclick", (event) => event.stopPropagation());
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// The window renders the live TUI (xterm.js) and forwards keystrokes; a rich composer uploads
|
|
7
7
|
// pasted images to /tmp/uploads and injects the composed message as one bracketed paste.
|
|
8
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=),
|
|
9
|
+
// Every route that carries the relay is gated by the key the URL holds (?k=), created fresh at every start.
|
|
10
10
|
// Auth and sandbox are inherited: the spawned CLI sees the same agent config dirs and the same
|
|
11
11
|
// container isolation it has in the terminal. Nothing here touches credentials.
|
|
12
12
|
|
|
@@ -176,7 +176,7 @@ function publishAgent() {
|
|
|
176
176
|
* The directories the picker offers. Bounded on purpose (see DIR_SCAN_* in config.js): a deep walk of a
|
|
177
177
|
* real workspace is slow and the list would be unreadable anyway. Dot directories and the skip list are
|
|
178
178
|
* never descended into, and neither are symlinks - a link out of the workspace would list paths that the
|
|
179
|
-
* picker then refuses. Breadth-first, so a
|
|
179
|
+
* picker then refuses. Breadth-first, so a limit that bites drops the deepest entries rather than a whole
|
|
180
180
|
* branch, and it reports the cut so the caller can say the list is partial.
|
|
181
181
|
*/
|
|
182
182
|
function scanDirs() {
|
|
@@ -213,7 +213,7 @@ function scanDirs() {
|
|
|
213
213
|
// --- The key gate ------------------------------------------------------------------------------------------------------------------------
|
|
214
214
|
|
|
215
215
|
// Publish the live key where the things that print the URL can read it: the `webterm` launcher, the
|
|
216
|
-
// container greeting, and chamba on the host before it
|
|
216
|
+
// container greeting, and chamba on the host before it checks /status. Called once the port is bound and
|
|
217
217
|
// never before - a second server that loses the bind must not leave its key behind as if it had won.
|
|
218
218
|
// Owner-only, and chmod'ed after the write because the mode above applies to a file being created rather
|
|
219
219
|
// than to one that already exists.
|
|
@@ -347,7 +347,7 @@ app.use("/vendor/xterm", express.static(join(import.meta.dirname, "node_modules"
|
|
|
347
347
|
app.use("/vendor/xterm-fit", express.static(join(import.meta.dirname, "node_modules", "@xterm", "addon-fit", "lib")));
|
|
348
348
|
|
|
349
349
|
// Accept a raw image body (the client POSTs the pasted/dropped blob with its Content-Type).
|
|
350
|
-
// Reject non-image types up front;
|
|
350
|
+
// Reject non-image types up front; limit the size so a bad request cannot fill the disk.
|
|
351
351
|
app.post("/upload", requireKey, express.raw({ type: () => true, limit: MAX_UPLOAD_BYTES }), (req, res) => {
|
|
352
352
|
const contentType = String(req.headers["content-type"] || "")
|
|
353
353
|
.split(";")[0]
|
|
@@ -489,7 +489,14 @@ function paneSessions() {
|
|
|
489
489
|
const session = registry.get(entry.id);
|
|
490
490
|
const pid = Number(session?.term?.pid);
|
|
491
491
|
if (!session || !Number.isInteger(pid)) continue;
|
|
492
|
-
live.push({
|
|
492
|
+
live.push({
|
|
493
|
+
sid: session.id,
|
|
494
|
+
agent: session.agent,
|
|
495
|
+
cwd: session.cwd,
|
|
496
|
+
pid,
|
|
497
|
+
since: session.createdAt,
|
|
498
|
+
resumedFrom: resumedBy.get(pid) ?? null,
|
|
499
|
+
});
|
|
493
500
|
}
|
|
494
501
|
return live;
|
|
495
502
|
}
|
|
@@ -865,13 +872,19 @@ function resumeArgv(agent, cwd) {
|
|
|
865
872
|
// auth flows through. The first session after a container start continues the most recent conversation;
|
|
866
873
|
// every later session starts fresh, which is what the user wants once mid-work.
|
|
867
874
|
// AGENT_ARGS belong to the agent the launcher named, so any other agent is spawned bare.
|
|
875
|
+
// Which conversation id a session's agent was told to resume, by the PTY leader's pid. Claude gives a
|
|
876
|
+
// resumed conversation a fresh id and records no link back to the old one, so the command built here is the
|
|
877
|
+
// only witness - the pane reads this to carry the old id's pages into the new one. One resume per container
|
|
878
|
+
// start, so this holds at most one entry that matters.
|
|
879
|
+
const resumedBy = new Map();
|
|
880
|
+
|
|
868
881
|
function spawnAgent({ cwd, agent }) {
|
|
869
882
|
const resume = resumeArgv(agent, cwd);
|
|
870
883
|
const [spawnCmd, ...baseArgs] = resume ?? [agent, ...(agent === DEFAULT_AGENT ? AGENT_ARGS : [])];
|
|
871
884
|
if (resume) console.log(`[webterm] resuming most recent conversation: ${resume.join(" ")}`);
|
|
872
885
|
// Append the browser-awareness flag for claude (fresh or resumed); every other agent is untouched.
|
|
873
886
|
const spawnArgs = agentSpawnArgv(spawnCmd, baseArgs);
|
|
874
|
-
|
|
887
|
+
const term = pty.spawn(spawnCmd, spawnArgs, {
|
|
875
888
|
name: "xterm-256color",
|
|
876
889
|
cols: 80,
|
|
877
890
|
rows: 24,
|
|
@@ -882,6 +895,9 @@ function spawnAgent({ cwd, agent }) {
|
|
|
882
895
|
// var is the one answer all three can read.
|
|
883
896
|
env: { ...process.env, CHAMBA_WEB_SESSION: "1" },
|
|
884
897
|
});
|
|
898
|
+
// Only a resume-by-id names a conversation to carry pages from; "--continue" style resumes name nothing.
|
|
899
|
+
if (resume?.[1] === "--resume" && typeof resume[2] === "string") resumedBy.set(term.pid, resume[2]);
|
|
900
|
+
return term;
|
|
885
901
|
}
|
|
886
902
|
|
|
887
903
|
// --- Frames from a window ----------------------------------------------------------------------------------------------------------------
|
|
@@ -1021,7 +1037,7 @@ function handleFrame(ws, msg) {
|
|
|
1021
1037
|
greet(ws, sid, msg.fresh === true);
|
|
1022
1038
|
return;
|
|
1023
1039
|
case "ping":
|
|
1024
|
-
// Liveness
|
|
1040
|
+
// Liveness check from a window that just woke up: an answer proves the socket really works,
|
|
1025
1041
|
// so it can reconnect at once instead of waiting for TCP to give up.
|
|
1026
1042
|
send(ws, { t: "pong" });
|
|
1027
1043
|
return;
|
|
@@ -1141,16 +1157,16 @@ setInterval(checkUploads, CHECK_INTERVAL_MS).unref();
|
|
|
1141
1157
|
// actually changed.
|
|
1142
1158
|
setInterval(() => registry.tick(), WORK_TICK_MS).unref();
|
|
1143
1159
|
|
|
1144
|
-
// The pane's own
|
|
1160
|
+
// The pane's own scan, on the same principle: one pass over the live sessions asks the two questions that
|
|
1145
1161
|
// are about time rather than about an event - has this agent written down which conversation it is having
|
|
1146
1162
|
// yet, and has anything been written into its pane directory that did not come through the publish route.
|
|
1147
1163
|
setInterval(() => pane.sweep(), PANE_SCAN_MS).unref();
|
|
1148
1164
|
|
|
1149
|
-
// The strip's
|
|
1165
|
+
// The strip's scan, for the same reason: a snapshot file is written by a shell script into a bind mount, so
|
|
1150
1166
|
// the only way to know it moved is to look.
|
|
1151
1167
|
setInterval(() => sweepStatus(), STATUS_SCAN_MS).unref();
|
|
1152
1168
|
|
|
1153
|
-
// Keepalive
|
|
1169
|
+
// Keepalive pass. A window that stops answering is terminated, which releases the session it was
|
|
1154
1170
|
// driving so the window that comes back can pick it up without a takeover prompt. Sessions themselves
|
|
1155
1171
|
// are never touched here - a dead socket says nothing about whether a conversation is worth keeping.
|
|
1156
1172
|
setInterval(() => {
|
|
@@ -1185,7 +1201,7 @@ server.listen(PORT, "0.0.0.0", () => {
|
|
|
1185
1201
|
if (defaultCwdRefused) {
|
|
1186
1202
|
console.warn(`[webterm] ignored WEBTERM_CWD="${WEBTERM_CWD_RAW}": not a directory inside ${WORKSPACE_ROOT}`);
|
|
1187
1203
|
}
|
|
1188
|
-
// Publish the default agent for the `webterm` launcher: a port
|
|
1204
|
+
// Publish the default agent for the `webterm` launcher: a port check proves something is listening, not
|
|
1189
1205
|
// what it runs. Written after listen so the file only exists once the port is really bound.
|
|
1190
1206
|
publishAgent();
|
|
1191
1207
|
});
|
|
@@ -88,7 +88,7 @@ export const WORK_TICK_MS = 300;
|
|
|
88
88
|
export const MAX_NAME_LENGTH = 40;
|
|
89
89
|
|
|
90
90
|
// Turn a raw name from the client into what gets stored: control characters (newlines, tabs, the lot)
|
|
91
|
-
// stripped so a name is always one clean line, trimmed, and cut to the
|
|
91
|
+
// stripped so a name is always one clean line, trimmed, and cut to the length limit. An empty result means
|
|
92
92
|
// "no name" - the session falls back to its default label. Filtering by code point rather than a regex
|
|
93
93
|
// keeps the source free of literal control characters.
|
|
94
94
|
export function cleanName(raw) {
|
|
@@ -115,8 +115,8 @@ function isOpen(socket) {
|
|
|
115
115
|
* Create the session registry.
|
|
116
116
|
*
|
|
117
117
|
* - `spawn({ cwd })` returns a PTY-like object: { onData, onExit, write, resize, kill }.
|
|
118
|
-
* - `maxSessions`
|
|
119
|
-
* - `maxBuffer`
|
|
118
|
+
* - `maxSessions` limits how many agents may be alive at once. This is about memory, not correctness.
|
|
119
|
+
* - `maxBuffer` limits the replay buffer kept per session (bytes).
|
|
120
120
|
* - `onEvent(event)` is called with { t: "replay" | "out" | "taken" | "exit" | "changed", ... }.
|
|
121
121
|
* Events that target one window carry that window's socket as `client`; "changed" means the session
|
|
122
122
|
* list moved and every window needs to hear about it.
|
|
@@ -305,9 +305,9 @@ export function createRegistry({ spawn, maxSessions, maxBuffer, onEvent }) {
|
|
|
305
305
|
/**
|
|
306
306
|
* Move every session's working state on by one step, and raise the alert on the ones that just stopped.
|
|
307
307
|
* Called on a timer by the server rather than driven by a timer per session: the state is a function of
|
|
308
|
-
* "how long output has been running", so one
|
|
308
|
+
* "how long output has been running", so one pass answers it for every session, and a registry with no
|
|
309
309
|
* timers of its own stays testable by calling this by hand. Every flip is announced here, so the whole
|
|
310
|
-
*
|
|
310
|
+
* pass costs at most one broadcast however many sessions moved.
|
|
311
311
|
*
|
|
312
312
|
* Every ending raises the alert, whoever is watching and whatever they were doing a moment ago. Whether it
|
|
313
313
|
* is also worth a sound is settled afterwards, by whether anyone touches the session - see "Are you there?".
|
|
@@ -372,7 +372,7 @@ export function createRegistry({ spawn, maxSessions, maxBuffer, onEvent }) {
|
|
|
372
372
|
* and how one is written for the browser belong to the server, and the registry only carries the values -
|
|
373
373
|
* the command and path to the PTY, the labels to the bar.
|
|
374
374
|
*
|
|
375
|
-
* { ok: true, session } or
|
|
375
|
+
* { ok: true, session }, or a refusal with the session-limit error code when the limit is reached.
|
|
376
376
|
*/
|
|
377
377
|
function create({ cwd, cwdLabel, agent } = {}) {
|
|
378
378
|
if (sessions.size >= maxSessions) return { ok: false, error: "cap" };
|
|
@@ -384,7 +384,7 @@ export function createRegistry({ spawn, maxSessions, maxBuffer, onEvent }) {
|
|
|
384
384
|
// Named after what it runs, since the bar can hold several different agents at once.
|
|
385
385
|
label: `${agent} ${seq}`,
|
|
386
386
|
// A user-chosen label, or null to fall back to `label`. The number in `label` is always kept,
|
|
387
|
-
// so clearing the name shows "claude 7" again and the tooltip can still
|
|
387
|
+
// so clearing the name shows "claude 7" again and the tooltip can still show it.
|
|
388
388
|
name: null,
|
|
389
389
|
agent,
|
|
390
390
|
colorIndex: (seq - 1) % PALETTE_SIZE,
|
|
@@ -28,7 +28,7 @@ import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
|
28
28
|
import { join } from "node:path";
|
|
29
29
|
import { isSameProcess, isSelfOrDescendant } from "./proc.js";
|
|
30
30
|
|
|
31
|
-
// The largest a snapshot can be and still be read. The script writes a few hundred bytes; the
|
|
31
|
+
// The largest a snapshot can be and still be read. The script writes a few hundred bytes; the limit only keeps
|
|
32
32
|
// an unrelated file that happens to sit in the directory from being pulled into memory.
|
|
33
33
|
const MAX_SNAPSHOT_BYTES = 64 * 1024;
|
|
34
34
|
|
|
@@ -45,7 +45,7 @@ export function writesSnapshots(agent) {
|
|
|
45
45
|
return agent === "claude";
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
/** The snapshot files in `dir`, newest first and
|
|
48
|
+
/** The snapshot files in `dir`, newest first and limited in number. A directory that is not there contributes nothing. */
|
|
49
49
|
function snapshotFiles(dir) {
|
|
50
50
|
let entries;
|
|
51
51
|
try {
|