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.
@@ -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
- // probed just-in-time on the create path (assertHostPortsAvailable), never on resume/connect.
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() probing the web port and this create path (profile selection, a
247
- // recreate confirm, the image build), so re-probe it here. A port taken in the meantime drops the
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 probe host availability up front. The old container was already removed on
259
- // every path that reaches here, so we never probe our own live port. A taken host port fails clearly and
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-probe above: this gates the greeting's URL line and the
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 probe
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 probe,
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-probes the web port and drops it when something took it while the image built, so
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-probe.
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 minted at every server start and nothing on the host keeps one.
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 minted, so the live one is shown
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);
@@ -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 canonical formatting. Returns a result describing what happened.
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);
@@ -1,5 +1,5 @@
1
1
  // =========================================================================================================================================
2
- // src/lib/constants.ts - Canonical constants used across the chamba codebase
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 minted every time
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 probes /status. Container-side path - the host only ever reads it through `docker exec`.
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 sweeps it by age.
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 probe (net bind) cannot bind ports below 1024.
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 probe on loopback only - never 0.0.0.0. Nothing on the LAN may reach these sessions.
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 probe still applies).
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 probe our own live port. Throws a clear error naming the taken
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.
@@ -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 probes the host, so it can say a port is assigned but not
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 probe and any host port the workspace already declares in chamba.yaml. When the sticky
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 minted at every server start and nothing on the host stores one,
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 probe without a key, which is exactly what such an older server
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 mints: 16 random bytes, hex. The key file is writable by every process in the
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 mints. */
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 probe gets:
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.0",
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 prose, and short paragraphs. Keep it small - a couple of hundred kilobytes is plenty. The per-page cap is 2 MB, and a page past it is refused rather than trimmed.
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 surface 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.
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 mints a new one at each start, so the key lives exactly as long as the process that issued it.
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 sweeping 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.
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 sweep, nothing removed when a session closes. Growth is bounded instead: a page over the per-page cap 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.
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 minted 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.
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-capped, and written only under `/tmp/uploads/`.
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 cap. Listing a page reads only the first few kilobytes, looking for its title, and serving one is refused outright past the per-page cap - 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 cap is refused rather than read into memory.
147
- - **Feedback is bounded and bound.** The body has a size cap, 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 cap 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 capped 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.
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 cap, sweep 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, caps, scan interval, feedback bounds and agent stores, and the status strip's scan interval).
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 minting one, which is for tests and hand-run debugging - there is no way to turn the gate off.
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
- // cap is reached; past the cap a publish is refused and what exists stays. And nothing outside the root is
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 minted to match.
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
- * capped. Nothing typed into a terminal ever comes from here - this is what gets written to a file.
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
- * cap, so a listing that read whole files would stall the whole interface on one huge one - and a listing
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` caps a single page, `maxTotalBytes` and `maxTotalFiles` the whole root, and
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 capped too - both numbers come out of this one walk, and
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 cap, not something a caller asks for. */
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 cap it met. */
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 cap, and
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 cap, "full" when the
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 caps stand in the way, and the count is the
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
- // Caps, enforced by the server rather than by the helper: a page is refused above the first, and a publish
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 cap is a message, not a
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 cap on how many files, not only how many bytes. Answers are small, so the byte cap alone would let 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 cap is what the endpoint accepts at all; the rest bound what
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 cap, is what bounds how often a page can drive an
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 minted every time
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://evil.com.
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
- // Cap on the replayed session buffer (bytes), per session. A session's PTY outlives every socket, so
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 cap is dropped from the replay (the agent's own history is unaffected).
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 sweep asks again.
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 cap is only there so an unrelated file that happens to be in the directory
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, capped.
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 cap only keeps a container that has been up
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 the discovery
40
- * needs, handed in rather than reached for, so this file needs no registry and no PTYs to be tested.
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 cap is what keeps a malformed /proc, or a
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 sweeping along it
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, which is a sweep and not a dash.
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 sweeps back instead of jumping to the start.
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 sweeping from end to end. The bottom edge is the far
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 (!ctx || ctx.state !== "running") return;
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 caps it at seven lines. Both are
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 minted a new
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 probe is waiting to hear. Called by the frame
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 surface the "use a real browser" hint.
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 probe is waiting to hear.
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 probe all carry it, and without a valid one the page is never served in the first place.
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 capped, so say when the list is partial instead of letting it look complete.
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 mints a new one every time it starts and refuses everything
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 cap for the field; the server trims to its own limit and strips control characters.
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=), minted fresh at every start.
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 cap that bites drops the deepest entries rather than a whole
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 probes /status. Called once the port is bound and
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; cap the size so a bad request cannot fill the disk.
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({ sid: session.id, agent: session.agent, cwd: session.cwd, pid, since: session.createdAt });
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
- return pty.spawn(spawnCmd, spawnArgs, {
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 probe from a window that just woke up: an answer proves the socket really works,
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 sweep, on the same principle: one pass over the live sessions asks the two questions that
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 sweep, for the same reason: a snapshot file is written by a shell script into a bind mount, so
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 sweep. A window that stops answering is terminated, which releases the session it was
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 probe proves something is listening, not
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 cap. An empty result means
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` caps how many agents may be alive at once. This is about memory, not correctness.
119
- * - `maxBuffer` caps the replay buffer kept per session (bytes).
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 sweep answers it for every session, and a registry with no
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
- * sweep costs at most one broadcast however many sessions moved.
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 { ok: false, error: "cap" } when the limit is reached.
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 surface it.
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 cap only keeps
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 capped. A directory that is not there contributes nothing. */
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 {