chamba 0.5.1 → 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.
Files changed (42) hide show
  1. package/dist/commands/dev.js +11 -11
  2. package/dist/commands/onboard.js +2 -1
  3. package/dist/commands/settings.js +1 -1
  4. package/dist/lib/agent-context.js +10 -3
  5. package/dist/lib/chamba-yaml.js +1 -1
  6. package/dist/lib/constants.js +4 -4
  7. package/dist/lib/git-env.js +21 -0
  8. package/dist/lib/ports.js +4 -4
  9. package/dist/lib/shadows.js +2 -0
  10. package/dist/lib/webterm.js +7 -7
  11. package/package.json +1 -3
  12. package/templates/claude-statusline.sh +7 -4
  13. package/templates/context/web-pane-craft.md +57 -0
  14. package/templates/context-usage.sh +30 -13
  15. package/templates/skills/chamba-statusline/SKILL.md +16 -6
  16. package/templates/skills/web-pane/SKILL.md +1 -55
  17. package/templates/webterm/README.md +16 -13
  18. package/templates/webterm/artifacts.js +25 -12
  19. package/templates/webterm/config.js +9 -9
  20. package/templates/webterm/conversation.js +3 -3
  21. package/templates/webterm/pane.js +23 -4
  22. package/templates/webterm/proc.js +1 -1
  23. package/templates/webterm/public/app/alerts.js +5 -5
  24. package/templates/webterm/public/app/composer.js +25 -4
  25. package/templates/webterm/public/app/connection.js +2 -2
  26. package/templates/webterm/public/app/dictation.js +1 -1
  27. package/templates/webterm/public/app/dom.js +4 -0
  28. package/templates/webterm/public/app/frames.js +4 -2
  29. package/templates/webterm/public/app/main.js +6 -2
  30. package/templates/webterm/public/app/new-session.js +1 -1
  31. package/templates/webterm/public/app/pane-arrival.js +36 -0
  32. package/templates/webterm/public/app/pane.js +98 -13
  33. package/templates/webterm/public/app/state.js +1 -1
  34. package/templates/webterm/public/app/status-strip.js +26 -34
  35. package/templates/webterm/public/app/strip-format.js +36 -0
  36. package/templates/webterm/public/app/tabs.js +67 -5
  37. package/templates/webterm/public/index.html +23 -3
  38. package/templates/webterm/public/styles.css +159 -14
  39. package/templates/webterm/server.js +39 -13
  40. package/templates/webterm/sessions.js +7 -7
  41. package/templates/webterm/snapshot.js +6 -4
  42. package/templates/skills/context-usage/SKILL.md +0 -53
@@ -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.
@@ -34,6 +34,7 @@ The page is mission control for the container: the tab bar at the top is every a
34
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
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
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
+ - **One button asks for a page.** The fourth button in the composer, above Send, sends `Put your last answer in the web pane as a page.` in one click - the one message that would otherwise be typed again every day. It goes through the same paste path as anything typed, so the agent cannot tell the two apart, and it does nothing else: a half-written message stays in the box, the draft is kept, the caret does not move, and the sentence is not added to the Up-arrow history, which is what *you* typed. It is in every session whatever agent it runs, and it is unavailable exactly when Send is - with no connection, or with no session attached.
37
38
  - **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
39
  - **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
40
  - **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.
@@ -58,7 +59,8 @@ That is why a session showing as working never also shows as waiting for you.
58
59
 
59
60
  - **A light travels round a tab** while that session's agent is working.
60
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.
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
+ - **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 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.
62
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.
63
65
 
64
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.
@@ -111,13 +113,14 @@ Beside the terminal, each session has a pane of pages the agent published - a pl
111
113
  - **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.
112
114
  - **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.
113
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.
114
- - **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.
115
- - **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.
116
- - **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.
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
+ - **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 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.
117
119
 
118
120
  ## The status strip
