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.
- package/dist/commands/dev.js +11 -11
- package/dist/commands/onboard.js +2 -1
- package/dist/commands/settings.js +1 -1
- package/dist/lib/agent-context.js +10 -3
- package/dist/lib/chamba-yaml.js +1 -1
- package/dist/lib/constants.js +4 -4
- package/dist/lib/git-env.js +21 -0
- package/dist/lib/ports.js +4 -4
- package/dist/lib/shadows.js +2 -0
- package/dist/lib/webterm.js +7 -7
- package/package.json +1 -3
- package/templates/claude-statusline.sh +7 -4
- package/templates/context/web-pane-craft.md +57 -0
- package/templates/context-usage.sh +30 -13
- package/templates/skills/chamba-statusline/SKILL.md +16 -6
- package/templates/skills/web-pane/SKILL.md +1 -55
- package/templates/webterm/README.md +16 -13
- package/templates/webterm/artifacts.js +25 -12
- package/templates/webterm/config.js +9 -9
- package/templates/webterm/conversation.js +3 -3
- package/templates/webterm/pane.js +23 -4
- package/templates/webterm/proc.js +1 -1
- package/templates/webterm/public/app/alerts.js +5 -5
- package/templates/webterm/public/app/composer.js +25 -4
- package/templates/webterm/public/app/connection.js +2 -2
- package/templates/webterm/public/app/dictation.js +1 -1
- package/templates/webterm/public/app/dom.js +4 -0
- package/templates/webterm/public/app/frames.js +4 -2
- package/templates/webterm/public/app/main.js +6 -2
- package/templates/webterm/public/app/new-session.js +1 -1
- package/templates/webterm/public/app/pane-arrival.js +36 -0
- package/templates/webterm/public/app/pane.js +98 -13
- package/templates/webterm/public/app/state.js +1 -1
- package/templates/webterm/public/app/status-strip.js +26 -34
- package/templates/webterm/public/app/strip-format.js +36 -0
- package/templates/webterm/public/app/tabs.js +67 -5
- package/templates/webterm/public/index.html +23 -3
- package/templates/webterm/public/styles.css +159 -14
- package/templates/webterm/server.js +39 -13
- package/templates/webterm/sessions.js +7 -7
- package/templates/webterm/snapshot.js +6 -4
- 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
|
|
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
|
-
- **
|
|
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
|
|
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.
|
|
116
|
-
- **Nothing is deleted.** No age
|
|
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
|
|
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
|
|
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-
|
|
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
|
|
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
|
|
144
|
-
- **Feedback is bounded and bound.** The body has a size
|
|
145
|
+
- **A page bigger than a page is never read whole.** Anything can write into the pane directory, so a file there is not held to the publish limit. Listing a page reads only the first few kilobytes, looking for its title, and serving one is refused outright past the per-page limit - one huge file cannot stall the interface for every session in the container. The conversation-id discovery reads its stores the same way.
|
|
146
|
+
- **Pages are read without leaving the pane directory.** Keys and filenames are checked against a narrow pattern before they become a path, symlinks and anything that is not a regular file are refused rather than followed, and the resolved path is checked to still be inside the root. A file over the per-page limit is refused rather than read into memory.
|
|
147
|
+
- **Feedback is bounded and bound.** The body has a size limit, a page accepts one submission per second and only so many in its life (past that the page is refused, and no line is typed into the agent's terminal - answers are small, so a byte limit alone would let a page in a loop drive an agent for days), the answers count against the directory's total and its file count like pages do (past either, they are refused, and nothing is removed), and the directory, the page and the filename all come from the artifact the pane is showing - the request body contributes nothing but the answers, which are themselves limited in count and length. The line typed into the agent's terminal is built only from text the server controls: a title reduced to a single clean line, and a path the server generated.
|
|
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
|
|
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
|
|
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
|
-
//
|
|
13
|
+
// limit is reached; past the limit a publish is refused and what exists stays. And nothing outside the root is
|
|
14
14
|
// ever read: names are checked against a narrow pattern, symlinks are refused rather than followed, and the
|
|
15
15
|
// resolved path is checked to still be inside the root before a byte is read.
|
|
16
16
|
|
|
@@ -33,7 +33,7 @@ import { join } from "node:path";
|
|
|
33
33
|
|
|
34
34
|
// A conversation key is a directory name, so it is checked like one: it may not be "." or "..", may not hold
|
|
35
35
|
// a separator, and must start with something ordinary. Agent conversation ids (uuids, "ses_" ids) pass; the
|
|
36
|
-
// server's own provisional keys are
|
|
36
|
+
// server's own provisional keys are created to match.
|
|
37
37
|
const KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
38
38
|
|
|
39
39
|
// A page is one HTML file directly inside a conversation's directory. Same reasoning as the key: no
|
|
@@ -91,7 +91,7 @@ export function cleanLine(raw, max = MAX_TITLE_LENGTH) {
|
|
|
91
91
|
/**
|
|
92
92
|
* The same cleaning for text that is allowed to be more than one line: a form's free-text box, a long answer
|
|
93
93
|
* typed into a textarea. Line breaks survive, every other control character does not, and the length is
|
|
94
|
-
*
|
|
94
|
+
* limited. Nothing typed into a terminal ever comes from here - this is what gets written to a file.
|
|
95
95
|
*/
|
|
96
96
|
export function cleanText(raw, max) {
|
|
97
97
|
if (typeof raw !== "string") return "";
|
|
@@ -120,7 +120,7 @@ export function slugify(raw) {
|
|
|
120
120
|
* The first `limit` bytes of a file, as text, or "" when it cannot be read.
|
|
121
121
|
*
|
|
122
122
|
* Bounded at the read rather than after it: a page put into the directory by hand is not held to the publish
|
|
123
|
-
*
|
|
123
|
+
* limit, so a listing that read whole files would stall the whole interface on one huge one - and a listing
|
|
124
124
|
* happens on a timer, for every page, in every pane.
|
|
125
125
|
*/
|
|
126
126
|
function headOf(path, limit) {
|
|
@@ -167,7 +167,7 @@ function titleFor(path, id) {
|
|
|
167
167
|
/**
|
|
168
168
|
* The pane store over one artifacts root.
|
|
169
169
|
*
|
|
170
|
-
* `maxPageBytes`
|
|
170
|
+
* `maxPageBytes` limits a single page, `maxTotalBytes` and `maxTotalFiles` the whole root, and
|
|
171
171
|
* `maxFeedbackPerPage` how many answers one page may file. All of them are enforced here rather than in the
|
|
172
172
|
* helper, because the helper is one way in and a file copied by hand is another.
|
|
173
173
|
*/
|
|
@@ -243,7 +243,7 @@ export function createPaneStore({ root, maxPageBytes, maxTotalBytes, maxTotalFil
|
|
|
243
243
|
|
|
244
244
|
/**
|
|
245
245
|
* What the whole root holds, pages and feedback together: bytes and file count. Walked per write, which
|
|
246
|
-
* is affordable only because the file count is
|
|
246
|
+
* is affordable only because the file count is limited too - both numbers come out of this one walk, and
|
|
247
247
|
* the count is what keeps the walk itself small.
|
|
248
248
|
*/
|
|
249
249
|
function totals() {
|
|
@@ -279,12 +279,12 @@ export function createPaneStore({ root, maxPageBytes, maxTotalBytes, maxTotalFil
|
|
|
279
279
|
return { bytes: total, files };
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
-
/** Bytes held by the whole root. The count that goes with it is a
|
|
282
|
+
/** Bytes held by the whole root. The count that goes with it is a limit, not something a caller asks for. */
|
|
283
283
|
function totalBytes() {
|
|
284
284
|
return totals().bytes;
|
|
285
285
|
}
|
|
286
286
|
|
|
287
|
-
/** Room for one more file of this size, or the refusal that says which
|
|
287
|
+
/** Room for one more file of this size, or the refusal that says which limit it met. */
|
|
288
288
|
function roomFor(bytes) {
|
|
289
289
|
const held = totals();
|
|
290
290
|
if (held.bytes + bytes > maxTotalBytes) return "full";
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
60
|
+
// Limits, enforced by the server rather than by the helper: a page is refused above the first, and a publish
|
|
61
61
|
// is refused once the whole directory has reached the second. Nothing is ever deleted to make room - a page
|
|
62
|
-
// the user has not read yet is not the server's to throw away - so reaching the
|
|
62
|
+
// the user has not read yet is not the server's to throw away - so reaching the limit is a message, not a
|
|
63
63
|
// cleanup. A page is a document, so a couple of megabytes is already generous; the total is the backstop.
|
|
64
64
|
export const MAX_PAGE_BYTES = Number(process.env.WEBTERM_MAX_PAGE_BYTES) || 2 * 1024 * 1024;
|
|
65
65
|
export const MAX_PANE_BYTES = Number(process.env.WEBTERM_MAX_PANE_BYTES) || 128 * 1024 * 1024;
|
|
66
66
|
|
|
67
|
-
// And a
|
|
67
|
+
// And a limit on how many files, not only how many bytes. Answers are small, so the byte limit alone would let a
|
|
68
68
|
// page that submits to itself put hundreds of thousands of them in the directory - and every write walks the
|
|
69
69
|
// whole tree to add the bytes up, so that walk is what would slow down. A personal pane holds tens of pages.
|
|
70
70
|
export const MAX_PANE_FILES = Number(process.env.WEBTERM_MAX_PANE_FILES) || 5_000;
|
|
@@ -83,7 +83,7 @@ export const PANE_SCAN_MS = Number(process.env.WEBTERM_PANE_SCAN_MS) || 1_500;
|
|
|
83
83
|
// catches up", and a frame goes out only when something in it actually changed.
|
|
84
84
|
export const STATUS_SCAN_MS = Number(process.env.WEBTERM_STATUS_SCAN_MS) || 2_000;
|
|
85
85
|
|
|
86
|
-
// What a feedback submission may be. The body
|
|
86
|
+
// What a feedback submission may be. The body limit is what the endpoint accepts at all; the rest bound what
|
|
87
87
|
// is written, so a page with a runaway form produces a file an agent can still read.
|
|
88
88
|
export const MAX_FEEDBACK_BYTES = 64 * 1024;
|
|
89
89
|
export const MAX_FEEDBACK_FIELDS = 40;
|
|
@@ -96,7 +96,7 @@ export const FEEDBACK_MIN_INTERVAL_MS = Number(process.env.WEBTERM_FEEDBACK_MIN_
|
|
|
96
96
|
|
|
97
97
|
// How many answers one page may file at all. A person submits a form once, or a few times after changing
|
|
98
98
|
// their mind. Past that it is a script in the page submitting to itself, and each submission also types a
|
|
99
|
-
// line into the agent's terminal - so this, not the byte
|
|
99
|
+
// line into the agent's terminal - so this, not the byte limit, is what bounds how often a page can drive an
|
|
100
100
|
// agent. The gap above only slows that down; this stops it.
|
|
101
101
|
export const MAX_FEEDBACK_PER_PAGE = Number(process.env.WEBTERM_MAX_FEEDBACK_PER_PAGE) || 20;
|
|
102
102
|
|
|
@@ -195,7 +195,7 @@ export const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
|
|
195
195
|
export const UPLOAD_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
196
196
|
export const CHECK_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
197
197
|
|
|
198
|
-
// The key that the URL carries (`/?k=<key>`) and the relay demands. A fresh one is
|
|
198
|
+
// The key that the URL carries (`/?k=<key>`) and the relay demands. A fresh one is created every time
|
|
199
199
|
// this server starts, so a key never outlives the process that issued it and nothing has to store it:
|
|
200
200
|
// whoever prints the URL reads it back from KEY_FILE. WEBTERM_KEY pins it (tests, hand-run debugging).
|
|
201
201
|
// There is deliberately no unauthenticated mode - without the gate the relay would drive an agent for
|
|
@@ -224,7 +224,7 @@ export function isAuthorized(candidate) {
|
|
|
224
224
|
// relay even if it somehow holds a key. We allow any localhost / 127.0.0.1 origin regardless of port,
|
|
225
225
|
// because the published host port can differ from the container port (e.g. 3900:3899) so the browser's
|
|
226
226
|
// Origin varies per workspace. The publish is loopback-only, so "any local port" is the right
|
|
227
|
-
// granularity: it still rejects remote origins like http://
|
|
227
|
+
// granularity: it still rejects remote origins like http://attacker.com.
|
|
228
228
|
export function isAllowedOrigin(origin) {
|
|
229
229
|
if (!origin) return false;
|
|
230
230
|
try {
|
|
@@ -253,9 +253,9 @@ export const SUBMIT_DELAY_MS = Number(process.env.WEBTERM_SUBMIT_DELAY_MS) || 15
|
|
|
253
253
|
export const STOP_ANNOUNCE_MS = 150;
|
|
254
254
|
export const STOP_TIMEOUT_MS = 3_000;
|
|
255
255
|
|
|
256
|
-
//
|
|
256
|
+
// Limit on the replayed session buffer (bytes), per session. A session's PTY outlives every socket, so
|
|
257
257
|
// on attach we replay up to this much recent output and the window lands back in the live conversation.
|
|
258
|
-
// Older output beyond the
|
|
258
|
+
// Older output beyond the limit is dropped from the replay (the agent's own history is unaffected).
|
|
259
259
|
export const MAX_OUTPUT_BUFFER = 1_000_000;
|
|
260
260
|
|
|
261
261
|
// How many sessions (live agent processes) may exist at once. Sessions are never closed for the user,
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// matches it to the session by the two facts the server holds: the process tree and the directory.
|
|
12
12
|
//
|
|
13
13
|
// A miss is normal and cheap. An agent that has not written its first record yet has no id to find, so the
|
|
14
|
-
// answer is null, the pane keeps its provisional directory, and the next
|
|
14
|
+
// answer is null, the pane keeps its provisional directory, and the next pass asks again.
|
|
15
15
|
|
|
16
16
|
import { closeSync, openSync, readdirSync, readFileSync, readSync, statSync } from "node:fs";
|
|
17
17
|
import { basename, join } from "node:path";
|
|
@@ -26,7 +26,7 @@ const HEAD_BYTES = 64 * 1024;
|
|
|
26
26
|
const MAX_CANDIDATES = 40;
|
|
27
27
|
|
|
28
28
|
// The largest a snapshot or a session record can be and still be read whole. The files these stores write
|
|
29
|
-
// are a few hundred bytes; the
|
|
29
|
+
// are a few hundred bytes; the limit is only there so an unrelated file that happens to be in the directory
|
|
30
30
|
// cannot be pulled into memory.
|
|
31
31
|
const MAX_JSON_BYTES = 1024 * 1024;
|
|
32
32
|
|
|
@@ -65,7 +65,7 @@ function walkStore(dir, pattern, depth, onFile) {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
/**
|
|
68
|
-
* Files under `dir` matching `pattern`, newest first,
|
|
68
|
+
* Files under `dir` matching `pattern`, newest first, limited in number.
|
|
69
69
|
* Exported for resume.js, which reads the same stores to answer a different question.
|
|
70
70
|
*/
|
|
71
71
|
export function filesNewestFirst(dir, pattern, depth = MAX_STORE_DEPTH) {
|
|
@@ -22,7 +22,7 @@ import { conversationId } from "./conversation.js";
|
|
|
22
22
|
const PROVISIONAL_PREFIX = "pending-";
|
|
23
23
|
|
|
24
24
|
// How many sessions the key map remembers. Entries outlive their session on purpose: feedback submitted after
|
|
25
|
-
// an agent exited still has to land in the right directory. The
|
|
25
|
+
// an agent exited still has to land in the right directory. The limit only keeps a container that has been up
|
|
26
26
|
// for weeks from growing the map without end.
|
|
27
27
|
const MAX_REMEMBERED = 64;
|
|
28
28
|
|
|
@@ -36,8 +36,10 @@ export function isProvisional(key) {
|
|
|
36
36
|
*
|
|
37
37
|
* - `store` is the artifacts store.
|
|
38
38
|
* - `stores` is where each agent writes its conversation id (config's AGENT_STORES).
|
|
39
|
-
* - `sessions()` returns the live sessions as `{ sid, agent, cwd, pid, since }` - everything
|
|
40
|
-
* needs, handed in rather than reached for, so this file needs no registry and no PTYs to be
|
|
39
|
+
* - `sessions()` returns the live sessions as `{ sid, agent, cwd, pid, since, resumedFrom }` - everything
|
|
40
|
+
* the discovery needs, handed in rather than reached for, so this file needs no registry and no PTYs to be
|
|
41
|
+
* tested. `resumedFrom` is the conversation id the server resumed this session with, or null: the one fact
|
|
42
|
+
* only the server has, because claude gives a resumed conversation a fresh id and records no link back.
|
|
41
43
|
* - `onChange(sid)` is called whenever that session's page list moved, for whatever reason.
|
|
42
44
|
*/
|
|
43
45
|
export function createPane({ store, stores, sessions, onChange }) {
|
|
@@ -100,6 +102,15 @@ export function createPane({ store, stores, sessions, onChange }) {
|
|
|
100
102
|
);
|
|
101
103
|
return true;
|
|
102
104
|
}
|
|
105
|
+
// Claude names a fresh id on every resume, so a resumed conversation arrives here under a new id
|
|
106
|
+
// while its pages still sit under the id it was resumed from. The server witnessed the resume - it
|
|
107
|
+
// built the command - so those pages are carried into the new id first, ahead of the provisional
|
|
108
|
+
// directory, keeping their numbers and their order. An old id another live session still holds stays
|
|
109
|
+
// where it is: those pages are not this session's to take.
|
|
110
|
+
const from = session.resumedFrom;
|
|
111
|
+
if (typeof from === "string" && from !== id && !liveKeys(session.sid).has(from)) {
|
|
112
|
+
store.adopt(from, id);
|
|
113
|
+
}
|
|
103
114
|
if (!store.adopt(entry.key, id)) return false;
|
|
104
115
|
entry.key = id;
|
|
105
116
|
entry.adopted = true;
|
|
@@ -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
|
|
16
|
+
// A process tree in a container is a handful of levels deep. The limit is what keeps a malformed /proc, or a
|
|
17
17
|
// cycle that should not exist, from spinning here.
|
|
18
18
|
const MAX_ANCESTRY_DEPTH = 64;
|
|
19
19
|
|
|
@@ -18,7 +18,7 @@ import { WORKSPACE_COLOR } from "./theme.js";
|
|
|
18
18
|
// state of the container in one mark:
|
|
19
19
|
//
|
|
20
20
|
// nothing - nothing is happening in here
|
|
21
|
-
// white bar across the bottom - an agent is working, with a lit segment
|
|
21
|
+
// white bar across the bottom - an agent is working, with a lit segment moving along it
|
|
22
22
|
// green dot in the top corner - an agent finished and is waiting for you, pulsing until you go and look
|
|
23
23
|
//
|
|
24
24
|
// One mark at a time, and the two are told apart by where they sit before any colour is read: working owns the
|
|
@@ -46,7 +46,7 @@ const ICON_UNITS = 32;
|
|
|
46
46
|
const ICON_TICK_MS = 240;
|
|
47
47
|
// The waiting dot's bright-to-dim step, in ticks. Slow enough to read as a pulse rather than a flicker.
|
|
48
48
|
const PULSE_TICKS = 3;
|
|
49
|
-
// One out-and-back of the working segment, in ticks - about two seconds each way,
|
|
49
|
+
// One out-and-back of the working segment, in ticks - about two seconds each way, a glide and not a dash.
|
|
50
50
|
const SWEEP_TICKS = 16;
|
|
51
51
|
const DONE_COLOR = "#2fe58a";
|
|
52
52
|
const BUSY_COLOR = "#e6edf3";
|
|
@@ -69,7 +69,7 @@ function pulseDim() {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
// Where the working segment is on its track - 0 at one end, 1 at the other, and back down again. A triangle
|
|
72
|
-
// rather than a saw, so the segment
|
|
72
|
+
// rather than a saw, so the segment travels back instead of jumping to the start.
|
|
73
73
|
function sweepAt() {
|
|
74
74
|
const half = SWEEP_TICKS / 2;
|
|
75
75
|
const step = iconFrame % SWEEP_TICKS;
|
|
@@ -132,7 +132,7 @@ function drawIcon(ctx, badgeColor, dim, sweep) {
|
|
|
132
132
|
ctx.fill();
|
|
133
133
|
ctx.stroke();
|
|
134
134
|
|
|
135
|
-
// Working: a bar across the bottom, with a lit segment
|
|
135
|
+
// Working: a bar across the bottom, with a lit segment moving from end to end. The bottom edge is the far
|
|
136
136
|
// side of the icon from the corner waiting owns, which is what tells the two states apart at 16px - a thin
|
|
137
137
|
// line down the right edge, which is what this used to be, reads as a scrollbar rather than as anything the
|
|
138
138
|
// container is doing. The track is 20 wide and the segment 8, so the segment travels the 12 between them.
|
|
@@ -321,7 +321,7 @@ function playChime() {
|
|
|
321
321
|
const ctx = audio();
|
|
322
322
|
// Blocked until this page has been clicked or typed into. Nearly never true here - you click the terminal to type -
|
|
323
323
|
// and the tab, the title and the icon are all still saying it either way.
|
|
324
|
-
if (
|
|
324
|
+
if (ctx?.state !== "running") return;
|
|
325
325
|
const at = ctx.currentTime + 0.02;
|
|
326
326
|
for (const [ratio, level, decay] of CHIME_PARTIALS) {
|
|
327
327
|
const osc = ctx.createOscillator();
|
|
@@ -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
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
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
|
|
26
|
+
// locked - the relay answered and refused this window's key. The interface restarted and created a new
|
|
27
27
|
// one, so this URL is spent; reconnecting is pointless and stops.
|
|
28
28
|
// stopping - the user just stopped the container from here. Same end state as "down", but it is not a
|
|
29
29
|
// failure and must not read like one.
|
|
@@ -181,7 +181,7 @@ export function sendFrame(frame) {
|
|
|
181
181
|
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(frame));
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
-
// Any frame at all proves the socket works, which is what a wake
|
|
184
|
+
// Any frame at all proves the socket works, which is what a wake check is waiting to hear. Called by the frame
|
|
185
185
|
// router, since every frame counts and only it sees them all.
|
|
186
186
|
export function noteSocketAlive() {
|
|
187
187
|
if (probeTimer) {
|
|
@@ -64,7 +64,7 @@ if (!SpeechRecognition) {
|
|
|
64
64
|
|
|
65
65
|
// Prime the mic permission via getUserMedia first: SpeechRecognition on its own often fails
|
|
66
66
|
// straight to "not-allowed" without prompting. In an embedded webview getUserMedia is missing
|
|
67
|
-
// or blocked, so we
|
|
67
|
+
// or blocked, so we show the "use a real browser" hint.
|
|
68
68
|
async function startDictation() {
|
|
69
69
|
try {
|
|
70
70
|
if (!navigator.mediaDevices?.getUserMedia) {
|
|
@@ -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
|
|
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();
|