chamba 0.8.0 → 0.10.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.
Files changed (73) hide show
  1. package/README.md +14 -3
  2. package/dist/commands/dev.js +11 -11
  3. package/dist/commands/menu.js +1 -1
  4. package/dist/commands/settings.js +9 -6
  5. package/dist/lib/awake-holder.js +3 -0
  6. package/dist/lib/awake-marker.js +4 -0
  7. package/dist/lib/awake.js +1 -0
  8. package/dist/lib/chamba-yaml.js +4 -4
  9. package/dist/lib/constants.js +1 -1
  10. package/dist/lib/global-config.js +3 -3
  11. package/dist/lib/skills.js +2 -2
  12. package/package.json +1 -1
  13. package/templates/Dockerfile +3 -3
  14. package/templates/context/baseline.md +2 -2
  15. package/templates/context/web-pane-craft.md +1 -1
  16. package/templates/pane-apps/client/assets/api-CguEsvNp.js +12 -0
  17. package/templates/pane-apps/client/assets/api-Cyoc3E6K.css +1 -0
  18. package/templates/pane-apps/client/assets/bee-B7t97l5B.webp +0 -0
  19. package/templates/pane-apps/client/assets/buzz-CUUsbnXH.mp3 +0 -0
  20. package/templates/pane-apps/client/assets/home-CLFnmg7u.css +1 -0
  21. package/templates/pane-apps/client/assets/home-CrhBkqDQ.js +1 -0
  22. package/templates/pane-apps/client/assets/pop-D5ZANBC3.mp3 +0 -0
  23. package/templates/pane-apps/client/assets/reviews-Bq8EwmG-.js +153 -0
  24. package/templates/pane-apps/client/assets/reviews-DzHtA35Q.css +1 -0
  25. package/templates/pane-apps/client/assets/search-DgFnnu-J.js +1 -0
  26. package/templates/pane-apps/client/assets/specs-JvdNFtXR.js +13 -0
  27. package/templates/pane-apps/client/assets/useNow-Ww-Gffnk.js +1 -0
  28. package/templates/pane-apps/client/home/index.html +16 -0
  29. package/templates/pane-apps/client/reviews/index.html +6 -4
  30. package/templates/pane-apps/client/specs/index.html +4 -3
  31. package/templates/pane-apps/server/home.mjs +11 -0
  32. package/templates/pane-apps/server/reviews.mjs +19 -18
  33. package/templates/pane-apps/server/specs.mjs +4 -4
  34. package/templates/skills/dx-review/SKILL.md +16 -3
  35. package/templates/skills/dx-review/references/acts.md +86 -15
  36. package/templates/skills/dx-spec/SKILL.md +11 -19
  37. package/templates/skills/dx-spec/references/review-guide.md +15 -9
  38. package/templates/skills/dx-spec/references/spec-guide.md +12 -5
  39. package/templates/skills/dx-spec/references/stages.md +44 -24
  40. package/templates/skills/dx-spec-execute/SKILL.md +78 -28
  41. package/templates/startup.mjs +13 -13
  42. package/templates/tool-helper.sh +4 -2
  43. package/templates/webterm/README.md +41 -17
  44. package/templates/webterm/awake.js +27 -0
  45. package/templates/webterm/config.js +30 -0
  46. package/templates/webterm/public/app/alerts.js +92 -11
  47. package/templates/webterm/public/app/composer.js +37 -3
  48. package/templates/webterm/public/app/connection.js +14 -0
  49. package/templates/webterm/public/app/dom.js +1 -0
  50. package/templates/webterm/public/app/frames.js +1 -1
  51. package/templates/webterm/public/app/home-host.js +16 -0
  52. package/templates/webterm/public/app/main.js +4 -2
  53. package/templates/webterm/public/app/pane-shape.js +5 -3
  54. package/templates/webterm/public/app/pane-shell.js +45 -5
  55. package/templates/webterm/public/app/pane.js +8 -2
  56. package/templates/webterm/public/app/reviews-host.js +5 -1
  57. package/templates/webterm/public/app/state.js +4 -0
  58. package/templates/webterm/public/app/tabs.js +37 -3
  59. package/templates/webterm/public/app/tool-host.js +19 -3
  60. package/templates/webterm/public/app/workspace-color.js +1 -1
  61. package/templates/webterm/public/index.html +5 -1
  62. package/templates/webterm/public/styles.css +65 -0
  63. package/templates/webterm/server.js +24 -1
  64. package/templates/webterm/tool-document.js +3 -2
  65. package/templates/webterm/tools/home.js +80 -0
  66. package/templates/webterm/tools/index.js +5 -4
  67. package/templates/webterm/tools/reviews.js +7 -3
  68. package/templates/pane-apps/client/assets/files-D0nJgFAA.js +0 -12
  69. package/templates/pane-apps/client/assets/files-h0K1vZyQ.css +0 -1
  70. package/templates/pane-apps/client/assets/reviews-BsoGZ35r.css +0 -1
  71. package/templates/pane-apps/client/assets/reviews-iW0Mbn4e.js +0 -153
  72. package/templates/pane-apps/client/assets/specs-D9z3neR-.js +0 -13
  73. package/templates/skills/dx-spec/references/principles-template.md +0 -2
@@ -48,6 +48,7 @@ The page is mission control for the container: the tab bar at the top is every a
48
48
  Choosing there starts that one session and nothing else: the default stays where the container put it, and `webterm <agent>` in the container is what moves it.
49
49
  A name that is not one of the agents starts nothing: the browser names it, and it ends up as a process.
50
50
  - **Up to 8 sessions** (`WEBTERM_MAX_SESSIONS`). The limit is memory: each one is a full agent process.
51
+ - **A small sun at the right of the bar** says the host is keeping itself awake while this container is up, so an agent left to work keeps working. It is a sign and not a button: the setting is the machine's, and the chamba menu on the host is where it is changed. Nothing is shown while the machine sleeps as it always did.
51
52
  - **Call it a day with the power button** at the right of the bar: it stops the container, and with it every session in it - browser and terminal alike. It always asks first, naming what ends. The container stops itself by signalling PID 1, which only works because chamba gives the keep-alive a TERM trap; a container created before that says so instead of hanging, and points at the host.
52
53
 
53
54
  ## What the tabs are telling you
@@ -61,7 +62,14 @@ That is why a session showing as working never also shows as waiting for you.
61
62
  - **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
63
  - **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
64
  - **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.
65
+ - **And it goes grey when the window cannot reach the container.** Three seconds after the socket drops - long enough that a wifi blip or a closed lid passes without it - the spark and the frame drain to one flat grey, both marks go, and nothing in the icon moves again until the socket is back.
66
+ The title drops its count with them and keeps the workspace name alone.
67
+ Everything else the tab says comes from the last frame the server sent, and while the socket is down that frame only gets older, so a green dot would call you back to a session this window can no longer see.
68
+ The icon is the same grey for every kind of disconnect: a container that is stopped, a key that is spent, and a container that has gone.
69
+ It says whether this window can reach the container, not why - the curtain on the page is where the reason is.
64
70
  - **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.
