chamba 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -6
- package/dist/commands/dev.js +11 -11
- package/dist/commands/settings.js +1 -1
- package/dist/lib/agent-context.js +33 -7
- package/dist/lib/chamba-yaml.js +1 -1
- package/dist/lib/constants.js +4 -4
- package/dist/lib/dockerfile-builder.js +2 -1
- package/dist/lib/ports.js +4 -4
- package/dist/lib/safe-rm.js +13 -3
- package/dist/lib/webterm.js +7 -7
- package/package.json +3 -5
- package/templates/Dockerfile +20 -1
- package/templates/context/web-pane-craft.md +1 -1
- package/templates/pane-apps/client/assets/specs-B1970L17.css +1 -0
- package/templates/pane-apps/client/assets/specs-cEee_SPn.js +23 -0
- package/templates/pane-apps/client/specs/index.html +13 -0
- package/templates/pane-apps/server/specs.mjs +1588 -0
- package/templates/skills/chamba-statusline/SKILL.md +1 -1
- package/templates/skills/dx-spec/SKILL.md +365 -0
- package/templates/skills/dx-spec/references/imagination-guide.md +140 -0
- package/templates/skills/dx-spec/references/review-guide.md +173 -0
- package/templates/skills/dx-spec/references/spec-guide.md +125 -0
- package/templates/skills/dx-spec/references/stages.md +399 -0
- package/templates/skills/dx-spec-config/SKILL.md +313 -0
- package/templates/skills/dx-spec-config/references/principles-template.md +12 -0
- package/templates/skills/dx-spec-execute/SKILL.md +324 -0
- package/templates/specs.sh +106 -0
- package/templates/webterm/README.md +50 -14
- package/templates/webterm/artifacts.js +11 -11
- package/templates/webterm/config.js +52 -9
- package/templates/webterm/conversation.js +3 -3
- package/templates/webterm/pane.js +14 -3
- package/templates/webterm/proc.js +1 -1
- package/templates/webterm/public/app/alerts.js +5 -5
- package/templates/webterm/public/app/composer.js +5 -2
- 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 +13 -5
- package/templates/webterm/public/app/frames.js +8 -1
- package/templates/webterm/public/app/main.js +8 -2
- package/templates/webterm/public/app/new-session.js +1 -1
- package/templates/webterm/public/app/pane-shell.js +315 -0
- package/templates/webterm/public/app/pane.js +58 -183
- package/templates/webterm/public/app/specs-host.js +222 -0
- package/templates/webterm/public/app/state.js +1 -1
- package/templates/webterm/public/app/tabs.js +1 -1
- package/templates/webterm/public/app/terminal.js +8 -0
- package/templates/webterm/public/index.html +51 -27
- package/templates/webterm/public/styles.css +144 -30
- package/templates/webterm/server.js +300 -11
- package/templates/webterm/sessions.js +7 -7
- package/templates/webterm/snapshot.js +2 -2
- package/templates/webterm/specs.js +358 -0
- package/templates/webterm/tool-document.js +67 -0
- package/templates/webterm/typed-line.js +85 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# specs - move a piece of work through the Specs tool in this session's web pane.
|
|
3
|
+
# Baked into the image at /usr/local/share/chamba/specs.sh, on PATH as `specs`.
|
|
4
|
+
#
|
|
5
|
+
# The agent's half of the coordination channel. The pane writes a file and types one line into this terminal;
|
|
6
|
+
# these verbs are how the work moves the other way. Everything they change is a file in the spec directory,
|
|
7
|
+
# so every exchange is something git can see.
|
|
8
|
+
#
|
|
9
|
+
# The caller says who it is by its own pid and nothing more, exactly as `webpane` does. The server walks up
|
|
10
|
+
# the process tree from there to a session it started, and refuses a caller that is not in one.
|
|
11
|
+
set -euo pipefail
|
|
12
|
+
|
|
13
|
+
CONTAINER_PORT="${WEBTERM_PORT:-3899}"
|
|
14
|
+
KEY_FILE="${WEBTERM_KEY_FILE:-/tmp/webterm.key}"
|
|
15
|
+
|
|
16
|
+
usage() {
|
|
17
|
+
cat <<'TEXT'
|
|
18
|
+
Usage: specs <verb> [<spec>] < payload.json
|
|
19
|
+
|
|
20
|
+
Verbs:
|
|
21
|
+
stages Where the specs live, and every stage a protocol may hold.
|
|
22
|
+
state <spec> Where the work stands: protocol, activity, what awaits the user.
|
|
23
|
+
intake [<spec>] File a new piece of work. { name, text, attachments }
|
|
24
|
+
propose <spec> Recommend a protocol. { stages: [{ id, recommended, why }], note }
|
|
25
|
+
await <spec> Declare a gate or a round. { id, kind, title, file, payload }
|
|
26
|
+
post <spec> Say what is happening. { kind: activity | stage | phase | status | note, ... }
|
|
27
|
+
review <spec> Post a round of quality review. { round, verdict, findings, judgments }
|
|
28
|
+
answers <spec> Read back what the user answered. { id }
|
|
29
|
+
|
|
30
|
+
A payload is JSON on stdin, where the verb takes one. The answer is JSON on stdout.
|
|
31
|
+
TEXT
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
VERB="${1:-}"
|
|
35
|
+
SPEC="${2:-}"
|
|
36
|
+
|
|
37
|
+
case "$VERB" in
|
|
38
|
+
"" | -h | --help)
|
|
39
|
+
usage
|
|
40
|
+
exit 0
|
|
41
|
+
;;
|
|
42
|
+
esac
|
|
43
|
+
|
|
44
|
+
if [ ! -r "$KEY_FILE" ]; then
|
|
45
|
+
echo "specs: the web interface is not running in this container, so there is no pane to tell." >&2
|
|
46
|
+
exit 1
|
|
47
|
+
fi
|
|
48
|
+
|
|
49
|
+
# A payload only where one was piped in. A verb that takes none is called with nothing on stdin, and waiting
|
|
50
|
+
# for a terminal to close would hang the agent rather than answer it.
|
|
51
|
+
PAYLOAD="null"
|
|
52
|
+
if [ ! -t 0 ]; then
|
|
53
|
+
PIPED="$(cat)"
|
|
54
|
+
if [ -n "$PIPED" ]; then
|
|
55
|
+
PAYLOAD="$PIPED"
|
|
56
|
+
fi
|
|
57
|
+
fi
|
|
58
|
+
|
|
59
|
+
BODY_FILE="$(mktemp)"
|
|
60
|
+
REQUEST_FILE="$(mktemp)"
|
|
61
|
+
trap 'rm -f "$BODY_FILE" "$REQUEST_FILE"' EXIT
|
|
62
|
+
|
|
63
|
+
# The request is composed with jq so the payload stays JSON rather than being spliced into a string. jq is in
|
|
64
|
+
# the image; without it there is no safe way to build this, so the helper says so instead of guessing.
|
|
65
|
+
if ! command -v jq >/dev/null 2>&1; then
|
|
66
|
+
echo "specs: jq is not in this container, and the request cannot be composed without it." >&2
|
|
67
|
+
exit 1
|
|
68
|
+
fi
|
|
69
|
+
|
|
70
|
+
if ! jq -n --arg verb "$VERB" --arg spec "$SPEC" --argjson payload "$PAYLOAD" \
|
|
71
|
+
'{ verb: $verb, spec: $spec, payload: $payload }' >"$REQUEST_FILE" 2>/dev/null; then
|
|
72
|
+
echo "specs: the payload on stdin is not JSON." >&2
|
|
73
|
+
exit 1
|
|
74
|
+
fi
|
|
75
|
+
|
|
76
|
+
# $$ is this script's own pid. The server climbs from it to whichever session started it.
|
|
77
|
+
STATUS="$(
|
|
78
|
+
curl -sS -o "$BODY_FILE" -w '%{http_code}' \
|
|
79
|
+
--connect-timeout 3 --max-time 30 \
|
|
80
|
+
-X POST \
|
|
81
|
+
-H "Content-Type: application/json" \
|
|
82
|
+
-H "X-Webterm-Key: $(head -n 1 "$KEY_FILE")" \
|
|
83
|
+
-H "X-Specs-Pid: $$" \
|
|
84
|
+
--data-binary "@${REQUEST_FILE}" \
|
|
85
|
+
"http://127.0.0.1:${CONTAINER_PORT}${WEBTERM_SPECS_AGENT_PATH:-/specs/agent}" 2>/dev/null
|
|
86
|
+
)" || {
|
|
87
|
+
echo "specs: could not reach the web interface on this container's port ${CONTAINER_PORT}." >&2
|
|
88
|
+
exit 1
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if [ "$STATUS" = "200" ]; then
|
|
92
|
+
jq . <"$BODY_FILE"
|
|
93
|
+
exit 0
|
|
94
|
+
fi
|
|
95
|
+
|
|
96
|
+
REASON="$(jq -r '.error // empty' <"$BODY_FILE" 2>/dev/null)"
|
|
97
|
+
if [ -z "$REASON" ]; then
|
|
98
|
+
case "$STATUS" in
|
|
99
|
+
403) REASON="the key on disk is not the one the interface is using" ;;
|
|
100
|
+
409) REASON="this is not running inside a web session" ;;
|
|
101
|
+
413) REASON="the payload is too large" ;;
|
|
102
|
+
*) REASON="the interface answered ${STATUS}" ;;
|
|
103
|
+
esac
|
|
104
|
+
fi
|
|
105
|
+
echo "specs: nothing was done - ${REASON}." >&2
|
|
106
|
+
exit 1
|
|
@@ -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,7 +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
|
|
37
|
+
- **One button asks for a page.** The fourth button in the composer, above Send, sends `Put your last answer on 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 names no pane, because where a page belongs follows what the session is doing: a page made during spec work is an artifact of that work and is saved beside it, and anything else goes to Pages - the skills carry that rule. 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.
|
|
38
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.
|
|
39
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.
|
|
40
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.
|
|
@@ -60,7 +60,7 @@ That is why a session showing as working never also shows as waiting for you.
|
|
|
60
60
|
- **A light travels round a tab** while that session's agent is working.
|
|
61
61
|
- **A tab flashes and then stays lit** when its agent finishes. Every ending, whoever is watching: it costs nothing to say a thing twice on screen, and one rule for every session is easier to trust than one for the tab you have open and another for the rest. Touching the session, or arriving at it, is what puts it out.
|
|
62
62
|
- **A tab takes its session's colour and grows a small page** beside its age when a page is published into that session's pane while you are looking at another one, and flashes once as it arrives. It holds until you get to that session, where the newest page opens itself; anything older that piled up behind it keeps its chip badge, so the tab marks itself again - without a second flash - once you leave. Its own state, not the one above: that one is an agent finishing, and this one is a page waiting to be read.
|
|
63
|
-
- **The browser tab speaks too**, because that is all a window behind something else can do. The title counts the sessions waiting for you, and the favicon carries one mark: a white bar across its bottom edge, with a lit segment
|
|
63
|
+
- **The browser tab speaks too**, because that is all a window behind something else can do. The title counts the sessions waiting for you, and the favicon carries one mark: a white bar across its bottom edge, with a lit segment moving along it, while an agent is working, and a green dot in its top corner - pulsing until you go and look - when one is waiting. Two shapes at opposite ends of the icon rather than two colours in one place, because at 16px a hue is the first thing to go. White for working because every hue here belongs to a workspace or a session, and the frame around the icon is one of them.
|
|
64
64
|
- **And ten seconds later it says so out loud** - one soft chime, once, for an ending nobody came back to. A sound cannot be taken back and it reaches you in the next room, so it is the one thing here that asks whether you were there. Several sessions finishing together are one chime, and so are two windows open on the same container. The bell beside the power button mutes it, and remembers.
|
|
65
65
|
|
|
66
66
|
Whether you were there is answered by whether that session gets touched - a keystroke, a click, a scroll - in the ten seconds after it finishes, not by whether the browser had focus.
|
|
@@ -107,15 +107,40 @@ Sessions already open keep running the agent they were started with.
|
|
|
107
107
|
|
|
108
108
|
## The web pane
|
|
109
109
|
|
|
110
|
-
Beside the terminal
|
|
110
|
+
Beside the terminal is a pane, and the pane holds tools. **Pages** is the first of them: the pages this session's agent published - a plan, a table, a diagram, a question with more structure than a terminal prompt can hold. **Specs** sits beside it, and has a section of its own below.
|
|
111
111
|
|
|
112
|
+
- **The pane's own chrome.** One tab per tool, and the width controls beside them. Everything in that bar is the pane's, never a tool's, so no tool can resize the pane or say which tool you are looking at - the same reason the chips bar is drawn outside the page it lists. Width has three states and only one of them is a width: the divider drags anything between the two ends, full width takes the whole row and squeezes the terminal out, and collapse folds the pane into the vertical "Web pane" spine. The grip stays on the screen in every one of them, so dragging it is always the way back, and a second press of the full-width button is the other. The spine carries what every tool is waiting on, whichever tool is open. Which tool is open, how wide it is and which of the three states it is in all belong to the window and survive a reload.
|
|
112
113
|
- **Publishing.** `webpane <file.html> [--title "..."]` inside a session, from an agent or by hand. Anything in the session's process tree may publish: the helper sends only its own pid, and the server walks up the process tree to a session it started, so no caller ever names a directory.
|
|
113
114
|
- **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.
|
|
114
115
|
- **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.
|
|
115
116
|
- **Feedback.** A page may carry `<form data-feedback>`. Submitting it writes `~/.webpane/<conversation-id>/feedback/<page>-<epoch-ms>.json` - `{ page, submittedAt, fields, text }`, where a field named `text` becomes the free-text box - and types one line into the agent's terminal saying where to read it. An agent that has already exited still gets the file; only the line is skipped.
|
|
116
|
-
- **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.
|
|
117
|
+
- **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. A page that lands while the pane is open on another tool changes nothing on the screen either: it is selected, ready for when you come back, and the number on the Pages tab is what says so. 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 collapse button in the pane's own bar puts the pane away into the spine, which 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
118
|
- **How an agent comes to use it.** Two layers, so it happens without being asked each time. Every agent's injected context carries the standing rule - prefer a page when the answer is longer or more structured than a terminal reply carries well, and when a question has more options or structure than the agent's own question tooling holds - plus the palette to match and the form contract. Every agent also gets the craft in full - a standalone dark page, inline everything, how to ask with a form, and what a submitted form does and does not prove - from one file, `templates/context/web-pane-craft.md`: claude reads it as the body of a `web-pane` skill, whose description is the phrases a user actually says ("show me", "as a page", "in the pane", "publish"), and codex and opencode read it as a section of the `AGENTS.md` chamba writes them, since a skill is a Claude Code mechanism and neither has one. The frontmatter and the title are claude's alone; the advice is written once. The session greeting names the pane too, so a user who has never heard of it learns it exists in the first sentence.
|
|
118
|
-
- **Nothing is deleted.** No age
|
|
119
|
+
- **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.
|
|
120
|
+
|
|
121
|
+
## The Specs tool
|
|
122
|
+
|
|
123
|
+
**Specs** is the pane's second tab, and the first thing in it that is not the interface's own: a separate application - its own client, its own server side - that webterm hosts rather than contains.
|
|
124
|
+
It reads the specs the workspace holds, and it is where the spec workflow grows.
|
|
125
|
+
|
|
126
|
+
- **Where it comes from.** The private `pane-apps` package builds one dist - a client per tool, and one bundled server module per tool - which chamba's build stages into its templates and the image bakes at `/usr/local/share/chamba/pane-apps/`, beside this directory. Nothing is installed for it: the module imports node builtins and nothing else.
|
|
127
|
+
- **How it is mounted.** `specs.js` is the mount. It hands the module the directory to serve and an `onChange`, registers its answers behind gates the module does not hold itself, and turns its changes into socket frames - the same shape the pane store is mounted with. There is no second process and no second port. A failure inside the module is one request answered with a status: every terminal in the container carries on, and the tab recovers on the next thing you do.
|
|
128
|
+
- **What it serves.** One directory of the workspace, `WEBTERM_SPECS_ROOT`, which is `specs/` unless it is set. The directory is a mount parameter rather than a name inside the tool, so the tool serves whatever a repository calls its specs.
|
|
129
|
+
- **Live.** Every change under that root reaches every open window, not one: a spec belongs to the repository rather than to a session, so two windows reading the same spec both hear it.
|
|
130
|
+
- **In a frame of its own.** The client is not part of this page. It is loaded into an iframe with an opaque origin, and what it may ask the server for is decided by two scoped credentials the shell hands in - see Security below.
|
|
131
|
+
- **What it shows.** Three panes: the specs and their artifacts on the left, one artifact read as one continuous document in the middle, and the state of the work on the right. A section carries a read mark, and a section that changed since it was read says so. A page or a diagram beside a spec renders in a frame of the jailed route, an image renders as an image, and anything else is named rather than drawn.
|
|
132
|
+
- **Where a link goes.** A link inside a document is never followed by the frame, which has to stay the document the shell put in it. One inside the spec opens in place at the section it named; one out of the repository is asked of the shell, which opens it as a window of its own.
|
|
133
|
+
- **Where a piece of work stands.** One `state.json` inside each spec's `.specs/`, written by the tool alone: the confirmed protocol stage by stage, the mode flags, what the agent is doing right now, what is waiting for the user, and the dated log. `README.md` beside it is rendered from that file every time it moves, so the board in the pane and the file in git are the same facts rather than two copies somebody keeps in step.
|
|
134
|
+
- **Starting one.** `New` in the navigator is one form - a working name, one box for everything you have to say, and files by drop or paste. Sending it creates the directory, saves what you wrote as `intake.md`, starts the state file, and tells the agent. An agent that hears the idea in conversation files the same intake through the helper, and gets the same directory.
|
|
135
|
+
- **Choosing the steps.** The agent reads the intake and posts a recommendation; the pane renders it as a form with every stage of the catalog in it, recommended or not, each with the agent's one line. Confirming writes the protocol into the state file in catalog order. Imagination mode is offered exactly when the run includes exploration or the mocks, and `Ask the agent first` sends the question to the terminal instead of answering it here.
|
|
136
|
+
- **The helper.** `specs <verb>` on PATH inside any session, gated the way `webpane` is - the key from the key file, the caller as its own pid, refused for a process that is not in a session. The verbs are `stages` (the catalog, as data rather than something an agent remembers), `state`, `intake`, `propose`, `await`, `post`, `review` and `answers`. A payload is JSON on stdin and the answer is JSON on stdout. The agent moves the work only through these: nothing hand-edits the state file.
|
|
137
|
+
- **Annotating.** Select text in a document, or point at an image or the artifact itself, and there are two actions and no others: say something about it, or ask for it to be removed. Each one joins a queue in the right pane, where it can be written, edited or dropped, and nothing is on disk and nobody has been told until it is sent. A request to remove something is complete by pointing; a comment with nothing written in it does not go.
|
|
138
|
+
- **Telling the agent.** Sending writes one round into the spec's own `.specs/user-feedback.json` and then types one line into the terminal beside the pane, naming the file rather than carrying what is in it. The line goes to the session that window is driving - the tool names no session, and there is none for it to name. With no session running, the tool asks first, and starts one only if you say so; the line then waits a moment for the agent to be there to read it. `Continue this spec` is the same channel with no file: it asks the session to pick the spec up.
|
|
139
|
+
- **What the run asks you.** Three things, and one mechanism under all of them. A gate judges one artifact: it opens that artifact and stands under it, and it takes an approval or a change request with the words that say what to change. A round asks questions: each one carries its options, whether one answer or several are allowed, a marked recommendation, and a box of its own for anything the options missed. A decision card carries what was found and what each way costs, and its two answers are apply it and dismiss it. Each of the three is one file the agent declared under the spec's `.specs/rounds/`, and answering writes the answer into that same file.
|
|
140
|
+
- **Answering.** A round goes with whatever you answered: a question you left alone is written as unanswered rather than left out, so the agent can tell a "no" from a question you skipped. The first answer wins - two windows can be looking at one gate, and the second is told it was already answered instead of overwriting the first. The outcome becomes a dated line in the state file, the item stops waiting, and one framed line goes to the terminal naming the file the answer went into.
|
|
141
|
+
- **What is waiting for you.** Everything the run is waiting on is a card in the right pane, each naming the artifact it is about and each opening it. The count is on the Specs tab itself, so it is visible from Pages and from the collapsed spine, and it goes when the last item is answered.
|
|
142
|
+
- **The run.** Once there is an execution plan, the board draws its phases and where each one stands, and each round of quality review with its verdict and how many findings it made. A pause in the run is a gate like any other: the document explaining it opens, and the gate to carry on stands beside it.
|
|
143
|
+
- **How an agent comes to use it.** The `dx-spec` family of skills, shipped in the image and injected like every other skill: `/dx-spec` builds a spec, `/dx-spec-execute` runs its plan, and `/dx-spec-config` keeps the settings and principles beside them. They carry the craft alone - how to interview, what goes in a spec, how to review it, how to run a plan - and they ask through the tab, because the forms are the product's half. None of them starts a flow in a terminal with no web session: they say how to open one and stop, since there is one flow and it needs the tab.
|
|
119
144
|
|
|
120
145
|
## The status strip
|
|
121
146
|
|
|
@@ -132,19 +157,30 @@ A window the account does not have takes its whole meter off the strip rather th
|
|
|
132
157
|
- The server binds container port **3899**; chamba publishes it loopback-only (`127.0.0.1:<port>:3899`), so nothing on the LAN can reach it.
|
|
133
158
|
That container port is reserved: a `chamba.yaml` entry publishing it is rejected, so nothing else can front the web URL.
|
|
134
159
|
- Inside the container it listens on all interfaces - required for a published port to reach it. So any process on the host, and any container on the same Docker network, can open the port. Reaching the port is not the same as getting in: the key is.
|
|
135
|
-
- **The URL carries a key, and the relay refuses anything without it.** A new one (16 random bytes, hex) is
|
|
160
|
+
- **The URL carries a key, and the relay refuses anything without it.** A new one (16 random bytes, hex) is created at every server start and published to `/tmp/webterm.key` the moment the port is bound; the launcher and the container greeting read it back, which is how the printed URL is always the live one. Gated: the page itself, `POST /upload`, `GET /status`, `GET /dirs`, `POST /cwd`, `POST /agent`, the three `/pane/*` routes, and the WebSocket handshake. Not gated: the page's modules under `/app/`, the stylesheet, the icon and the xterm files under `/vendor` - they hold nothing secret and drive nothing, and a window that never got the page opens no socket.
|
|
136
161
|
- The key stays in the URL rather than in a cookie on purpose. Cookies on `localhost` are shared across ports, so a page served by any other local port could ride this workspace's; a key in the URL is scoped to the window that was handed it.
|
|
137
162
|
- Comparison is constant time, so a caller cannot learn the key one character at a time from how long a refusal takes.
|
|
138
163
|
- The WebSocket handshake also rejects any non-loopback `Origin`, checked alongside the key. On its own that is a browser-behavior gate - a non-browser client sets any Origin it likes - so it is the second lock, not the first.
|
|
139
164
|
- A key dies with the server that issued it. Restarting the interface (or the container) invalidates every open window, which is why a spent URL gets a page that says where the current one is rather than a silent refusal.
|
|
140
|
-
- Uploads are images only, size-
|
|
165
|
+
- Uploads are images only, size-limited, and written only under `/tmp/uploads/`.
|
|
141
166
|
- **A published page never runs as part of this app.** The pane fetches its content over the key-gated route and renders it into an iframe with `sandbox="allow-scripts allow-forms"` and no `allow-same-origin`, so the page has an opaque origin: its scripts run, and there is nothing of the interface for them to reach. A form talks to the pane by `postMessage`, and only the pane - which is interface code - holds the key and calls the endpoint.
|
|
142
167
|
- **A page cannot read the window's URL, which is where the key is.** A frame rendered from `srcdoc` has no URL of its own and falls back to the base URL of the document holding it, which a script inside the page can read as `document.baseURI` - an opaque origin does not cover this, because `baseURI` is a plain string on the page's own document rather than a cross-origin read. Two things close it. The shell declares `<base href="/">`, so the value a frame falls back to is the bare origin instead of a URL with a key on it; that is the half a page cannot affect. The rendered document is also given `<base href="about:srcdoc">` as its first element, so nothing is inherited in the first place; a page can delete that one, which is why the first exists. The frame is `referrerpolicy="no-referrer"` as well, since a srcdoc document otherwise inherits the parent's referrer and sends it with everything it loads.
|
|
143
168
|
- **A feedback file says a page was submitted, not that a person answered.** The pane cannot tell a form a user filled in from a script in the same page posting the message the form would have. The file and the nudge are the channel, and the answers are bounded and filed either way; an agent should read one as data from its page, not as proof of consent.
|
|
144
169
|
- **The pane's routes take their key in a header** (`X-Webterm-Key`) and refuse it in the URL. A script inside a page can read the URL of the document it is in, so no URL that carries a page's content may carry the key; there is nothing to open by hand and nothing to leak. The refusal is the server's, not a habit of the client's, so a second caller written later cannot reopen it. Every other route keeps `?k=`, which is what makes the interface openable from a link.
|
|
145
|
-
- **A page bigger than a page is never read whole.** Anything can write into the pane directory, so a file there is not held to the publish
|
|
146
|
-
- **Pages are read without leaving the pane directory.** Keys and filenames are checked against a narrow pattern before they become a path, symlinks and anything that is not a regular file are refused rather than followed, and the resolved path is checked to still be inside the root. A file over the per-page
|
|
147
|
-
- **Feedback is bounded and bound.** The body has a size
|
|
170
|
+
- **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.
|
|
171
|
+
- **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.
|
|
172
|
+
- **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.
|
|
173
|
+
- **The Specs tool never holds this interface's key.** Its client renders in a frame with `sandbox="allow-scripts allow-forms"` and no `allow-same-origin`, exactly like a published page, so it has an opaque origin and nothing of the shell - the DOM, storage, the URL the key is in - is reachable from inside it. It gets two credentials of its own instead, both made fresh at server start and neither one the master key: a Specs key that opens the tool's data routes, presented as a header, and a raw-route token that opens workspace files, carried as a path segment because a frame and an image send no headers. Neither opens any other route, and presenting either one to anything else is refused. So one bug in spec rendering leaks spec reading at worst, never the key that types into terminals.
|
|
174
|
+
- **The key is handed over once, and never on request.** The shell posts it into the frame on the load of the document it put there, unprompted. A frame's window object survives a navigation, so a document that navigated itself in would ask with the same identity as the one that was loaded; a second load is therefore read as a navigation, and the frame is thrown away and built again rather than handed anything.
|
|
175
|
+
- **A workspace file is served jailed, whatever it is.** Every answer from the raw route carries `Content-Security-Policy: sandbox allow-scripts allow-forms` and `X-Content-Type-Options: nosniff`, on every media type and on refusals too - a list of scriptable types would be one enumeration away from being wrong. The route is frame-and-subresource only: the jail's second mechanism is the sandbox of whatever embeds the file, and a top-level document has no embedder, so a request that says it is one is refused - and so is a request that will not say, since reading silence as "not a document" would hand the refusal to whoever leaves the header off. The cost of that is a browser with no Fetch Metadata, where workspace files do not render at all.
|
|
176
|
+
- **No other origin may read a workspace file.** A jailed document can read its own URL, and that URL carries the raw token; if the files that token opens were readable across origins, one bad render would be every spec in the repository, posted anywhere. So nothing on that route says anything may be read - a frame and an image need no permission - and what the tool itself reads as text comes back through the data routes instead, where a header is what opens it.
|
|
177
|
+
- **Only the Specs data routes answer a cross-origin preflight.** The tool's key is a header, which is what makes its requests ask permission first; nothing else on this origin gives that permission. The tool's own client and assets are served open and readable, which is a plain GET rather than an answer to a preflight, and they hold nothing secret and drive nothing. They do carry `frame-ancestors 'self'`, so a page elsewhere cannot frame a tool's document without the sandbox this interface puts around it.
|
|
178
|
+
- **A workspace document is walked before it is put on the page.** Markdown from the workspace is rendered, and then every node of what came out is walked, element by element and attribute by attribute, against written-out lists: anything not on them is removed, and a URL is decided by parsing it rather than by matching its text. What survives is moved into the page node by node, so no HTML string exists after the walk - a serialize-and-reparse is the shape most sanitizer bypasses take, and there is not one here. See `packages/pane-apps/src/lib/sanitize.ts`.
|
|
179
|
+
- **A tool's own document names no origin in what decides execution.** It is served ahead of the open statics, under a policy of its own: `default-src 'none'`, a per-response nonce for its one script and its one stylesheet, and the origin it was loaded from named only where bytes are fetched. `'self'` is not usable here and would not be safe if it were - the document has an opaque origin, where `'self'` matches nothing, and this origin also serves workspace bytes through the raw route, so a host source in `script-src` would be a way to load a workspace file as code. This is the second mechanism over workspace markdown, and it is tested against the same payload list the walk is. See `tool-document.js`.
|
|
180
|
+
- **A delivery is a pointer and a sentence the server owns.** What the tool sends is a kind of event, one line of detail, and a file it wrote. The sentence is picked from a table in `server.js` by that event key, so a tool chooses which of a few sentences is typed and never what it says. The path has to resolve to one of the tool's own files - inside a `.specs/`, or one of the two artifacts the tool renders into a spec directory, `intake.md` and `README.md` - or the line names none. The detail is the one part that came from somewhere else, and it is reduced before it reaches a terminal: everything that is not a visible character becomes a space, invisible padding and tag characters are dropped, and the quote characters that would close the frame become plain ones. So a document read in the pane cannot write a line that reads as a second voice, and cannot hide a tail behind the width of the row. See `typed-line.js`.
|
|
181
|
+
- **A delivery is bounded, and refuses out loud.** The detail has a length limit checked on the text as it arrived, so an over-limit message is refused with a reason rather than cut into half a sentence. A session takes one delivery per second and only so many for as long as the container runs - a tool in a loop would otherwise drive an agent for days at one line a second. Every refusal is a sentence the tool shows the reader, so nothing is quietly dropped.
|
|
182
|
+
- **The helper's door is not the pane's.** `POST /specs/agent` takes the master key like every other helper route, and the caller has to be a process inside a session, found by walking up the process tree from the pid it sends. It takes no session name from the caller - a name would be a value the server has to trust, and everything in this container could send one. The Specs key opens the pane's data routes and not this door, and this door opens none of those.
|
|
183
|
+
- **A delivery cannot choose a terminal.** The message rides the window's own socket and the server resolves the session from it, exactly as the pane's own routes do. Two locks stand in front of that: the shell forwards a delivery only from the frame's own window, and only when it carries the Specs key, so a workspace document nested inside the tool has neither, and one that navigated the frame has the window and not the key.
|
|
148
184
|
- **The key gate confines the browser, not the container.** `/tmp/webterm.key` is readable by `devuser`, because the `webpane` helper has to read it, and everything in the container is `devuser`. So a script an agent writes can do anything a browser window can: attach to another session, type into it, publish into its pane. That is the same boundary the container has always had - one agent session can already reach another's files - and it is why the isolation that matters is the container's, not the key's.
|
|
149
185
|
- Runs as the non-root `devuser`; the agent inherits the same sandbox and auth it has in the terminal.
|
|
150
186
|
|
|
@@ -154,9 +190,9 @@ A window the account does not have takes its whole meter off the strip rather th
|
|
|
154
190
|
|
|
155
191
|
## Config
|
|
156
192
|
|
|
157
|
-
`config.js` holds the knobs (port, agent list and default agent, key and key file, upload dir, size
|
|
158
|
-
Env overrides: `WEBTERM_PORT`, `WEBTERM_CWD`, `WEBTERM_AGENT`, `WEBTERM_AGENT_ARGS`, `WEBTERM_KEY`, `WEBTERM_KEY_FILE`, `WEBTERM_RESUME_STAMP`, `WEBTERM_STATE_FILE`, `WEBTERM_MAX_SESSIONS`, `WEBTERM_PING_INTERVAL_MS`, `WEBTERM_WORKSPACE`, `WEBTERM_CONTEXT_FILE`, `WEBTERM_PANE_DIR`, `WEBTERM_MAX_PAGE_BYTES`, `WEBTERM_MAX_PANE_BYTES`, `WEBTERM_MAX_PANE_FILES`, `WEBTERM_PANE_SCAN_MS`, `WEBTERM_FEEDBACK_MIN_INTERVAL_MS`, `WEBTERM_MAX_FEEDBACK_PER_PAGE`, `WEBTERM_STATUS_SCAN_MS`, `WEBTERM_SUBMIT_DELAY_MS`, and the four `WEBTERM_*_DIR` agent-store paths, plus `WEBTERM_PROC_ROOT`, which belongs to `proc.js` rather than to `config.js`.
|
|
159
|
-
`WEBTERM_KEY` pins the key instead of
|
|
193
|
+
`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, the status strip's scan interval, where the pane's tools sit with the root and the two scoped credentials the Specs tool is mounted with, and the bounds on a delivery).
|
|
194
|
+
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`, `WEBTERM_SPECS_ROOT`, `WEBTERM_SPECS_KEY`, `WEBTERM_SPECS_RAW_TOKEN`, `WEBTERM_MAX_SPECS_LINE_LENGTH`, `WEBTERM_SPECS_LINE_MIN_INTERVAL_MS`, `WEBTERM_MAX_SPECS_LINES_PER_SESSION`, `WEBTERM_SPECS_NEW_SESSION_DELAY_MS`, and the four `WEBTERM_*_DIR` agent-store paths, plus `WEBTERM_PROC_ROOT`, which belongs to `proc.js` rather than to `config.js`.
|
|
195
|
+
`WEBTERM_KEY` pins the key instead of creating one, and the two `WEBTERM_SPECS_*` credentials the same way, which is for tests and hand-run debugging - there is no way to turn any of the three gates off.
|
|
160
196
|
`WEBTERM_CWD` is where new sessions start, not where they must stay: the browser can name another directory per session, and `POST /cwd` moves the default.
|
|
161
197
|
`WEBTERM_AGENT` is the same shape: the agent new sessions start with, which the browser can override per session and `POST /agent` moves. A value that is not one of the three falls back to the first, so nothing arbitrary can be spawned through it.
|
|
162
198
|
`WEBTERM_AGENT_ARGS` belongs to `WEBTERM_AGENT` alone - it comes from the same launcher run - so any other agent is spawned bare.
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// without ever leaving the root. The server owns who is asking and what to do about it.
|
|
11
11
|
//
|
|
12
12
|
// Two rules run through all of it. Nothing is ever deleted - not by age, not on session close, not when the
|
|
13
|
-
//
|
|
13
|
+
// limit is reached; past the limit a publish is refused and what exists stays. And nothing outside the root is
|
|
14
14
|
// ever read: names are checked against a narrow pattern, symlinks are refused rather than followed, and the
|
|
15
15
|
// resolved path is checked to still be inside the root before a byte is read.
|
|
16
16
|
|
|
@@ -33,7 +33,7 @@ import { join } from "node:path";
|
|
|
33
33
|
|
|
34
34
|
// A conversation key is a directory name, so it is checked like one: it may not be "." or "..", may not hold
|
|
35
35
|
// a separator, and must start with something ordinary. Agent conversation ids (uuids, "ses_" ids) pass; the
|
|
36
|
-
// server's own provisional keys are
|
|
36
|
+
// server's own provisional keys are created to match.
|
|
37
37
|
const KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
38
38
|
|
|
39
39
|
// A page is one HTML file directly inside a conversation's directory. Same reasoning as the key: no
|
|
@@ -91,7 +91,7 @@ export function cleanLine(raw, max = MAX_TITLE_LENGTH) {
|
|
|
91
91
|
/**
|
|
92
92
|
* The same cleaning for text that is allowed to be more than one line: a form's free-text box, a long answer
|
|
93
93
|
* typed into a textarea. Line breaks survive, every other control character does not, and the length is
|
|
94
|
-
*
|
|
94
|
+
* limited. Nothing typed into a terminal ever comes from here - this is what gets written to a file.
|
|
95
95
|
*/
|
|
96
96
|
export function cleanText(raw, max) {
|
|
97
97
|
if (typeof raw !== "string") return "";
|
|
@@ -120,7 +120,7 @@ export function slugify(raw) {
|
|
|
120
120
|
* The first `limit` bytes of a file, as text, or "" when it cannot be read.
|
|
121
121
|
*
|
|
122
122
|
* Bounded at the read rather than after it: a page put into the directory by hand is not held to the publish
|
|
123
|
-
*
|
|
123
|
+
* limit, so a listing that read whole files would stall the whole interface on one huge one - and a listing
|
|
124
124
|
* happens on a timer, for every page, in every pane.
|
|
125
125
|
*/
|
|
126
126
|
function headOf(path, limit) {
|
|
@@ -167,7 +167,7 @@ function titleFor(path, id) {
|
|
|
167
167
|
/**
|
|
168
168
|
* The pane store over one artifacts root.
|
|
169
169
|
*
|
|
170
|
-
* `maxPageBytes`
|
|
170
|
+
* `maxPageBytes` limits a single page, `maxTotalBytes` and `maxTotalFiles` the whole root, and
|
|
171
171
|
* `maxFeedbackPerPage` how many answers one page may file. All of them are enforced here rather than in the
|
|
172
172
|
* helper, because the helper is one way in and a file copied by hand is another.
|
|
173
173
|
*/
|
|
@@ -243,7 +243,7 @@ export function createPaneStore({ root, maxPageBytes, maxTotalBytes, maxTotalFil
|
|
|
243
243
|
|
|
244
244
|
/**
|
|
245
245
|
* What the whole root holds, pages and feedback together: bytes and file count. Walked per write, which
|
|
246
|
-
* is affordable only because the file count is
|
|
246
|
+
* is affordable only because the file count is limited too - both numbers come out of this one walk, and
|
|
247
247
|
* the count is what keeps the walk itself small.
|
|
248
248
|
*/
|
|
249
249
|
function totals() {
|
|
@@ -279,12 +279,12 @@ export function createPaneStore({ root, maxPageBytes, maxTotalBytes, maxTotalFil
|
|
|
279
279
|
return { bytes: total, files };
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
-
/** Bytes held by the whole root. The count that goes with it is a
|
|
282
|
+
/** Bytes held by the whole root. The count that goes with it is a limit, not something a caller asks for. */
|
|
283
283
|
function totalBytes() {
|
|
284
284
|
return totals().bytes;
|
|
285
285
|
}
|
|
286
286
|
|
|
287
|
-
/** Room for one more file of this size, or the refusal that says which
|
|
287
|
+
/** Room for one more file of this size, or the refusal that says which limit it met. */
|
|
288
288
|
function roomFor(bytes) {
|
|
289
289
|
const held = totals();
|
|
290
290
|
if (held.bytes + bytes > maxTotalBytes) return "full";
|
|
@@ -359,7 +359,7 @@ export function createPaneStore({ root, maxPageBytes, maxTotalBytes, maxTotalFil
|
|
|
359
359
|
* - "missing": there is no such page, or it is not a regular file - a directory, a device, a symlink.
|
|
360
360
|
* Symlinks are refused rather than followed, which is what stops a link inside the pane from serving
|
|
361
361
|
* the key file or anything else in the container.
|
|
362
|
-
* - "toobig": bigger than a page may be. A file put there by hand is not bound by the publish
|
|
362
|
+
* - "toobig": bigger than a page may be. A file put there by hand is not bound by the publish limit, and
|
|
363
363
|
* reading an arbitrarily large file into memory to answer a request is not something to allow.
|
|
364
364
|
*/
|
|
365
365
|
function read(key, id) {
|
|
@@ -392,7 +392,7 @@ export function createPaneStore({ root, maxPageBytes, maxTotalBytes, maxTotalFil
|
|
|
392
392
|
* the file); the number in front of the name counts up per conversation, so the pane reads in the order
|
|
393
393
|
* the pages were published however they are sorted elsewhere.
|
|
394
394
|
*
|
|
395
|
-
* Refusals: "name" for a key that is not one, "toobig" for a page over the per-page
|
|
395
|
+
* Refusals: "name" for a key that is not one, "toobig" for a page over the per-page limit, "full" when the
|
|
396
396
|
* root has reached its total - nothing is deleted to make room, because a page the user has not read yet
|
|
397
397
|
* is not the server's to throw away - and "write" when the filesystem said no.
|
|
398
398
|
*/
|
|
@@ -451,7 +451,7 @@ export function createPaneStore({ root, maxPageBytes, maxTotalBytes, maxTotalFil
|
|
|
451
451
|
};
|
|
452
452
|
const contents = `${JSON.stringify(body, null, 2)}\n`;
|
|
453
453
|
// A form is submitted by whatever is inside the page - a person clicking, or a script in it - so an
|
|
454
|
-
// endless stream of them is a thing that can happen. Two
|
|
454
|
+
// endless stream of them is a thing that can happen. Two limits stand in the way, and the count is the
|
|
455
455
|
// one that matters: answers are small, so bytes alone would allow a very large number of them, and
|
|
456
456
|
// every one of them also types a line into the agent's terminal.
|
|
457
457
|
if (feedbackFiles(key, stem).length + 1 > maxFeedbackPerPage) return { ok: false, error: "toomany" };
|
|
@@ -57,14 +57,14 @@ export const HOME = process.env.HOME || "/home/devuser";
|
|
|
57
57
|
|
|
58
58
|
export const PANE_DIR = process.env.WEBTERM_PANE_DIR || `${HOME}/.webpane`;
|
|
59
59
|
|
|
60
|
-
//
|
|
60
|
+
// Limits, enforced by the server rather than by the helper: a page is refused above the first, and a publish
|
|
61
61
|
// is refused once the whole directory has reached the second. Nothing is ever deleted to make room - a page
|
|
62
|
-
// the user has not read yet is not the server's to throw away - so reaching the
|
|
62
|
+
// the user has not read yet is not the server's to throw away - so reaching the limit is a message, not a
|
|
63
63
|
// cleanup. A page is a document, so a couple of megabytes is already generous; the total is the backstop.
|
|
64
64
|
export const MAX_PAGE_BYTES = Number(process.env.WEBTERM_MAX_PAGE_BYTES) || 2 * 1024 * 1024;
|
|
65
65
|
export const MAX_PANE_BYTES = Number(process.env.WEBTERM_MAX_PANE_BYTES) || 128 * 1024 * 1024;
|
|
66
66
|
|
|
67
|
-
// And a
|
|
67
|
+
// And a limit on how many files, not only how many bytes. Answers are small, so the byte limit alone would let a
|
|
68
68
|
// page that submits to itself put hundreds of thousands of them in the directory - and every write walks the
|
|
69
69
|
// whole tree to add the bytes up, so that walk is what would slow down. A personal pane holds tens of pages.
|
|
70
70
|
export const MAX_PANE_FILES = Number(process.env.WEBTERM_MAX_PANE_FILES) || 5_000;
|
|
@@ -75,6 +75,49 @@ export const MAX_PANE_FILES = Number(process.env.WEBTERM_MAX_PANE_FILES) || 5_00
|
|
|
75
75
|
// every host.
|
|
76
76
|
export const PANE_SCAN_MS = Number(process.env.WEBTERM_PANE_SCAN_MS) || 1_500;
|
|
77
77
|
|
|
78
|
+
// --- The Specs tool ----------------------------------------------------------------------------------------------------------------------
|
|
79
|
+
//
|
|
80
|
+
// The first of the pane's tools. Its client and its server module are built from the pane-apps package and
|
|
81
|
+
// baked beside webterm, so this side names where they sit, what they are pointed at, and the two scoped
|
|
82
|
+
// credentials that open them.
|
|
83
|
+
|
|
84
|
+
// Where the built tool clients and server modules sit: a sibling of this directory, which is true both of the
|
|
85
|
+
// image's bake and of a checkout, where chamba's build stages the same dist.
|
|
86
|
+
export const PANE_APPS_DIR = join(import.meta.dirname, "..", "pane-apps");
|
|
87
|
+
export const PANE_APPS_CLIENT_DIR = join(PANE_APPS_DIR, "client");
|
|
88
|
+
|
|
89
|
+
// The directory of specs the tool serves. A mount parameter rather than a name inside the tool, so a
|
|
90
|
+
// repository is served whatever it calls its specs directory.
|
|
91
|
+
export const SPECS_ROOT = process.env.WEBTERM_SPECS_ROOT || join(WORKSPACE_ROOT, "specs");
|
|
92
|
+
|
|
93
|
+
// The two scoped credentials, fresh every time this server starts, like the master key and for the same
|
|
94
|
+
// reason: nothing has to store them, and one never outlives the process that issued it. The key opens the
|
|
95
|
+
// data routes and travels in a header; the token opens workspace files and rides as a path segment, because a
|
|
96
|
+
// frame and an image send no headers. Neither opens any other route, and neither is the master key.
|
|
97
|
+
// The env overrides exist for tests and hand-run debugging, exactly as WEBTERM_KEY does.
|
|
98
|
+
export const SPECS_KEY = process.env.WEBTERM_SPECS_KEY || randomBytes(16).toString("hex");
|
|
99
|
+
export const SPECS_RAW_TOKEN = process.env.WEBTERM_SPECS_RAW_TOKEN || randomBytes(16).toString("hex");
|
|
100
|
+
|
|
101
|
+
// The largest body a Specs data route accepts. A round of feedback carries its images inside it, which is
|
|
102
|
+
// what makes this a file size rather than a form size; the tool writes those images out as real files.
|
|
103
|
+
export const MAX_SPECS_BYTES = 64 * 1024 * 1024;
|
|
104
|
+
|
|
105
|
+
// What a Specs delivery may be - one line typed into the agent's terminal saying that something arrived and
|
|
106
|
+
// where to read it. The three bounds mirror the pane's feedback limits and are here for the same reasons.
|
|
107
|
+
//
|
|
108
|
+
// The length is of the source text, checked before the line is reduced, so an over-limit message is refused
|
|
109
|
+
// with a reason rather than cut into half a sentence. The gap is what a person delivering things looks like:
|
|
110
|
+
// anything faster is a stuck button or a script. And the total is what the gap alone cannot stop - a tool in
|
|
111
|
+
// a loop would otherwise drive an agent for days at one line a second, so a session accepts only so many
|
|
112
|
+
// deliveries for as long as this container runs.
|
|
113
|
+
export const MAX_SPECS_LINE_LENGTH = Number(process.env.WEBTERM_MAX_SPECS_LINE_LENGTH) || 600;
|
|
114
|
+
export const SPECS_LINE_MIN_INTERVAL_MS = Number(process.env.WEBTERM_SPECS_LINE_MIN_INTERVAL_MS) || 1_000;
|
|
115
|
+
export const MAX_SPECS_LINES_PER_SESSION = Number(process.env.WEBTERM_MAX_SPECS_LINES_PER_SESSION) || 500;
|
|
116
|
+
|
|
117
|
+
// How long a delivery waits when it had to start the session it is for. An agent CLI takes a moment to draw
|
|
118
|
+
// its prompt, and a paste that arrives before it does is read by the terminal rather than by the agent.
|
|
119
|
+
export const SPECS_NEW_SESSION_DELAY_MS = Number(process.env.WEBTERM_SPECS_NEW_SESSION_DELAY_MS) || 3_000;
|
|
120
|
+
|
|
78
121
|
// --- The status strip --------------------------------------------------------------------------------------------------------------------
|
|
79
122
|
|
|
80
123
|
// How often a session's snapshot file is looked at again for the strip above the composer. A poll for the
|
|
@@ -83,7 +126,7 @@ export const PANE_SCAN_MS = Number(process.env.WEBTERM_PANE_SCAN_MS) || 1_500;
|
|
|
83
126
|
// catches up", and a frame goes out only when something in it actually changed.
|
|
84
127
|
export const STATUS_SCAN_MS = Number(process.env.WEBTERM_STATUS_SCAN_MS) || 2_000;
|
|
85
128
|
|
|
86
|
-
// What a feedback submission may be. The body
|
|
129
|
+
// What a feedback submission may be. The body limit is what the endpoint accepts at all; the rest bound what
|
|
87
130
|
// is written, so a page with a runaway form produces a file an agent can still read.
|
|
88
131
|
export const MAX_FEEDBACK_BYTES = 64 * 1024;
|
|
89
132
|
export const MAX_FEEDBACK_FIELDS = 40;
|
|
@@ -96,7 +139,7 @@ export const FEEDBACK_MIN_INTERVAL_MS = Number(process.env.WEBTERM_FEEDBACK_MIN_
|
|
|
96
139
|
|
|
97
140
|
// How many answers one page may file at all. A person submits a form once, or a few times after changing
|
|
98
141
|
// 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
|
|
142
|
+
// line into the agent's terminal - so this, not the byte limit, is what bounds how often a page can drive an
|
|
100
143
|
// agent. The gap above only slows that down; this stops it.
|
|
101
144
|
export const MAX_FEEDBACK_PER_PAGE = Number(process.env.WEBTERM_MAX_FEEDBACK_PER_PAGE) || 20;
|
|
102
145
|
|
|
@@ -195,7 +238,7 @@ export const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
|
|
195
238
|
export const UPLOAD_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
196
239
|
export const CHECK_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
197
240
|
|
|
198
|
-
// The key that the URL carries (`/?k=<key>`) and the relay demands. A fresh one is
|
|
241
|
+
// The key that the URL carries (`/?k=<key>`) and the relay demands. A fresh one is created every time
|
|
199
242
|
// this server starts, so a key never outlives the process that issued it and nothing has to store it:
|
|
200
243
|
// whoever prints the URL reads it back from KEY_FILE. WEBTERM_KEY pins it (tests, hand-run debugging).
|
|
201
244
|
// There is deliberately no unauthenticated mode - without the gate the relay would drive an agent for
|
|
@@ -224,7 +267,7 @@ export function isAuthorized(candidate) {
|
|
|
224
267
|
// relay even if it somehow holds a key. We allow any localhost / 127.0.0.1 origin regardless of port,
|
|
225
268
|
// because the published host port can differ from the container port (e.g. 3900:3899) so the browser's
|
|
226
269
|
// 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://
|
|
270
|
+
// granularity: it still rejects remote origins like http://attacker.com.
|
|
228
271
|
export function isAllowedOrigin(origin) {
|
|
229
272
|
if (!origin) return false;
|
|
230
273
|
try {
|
|
@@ -253,9 +296,9 @@ export const SUBMIT_DELAY_MS = Number(process.env.WEBTERM_SUBMIT_DELAY_MS) || 15
|
|
|
253
296
|
export const STOP_ANNOUNCE_MS = 150;
|
|
254
297
|
export const STOP_TIMEOUT_MS = 3_000;
|
|
255
298
|
|
|
256
|
-
//
|
|
299
|
+
// Limit on the replayed session buffer (bytes), per session. A session's PTY outlives every socket, so
|
|
257
300
|
// on attach we replay up to this much recent output and the window lands back in the live conversation.
|
|
258
|
-
// Older output beyond the
|
|
301
|
+
// Older output beyond the limit is dropped from the replay (the agent's own history is unaffected).
|
|
259
302
|
export const MAX_OUTPUT_BUFFER = 1_000_000;
|
|
260
303
|
|
|
261
304
|
// How many sessions (live agent processes) may exist at once. Sessions are never closed for the user,
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// matches it to the session by the two facts the server holds: the process tree and the directory.
|
|
12
12
|
//
|
|
13
13
|
// A miss is normal and cheap. An agent that has not written its first record yet has no id to find, so the
|
|
14
|
-
// answer is null, the pane keeps its provisional directory, and the next
|
|
14
|
+
// answer is null, the pane keeps its provisional directory, and the next pass asks again.
|
|
15
15
|
|
|
16
16
|
import { closeSync, openSync, readdirSync, readFileSync, readSync, statSync } from "node:fs";
|
|
17
17
|
import { basename, join } from "node:path";
|
|
@@ -26,7 +26,7 @@ const HEAD_BYTES = 64 * 1024;
|
|
|
26
26
|
const MAX_CANDIDATES = 40;
|
|
27
27
|
|
|
28
28
|
// The largest a snapshot or a session record can be and still be read whole. The files these stores write
|
|
29
|
-
// are a few hundred bytes; the
|
|
29
|
+
// are a few hundred bytes; the limit is only there so an unrelated file that happens to be in the directory
|
|
30
30
|
// cannot be pulled into memory.
|
|
31
31
|
const MAX_JSON_BYTES = 1024 * 1024;
|
|
32
32
|
|
|
@@ -65,7 +65,7 @@ function walkStore(dir, pattern, depth, onFile) {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
/**
|
|
68
|
-
* Files under `dir` matching `pattern`, newest first,
|
|
68
|
+
* Files under `dir` matching `pattern`, newest first, limited in number.
|
|
69
69
|
* Exported for resume.js, which reads the same stores to answer a different question.
|
|
70
70
|
*/
|
|
71
71
|
export function filesNewestFirst(dir, pattern, depth = MAX_STORE_DEPTH) {
|
|
@@ -22,7 +22,7 @@ import { conversationId } from "./conversation.js";
|
|
|
22
22
|
const PROVISIONAL_PREFIX = "pending-";
|
|
23
23
|
|
|
24
24
|
// How many sessions the key map remembers. Entries outlive their session on purpose: feedback submitted after
|
|
25
|
-
// an agent exited still has to land in the right directory. The
|
|
25
|
+
// an agent exited still has to land in the right directory. The limit only keeps a container that has been up
|
|
26
26
|
// for weeks from growing the map without end.
|
|
27
27
|
const MAX_REMEMBERED = 64;
|
|
28
28
|
|
|
@@ -36,8 +36,10 @@ export function isProvisional(key) {
|
|
|
36
36
|
*
|
|
37
37
|
* - `store` is the artifacts store.
|
|
38
38
|
* - `stores` is where each agent writes its conversation id (config's AGENT_STORES).
|
|
39
|
-
* - `sessions()` returns the live sessions as `{ sid, agent, cwd, pid, since }` - everything
|
|
40
|
-
* needs, handed in rather than reached for, so this file needs no registry and no PTYs to be
|
|
39
|
+
* - `sessions()` returns the live sessions as `{ sid, agent, cwd, pid, since, resumedFrom }` - everything
|
|
40
|
+
* the discovery needs, handed in rather than reached for, so this file needs no registry and no PTYs to be
|
|
41
|
+
* tested. `resumedFrom` is the conversation id the server resumed this session with, or null: the one fact
|
|
42
|
+
* only the server has, because claude gives a resumed conversation a fresh id and records no link back.
|
|
41
43
|
* - `onChange(sid)` is called whenever that session's page list moved, for whatever reason.
|
|
42
44
|
*/
|
|
43
45
|
export function createPane({ store, stores, sessions, onChange }) {
|
|
@@ -100,6 +102,15 @@ export function createPane({ store, stores, sessions, onChange }) {
|
|
|
100
102
|
);
|
|
101
103
|
return true;
|
|
102
104
|
}
|
|
105
|
+
// Claude names a fresh id on every resume, so a resumed conversation arrives here under a new id
|
|
106
|
+
// while its pages still sit under the id it was resumed from. The server witnessed the resume - it
|
|
107
|
+
// built the command - so those pages are carried into the new id first, ahead of the provisional
|
|
108
|
+
// directory, keeping their numbers and their order. An old id another live session still holds stays
|
|
109
|
+
// where it is: those pages are not this session's to take.
|
|
110
|
+
const from = session.resumedFrom;
|
|
111
|
+
if (typeof from === "string" && from !== id && !liveKeys(session.sid).has(from)) {
|
|
112
|
+
store.adopt(from, id);
|
|
113
|
+
}
|
|
103
114
|
if (!store.adopt(entry.key, id)) return false;
|
|
104
115
|
entry.key = id;
|
|
105
116
|
entry.adopted = true;
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { readFileSync } from "node:fs";
|
|
15
15
|
|
|
16
|
-
// A process tree in a container is a handful of levels deep. The
|
|
16
|
+
// A process tree in a container is a handful of levels deep. The limit is what keeps a malformed /proc, or a
|
|
17
17
|
// cycle that should not exist, from spinning here.
|
|
18
18
|
const MAX_ANCESTRY_DEPTH = 64;
|
|
19
19
|
|