chamba 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +47 -44
- package/bin/chamba.js +212 -0
- package/dist/commands/advanced.js +278 -0
- package/dist/commands/dev.js +619 -0
- package/dist/commands/doctor.js +29 -0
- package/dist/commands/menu.js +80 -0
- package/dist/commands/onboard.js +229 -0
- package/dist/commands/settings.js +349 -0
- package/dist/lib/agent-context.js +177 -0
- package/dist/lib/browser.js +40 -0
- package/dist/lib/chamba-yaml.js +191 -0
- package/dist/lib/constants.js +135 -0
- package/dist/lib/dockerfile-builder.js +267 -0
- package/dist/lib/env.js +78 -0
- package/dist/lib/global-config.js +66 -0
- package/dist/lib/pnpm-store.js +19 -0
- package/dist/lib/ports.js +210 -0
- package/dist/lib/safe-rm.js +26 -0
- package/dist/lib/sessions.js +34 -0
- package/dist/lib/shadows.js +174 -0
- package/dist/lib/webterm.js +490 -0
- package/dist/lib/workspace-identity.js +260 -0
- package/package.json +61 -23
- package/schema/chamba.schema.json +65 -0
- package/templates/.dockerignore +3 -0
- package/templates/Dockerfile +173 -0
- package/templates/claude-statusline.sh +120 -0
- package/templates/context/baseline.md +13 -0
- package/templates/context/context-usage.md +1 -0
- package/templates/context/git-mode-local.md +1 -0
- package/templates/context/git-mode-strict.md +1 -0
- package/templates/context/git-mode-unrestricted.md +1 -0
- package/templates/context/git-unavailable.md +1 -0
- package/templates/context/shadow-paths.md +3 -0
- package/templates/context-usage.sh +249 -0
- package/templates/git-readonly-wrapper.mjs +309 -0
- package/templates/npmrc +2 -0
- package/templates/pnpm-config.yaml +9 -0
- package/templates/runtime-constants.mjs +18 -0
- package/templates/skills/chamba-statusline/SKILL.md +79 -0
- package/templates/skills/context-usage/SKILL.md +53 -0
- package/templates/skills/web-pane/SKILL.md +62 -0
- package/templates/startup-git-mode.mjs +145 -0
- package/templates/startup.mjs +333 -0
- package/templates/webpane.sh +126 -0
- package/templates/webterm/README.md +157 -0
- package/templates/webterm/artifacts.js +583 -0
- package/templates/webterm/config.js +269 -0
- package/templates/webterm/context/claude.md +14 -0
- package/templates/webterm/conversation.js +248 -0
- package/templates/webterm/package-lock.json +884 -0
- package/templates/webterm/package.json +17 -0
- package/templates/webterm/pane.js +156 -0
- package/templates/webterm/proc.js +89 -0
- package/templates/webterm/public/app/alerts.js +472 -0
- package/templates/webterm/public/app/cards.js +123 -0
- package/templates/webterm/public/app/clipboard.js +229 -0
- package/templates/webterm/public/app/composer.js +226 -0
- package/templates/webterm/public/app/connection.js +342 -0
- package/templates/webterm/public/app/dictation.js +98 -0
- package/templates/webterm/public/app/dom.js +37 -0
- package/templates/webterm/public/app/drafts.js +244 -0
- package/templates/webterm/public/app/frames.js +166 -0
- package/templates/webterm/public/app/main.js +82 -0
- package/templates/webterm/public/app/new-session.js +188 -0
- package/templates/webterm/public/app/note.js +24 -0
- package/templates/webterm/public/app/pane-frame.js +166 -0
- package/templates/webterm/public/app/pane.js +353 -0
- package/templates/webterm/public/app/state.js +51 -0
- package/templates/webterm/public/app/status-strip.js +170 -0
- package/templates/webterm/public/app/tabs.js +475 -0
- package/templates/webterm/public/app/terminal.js +102 -0
- package/templates/webterm/public/app/theme.js +46 -0
- package/templates/webterm/public/favicon.svg +21 -0
- package/templates/webterm/public/index.html +105 -0
- package/templates/webterm/public/styles.css +1193 -0
- package/templates/webterm/server.js +1142 -0
- package/templates/webterm/sessions.js +515 -0
- package/templates/webterm/snapshot.js +135 -0
- package/templates/webterm.sh +167 -0
- package/dist/cli.js +0 -1582
- package/dist/server.js +0 -1831
- package/inject/annotate.js +0 -18
- package/skill/README.md +0 -12
- package/skill/SKILL.md +0 -93
- package/web/assets/highlighted-body-OFNGDK62-Bn4Eu7CG.js +0 -1
- package/web/assets/index-B9DI4F1Z.js +0 -202
- package/web/assets/index-DK_n6CTo.css +0 -2
- package/web/assets/mermaid-GHXKKRXX-CEMduc-U.js +0 -1
- package/web/index.html +0 -28
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# webterm
|
|
2
|
+
|
|
3
|
+
A browser front-end for the interactive AI agents (`claude`, `opencode`, `codex`) running inside a chamba container.
|
|
4
|
+
One server runs all three, one agent per session, so a single bar can hold a claude tab and a codex tab at once.
|
|
5
|
+
|
|
6
|
+
It relays the real agent TUI over a PTY (not the Agent SDK, not headless mode), so it uses your subscription through the container's config dir (e.g. `~/.claude`) - no API key - and stays inside the chamba sandbox.
|
|
7
|
+
On top of the terminal it adds a rich composer so you can paste images, drop or upload attachments, and dictate - things the container terminal alone cannot do.
|
|
8
|
+
|
|
9
|
+
## How it works
|
|
10
|
+
|
|
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.
|
|
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
|
+
- 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
|
+
- The page (`public/`) lists every live session as a tab, renders the attached one's TUI with xterm.js, and forwards keystrokes.
|
|
16
|
+
- The page is plain ES modules under `public/app/`, one per subject (the bar, the composer, the socket and curtain, the frame router, the clipboard, and so on), loaded through `app/main.js`. No build step: what the browser runs is what is in the file. `main.js` lists the modules and holds the boot order, and it is the only place that calls into a module while the page is starting - the graph has cycles, so nothing else may.
|
|
17
|
+
- The composer sends its message (text plus any image paths) as one bracketed paste, so a multi-line message is submitted once.
|
|
18
|
+
- Tab order is registry state (`reorder`), so a drag is a frame and the new bar comes back as an ordinary broadcast - a locally sorted bar would be undone by the next one. The bar holds still while a drag is in flight, because rebuilding it would replace the element being dragged and cancel the drag.
|
|
19
|
+
- Composer drafts are the client's, kept per session id in `sessionStorage` and swapped on every attach. The text, the `[Image #N]` tokens and the counter behind them move together: a token only means something next to the map that expands it, so one shared map would let `[Image #1]` in one session resolve to another session's file.
|
|
20
|
+
- Pasted/dropped/uploaded images are POSTed to the server, saved under `/tmp/uploads/`, and their in-container paths are injected into the message. The agent reads the images from those paths.
|
|
21
|
+
- When a session's agent is `claude`, a short note (`context/claude.md`) is appended to its system prompt via `--append-system-prompt-file`, so it knows it is reached through the browser rather than a terminal. Decided per spawn, since the next session may be a different agent. Only claude has this per-launch hook; the shared managed `CLAUDE.md` (which the terminal reads too) is untouched. `WEBTERM_CONTEXT_FILE` overrides the file.
|
|
22
|
+
- The page owns the clipboard, because a TUI can own the mouse. claude turns on mouse tracking, so xterm hands drags to the agent and has no selection of its own; the agent then pushes the selected text out with **OSC 52**, which the page decodes (base64 to UTF-8, not bare `atob`) and writes to the clipboard. A clipboard *read* request (`OSC 52` with `?`) is never answered, so nothing in the container can pull the host clipboard out through the relay. Where the terminal does own the selection, `Cmd+C` / `Ctrl+Shift+C` and the terminal's right-click menu copy it; `Ctrl+C` is always an interrupt.
|
|
23
|
+
- There is one clipboard - the machine's - and only a copy made in the window in front may write it. A replayed screen is the session's own past output, so it still contains the OSC 52 of any copy made in that session earlier: replays go through `writeReplay`, which makes the OSC 52 handler ignore them. Without that, every attach (a session switch, a reload, a reconnect after sleep) put that session's stale text back on the clipboard, so each session tab looked like it carried a clipboard of its own. An unfocused window does not write either - its text waits for the copy shortcut rather than replacing what was copied in another app.
|
|
24
|
+
- When this window cannot do anything at all, one curtain covers the whole page and says why - the container is gone, this URL's key is spent, or the container is being stopped. Stopping is the one that is a wait rather than a report, so it shows as one (dots, and no advice about a container that has not gone yet) and the closing socket turns it into "the container is stopped" - nothing else takes the relay down at that moment, and a wait left up for good is indistinguishable from a page that hung. It replaces disabling each control on its own, which is what used to leave a dead page looking alive: tabs that still hovered, and an X that opened an end-session prompt nothing would answer. A dropped socket makes the page inert at once but waits 3s before drawing the curtain, so a sleeping laptop or a wifi blip heals without one appearing.
|
|
25
|
+
- Clicking in the session bar blurs the terminal (a tab is a plain div; `+ New session` is a button), and a blurred terminal receives neither keystrokes nor `Cmd+V`, so a tab click and every attach hand focus back to it. The composer, a rename editor and an open card keep focus.
|
|
26
|
+
|
|
27
|
+
## Sessions
|
|
28
|
+
|
|
29
|
+
The page is mission control for the container: the tab bar at the top is every agent session running in it, however long ago you started it.
|
|
30
|
+
|
|
31
|
+
- **Nothing is ended for you.** A closed browser, dropped wifi and a slept laptop are indistinguishable from the server, so none of them ends a session. Close the lid on an unanswered question, open it a day later, and the question is still there. A session ends only when you close it, when the agent exits on its own, or when the container stops.
|
|
32
|
+
- **A session is picked up, not recreated.** Reconnecting replays the session's screen and carries on. Open the same URL in another browser and every session is listed there too.
|
|
33
|
+
- **Closing the session you are in lands you on its neighbour** - the tab to its right, else the one to its left - rather than on a dead screen. Closing a session you were not in leaves you where you are. When every session that is left is open in another window, nothing is taken from it: this window says so instead of leaving the closed session's screen up looking live.
|
|
34
|
+
- **A tab is named after what it runs** - `claude 3`, `codex 4` - so a bar with two agents in it reads without a legend. **Rename** by double-clicking the name, so a long-lived conversation reads by what it is about instead; the name shows in every window and clearing it brings the default back. Names last as long as the session; nothing is saved to disk.
|
|
35
|
+
- **Drag a tab to reorder the bar.** The order is the registry's, not the window's, so it moves in every window at once, and the number in `claude 3` stays with the session rather than with the position. Mouse and trackpad only - this is the browser's own drag and drop, which touch does not fire.
|
|
36
|
+
- **The composer belongs to the session you are in.** A half-written message stays with its conversation: switch tabs and the box holds the next session's draft, switch back and yours is as you left it, image attachments included. Ending a session throws its draft away with it. Drafts are per browser window - they survive a switch, a reload and a sleep, but they do not follow a session into another window, and nothing is saved to disk.
|
|
37
|
+
- **Shift+Enter is a newline in both boxes.** It always was in the composer; in the terminal above it, Shift+Enter now sends the same ESC-then-Return that Alt+Enter does, which is what the agents read as "a newline, not send". A terminal has no Shift+Enter of its own - Enter is a carriage return whatever else is held - so this is a second key onto a sequence the agent already understands. Alt+Enter still works.
|
|
38
|
+
- **Up recalls what you already sent.** From an empty composer, Up brings back the last message sent to that session and keeps stepping back; Down comes forward, and past the newest is the empty box you started from. Once a message is showing the arrows only step on from its first and last line, so they still move the caret around a long one, and typing anything ends the walk. What is stored is what the agent actually received, image tokens already expanded, so sending a recalled message again means the same thing. History is per session and per browser window, it holds what went through the composer rather than what was typed straight into the terminal, and it goes when the session does.
|
|
39
|
+
- **One window drives a session at a time**, because a PTY has one size and two drivers would fight over it. Opening a session another window is watching offers to switch it to this one; the window that loses it can take it back.
|
|
40
|
+
- **Opening the URL always lands you somewhere:** the session you were last looking at, or - when nothing is running - one freshly started session, which is what resumes the most recent conversation.
|
|
41
|
+
Reconnecting is not the same as opening, and starts nothing: a wifi blip or a slept laptop puts you back on exactly what you left, an empty bar included.
|
|
42
|
+
- **A session starts where you did, and stays there.** `+ New session` opens the agent in the directory `npx chamba` ran in, the same directory a terminal session lands in - so `npx chamba` inside `apps/api` gets you an agent working on `apps/api`.
|
|
43
|
+
The directory belongs to the session for its whole life, so several sessions can work in different parts of the workspace at once.
|
|
44
|
+
A path has to name a directory that exists inside the workspace, or nothing is started - the container is the sandbox boundary either way, this is what keeps the picker honest.
|
|
45
|
+
- **And with the agent you asked for**, because one server runs every agent it knows, one per session: claude and codex can be two tabs of the same bar, each in its own directory.
|
|
46
|
+
Which agent and which directory are the only two things that make a session, and neither can be changed later, so the chevron beside `+ New session` asks both in one panel - anchored under the button, since it opens on the way to something routine.
|
|
47
|
+
Choosing there starts that one session and nothing else: the default stays where the container put it, and `webterm <agent>` in the container is what moves it.
|
|
48
|
+
A name that is not one of the agents starts nothing: the browser names it, and it ends up as a process.
|
|
49
|
+
- **Up to 8 sessions** (`WEBTERM_MAX_SESSIONS`). The limit is memory: each one is a full agent process.
|
|
50
|
+
- **Call it a day with the power button** at the right of the bar: it stops the container, and with it every session in it - browser and terminal alike. It always asks first, naming what ends. The container stops itself by signalling PID 1, which only works because chamba gives the keep-alive a TERM trap; a container created before that says so instead of hanging, and points at the host.
|
|
51
|
+
|
|
52
|
+
## What the tabs are telling you
|
|
53
|
+
|
|
54
|
+
Whether an agent is working is read off the rhythm of its output - a sustained stretch means working, going quiet means it stopped - so it is the same for every agent and does not depend on reading anyone's TUI.
|
|
55
|
+
Going quiet is not the end of a turn on its own, though: an agent pauses mid-turn (a slow first token, a tool that prints nothing while it runs) and carries straight on.
|
|
56
|
+
So the light goes out with the output, but the stop has to hold for a few seconds before anything says the agent finished - and work that resumes is never reported as finished at all.
|
|
57
|
+
That is why a session showing as working never also shows as waiting for you.
|
|
58
|
+
|
|
59
|
+
- **A light travels round a tab** while that session's agent is working.
|
|
60
|
+
- **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.
|
|
61
|
+
- **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.
|
|
62
|
+
- **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.
|
|
63
|
+
|
|
64
|
+
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.
|
|
65
|
+
Focus was the first answer and the wrong one: it varies by browser and platform, it has to be re-reported on every reconnect, and every hole in it fails towards silence, which is the one direction a notification must not fail in.
|
|
66
|
+
Nothing before the ending counts, the prompt that started the turn included - sitting and watching a reply arrive is not using the session, and counting the prompt is what once made a short turn silent for good.
|
|
67
|
+
What it costs is the user who reads a long reply without touching anything: they get one chime for something they were looking at. A redundant sound you can mute is the cheaper mistake.
|
|
68
|
+
|
|
69
|
+
`GET /dirs?k=<key>` answers with the default directory and the ones the picker suggests: `{"default":"apps/api","dirs":["apps","apps/api",...],"truncated":false}`.
|
|
70
|
+
The walk is bounded (3 levels deep, 400 entries, no dot directories and no `node_modules`), and says when the list is partial - anything deeper can still be typed in.
|
|
71
|
+
|
|
72
|
+
`POST /cwd?k=<key>` with `{"cwd":"/workspace/apps/api"}` moves where new sessions start.
|
|
73
|
+
chamba calls it at every session start that finds the interface already running: the launcher only passes the directory once per container start, so without this the browser would keep opening sessions in the directory of whichever session first started the container.
|
|
74
|
+
Existing sessions are untouched - a session's directory never changes under it.
|
|
75
|
+
|
|
76
|
+
`POST /agent?k=<key>` with `{"agent":"codex"}` moves which agent new sessions start with, and nothing else - live sessions keep the agent they were started with.
|
|
77
|
+
`webterm <agent>` in the container calls it when it finds the interface already running, which is what replaced killing the server to change agents.
|
|
78
|
+
|
|
79
|
+
`GET /status?k=<key>` reports the agent new sessions get, what is actually running, how many sessions are alive, and how many of them a browser is watching: `{"agent":"claude","agents":{"claude":2},"sessions":2,"attached":1}`.
|
|
80
|
+
chamba asks it when the last terminal session closes, so `exit` in the terminal warns before it stops a container with live browser sessions in it.
|
|
81
|
+
A window whose socket dropped asks it too: an answer means the relay is fine, a `403` means this window's key is spent, and no answer at all means the container is gone - three different things to say, and this is what tells them apart.
|
|
82
|
+
|
|
83
|
+
## Run
|
|
84
|
+
|
|
85
|
+
webterm is baked into the chamba image with its dependencies preinstalled, and there is nothing to turn on.
|
|
86
|
+
|
|
87
|
+
1. Open a session with `npx chamba`.
|
|
88
|
+
Every workspace gets its own sticky host port from the configured range (default `3900-3999`), stored host-side and never in `chamba.yaml`.
|
|
89
|
+
A port that cannot be used moves the workspace to the next free one in the range, and it stays there.
|
|
90
|
+
2. chamba starts the server on every container start and opens the URL in your browser, e.g. `http://127.0.0.1:3900/?k=9f2c41ae8b...`.
|
|
91
|
+
On a host that cannot open a browser, the same URL is printed by chamba and again in the container greeting.
|
|
92
|
+
The key on the end is what the relay checks, and it changes every time the server starts.
|
|
93
|
+
3. In the container, `webterm <agent>` (`claude`, `opencode`, or `codex`) moves which agent new browser sessions start with.
|
|
94
|
+
The agent is always explicit; `webterm` on its own prints usage and the URL, and never picks one for you.
|
|
95
|
+
The choice is written to `~/.chamba-webterm/agent`, a directory chamba bind-mounts from the workspace cache, and the host reads it back at the next container start - so it holds across restarts.
|
|
96
|
+
|
|
97
|
+
The first session after a container start resumes the most recent conversation; later sessions start fresh.
|
|
98
|
+
Only a session running that same agent may claim the resume - the marker holds a command for it, and any other agent would consume the conversation without being able to open it.
|
|
99
|
+
|
|
100
|
+
Running `webterm <other-agent>` while the server is up starts nothing and ends nothing: it moves what `+ New session` will start, and prints the URL.
|
|
101
|
+
Sessions already open keep running the agent they were started with.
|
|
102
|
+
|
|
103
|
+
## The web pane
|
|
104
|
+
|
|
105
|
+
Beside the terminal, each session has a pane of pages the agent published - a plan, a table, a diagram, a question with more structure than a terminal prompt can hold.
|
|
106
|
+
|
|
107
|
+
- **Publishing.** `webpane <file.html> [--title "..."]` inside a session, from an agent or by hand. Anything in the session's process tree may publish: the helper sends only its own pid, and the server walks up the process tree to a session it started, so no caller ever names a directory.
|
|
108
|
+
- **The files.** `~/.webpane/<conversation-id>/<NN>-<slug>.html`, bind-mounted from the host workspace cache. A page is a plain standalone HTML file, and a file copied into the directory by hand shows up in the pane within a second or two, exactly like a published one.
|
|
109
|
+
- **Per conversation, not per session.** The directory is named after the agent's own conversation id, so resuming a conversation - after a container restart, or by hand with `claude --resume <id>` - opens with its pages again. A session whose agent has not written its id down yet publishes into a `pending-...` directory, which is renamed the moment the id appears. Two live sessions never share a directory: a conversation already open elsewhere is refused, and the second session gets its own empty pane with a notice.
|
|
110
|
+
- **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.
|
|
111
|
+
- **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 never takes the screen - its chip gets a `new` badge and waits for a click - except in a pane that is still empty, where the first page opens on arrival - which is also what opens a pane nobody has touched yet, 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. Drag the divider to set the width. 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 and the collapse belong to the window and survive switching tabs; the pages, the selection and the badges belong to the session and switch with it.
|
|
112
|
+
- **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. claude also gets a `web-pane` skill whose description is the phrases a user actually says ("show me", "as a page", "in the pane", "publish"), and whose body is the craft: a standalone dark page, inline everything, how to ask with a form, and what a submitted form does and does not prove. The session greeting names the pane too, so a user who has never heard of it learns it exists in the first sentence.
|
|
113
|
+
- **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.
|
|
114
|
+
|
|
115
|
+
## The status strip
|
|
116
|
+
|
|
117
|
+
Between the terminal and the composer, a claude session shows its own numbers, live: the model and its effort, the context it is holding against the window it fits in, what is left of the five-hour quota with the time until it recharges, and the installed Claude Code version.
|
|
118
|
+
|
|
119
|
+
- **Where the numbers come from.** The status line script chamba installs (`claude-statusline.sh`, baked into the image) prints nothing at all - in a browser a status line would scroll away with the output and cost a terminal row every prompt. What it does instead is write `~/.claude/context-usage/<session-id>.json` on every prompt render, and the strip is drawn from that file. The same file is what the `context-usage` helper reads, so an agent asking about its own context and the strip above it always agree.
|
|
120
|
+
- **Whose numbers they are.** A snapshot records the pid of the claude process that wrote it, and the server matches it to a session by walking up the process tree from that pid to the session's PTY leader - not by assuming the two are equal, since a wrapper or a shell may sit between them. The start time recorded beside the pid is the tiebreak: the snapshot directory is a chamba mount, so it holds files written by containers that are gone, and their pids can be live again as something else.
|
|
121
|
+
- **Only claude has one.** The snapshot is a Claude Code side effect, so a codex or opencode tab has no strip - the server sends no frame for one rather than an empty one. A claude session that has not rendered a prompt yet has no strip either, and gets one within a second or two of its first.
|
|
122
|
+
- **Live, and only when it moved.** The file is polled (`WEBTERM_STATUS_SCAN_MS`, 2s) rather than watched - it is written by a shell script into a bind mount - and a frame goes out only when something the strip shows actually changed. It is pushed to the one window watching that session, never broadcast.
|
|
123
|
+
|
|
124
|
+
## Security
|
|
125
|
+
|
|
126
|
+
- 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.
|
|
127
|
+
That container port is reserved: a `chamba.yaml` entry publishing it is rejected, so nothing else can front the web URL.
|
|
128
|
+
- 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.
|
|
129
|
+
- **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.
|
|
130
|
+
- 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.
|
|
131
|
+
- Comparison is constant time, so a caller cannot learn the key one character at a time from how long a refusal takes.
|
|
132
|
+
- 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.
|
|
133
|
+
- 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.
|
|
134
|
+
- Uploads are images only, size-capped, and written only under `/tmp/uploads/`.
|
|
135
|
+
- **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.
|
|
136
|
+
- **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.
|
|
137
|
+
- **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.
|
|
138
|
+
- **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.
|
|
139
|
+
- **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.
|
|
140
|
+
- **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.
|
|
141
|
+
- **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.
|
|
142
|
+
- **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.
|
|
143
|
+
- Runs as the non-root `devuser`; the agent inherits the same sandbox and auth it has in the terminal.
|
|
144
|
+
|
|
145
|
+
## Cleanup
|
|
146
|
+
|
|
147
|
+
`/tmp/uploads/` is swept on startup and once a week: files older than 7 days are deleted, and only files strictly inside `/tmp/uploads/` are ever touched.
|
|
148
|
+
|
|
149
|
+
## Config
|
|
150
|
+
|
|
151
|
+
`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 marker, 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).
|
|
152
|
+
Env overrides: `WEBTERM_PORT`, `WEBTERM_CWD`, `WEBTERM_AGENT`, `WEBTERM_AGENT_ARGS`, `WEBTERM_KEY`, `WEBTERM_KEY_FILE`, `WEBTERM_RESUME_MARKER`, `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`.
|
|
153
|
+
`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.
|
|
154
|
+
`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.
|
|
155
|
+
`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.
|
|
156
|
+
`WEBTERM_AGENT_ARGS` belongs to `WEBTERM_AGENT` alone - it comes from the same launcher run - so any other agent is spawned bare.
|
|
157
|
+
The `webterm` launcher on PATH sets these from the container's chamba-injected environment, and falls back to its own `$PWD` for the session directory - so a hand-run `webterm claude` starts sessions where that shell is.
|