71
+ A grey tab is a silent one: a finish that comes due while the icon is grey is spent without a sound, and the reconnect does not play it late.
72
+ By then the chime would be calling you to a session this window could not see when the finish came due, and a sound is the one thing here that cannot be taken back.
65
73
 
66
74
  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.
67
75
  Focus was the first answer and the wrong one: it varies by browser and platform, it has to be re-reported on every reconnect, and every hole in it fails towards silence, which is the one direction a notification must not fail in.
@@ -85,7 +93,7 @@ A window whose socket dropped asks it too: an answer means the relay is fine, a
85
93
  ## The workspace colour
86
94
 
87
95
  One colour says which window this is from across a screen full of them: the composer border, the active tab's edge, the buttons, and the frame around the browser tab's icon are all of them that one hue.
88
- The palette button in the bar, between the workspace name and the bell, is where it is set.
96
+ The palette button in the bar, before the bell, is where it is set.
89
97
  It opens a panel of eleven colours with a way back to automatic in the last cell, and one click applies the colour with nothing to confirm.
90
98
 
91
99
  Until someone picks one, the colour comes from the published port, so two containers open side by side are almost never alike.
@@ -109,8 +117,10 @@ webterm is baked into the chamba image with its dependencies preinstalled, and t
109
117
  3. In the container, `webterm <agent>` (`claude`, `opencode`, or `codex`) moves which agent new browser sessions start with.
110
118
  The agent is always explicit; `webterm` on its own prints usage and the URL, and never picks one for you.
111
119
  The choice is written to `~/.chamba-webterm/agent`, and the host reads it back at the next container start - so it holds across restarts.
112
- chamba bind-mounts that directory from the workspace cache, and three files live in it: `agent`, the resume stamp `resumed-at`, and `color`, which holds the workspace colour.
113
- A rebuild replaces the container and leaves all three where they are.
120
+ chamba bind-mounts that directory from the workspace cache, and three files the server writes live in it: `agent`, the resume stamp `resumed-at`, and `color`, which holds the workspace colour.
121
+ Home's cache directory `.home/` sits beside them, for the same reason: what outlives the container goes here.
122
+ The `awake` marker sits there too, and it is the one file the host writes and the server only reads: a holder on the host keeps it while it holds the machine awake for this container.
123
+ A rebuild replaces the container and leaves all of it where it is.
114
124
 
115
125
  The first session after a container start reopens the most recent conversation; later sessions start fresh.
116
126
  The server decides that itself, in `resume.js`, by reading the same agent stores the pane reads - the host is not involved and plants nothing.
@@ -123,20 +133,33 @@ Sessions already open keep running the agent they were started with.
123
133
 
124
134
  ## The web pane
125
135
 
126
- 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** and **Code Reviews** sit beside it, and each has a section of its own below.
136
+ Beside the terminal is a pane, and the pane holds tools. **Home** is the first of them, and the tool a pane with no shape of its own opens on: the pulse of the workspace repository, read from git. **Pages** is the interface's own: 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** and **Code Reviews** sit beside them, and Home, Specs and Code Reviews each have a section of their own below.
127
137
 
128
- - **The pane's own chrome.** One tab per tool, drawn as a flat label with a line under the open one in the workspace's own colour, 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. Between those two buttons is a third for half the window, which is the width the pane opens at - half itself rather than the pixels half came to, so the pane stays at half when the window is resized, and the button is lit exactly while it is there. 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. The pane's shape belongs to the session it is beside - which tool is open, how wide it is, which of the three states it is in, how big a page is drawn and which palette the reading is in - so switching sessions gives each one the pane it was left with, and a reload gives them all back. A session nobody has shaped opens at half the window, on Pages, whether or not the agent has published anything yet. The browser keeps this, one shape per session, and a session that leaves the bar takes its shape with it.
138
+ - **The pane's own chrome.** One tab per tool, drawn as a flat label with a line under the open one in the workspace's own colour, 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. Between those two buttons is a third for half the window, which is the width the pane opens at - half itself rather than the pixels half came to, so the pane stays at half when the window is resized, and the button is lit exactly while it is there. 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. The pane's shape belongs to the session it is beside - which tool is open, how wide it is, which of the three states it is in, how big a page is drawn and which palette the reading is in - so switching sessions gives each one the pane it was left with, and a reload gives them all back. A session nobody has shaped opens at half the window, on Home. The browser keeps this, one shape per session, and a session that leaves the bar takes its shape with it.
129
139
  - **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.
130
140
  - **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.
131
141
  - **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.
132
142
  - **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.
133
- - **What you see.** The pane is beside the terminal from the start, open at half the window in a session that has published nothing - a pane that only appears once an agent has used it is one nobody knows to ask for. The 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 a session was never put away in is open already, so nothing has to arrive for it to be there. 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, the text size and the open tool belong to the session, the way the pages, the selection and the badges do, so switching sessions gives each one the pane it was left with.
143
+ - **What you see.** The pane is beside the terminal from the start, open at half the window in a session that has published nothing - a pane that only appears once an agent has used it is one nobody knows to ask for. The 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 where Pages is the tool it was put away on, at no less than a readable width - the one place this interface overrides a choice you made. A pane put away on another tool stays away, and its Pages tab carries the page until you get there. A pane a session was never put away in is open already, so nothing has to arrive for it to be there. 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, the text size and the open tool belong to the session, the way the pages, the selection and the badges do, so switching sessions gives each one the pane it was left with.
134
144
  - **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 - through one channel: the `web-pane` skill, whose body is `templates/context/web-pane-craft.md` and whose description is the phrases a user actually says ("show me", "as a page", "in the pane", "publish"). All three read it that way, from the skills home their own CLI reads. The craft is long and it is needed on the turns that write a page, so it arrives when the skill loader shows its description and the model opens it, rather than in context on every turn. The standing rule above is what makes that safe: reaching for a page does not wait on the skill loading. The session greeting names the pane too, so a user who has never heard of it learns it exists in the first sentence.
135
145
  - **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.
136
146
 