119
121
 
120
- 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.
122
+ 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 each rate-limit window - the five-hour one and the seven-day one, tagged `5h` and `7d` - with the time until each recharges, and the installed Claude Code version.
123
+ A window the account does not have takes its whole meter off the strip rather than showing an empty one.
121
124
 
122
125
  - **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.
123
126
  - **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.
@@ -129,19 +132,19 @@ Between the terminal and the composer, a claude session shows its own numbers, l
129
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.
130
133
  That container port is reserved: a `chamba.yaml` entry publishing it is rejected, so nothing else can front the web URL.
131
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.
132
- - **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.
133
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.
134
137
  - Comparison is constant time, so a caller cannot learn the key one character at a time from how long a refusal takes.
135
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.
136
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.
137
- - 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/`.
138
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.
139
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.
140
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.
141
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.
142
- - **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.
143
- - **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.
144
- - **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.
145
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.
146
149
  - Runs as the non-root `devuser`; the agent inherits the same sandbox and auth it has in the terminal.
147
150
 
@@ -151,9 +154,9 @@ Between the terminal and the composer, a claude session shows its own numbers, l
151
154
 
152
155
  ## Config
153
156
 
154
- `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).
155
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`.
156
- `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.
157
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.
158
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.
159
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";
@@ -332,6 +332,19 @@ export function createPaneStore({ root, maxPageBytes, maxTotalBytes, maxTotalFil
332
332
  return changed;
333
333
  }
334
334
 
335
+ /**
336
+ * How many of a key's pages nobody has opened. Answered from the last scan rather than from the directory:
337
+ * this rides on the session bar, which is broadcast to every window far more often than a pane changes,
338
+ * and reading the head of every page file to answer it would be paying a listing's price for a number.
339
+ */
340
+ function unreadCount(key) {
341
+ let count = 0;
342
+ for (const entry of scanned.get(key)?.values() ?? []) {
343
+ if (entry.unread) count += 1;
344
+ }
345
+ return count;
346
+ }
347
+
335
348
  /** The window opened this page, so it is no longer new. True when that actually changed something. */
336
349
  function markRead(key, id) {
337
350
  const entry = scanned.get(key)?.get(id);
@@ -346,7 +359,7 @@ export function createPaneStore({ root, maxPageBytes, maxTotalBytes, maxTotalFil
346
359
  * - "missing": there is no such page, or it is not a regular file - a directory, a device, a symlink.
347
360
  * Symlinks are refused rather than followed, which is what stops a link inside the pane from serving
348
361
  * the key file or anything else in the container.
349
- * - "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
350
363
  * reading an arbitrarily large file into memory to answer a request is not something to allow.
351
364
  */
352
365
  function read(key, id) {
@@ -379,7 +392,7 @@ export function createPaneStore({ root, maxPageBytes, maxTotalBytes, maxTotalFil
379
392
  * the file); the number in front of the name counts up per conversation, so the pane reads in the order
380
393
  * the pages were published however they are sorted elsewhere.
381
394
  *
382
- * 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
383
396
  * root has reached its total - nothing is deleted to make room, because a page the user has not read yet
384
397
  * is not the server's to throw away - and "write" when the filesystem said no.
385
398
  */
@@ -438,7 +451,7 @@ export function createPaneStore({ root, maxPageBytes, maxTotalBytes, maxTotalFil
438
451
  };
439
452
  const contents = `${JSON.stringify(body, null, 2)}\n`;
440
453
  // A form is submitted by whatever is inside the page - a person clicking, or a script in it - so an
441
- // 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
442
455
  // one that matters: answers are small, so bytes alone would allow a very large number of them, and
443
456
  // every one of them also types a line into the agent's terminal.
444
457
  if (feedbackFiles(key, stem).length + 1 > maxFeedbackPerPage) return { ok: false, error: "toomany" };
@@ -559,7 +572,7 @@ export function createPaneStore({ root, maxPageBytes, maxTotalBytes, maxTotalFil
559
572
  }
560
573
  }
561
574
 
562
- return { adopt, ensureDir, keyDir, list, markRead, publish, read, refresh, totalBytes, writeFeedback };
575
+ return { adopt, ensureDir, keyDir, list, markRead, publish, read, refresh, totalBytes, unreadCount, writeFeedback };
563
576
  }
564
577
 
565
578
  /**
@@ -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;
@@ -127,6 +138,14 @@ export function createPane({ store, stores, sessions, onChange }) {
127
138
  return { pages: store.list(keyFor(sid)), notice: noticeFor(sid) };
128
139
  }
129
140
 
141
+ /**
142
+ * How many pages of this session's pane nobody has opened. This is what a window hears about a session it
143
+ * is not driving: the pages themselves only ever reach the window that session belongs to.
144
+ */
145
+ function unreadCount(sid) {
146
+ return store.unreadCount(keyFor(sid));
147
+ }
148
+
130
149
  /** Publish into this session's pane. The page is new, so whoever is watching hears about it at once. */
131
150
  function publish(sid, page) {
132
151
  const result = store.publish(keyFor(sid), page);
@@ -152,5 +171,5 @@ export function createPane({ store, stores, sessions, onChange }) {
152
171
  return bySession.has(sid);
153
172
  }
154
173
 
155
- return { discover, feedback, keyFor, knows, noticeFor, pages, publish, read, sweep };
174
+ return { discover, feedback, keyFor, knows, noticeFor, pages, publish, read, sweep, unreadCount };
156
175
  }
@@ -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();
@@ -4,7 +4,7 @@
4
4
  // frame the server wraps in bracketed-paste markers, so a multi-line message arrives as one message.
5
5
 
6
6
  import { connected, everConnected, sendFrame } from "./connection.js";
7
- import { attachBtn, fileInput, input, sendBtn } from "./dom.js";
7
+ import { askPageBtn, attachBtn, fileInput, input, sendBtn } from "./dom.js";
8
8
  import {
9
9
  addImageToDraft,
10
10
  atFirstLine,
@@ -33,6 +33,10 @@ export function refreshComposer() {
33
33
  input.classList.toggle("online", live);
34
34
  input.classList.toggle("waiting", !live);
35
35
  input.disabled = !live;
36
+ // Send is harmlessly inert with nowhere to go, because it reads an empty box - but the pane button sends a
37
+ // sentence of its own, so it would fire into a dead session. One rule, on both, rather than one each.
38
+ sendBtn.disabled = !live;
39
+ askPageBtn.disabled = !live;
36
40
  if (!connected) input.placeholder = everConnected ? "Reconnecting... your session kept running." : "Connecting...";
37
41
  else if (live) input.placeholder = INPUT_PLACEHOLDER;
38
42
  else if (sessions.length === 0) input.placeholder = `No sessions. Use "+ New session" above to start ${defaultAgent}.`;
@@ -41,9 +45,13 @@ export function refreshComposer() {
41
45
 
42
46
  export function autoGrow() {
43
47
  input.style.height = "auto";
44
- // Floor at the default height (112px, enough for the stacked buttons) so the box never shrinks
45
- // behind them; grow with content up to a cap.
46
- input.style.height = `${Math.min(Math.max(input.scrollHeight, 112), 200)}px`;
48
+ // The floor keeps the box off the button block behind it and the ceiling limits it to seven lines. Both are
49
+ // read from the box itself rather than written here as well: the same two numbers in two files is how the
50
+ // box comes to spring back to a height the stylesheet no longer says.
51
+ const box = getComputedStyle(input);
52
+ const floor = Number.parseFloat(box.minHeight) || 0;
53
+ const ceiling = Number.parseFloat(box.maxHeight) || Number.POSITIVE_INFINITY;
54
+ input.style.height = `${Math.min(Math.max(input.scrollHeight, floor), ceiling)}px`;
47
55
  }
48
56
  input.addEventListener("input", () => {
49
57
  autoGrow();
@@ -148,6 +156,19 @@ function send() {
148
156
 
149
157
  sendBtn.addEventListener("click", send);
150
158
 
159
+ // The one message nobody should have to type twice. It goes through the same paste path as a typed one, so the
160
+ // agent cannot tell the two apart - and that is all it does: the box keeps whatever is half-written in it, the
161
+ // draft is not dropped, the Up-arrow history is what you typed and does not gain this, and the terminal is not
162
+ // given the focus. Preventing the default on mousedown is what keeps the caret where it was: a button takes the
163
+ // focus when it is clicked, and this one has no use for it.
164
+ const PANE_REQUEST = "Put your last answer in the web pane as a page.";
165
+
166
+ askPageBtn.addEventListener("mousedown", (e) => e.preventDefault());
167
+ askPageBtn.addEventListener("click", () => {
168
+ if (!connected || !attachedSid) return;
169
+ sendFrame({ t: "paste", data: PANE_REQUEST });
170
+ });
171
+
151
172
  // Enter sends; Shift+Enter inserts a newline. Ctrl+C clears the composer (when nothing is selected,
152
173
  // so a real copy still works), mirroring claude's "Ctrl+C clears the input line" behavior.
153
174
  // Up and Down walk back through what this session was already sent - see drafts.js for when they
@@ -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) {
@@ -13,6 +13,7 @@ export const curtainCard = document.getElementById("curtain-card");
13
13
  export const input = document.getElementById("input");
14
14
  export const sendBtn = document.getElementById("send");
15
15
  export const attachBtn = document.getElementById("attach");
16
+ export const askPageBtn = document.getElementById("ask-page");
16
17
  export const fileInput = document.getElementById("file-input");
17
18
  export const note = document.getElementById("note");
18
19
 
@@ -25,6 +26,9 @@ export const panePrev = document.getElementById("pane-prev");
25
26
  export const paneNext = document.getElementById("pane-next");
26
27
  export const chips = document.getElementById("chips");
27
28
  export const pageCount = document.getElementById("page-count");
29
+ export const pageSmaller = document.getElementById("page-smaller");
30
+ export const pageBigger = document.getElementById("page-bigger");
31
+ export const pageSizeVal = document.getElementById("page-size-val");
28
32
  export const paneCollapse = document.getElementById("pane-collapse");
29
33
  export const pageDoc = document.getElementById("page-doc");
30
34
  export const spine = document.getElementById("spine");
@@ -15,7 +15,7 @@ import { noteMsg } from "./note.js";
15
15
  import { applyPages, prunePanes, refreshPane } from "./pane.js";
16
16
  import { adoptSessionsFrame, attachedSid, defaultAgent, sessions, setAttachedSid } from "./state.js";
17
17
  import { applySnapshot, pruneStatuses, refreshStrip } from "./status-strip.js";
18
- import { noteArrivals, renderBar } from "./tabs.js";
18
+ import { noteArrivals, notePageArrivals, renderBar } from "./tabs.js";
19
19
  import { focusTerminal, scheduleRefresh, sendResize, term } from "./terminal.js";
20
20
 
21
21
  // What the terminal is holding: either a live session's screen (`shownSid`), or a message where a session used
@@ -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);
@@ -118,6 +118,8 @@ function applySessions(msg) {
118
118
  adoptSessionsFrame(msg);
119
119
  // Before the bar draws: its flash reads these timestamps, so an alert plays once instead of on every frame.
120
120
  noteArrivals();
121
+ // The same, for a page published in a session this window is not looking at.
122
+ notePageArrivals();
121
123
  renderBar();
122
124
  // Said again where a window that is behind something else can still be heard.
123
125
  refreshBrowserTab();