147
+ ## The Home tool
148
+
149
+ **Home** is the pane's first tab, and the tool a session with no stored shape opens on.
150
+ It reads the workspace repository through git and draws the pulse of it, so the first thing the pane shows is the work itself rather than an empty panel.
151
+
152
+ - **What it serves.** The workspace as a repository, and nothing else. A directory that is no repository is a state Home draws - one plain line - rather than a failure, and so is a repository with no commit yet.
153
+ - **The child process.** git, through the same shared runner Code Reviews uses, as a closed list of read-only operations of Home's own. A branch name never reaches a `--format=` string as text: where a branch is the subject, Home passes its object id.
154
+ - **When it reads.** When its tab is first given a width, and when the user presses Refresh. Nothing watches the repository and nothing polls it, so a commit made in the terminal beside the tab shows on the next opening or the next refresh. The button that reads again says in its tip how long ago the page read. The one read that happens without the user is the growth card filling in: its points are counted in the background, so while that read says there are points left the page asks it again, and it stops as soon as the sampler is done.
155
+ - **What it counts.** chamba's own directories stay out of every count of lines and of files: the specs root and the reviews root, as the mount names them. Both tools write what they serve - a spec's forms and answers, a review's grouping and its baseline blobs - and counted as code they say a workspace is mostly JSON. A switch in the header row, beside Refresh, puts them back for the whole page at once: the tree card, the growth card, the plant heights in the garden and the lines tile. It moves counts of lines and of files alone. No count of commits moves with it, and neither does the uncommitted changes beside the branch, the branches card or the size of the ignored paths. The state is held in memory, so a reload comes back with chamba's files left out. The two roots reach the module as a parameter, which is why a workspace that keeps either one somewhere else is served the same way, and a workspace with neither gets no switch.
156
+ - **The cache.** Derived numbers alone - a walk of the history, a verdict per branch, and the growth points, one count of files and lines per commit id - kept in `.home/` under the bind-mounted state directory. The module refuses a cache directory inside the workspace, because a file there would show up in the `git status` of every repository Home opens, and a file in the container would die with it.
157
+ - **No door for the agent.** Three routes, each a GET the page calls: the history, the branches and the tree. No verb, no helper on PATH, no framed sentence and no workspace file served raw. Home is the one tool an agent cannot reach at all.
158
+ - **The bee.** A bee crosses the page about once a minute, and a click on it plays one of two short clips and shows a quote. It is one animated image and two sound files, built into the Home client. Its sound is the bell in the bar: the pane hands that preference to every tool frame in the session message, so Home has no mute of its own. Under reduced motion the bee parks beside the header row and stays there.
159
+
137
160
  ## The Specs tool
138
161
 
139
- **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.
162
+ **Specs** is the pane's second tab: a separate application - its own client, its own server side - that webterm hosts rather than contains, built and mounted the way Home is.
140
163
  It reads the specs the workspace holds, and it is where the spec workflow grows.
141
164
 
142
165
  - **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.
@@ -144,11 +167,11 @@ It reads the specs the workspace holds, and it is where the spec workflow grows.
144
167
  - **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.
145
168
  - **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.
146
169
  - **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.
147
- - **What it shows.** The rail on the left, one artifact read as one continuous document in the middle, and, with a spec open, the state of that work on the right. The rail has two modes and never both at once - with nothing open it is what this root holds, the work in hand and the work signed off; with a spec open it is that spec's own artifacts and sections, with the way back at the top. The third pane follows the same rule from the other side: it says what is true of the open spec, so with nothing open there are two panes and the document takes the width. 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.
170
+ - **What it shows.** The rail on the left, one artifact read as one continuous document in the middle, and, with a spec open, the state of that work on the right. The rail has two modes and never both at once - with nothing open it is what this root holds, the work in hand under a heading that counts it and, where there is any, the work signed off under a heading of its own; with a spec open it is that spec's own artifacts and sections, with the way back at the top. The work signed off is the half that grows without limit, so its heading says how many and nothing under it is drawn until that heading is pressed. Pressing it also opens a search over those rows, by the name, by the directory, by where the work stands or by the date it was archived, which says "nothing by that name" where none match. Such a row is the name the work calls itself with that date under it, and no status, because everything in the archive carries the same one. The third pane follows the same rule from the other side: it says what is true of the open spec, so with nothing open there are two panes and the document takes the width. 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.
148
171
  - **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.
149
172
  - **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.
150
173
  - **Starting one.** `New spec` at the top of the rail is one form - a working name, one box for everything you have to say, and files by drop or paste. The name says what the work will be saved as as you type it, and one quiet line at the foot asks for imagination mode: a wish the agent shapes its recommendation around, and not a decision, because the steps are where the mode is confirmed. Sending it creates the directory, saves what you wrote as `intake.md`, starts the state file, and types the spec command with the fresh directory's name into the terminal - filing the form is the user's own act, so the line is the command the user would type. Each agent spells that command its own way, and the line is spelled for the session it goes to: `/dx-spec` for claude and for opencode, `$dx-spec` for codex. An agent that hears the idea in conversation files the same intake through the helper, and gets the same directory.
151
- - **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. One stage brings a question of its own, asked beside that stage and only while it is ticked: how wide to cast under research. 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 opens ticked where the intake asked for it, and `Ask the agent first` sends the question to the terminal instead of answering it here.
174
+ - **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. One stage brings a question of its own, asked beside that stage and only while it is ticked: how wide to cast under research. 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 opens ticked where the intake asked for it, and `Ask` sends the question to the terminal instead of answering it here.
152
175
  - **Changing them later.** `change` beside the board's Protocol heading reopens the same form over what was confirmed. A stage that is done or running is locked, because a step already taken is not untaken by unticking a box; a stage still waiting can go. Confirming writes the amended protocol through the same act that wrote the first one - there is no second writer - and types one line into the terminal saying what moved, so the agent in the middle of the work hears about it. `Quality review` in the rail is that same act in one press, for the one change that is asked for most.
153
176
  - **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`, `recommend`, `await`, `withdraw`, `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.
154
177
  - **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. A removal is complete by pointing, so it joins the queue in the right pane at once. A comment is written where it is about, in a small box that opens at the point, and nothing joins the queue until it is submitted - an empty box leaves nothing behind. What is queued reads back as what was said, with the acts to change it, send it on its own or drop it, and nobody has been told until it is sent. The queue is the piece of work's own: it is kept in that work's `.specs/` directory, so it survives a page reload, a session that ends and a container that is rebuilt, and every window open on the same spec draws the same list. Nothing an agent reads has changed until a round is sent.
@@ -156,24 +179,25 @@ It reads the specs the workspace holds, and it is where the spec workflow grows.
156
179
  - **Setting the run up.** One form carries every decision that shapes a run: how the work is saved, which branch it lands on, where it stops for you, and who reviews it, with a panel of one focus per reviewer. A reviewer on that panel is a chip of plain text with a button to drop it, because a focus is a sentence and a field cannot show one whole. One box at the end of the panel is where a reviewer is written: Enter or its own `add` button puts what is in it on the panel and empties it, a box left with words in it becomes a reviewer at submit, and clicking a chip's words puts them back in that box, which is how a reviewer is changed. It opens on the agent's own recommendation, marked as such, and on the built-in defaults where there is none - and on the decisions the run started with, once it has started, because a second execute is the same run carrying on. Submitting records the decisions in the state file and only then types the command that starts the run, so the agent reads what you decided rather than what the last run did. A pause or a checkpoint naming a phase the plan no longer holds is marked, and the form waits for you to re-place it.
157
180
  - **One view for each session.** The pane is one frame for the whole window, and the window drives one session at a time, so what is open follows the session rather than the window: two pieces of work in two sessions are two readers. Moving between sessions gives each one back the spec it was reading, where it was, the answer form it was in the middle of, and the New-spec form with what was typed into it. The queued annotations are not on that list: the queue belongs to the piece of work rather than to the reader of it, so it is the same list in every session and in every window open on that spec. A window with no session shows the overview, because a screen whose send actions have nowhere to go is not worth keeping. The one exception is the session that starts from the tool's own offer: it arrives at a window that was driving none, and takes over the spec the reader was looking at when they asked for it.
158
181
  - **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 is one question of that same kind, plus a pointer to the passage it is about: it names the artifact and the section, the tab opens that artifact at that section and draws the card beneath it, and the ways out are the agent's own options rather than two fixed buttons - what was found and what each way costs are in the question's own words. 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.
159
- - **Not answering yet.** Every one of the three offers two ways out that settle nothing. `Ask about this` sends what you typed to the terminal as a question and answers nothing: the ask stays on the list, the form stays open, and the agent's answer comes back beside it. `Later` closes the form and leaves the ask waiting, with what you wrote still in it when you open it again. Neither one writes anything into the round file, and neither one is a decision.
182
+ - **Not answering yet.** Every one of the three offers two ways out that settle nothing. `Ask` sends what you typed to the terminal as a question and answers nothing: the ask stays on the list, the form stays open, and the agent's answer comes back beside it. `Later` closes the form and leaves the ask waiting, with what you wrote still in it when you open it again. Neither one writes anything into the round file, and neither one is a decision.
160
183
  - **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.
161
184
  - **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. They sit at the top of the board, above the protocol, the run and the review rounds: all of those are a record of what has happened, and that record grows for as long as the work runs, so anything under it leaves the pane. 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.
162
185
  - **The board.** Under the protocol heading, one line per confirmed stage with a mark saying where it stands - green for done, the accent blue for running, muted for waiting. A stage's name is the way into what it produced: one artifact opens from the name itself, several fold out under it on a click and fold away on the next, and a stage that produced nothing is plain text. No list is drawn until it is asked for, because the same paths under stage after stage bury the shape of the work in its own filenames. What the agent is doing at this moment is under its own **Now** heading with a pulsing dot, which is the one line on the board that is not a record of what has happened. It is there while it is current, and the heading goes with it: an activity is over the moment anything else is posted, and one that nothing follows is held for half an hour, because a quiet step is still a step. So a run that ends mid-activity - a container rebuilt, a session closed - takes its line off the board by itself, and the board never says an agent is working when none is. Under **Execution**, once there is an execution plan, the phases and where each one stands; under **Review**, 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.
163
- - **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 and `/dx-spec-execute` runs its plan, both honoring the engineering principles the user keeps in `principles.md` beside the work. 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.
186
+ - **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 and `/dx-spec-execute` runs its plan, both honoring the rules the repository states in its own `AGENTS.md` or `CLAUDE.md`. 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.
164
187
 
165
188
  ## The Code Reviews tool
166
189
 
167
- **Code Reviews** is the pane's third tab, built and mounted the same way Specs is, and it is the one tool that reads the repository itself.
190
+ **Code Reviews** is the pane's third tab, built and mounted the same way Specs is, and it is the tool that reads the repository as a change.
168
191
  A review is a changeset the reviewer reads hunk by hunk, with the agent on the other side of it.
169
192
 
170
193
  - **What it serves.** One directory of the workspace, `WEBTERM_REVIEWS_ROOT`, which is `reviews/` unless it is set, and the workspace itself as the repository the reviews are of. Every git call runs with the repository as its working directory, and every diff the tool computes excludes the reviews root by pathspec, so a review is never a review of itself.
171
- - **The one child process.** git, and nothing else. It is run by an absolute path, with no shell, as a closed list of read-only operations with fixed arguments, and with limits on time and output. An argument outside the list is refused rather than passed on. The interface never writes to the workspace from a review: no checkout, no staging and no commit, so the commit at approval and a checkout from the "Different branch" banner are asked of the agent instead.
172
- - **What a review holds.** One directory each, under the reviews root, with the review's own files in a `.reviews/` inside it: the state, the baseline of the changeset with its raw diff and the blobs it needs, the reader's marks, the threads, the pending feedback, the agent's metadata and the approval. The tool is the single writer, every write is atomic, and `README.md` beside the state is rendered from it, so what the pane draws and what git holds are the same facts.
194
+ - **The child process.** git, and nothing else, which is the one program Home runs too. It is run by an absolute path, with no shell, as a closed list of read-only operations with fixed arguments, and with limits on time and output. An argument outside the list is refused rather than passed on. The interface never writes to the workspace from a review: no checkout, no staging and no commit, so the commit at approval and a checkout from the "Different branch" banner are asked of the agent instead.
195
+ - **What it shows.** A rail of the agent's groups on the left, one row each with what is left in it, and All groups as the last row. The group in view is a section in the middle, under a header that sticks to the top of the feed and holds the group's name, its counts, the agent's narrative and facts, the acts on the whole group, and four controls that walk the group hunk by hunk - the next unseen hunk, the next issue, the next attention note and the next unread reply. A hunk is a card of the diff, coloured by its own language, and the agent's notes are rows inside that diff, each one directly above the lines it is about. Under both columns runs one status bar with what is left of the review, the activity log and the way to approve.
196
+ - **What a review holds.** One directory each, under the reviews root, with the review's own files in a `.reviews/` inside it: the state, the baseline of the changeset with its raw diff and the blobs it needs, the reader's marks, the threads the comments made, what the reader put aside, the pending feedback, where the reader has read to, the agent's metadata and the approval. The tool is the single writer, every write is atomic, and `README.md` beside the state is rendered from it, so what the pane draws and what git holds are the same facts.
173
197
  - **Live.** Every change under the root reaches every open window, coalesced per review: a recompute writes hundreds of files and a window that re-reads the review has no use for hundreds of events.
174
198
  - **In a frame of its own.** As Specs: an opaque origin, two scoped credentials of its own, and nothing of the shell reachable from inside - see Security below.
175
199
  - **What a browser may load from a review.** Two locations and no others: a stored blob, addressed by its own object name, and the signature image of an approval. Both come back through the tool's own reader, jailed like every other workspace byte. Nothing else under a review, and nothing of the repository, is served that way.
176
- - **The helper.** `reviews <verb>` on PATH inside any session, the same script as `specs` under its other name and gated the same way. The verbs are `stages`, `state`, `start`, `metadata`, `reply`, `activity`, `done` and `log`. Nothing in that list marks a hunk seen, closes a thread, approves, or changes the baseline of its own accord: those are the reviewer's acts, through the tab.
200
+ - **The helper.** `reviews <verb>` on PATH inside any session, the same script as `specs` under its other name and gated the same way. The verbs are `stages`, `state`, `start`, `metadata`, `group`, `ungroup`, `reply`, `activity`, `done` and `log`. Nothing in that list marks a hunk seen, closes a thread, approves, or changes the baseline of its own accord: those are the reviewer's acts, through the tab.
177
201
  - **How an agent comes to use it.** The `dx-review` skill, shipped in the image and injected like every other skill. Every act that hands a review to the agent types that skill's command into the reviewer's session - `/dx-review` on claude and opencode, `$dx-review` on codex - and the skill reads the review's state to know which of its acts is being asked for. The three events that keep the review with the reviewer type a framed sentence instead, tagged `[reviews]`.
178
202
 
179
203
  ## The status strip
@@ -224,8 +248,8 @@ A window the account does not have takes its whole meter off the strip rather th
224
248
 
225
249
  ## Config
226
250
 
227
- `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, colour 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, the root and the two scoped credentials each pane tool is mounted with, and the bounds on a delivery, per tool).
228
- Env overrides: `WEBTERM_PORT`, `WEBTERM_CWD`, `WEBTERM_AGENT`, `WEBTERM_AGENT_ARGS`, `WEBTERM_KEY`, `WEBTERM_KEY_FILE`, `WEBTERM_RESUME_STAMP`, `WEBTERM_STATE_FILE`, `WEBTERM_COLOR_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`, the same seven for the Code Reviews tool - `WEBTERM_REVIEWS_ROOT`, `WEBTERM_REVIEWS_KEY`, `WEBTERM_REVIEWS_RAW_TOKEN`, `WEBTERM_MAX_REVIEWS_LINE_LENGTH`, `WEBTERM_REVIEWS_LINE_MIN_INTERVAL_MS`, `WEBTERM_MAX_REVIEWS_LINES_PER_SESSION` and `WEBTERM_REVIEWS_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`.
251
+ `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, colour file, the stay-awake marker and how old it may be, 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, the root and the two scoped credentials each pane tool is mounted with, Home's cache directory, and the bounds on a delivery, per tool).
252
+ Env overrides: `WEBTERM_PORT`, `WEBTERM_CWD`, `WEBTERM_AGENT`, `WEBTERM_AGENT_ARGS`, `WEBTERM_KEY`, `WEBTERM_KEY_FILE`, `WEBTERM_RESUME_STAMP`, `WEBTERM_STATE_FILE`, `WEBTERM_COLOR_FILE`, `WEBTERM_AWAKE_FILE`, `WEBTERM_AWAKE_STALE_MS`, `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`, the same seven for the Code Reviews tool - `WEBTERM_REVIEWS_ROOT`, `WEBTERM_REVIEWS_KEY`, `WEBTERM_REVIEWS_RAW_TOKEN`, `WEBTERM_MAX_REVIEWS_LINE_LENGTH`, `WEBTERM_REVIEWS_LINE_MIN_INTERVAL_MS`, `WEBTERM_MAX_REVIEWS_LINES_PER_SESSION` and `WEBTERM_REVIEWS_NEW_SESSION_DELAY_MS` - `WEBTERM_HOME_CACHE_DIR`, `WEBTERM_HOME_KEY` and `WEBTERM_HOME_RAW_TOKEN`, which are the whole of Home's own set, and the four `WEBTERM_*_DIR` agent-store paths, plus `WEBTERM_PROC_ROOT`, which belongs to `proc.js` rather than to `config.js`.
229
253
  `WEBTERM_KEY` pins the key instead of creating one, and each tool's two scoped credentials the same way, which is for tests and hand-run debugging - there is no way to turn any of those gates off.
230
254
  `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.
231
255
  `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.
@@ -0,0 +1,27 @@
1
+ // awake.js - whether the host is being kept awake for this container, as the container can tell.
2
+ //
3
+ // A holder process on the host keeps a marker file in the workspace's state directory while it holds the
4
+ // machine awake, and touches it every half-minute. The container reads that file and nothing else: it cannot
5
+ // see a host process, so freshness is the only honest test it has. A marker older than the trust window is
6
+ // a holder that stopped without taking its file away - a machine that lost power, a process killed outright -
7
+ // and is read as no hold at all.
8
+ //
9
+ // The file is on the host's side of a bind mount, so a write to it raises no event in here and there is
10
+ // nothing to subscribe to. The server looks again on a timer, as it does for the status strip.
11
+ //
12
+ // No globals and nothing done at load, so the tests import this file and run it.
13
+
14
+ import { statSync } from "node:fs";
15
+
16
+ /**
17
+ * Whether the marker says a holder is running now. False for a missing file, an unreadable one, and one whose
18
+ * last touch is older than the window - each of them a hold this container has no reason to believe in.
19
+ */
20
+ export function isHostAwake(file, staleMs, now = Date.now()) {
21
+ if (!file) return false;
22
+ try {
23
+ return now - statSync(file).mtimeMs < staleMs;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
@@ -48,6 +48,16 @@ export const STATE_FILE = process.env.WEBTERM_STATE_FILE || "/home/devuser/.cham
48
48
  // choice outlives the container: a rebuild replaces everything except what is kept out here.
49
49
  export const COLOR_FILE = process.env.WEBTERM_COLOR_FILE || "/home/devuser/.chamba-webterm/color";
50
50
 
51
+ // The stay-awake marker, and the one file in that directory the host writes and this server only reads: a
52
+ // holder process out there keeps it while it holds the machine awake for this container. Sits beside
53
+ // STATE_FILE in the same mounted directory. Empty disables the indicator.
54
+ export const AWAKE_FILE = process.env.WEBTERM_AWAKE_FILE || "/home/devuser/.chamba-webterm/awake";
55
+
56
+ // How old the marker may be and still be believed. The holder touches it every thirty seconds, so three
57
+ // missed touches is a holder that is not there any more: it cannot be seen from in here, and a machine that
58
+ // lost power leaves a file behind that nothing on this side would ever come back to remove.
59
+ export const AWAKE_STALE_MS = Number(process.env.WEBTERM_AWAKE_STALE_MS) || 90_000;
60
+
51
61
  // The mounted workspace, and the only tree a session may be started in. Every directory the interface
52
62
  // deals with is this one or something under it.
53
63
  export const WORKSPACE_ROOT = "/workspace";
@@ -149,6 +159,26 @@ export const MAX_REVIEWS_LINES_PER_SESSION = Number(process.env.WEBTERM_MAX_REVI
149
159
  // And the wait for a session this delivery had to start, which is about the agent CLI rather than the tool.
150
160
  export const REVIEWS_NEW_SESSION_DELAY_MS = Number(process.env.WEBTERM_REVIEWS_NEW_SESSION_DELAY_MS) || 3_000;
151
161
 
162
+ // --- Home --------------------------------------------------------------------------------------------------------------------------------
163
+
164
+ // Where the Home tool keeps what it worked out about the repository. Its numbers are all derived and belong
165
+ // to nobody, so they are not the work: a file of them in the workspace would show up in the `git status` of
166
+ // every repository Home opens, and a file of them in the container would die with it. This directory is bind-
167
+ // mounted from the host workspace cache, beside STATE_FILE, so the cache outlives a rebuild. The module
168
+ // refuses a directory inside the workspace, so the two can never be the same place.
169
+ export const HOME_CACHE_DIR = process.env.WEBTERM_HOME_CACHE_DIR || "/home/devuser/.chamba-webterm";
170
+
171
+ // This tool's own two credentials, made the same way and opening nothing of the other tools'. Home serves no
172
+ // workspace file at all, so the raw token opens nothing; it is issued all the same, because the raw door is
173
+ // registered for every tool and a tool with no token would be a tool with no door to refuse at.
174
+ export const HOME_KEY = process.env.WEBTERM_HOME_KEY || randomBytes(16).toString("hex");
175
+ export const HOME_RAW_TOKEN = process.env.WEBTERM_HOME_RAW_TOKEN || randomBytes(16).toString("hex");
176
+
177
+ // The largest body a Home data route accepts. Every route is a GET with no body, so this is the size of the
178
+ // nothing they carry; the door that gates a body reads it for every tool, and a tool with no number is a tool
179
+ // with no gate.
180
+ export const MAX_HOME_BYTES = 64 * 1024;
181
+
152
182
  // --- The status strip --------------------------------------------------------------------------------------------------------------------
153
183
 
154
184
  // How often a session's snapshot file is looked at again for the strip above the composer. A poll for the
@@ -9,6 +9,7 @@ import { sessions, workspaceName } from "./state.js";
9
9
  import { arrivedAt, renderBar } from "./tabs.js";
10
10
  import { focusTerminal } from "./terminal.js";
11
11
  import { workspaceColor } from "./theme.js";
12
+ import { toolsHearSound } from "./tool-host.js";
12
13
 
13
14
  // --- The browser's own tab ---------------------------------------------------------------------------------------------------------------
14
15
  //
@@ -20,6 +21,12 @@ import { workspaceColor } from "./theme.js";
20
21
  // nothing - nothing is happening in here
21
22
  // white bar across the bottom - an agent is working, with a lit segment moving along it
22
23
  // green dot in the top corner - an agent finished and is waiting for you, pulsing until you go and look
24
+ // grey all through, and still - this window cannot reach its container, so it says nothing else at all
25
+ //
26
+ // The grey one is the odd member of the four, because it is about this window rather than about the container.
27
+ // Every other mark stands on the last sessions frame the server sent, and while the socket is down that frame
28
+ // only gets older - so a green dot calls you back to a session this window can no longer see. Draining the
29
+ // icon says the one thing that is still true, and the title and the chime below are held to the same rule.
23
30
  //
24
31
  // One mark at a time, and the two are told apart by where they sit before any colour is read: working owns the
25
32
  // bottom edge, waiting owns the top-right corner. That was the lesson of the first version, where both were a
@@ -50,10 +57,27 @@ const PULSE_TICKS = 3;
50
57
  const SWEEP_TICKS = 16;
51
58
  const DONE_COLOR = "#2fe58a";
52
59
  const BUSY_COLOR = "#e6edf3";
60
+ // The offline pair: the interface's own muted text grey for the spark, and its border grey for the frame. Flat,
61
+ // with no gradient - at 16px a gradient between two greys says nothing. Anything fainter than this reads as a
62
+ // broken tab rather than an offline one, which is a different thing to tell someone.
63
+ const OFFLINE_SPARK = "#8b949e";
64
+ const OFFLINE_FRAME = "#30363d";
65
+ // How long the socket has to stay down before the icon says so. Most disconnects are a sleeping laptop or a wifi
66
+ // blip and are over inside this, so the icon holds still rather than flickering grey and back. The curtain waits
67
+ // the same three seconds, and the two waits cannot be shared: the curtain's is measured from the end of a /status
68
+ // request that has three seconds of its own, and it is skipped entirely for a window that has stopped trying.
69
+ // It is not only the icon's: the chime below reads the same flag, so lengthening this leaves a window sounding
70
+ // for a container it cannot reach for that much longer.
71
+ const OFFLINE_GRACE_MS = 3_000;
53
72
 
54
73
  let iconCanvas = null;
55
74
  let iconTimer = null;
56
75
  let iconFrame = 0;
76
+ // Whether this window has given up on reaching its container, and the grace that is counting towards it. A
77
+ // slower thing than the `offline` class on the body, which goes on at the first close: that one stops the page
78
+ // taking clicks it cannot answer, and this one waits to see whether there is anything worth saying.
79
+ let offline = false;
80
+ let offlineTimer = null;
57
81
 
58
82
  // What the dot should say right now, or null for no dot. Read fresh on every paint rather than passed in, so a
59
83
  // paint from anywhere - an incoming bar, or the pulse - draws what is true now.
@@ -85,7 +109,10 @@ function paintFavicon() {
85
109
  iconCanvas.height = ICON_SIZE;
86
110
  const ctx = iconCanvas.getContext("2d");
87
111
  if (!ctx) return;
88
- const badgeColor = dotColor();
112
+ // No mark at all while the window cannot reach its container, whatever the last frame said was happening:
113
+ // both marks stand on state nothing is coming to correct, so the grey is the whole message and the two ways
114
+ // of arriving at it - going offline while an agent works, and going offline while one waits - are one icon.
115
+ const badgeColor = offline ? null : dotColor();
89
116
  // Only one mark is ever drawn, so both phases can be handed over and the drawing picks the one it needs.
90
117
  try {
91
118
  drawIcon(ctx, badgeColor, badgeColor === DONE_COLOR && pulseDim(), sweepAt());
@@ -106,7 +133,9 @@ function drawIcon(ctx, badgeColor, dim, sweep) {
106
133
  ctx.beginPath();
107
134
  ctx.roundRect(0, 0, 32, 32, 7);
108
135
  ctx.fill();
109
- ctx.strokeStyle = workspaceColor();
136
+ // Offline drains the frame with everything else: the workspace hue is the liveliest thing in the icon, and
137
+ // one grey shape says more than a grey spark inside a bright ring.
138
+ ctx.strokeStyle = offline ? OFFLINE_FRAME : workspaceColor();
110
139
  ctx.lineWidth = 2;
111
140
  ctx.beginPath();
112
141
  ctx.roundRect(1, 1, 30, 30, 6);
@@ -115,9 +144,14 @@ function drawIcon(ctx, badgeColor, dim, sweep) {
115
144
  // The spark: four points with a waist, stroked as well as filled so it nearly fills the icon and still
116
145
  // reads at 16px. Same geometry and gradient as favicon.svg, which is what a browser without canvas still
117
146
  // gets - the two are one drawing in two languages, so a change to either is a change to both.
118
- const spark = ctx.createLinearGradient(0, 0, 0, 32);
119
- spark.addColorStop(0, "#f0c078");
120
- spark.addColorStop(1, "#d08a3c");
147
+ let spark;
148
+ if (offline) {
149
+ spark = OFFLINE_SPARK;
150
+ } else {
151
+ spark = ctx.createLinearGradient(0, 0, 0, 32);
152
+ spark.addColorStop(0, "#f0c078");
153
+ spark.addColorStop(1, "#d08a3c");
154
+ }
121
155
  ctx.fillStyle = spark;
122
156
  ctx.strokeStyle = spark;
123
157
  ctx.lineWidth = 2;
@@ -194,7 +228,9 @@ function tickIcon() {
194
228
  export function refreshBrowserTab() {
195
229
  // Just the workspace, no product name: a tab strip gives you a few characters, and the icon already says
196
230
  // this is chamba. What the title is for is which workspace, and how many sessions want you.
197
- const waiting = sessions.filter((entry) => entry.attention);
231
+ // The count goes with the icon's marks while the socket is down, and for the same reason. The title says the
232
+ // workspace and stops there: being offline is the icon's to say, and a tab strip cannot say it twice.
233
+ const waiting = offline ? [] : sessions.filter((entry) => entry.attention);
198
234
  const name = workspaceName || "chamba";
199
235
  document.title = waiting.length > 0 ? `(${waiting.length}) ${name}` : name;
200
236
 
@@ -202,7 +238,9 @@ export function refreshBrowserTab() {
202
238
  // what the sessions are doing: an alert arriving next to one already up is the same state, and restarting the
203
239
  // cycle on every frame the server sends would make the mark stutter.
204
240
  paintFavicon();
205
- if (!dotColor()) {
241
+ // The grey icon is one still frame: nothing in it moves, so nothing has to be redrawn, and a window sitting
242
+ // offline for a day costs exactly one paint.
243
+ if (offline || !dotColor()) {
206
244
  if (iconTimer) clearTimeout(iconTimer);
207
245
  iconTimer = null;
208
246
  iconFrame = 0;
@@ -211,6 +249,42 @@ export function refreshBrowserTab() {
211
249
  if (!iconTimer) iconTimer = setTimeout(tickIcon, ICON_TICK_MS);
212
250
  }
213
251
 
252
+ // The socket went. The only thing the connection has to say, and everything that follows from it - the grace, the
253
+ // flag, the title, the paint and the chime - is this module's, because all five are the same subject as the three
254
+ // states above.
255
+ //
256
+ // Counted from the first close and not restarted by the ones after it, which is the whole difference between
257
+ // three seconds and however long the reconnect loop takes to give up: every failed attempt closes a socket of its
258
+ // own, and each of those closes arrives here. The curtain's own wait is guarded the same way, for the same
259
+ // reason, and a tab that greys later than the curtain it sits above is a tab still showing a live green dot over
260
+ // a page that has already said the container cannot be reached.
261
+ export function socketWentDown() {
262
+ if (offline || offlineTimer) return;
263
+ offlineTimer = setTimeout(() => {
264
+ offlineTimer = null;
265
+ offline = true;
266
+ refreshBrowserTab();
267
+ }, OFFLINE_GRACE_MS);
268
+ }
269
+
270
+ // And came back.
271
+ export function socketCameBack() {
272
+ if (offlineTimer) {
273
+ clearTimeout(offlineTimer);
274
+ offlineTimer = null;
275
+ }
276
+ if (!offline) return;
277
+ // Spend whatever came due while the tab was grey, before the flag drops. An alert still standing and already
278
+ // due at this moment can only have come due during the disconnect, because nothing else was left running to
279
+ // catch it: the chime arms one timer for the soonest alert alone, and the icon's clock, which is what catches
280
+ // the rest, is stopped while the icon is grey. Without this, the first tick after a reconnect would find one
281
+ // of those alerts unspent and sound it - a chime for a finish that happened while this window was away.
282
+ maybeChime();
283
+ // Then straight back to whatever the sessions say, without waiting for the first frame of the new socket.
284
+ offline = false;
285
+ refreshBrowserTab();
286
+ }
287
+
214
288
  // --- A sound, for the session you are not at ---------------------------------------------------------------------------------------------
215
289
  //
216
290
  // The fourth thing the "an agent finished" alert does, and the only one that reaches a window behind an editor.
@@ -262,6 +336,9 @@ const CHIME_VOLUME = 0.35;
262
336
  // reload and covers every window of the same container without touching any other workspace. On by default: a
263
337
  // notification nobody discovers is not a notification, and the bell is right there to turn off.
264
338
  let soundOn = readSoundPref();
339
+ // Every pane tool that makes a sound of its own reads this window's one preference, so it is said once here at
340
+ // start and again on every toggle.
341
+ toolsHearSound(soundOn);
265
342
 
266
343
  function readSoundPref() {
267
344
  try {
@@ -389,7 +466,10 @@ function maybeChime() {
389
466
  // Spent whether or not it is heard, so a muted window does not save up its chimes for whenever the bell is
390
467
  // unmuted - nothing new would have happened by then.
391
468
  for (const entry of due) chimed.add(entry.id);
392
- if (!soundOn) return;
469
+ // A window that cannot reach its container is the second case of exactly that: calling you back to a session
470
+ // it can no longer see is worse than saying nothing, and a sound held for the reconnect would be reporting a
471
+ // state that has had a whole disconnect to move on.
472
+ if (offline || !soundOn) return;
393
473
  const now = Date.now();
394
474
  if (now - lastChimeAt < CHIME_GAP_MS) return;
395
475
  if (!claimChime()) return;
@@ -424,9 +504,9 @@ export function bellButton() {
424
504
  button.type = "button";
425
505
  button.classList.toggle("muted", !soundOn);
426
506
  button.title = soundOn
427
- ? "Sound on - a chime when an agent finishes in a session you have not touched in a while. Click to mute."
428
- : "Sound muted - click for a chime when an agent finishes in a session you have not touched in a while.";
429
- button.setAttribute("aria-label", soundOn ? "Mute the finish sound" : "Unmute the finish sound");
507
+ ? "Sound on - a chime when an agent finishes in a session you have not touched in a while, and the bee on the Home tab. Click to mute."
508
+ : "Sound muted - click for a chime when an agent finishes in a session you have not touched in a while, and for the bee on the Home tab.";
509
+ button.setAttribute("aria-label", soundOn ? "Mute the interface" : "Unmute the interface");
430
510
  button.setAttribute("aria-pressed", String(soundOn));
431
511
  const svg = document.createElementNS(SVG_NS, "svg");
432
512
  svg.setAttribute("viewBox", "0 0 24 24");
@@ -464,6 +544,7 @@ function toggleSound() {
464
544
  // Not remembered past this page, but the toggle still works for as long as it is open.
465
545
  }
466
546
  renderBar();
547
+ toolsHearSound(soundOn);
467
548
  // The click landed on a button, which took the keyboard off the terminal.
468
549
  focusTerminal();
469
550
  // Turning it on plays it once, so the first time it happens behind your editor it is a sound you have already
@@ -43,15 +43,49 @@ export function refreshComposer() {
43
43
  else input.placeholder = "Pick a session above to type in it.";
44
44
  }
45
45
 
46
+ // The box the text is measured in: a second textarea, hidden and of no height, styled as the composer is.
47
+ // The composer sits under the pane in one column, so a height it takes for one layout pass is a height the
48
+ // tool frames lose for that pass, and a frame in its own process paints at that size before the pass is
49
+ // corrected. Measuring here leaves the composer's height alone until the text needs another line.
50
+ let mirror = null;
51
+ function mirrorOf() {
52
+ if (mirror !== null) return mirror;
53
+ mirror = document.createElement("textarea");
54
+ mirror.id = "input-mirror";
55
+ mirror.tabIndex = -1;
56
+ mirror.setAttribute("aria-hidden", "true");
57
+ mirror.readOnly = true;
58
+ input.parentElement.appendChild(mirror);
59
+ return mirror;
60
+ }
61
+
46
62
  export function autoGrow() {
47
- input.style.height = "auto";
63
+ const box = getComputedStyle(input);
64
+ const ruler = mirrorOf();
65
+ // What decides where the text wraps and how tall a line is, copied from the composer as it stands now.
66
+ for (const name of [
67
+ "font",
68
+ "letterSpacing",
69
+ "lineHeight",
70
+ "padding",
71
+ "border",
72
+ "boxSizing",
73
+ "whiteSpace",
74
+ "wordBreak",
75
+ "overflowWrap",
76
+ "tabSize",
77
+ ]) {
78
+ ruler.style[name] = box[name];
79
+ }
80
+ ruler.style.width = `${input.clientWidth + Number.parseFloat(box.borderLeftWidth) + Number.parseFloat(box.borderRightWidth)}px`;
81
+ ruler.value = input.value;
48
82
  // The floor keeps the box off the button block behind it and the ceiling limits it to seven lines. Both are
49
83
  // read from the box itself rather than written here as well: the same two numbers in two files is how the
50
84
  // box comes to spring back to a height the stylesheet no longer says.
51
- const box = getComputedStyle(input);
52
85
  const floor = Number.parseFloat(box.minHeight) || 0;
53
86
  const ceiling = Number.parseFloat(box.maxHeight) || Number.POSITIVE_INFINITY;
54
- input.style.height = `${Math.min(Math.max(input.scrollHeight, floor), ceiling)}px`;
87
+ const wanted = `${Math.min(Math.max(ruler.scrollHeight, floor), ceiling)}px`;
88
+ if (input.style.height !== wanted) input.style.height = wanted;
55
89
  }
56
90
  input.addEventListener("input", () => {
57
91
  autoGrow();
@@ -4,6 +4,7 @@
4
4
  // the curtain is the one state that says "this window cannot do anything at all". They live together because
5
5
  // they are the same subject read from two ends: what the socket is doing, and what the user is told about it.
6
6
 
7
+ import { socketCameBack, socketWentDown } from "./alerts.js";
7
8
  import { refreshComposer } from "./composer.js";
8
9
  import { curtain, curtainCard } from "./dom.js";
9
10
  import { onFrame } from "./frames.js";
@@ -197,6 +198,12 @@ export function noteSocketAlive() {
197
198
  // being abandoned must not schedule a second reconnect or overwrite the new one's state.
198
199
  function dropSocket() {
199
200
  if (!ws) return;
201
+ // The one close nobody else hears about. A socket retired while the page still believes it is up is the wake
202
+ // case: after a sleep the socket reads OPEN for minutes with nothing on the other end, the ping gets no
203
+ // answer, and this replaces it - with the handlers nulled, so no onclose ever fires for it. The browser tab
204
+ // is told here instead, because a tab left wearing a green dot for a container it cannot reach is exactly
205
+ // what this state is. Its own grace covers the ordinary case, where the replacement opens a moment later.
206
+ if (connected) socketWentDown();
200
207
  ws.onopen = null;
201
208
  ws.onmessage = null;
202
209
  ws.onclose = null;
@@ -225,6 +232,9 @@ export function connect() {
225
232
  clearCurtainTimer();
226
233
  hideCurtain();
227
234
  document.body.classList.remove("offline");
235
+ // The browser tab reads the socket too, and it keeps a grace of its own. Told that the socket moved and
236
+ // nothing more: what the tab does about it belongs to the tab.
237
+ socketCameBack();
228
238
  // Ask for the session this window was last looking at. The server decides whether it can have
229
239
  // it back, and picks something sensible when it cannot.
230
240
  // The last session this window drove: the server hands it back when no other window is on it.
@@ -249,6 +259,10 @@ export function connect() {
249
259
  // Immediately, ahead of any curtain: every control on the page is answered over this socket, so
250
260
  // while it is down they must stop taking clicks.
251
261
  document.body.classList.add("offline");
262
+ // And the browser tab, which is the only thing a hidden window can say any of this with. Said on every
263
+ // close, whichever curtain the diagnosis ends at: one rule covers a container that is down, a spent key,
264
+ // a container being stopped and a container that has gone.
265
+ socketWentDown();
252
266
  // An anchored panel hangs off the body, so the bar's own offline rules do not reach it - and no frame
253
267
  // is coming to redraw the bar, which leaves the age tick up to a minute away. Both are shut here.
254
268
  closeNewPop();
@@ -28,6 +28,7 @@ export const paneHalf = document.getElementById("pane-half");
28
28
  export const paneCollapse = document.getElementById("pane-collapse");
29
29
  export const spine = document.getElementById("spine");
30
30
  export const spineBadge = document.getElementById("spine-badge");
31
+ export const toolHome = document.getElementById("tool-home");
31
32
  export const toolPages = document.getElementById("tool-pages");
32
33
  export const toolSpecs = document.getElementById("tool-specs");
33
34
  export const toolReviews = document.getElementById("tool-reviews");
@@ -169,7 +169,7 @@ function applySessions(msg) {
169
169
  pruneHistory();
170
170
  refreshComposer();
171
171
  // Same for the pane: a session that left the bar takes this window's copy of its page list with it. The
172
- // files themselves are untouched - a resumed conversation gets them back (FR-15).
172
+ // files themselves are untouched - a resumed conversation gets them back.
173
173
  const live = new Set(sessions.map((entry) => entry.id));
174
174
  prunePanes(live);
175
175
  // And for the pane itself: the shape a session was left in ends with that session, the way its draft does.
@@ -0,0 +1,16 @@
1
+ // home-host.js - the Home tool on the page.
2
+ //
3
+ // The host around a tool's frame is `tool-host.js`, where every tool's is. What is here is which tool this
4
+ // is: its name, which is what its routes and its messages are named after, the panel the pane gave it, and
5
+ // the sentence its tab shows while it is opening.
6
+
7
+ import { toolHome } from "./dom.js";
8
+ import { HOME } from "./pane-shell.js";
9
+ import { createToolHost } from "./tool-host.js";
10
+
11
+ createToolHost({
12
+ id: HOME,
13
+ label: "Home",
14
+ panel: toolHome,
15
+ opening: "Reading the pulse of this repository.",
16
+ });