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.
Files changed (55) hide show
  1. package/README.md +13 -6
  2. package/dist/commands/dev.js +11 -11
  3. package/dist/commands/settings.js +1 -1
  4. package/dist/lib/agent-context.js +33 -7
  5. package/dist/lib/chamba-yaml.js +1 -1
  6. package/dist/lib/constants.js +4 -4
  7. package/dist/lib/dockerfile-builder.js +2 -1
  8. package/dist/lib/ports.js +4 -4
  9. package/dist/lib/safe-rm.js +13 -3
  10. package/dist/lib/webterm.js +7 -7
  11. package/package.json +3 -5
  12. package/templates/Dockerfile +20 -1
  13. package/templates/context/web-pane-craft.md +1 -1
  14. package/templates/pane-apps/client/assets/specs-B1970L17.css +1 -0
  15. package/templates/pane-apps/client/assets/specs-cEee_SPn.js +23 -0
  16. package/templates/pane-apps/client/specs/index.html +13 -0
  17. package/templates/pane-apps/server/specs.mjs +1588 -0
  18. package/templates/skills/chamba-statusline/SKILL.md +1 -1
  19. package/templates/skills/dx-spec/SKILL.md +365 -0
  20. package/templates/skills/dx-spec/references/imagination-guide.md +140 -0
  21. package/templates/skills/dx-spec/references/review-guide.md +173 -0
  22. package/templates/skills/dx-spec/references/spec-guide.md +125 -0
  23. package/templates/skills/dx-spec/references/stages.md +399 -0
  24. package/templates/skills/dx-spec-config/SKILL.md +313 -0
  25. package/templates/skills/dx-spec-config/references/principles-template.md +12 -0
  26. package/templates/skills/dx-spec-execute/SKILL.md +324 -0
  27. package/templates/specs.sh +106 -0
  28. package/templates/webterm/README.md +50 -14
  29. package/templates/webterm/artifacts.js +11 -11
  30. package/templates/webterm/config.js +52 -9
  31. package/templates/webterm/conversation.js +3 -3
  32. package/templates/webterm/pane.js +14 -3
  33. package/templates/webterm/proc.js +1 -1
  34. package/templates/webterm/public/app/alerts.js +5 -5
  35. package/templates/webterm/public/app/composer.js +5 -2
  36. package/templates/webterm/public/app/connection.js +2 -2
  37. package/templates/webterm/public/app/dictation.js +1 -1
  38. package/templates/webterm/public/app/dom.js +13 -5
  39. package/templates/webterm/public/app/frames.js +8 -1
  40. package/templates/webterm/public/app/main.js +8 -2
  41. package/templates/webterm/public/app/new-session.js +1 -1
  42. package/templates/webterm/public/app/pane-shell.js +315 -0
  43. package/templates/webterm/public/app/pane.js +58 -183
  44. package/templates/webterm/public/app/specs-host.js +222 -0
  45. package/templates/webterm/public/app/state.js +1 -1
  46. package/templates/webterm/public/app/tabs.js +1 -1
  47. package/templates/webterm/public/app/terminal.js +8 -0
  48. package/templates/webterm/public/index.html +51 -27
  49. package/templates/webterm/public/styles.css +144 -30
  50. package/templates/webterm/server.js +300 -11
  51. package/templates/webterm/sessions.js +7 -7
  52. package/templates/webterm/snapshot.js +2 -2
  53. package/templates/webterm/specs.js +358 -0
  54. package/templates/webterm/tool-document.js +67 -0
  55. package/templates/webterm/typed-line.js +85 -0
@@ -1,58 +1,47 @@
1
- // pane.js - the web pane: the pages an agent published, beside the terminal it published them from.
1
+ // pane.js - the Pages tool: the pages an agent published, beside the terminal it published them from.
2
2
  //
3
- // The pane is chrome this interface draws - the chips bar, the counter, the spine - around a page it does
4
- // not trust (pane-frame.js renders that part). Keeping the two apart is the point: a published page can
5
- // never fake which session and which page the user is looking at, because everything that says so is drawn
6
- // outside its frame.
3
+ // One of the tools the pane holds (pane-shell.js holds them), and the whole of what this module draws is its
4
+ // own panel - the chips bar, the counter, and the page. The pane's own furniture, the tab bar and the width,
5
+ // is the shell's, so nothing here can move the pane or say which tool the user is looking at.
7
6
  //
8
- // What the pane shows belongs to the attached session, and it follows the tabs: switching sessions switches
9
- // the list, the selection, and the page. What the pane *is* belongs to the window - how wide it is, whether it
10
- // is collapsed, and how big a page is drawn in it - and that survives the switch, and a reload.
7
+ // The chips bar is chrome this interface draws around a page it does not trust (pane-frame.js renders that
8
+ // part). Keeping the two apart is the point: a published page can never fake which session and which page the
9
+ // user is looking at, because everything that says so is drawn outside its frame.
10
+ //
11
+ // What this tool shows belongs to the attached session, and it follows the tabs: switching sessions switches
12
+ // the list, the selection, and the page.
11
13
  //
12
14
  // A page that arrives opens itself, and says so with one pulse of the pane's edge. It never takes the
13
15
  // keyboard: the caret stays in the composer mid-sentence, which is the price of the pane opening at all.
14
16
  // "Arrives" is the server's own unread flag, not "new to this window" - the list here starts empty on every
15
17
  // reload, and a page nobody has opened in a session nobody has visited is still an arrival when they get to
16
18
  // it. A page for a session this window is not looking at touches nothing here; it marks that session's tab
17
- // (see tabs.js), keeps its chip badge and its place in the spine's counter, and waits.
19
+ // (see tabs.js), keeps its chip badge and its place in the spine's counter, and waits. A page that lands
20
+ // while the pane is open on another tool marks the Pages tab and waits there: an arrival is worth saying, and
21
+ // never worth taking the screen for.
18
22
  //
19
- // The pane exists before any of that. A session that has published nothing still has its spine, and opening it
23
+ // The tool exists before any of that. A session that has published nothing still has its tab, and opening it
20
24
  // says what the pane is for and what to ask for - a feature nobody can see is a feature nobody uses, and this
21
25
  // one is asked for in words rather than found in a menu.
22
26
 
23
27
  import { sendFrame } from "./connection.js";
24
- import {
25
- chips,
26
- grip,
27
- pageBigger,
28
- pagebar,
29
- pageCount,
30
- pageDoc,
31
- pageSizeVal,
32
- pageSmaller,
33
- pane,
34
- paneCollapse,
35
- paneNext,
36
- panePrev,
37
- spine,
38
- spineBadge,
39
- } from "./dom.js";
28
+ import { chips, pageBigger, pagebar, pageCount, pageDoc, pageSizeVal, pageSmaller, paneNext, panePrev, toolPages } from "./dom.js";
40
29
  import { arrivingPage } from "./pane-arrival.js";
41
30
  import { dropPage, fetchPage, renderPage, submitFeedback } from "./pane-frame.js";
31
+ import {
32
+ flashPane,
33
+ isPaneCollapsed,
34
+ isPaneShut,
35
+ isToolOpen,
36
+ noteTool,
37
+ PAGES,
38
+ registerTool,
39
+ rememberPageSize,
40
+ setPaneCollapsed,
41
+ storedPageSize,
42
+ } from "./pane-shell.js";
42
43
  import { attachedSid } from "./state.js";
43
44
 
44
- // Narrower than this and the pane is not a pane any more, so it becomes the spine instead. The mock's
45
- // threshold, and the width the spine itself takes.
46
- const COLLAPSE_AT = 150;
47
- // What the pane opens at, in a fraction of the window, and the most of the window a drag may give it.
48
- const DEFAULT_FRACTION = 0.44;
49
- const MAX_FRACTION = 0.7;
50
- // The least of the window the pane may come back into. Reopening is not the same as resizing: whatever width
51
- // it was put away at, a pane you have just asked for has to be wide enough to read a page in.
52
- const MIN_OPEN_FRACTION = 1 / 3;
53
- // The window's own pane preferences: how wide, whether it is collapsed, and how big a page is drawn in it.
54
- // Not the session's - a pane is furniture, and furniture does not move because you looked at another tab.
55
- const GEOMETRY_KEY = "webterm-pane";
56
45
  // The sizes a page can be shown at, smallest first, and where an untouched window starts. The spacing is
57
46
  // deliberately uneven: a correction near normal reading size wants a fine step, and someone going to 150%
58
47
  // wants to get there in two presses.
@@ -66,39 +55,13 @@ const AGE_TICK_MS = 30_000;
66
55
  // heard about, so switching back to a tab lands on the page it was left on.
67
56
  const panes = new Map();
68
57
 
69
- let width = null;
70
- // null means nobody has said. An untouched pane keeps out of the way while its session has nothing in it, and
71
- // is open once it has - which is the first page of a session opening on arrival, as it always did. A click on
72
- // the spine or on the collapse button is an answer, and from then on it is the only one that counts.
73
- let collapsed = null;
74
- // Which of TEXT_SIZES the shown page is drawn at.
75
- let sizeStep = DEFAULT_SIZE_STEP;
76
- // What the last render decided, for the two things that only need to know whether the pane is showing.
77
- let shut = true;
78
-
79
- // --- What the window remembers -----------------------------------------------------------------------------------------------------------
80
-
81
- try {
82
- const stored = JSON.parse(localStorage.getItem(GEOMETRY_KEY) ?? "{}");
83
- if (typeof stored.width === "number" && stored.width >= COLLAPSE_AT) width = stored.width;
84
- if (typeof stored.collapsed === "boolean") collapsed = stored.collapsed;
85
- // The multiplier is stored rather than its position, so a window that was left at a size this version no
86
- // longer offers falls back to the default instead of landing between two steps.
87
- const step = TEXT_SIZES.indexOf(stored.size);
88
- if (step !== -1) sizeStep = step;
89
- } catch {
90
- // A disabled or full store only means the pane opens at its default width.
91
- }
92
-
93
- function rememberGeometry() {
94
- try {
95
- localStorage.setItem(GEOMETRY_KEY, JSON.stringify({ width, collapsed, size: TEXT_SIZES[sizeStep] }));
96
- } catch {
97
- // The pane still works for this page's lifetime.
98
- }
99
- }
58
+ // Which of TEXT_SIZES the shown page is drawn at. The multiplier is what the pane stores, so a window that
59
+ // was left at a size this version no longer offers falls back to the default instead of landing between two
60
+ // steps.
61
+ const storedStep = TEXT_SIZES.indexOf(storedPageSize());
62
+ let sizeStep = storedStep === -1 ? DEFAULT_SIZE_STEP : storedStep;
100
63
 
101
- // --- The pane's own state ----------------------------------------------------------------------------------------------------------------
64
+ // --- The tool's own state ----------------------------------------------------------------------------------------------------------------
102
65
 
103
66
  function paneFor(sid) {
104
67
  let entry = panes.get(sid);
@@ -122,14 +85,16 @@ export function applyPages(msg) {
122
85
  // The page this frame opens, if it opens one - the rule itself is in pane-arrival.js, where it can be run,
123
86
  // and so is what a burst of publishes does under it.
124
87
  const arrived = arrivingPage(entry.pages);
88
+ // It is selected either way, so the tab the user comes back to is on the page that arrived. Only the
89
+ // saying-so is held back while another tool has the pane.
125
90
  if (arrived !== null) {
126
91
  select(msg.sid, arrived.id, { user: false });
127
- flashArrival();
92
+ if (isToolOpen(PAGES)) flashPane();
128
93
  }
129
94
  // A pane someone put away comes back for a page. Only for an answer they gave: the third state, nobody
130
95
  // has said, already opens the pane when its session stops being empty, and settling it here would open
131
96
  // the pane in every other empty session in this window too.
132
- if (arrived !== null && collapsed === true) setCollapsed(false);
97
+ if (arrived !== null && isToolOpen(PAGES) && isPaneCollapsed()) setPaneCollapsed(false);
133
98
  else render();
134
99
  }
135
100
 
@@ -141,7 +106,7 @@ export function prunePanes(live) {
141
106
  }
142
107
 
143
108
  /**
144
- * Draw the pane for whatever session this window is now on, asking for its pages when this window has never
109
+ * Draw the tool for whatever session this window is now on, asking for its pages when this window has never
145
110
  * been told them. The server pushes the list when a window lands on a session, so this covers the frame that
146
111
  * did not arrive - a socket replaced mid-attach, or a window that took a session over from another one.
147
112
  */
@@ -257,25 +222,16 @@ function chipFor(sid, page, selected) {
257
222
  return chip;
258
223
  }
259
224
 
260
- // How wide, and collapsed or not. Its own function because a drag is the one thing that changes the pane
261
- // without changing anything in it: sixty of these a second are cheap, and sixty full redraws are not.
262
- function applyGeometry(collapsedNow = shut) {
263
- pane.classList.toggle("collapsed", collapsedNow);
264
- // The width goes into a custom property rather than straight onto `width`, so the stylesheet keeps the
265
- // last word: the collapsed spine and the narrow-screen layout are rules, and an inline width would beat
266
- // both of them.
267
- pane.style.setProperty("--pane-width", width === null ? `${DEFAULT_FRACTION * 100}%` : `${width}px`);
268
- }
269
-
270
225
  // --- How big a page is drawn -------------------------------------------------------------------------------------------------------------
271
226
 
272
- // The size is a property on the pane, and the stylesheet zooms the frame from it. Two reasons, both structural:
273
- // the frame's document has an opaque origin and the shell cannot reach into it, and renderPage() throws the
274
- // iframe away and builds a fresh one on every chip click, so anything set on the frame itself would have to be
275
- // put back on every one of those paths. Setting it out here costs nothing and cannot be missed.
227
+ // The size is a property on this tool's panel, and the stylesheet zooms the frame from it. Two reasons, both
228
+ // structural: the frame's document has an opaque origin and the shell cannot reach into it, and renderPage()
229
+ // throws the iframe away and builds a fresh one on every chip click, so anything set on the frame itself
230
+ // would have to be put back on every one of those paths. Setting it out here costs nothing and cannot be
231
+ // missed.
276
232
  function applyTextSize() {
277
233
  const size = TEXT_SIZES[sizeStep];
278
- pane.style.setProperty("--page-zoom", String(size));
234
+ toolPages.style.setProperty("--page-zoom", String(size));
279
235
  pageSizeVal.textContent = `${Math.round(size * 100)}%`;
280
236
  // A control that can do nothing says so, rather than swallowing the press.
281
237
  pageSmaller.disabled = sizeStep === 0;
@@ -287,49 +243,25 @@ function stepTextSize(by) {
287
243
  if (next === sizeStep) return;
288
244
  sizeStep = next;
289
245
  applyTextSize();
290
- rememberGeometry();
246
+ rememberPageSize(TEXT_SIZES[sizeStep]);
291
247
  }
292
248
 
293
249
  pageSmaller.addEventListener("click", () => stepTextSize(-1));
294
250
  pageBigger.addEventListener("click", () => stepTextSize(1));
295
251
  applyTextSize();
296
252
 
297
- // --- Saying that a page arrived ----------------------------------------------------------------------------------------------------------
298
-
299
- // One pulse of the pane's edge, in the workspace colour. ARRIVAL_MS must match the keyframes in styles.css.
300
- const ARRIVAL_MS = 1_000;
301
- let arrivalTimer = null;
302
-
303
- // The class comes off on a timer rather than on animationend, because with a reduced-motion preference there
304
- // is no animation to end - the stylesheet holds the highlight instead of pulsing it, and this is what makes
305
- // the two last the same moment.
306
- function flashArrival() {
307
- clearTimeout(arrivalTimer);
308
- // A pulse still running when the next page lands restarts rather than stacking: off, force the browser to
309
- // notice, on again. Reading a layout property is what makes the restart real.
310
- pane.classList.remove("arriving");
311
- void pane.offsetWidth;
312
- pane.classList.add("arriving");
313
- arrivalTimer = setTimeout(() => pane.classList.remove("arriving"), ARRIVAL_MS);
314
- }
315
-
316
253
  function render() {
317
254
  const sid = attachedSid;
318
255
  const entry = sid === null ? null : (panes.get(sid) ?? null);
319
256
  const pages = entry?.pages ?? [];
320
257
  const unread = pages.filter((page) => page.unread).length;
321
258
 
322
- // Nothing published in this session, and nothing to say about it. The pane stays - as the spine, until
323
- // someone opens it - because a pane that only appears once an agent has used it is one nobody knows to
324
- // ask for. The grip is the part that goes: there is no width worth choosing for a page that does not exist.
259
+ // Nothing published in this session, and nothing to say about it. The tab stays - and the pane stays as
260
+ // the spine, until someone opens it - because a pane that only appears once an agent has used it is one
261
+ // nobody knows to ask for. Saying so is the shell's business: the tab's mark, and whether an untouched
262
+ // pane keeps out of the way, are the pane's decisions to take from what its open tool has.
325
263
  const bare = pages.length === 0 && !entry?.notice;
326
- shut = collapsed === null ? bare : collapsed;
327
- pane.hidden = false;
328
- grip.hidden = bare && shut;
329
-
330
- applyGeometry(shut);
331
- spineBadge.textContent = unread > 0 ? String(unread) : "";
332
- spineBadge.hidden = unread === 0;
264
+ noteTool(PAGES, { unread, empty: bare });
333
265
 
334
266
  chips.replaceChildren(...pages.map((page) => chipFor(sid, page, page.id === entry?.selected)));
335
267
  const at = pages.findIndex((page) => page.id === entry?.selected);
@@ -364,75 +296,18 @@ function render() {
364
296
  chips.querySelector(".chip.sel")?.scrollIntoView({ block: "nearest", inline: "nearest" });
365
297
  }
366
298
 
299
+ // The tool draws itself whenever the pane asks - a tab click, a width that changed, a session switch.
300
+ registerTool(PAGES, render);
301
+
367
302
  setInterval(() => {
368
- if (!shut) render();
303
+ if (!isPaneShut() && isToolOpen(PAGES)) render();
369
304
  }, AGE_TICK_MS);
370
305
 
371
- // --- The divider, and the spine ----------------------------------------------------------------------------------------------------------
372
-
373
- // Dragging sets a pixel width until it stops making sense, and below the threshold the pane becomes the
374
- // spine rather than a sliver. Pointer capture keeps the drag alive over the terminal and the frame alike -
375
- // a page is a document with its own event loop, and without capture the pointer would be lost in it.
376
- let dragging = false;
377
- // The width the pane had when the drag started. Dragging it shut is a request to put it away, not a request
378
- // for a 150px pane, so crossing the threshold hands its old width back rather than keeping the sliver the
379
- // pointer passed through on the way there.
380
- let widthBeforeDrag = null;
381
-
382
- grip.addEventListener("pointerdown", (event) => {
383
- dragging = true;
384
- widthBeforeDrag = width;
385
- document.body.classList.add("dragging");
386
- grip.setPointerCapture(event.pointerId);
387
- });
388
-
389
- grip.addEventListener("pointermove", (event) => {
390
- if (!dragging) return;
391
- const wanted = Math.max(0, window.innerWidth - event.clientX - grip.offsetWidth / 2);
392
- if (wanted < COLLAPSE_AT) {
393
- collapsed = true;
394
- width = widthBeforeDrag;
395
- } else {
396
- collapsed = false;
397
- width = Math.min(wanted, window.innerWidth * MAX_FRACTION);
398
- }
399
- // A drag is an answer too, so what it decides is what the next render starts from.
400
- shut = collapsed;
401
- applyGeometry(shut);
402
- });
403
-
404
- function endDrag() {
405
- if (!dragging) return;
406
- dragging = false;
407
- document.body.classList.remove("dragging");
408
- rememberGeometry();
409
- }
410
-
411
- grip.addEventListener("pointerup", endDrag);
412
- grip.addEventListener("pointercancel", endDrag);
413
-
414
- // Away, and back. Two ways in and one way out, because the pane is put away far more often than it is
415
- // resized: the button in the bar is the obvious one, and dragging the grip past the threshold is the other.
416
- function setCollapsed(next) {
417
- collapsed = next;
418
- shut = next;
419
- // Back at whatever width it had, unless that width would be a sliver - a pane dragged shut, or one from
420
- // a window remembered narrower than this one, has to open into something a page fits in.
421
- if (!collapsed) {
422
- const floor = Math.min(window.innerWidth * MIN_OPEN_FRACTION, window.innerWidth * MAX_FRACTION);
423
- if (width !== null && width < floor) width = floor;
424
- }
425
- rememberGeometry();
426
- render();
427
- }
428
-
429
- paneCollapse.addEventListener("click", () => setCollapsed(true));
430
- spine.addEventListener("click", () => setCollapsed(false));
431
-
432
- // The chips bar scrolls by roughly a bar's worth, which is what the arrows in the mock do.
433
- panePrev.addEventListener("click", () => chips.scrollBy({ left: -chips.clientWidth * 0.8, behavior: "smooth" }));
434
- paneNext.addEventListener("click", () => chips.scrollBy({ left: chips.clientWidth * 0.8, behavior: "smooth" }));
435
306
  // A bar that fits at one width may not at another, and the arrows only exist when it does not.
436
307
  window.addEventListener("resize", render);
308
+ // The bar hides its scrollbar, so these are the only way to reach a chip that scrolled out of it. A little
309
+ // under a full bar each time, so the chip at the edge stays on the screen and says where the bar now is.
310
+ panePrev.addEventListener("click", () => chips.scrollBy({ left: -chips.clientWidth * 0.8, behavior: "smooth" }));
311
+ paneNext.addEventListener("click", () => chips.scrollBy({ left: chips.clientWidth * 0.8, behavior: "smooth" }));
437
312
  // The pagebar is chrome: a click on it must not leave the terminal without focus for no reason.
438
313
  pagebar.addEventListener("mousedown", (event) => event.preventDefault());
@@ -0,0 +1,222 @@
1
+ // specs-host.js - the Specs tool: the spec workspace, in a frame of its own.
2
+ //
3
+ // The tool's client is not part of this interface. It is built elsewhere and rendered here in a frame with an
4
+ // opaque origin, the same way a published page is, so the shell's key never enters it and one rendering bug
5
+ // can leak spec reading at worst. This module is the host around that frame: it owns the panel, the frame,
6
+ // and the one message that gives the frame what it needs to ask the server anything.
7
+ //
8
+ // The handover is the whole point of this file. The frame gets a Specs-scoped key that opens the tool's data
9
+ // routes and a path prefix that opens workspace files, and it gets them once, unprompted, on the load of the
10
+ // document this shell put there. It is never handed out on request: a window object survives a navigation, so
11
+ // a document that navigated itself into this frame would ask with the same identity as the one that was
12
+ // loaded. A second load is therefore read as a navigation, and the frame is thrown away and built again.
13
+
14
+ import { sendFrame } from "./connection.js";
15
+ import { toolSpecs } from "./dom.js";
16
+ import { noteTool, registerTool, SPECS } from "./pane-shell.js";
17
+ import { KEY_HEADER, WEB_KEY } from "./state.js";
18
+
19
+ // Where the built client is served from, and where the shell asks for the frame's credentials. The second one
20
+ // is behind the master key in a header, which is the shell's own gate and never the frame's.
21
+ const FRAME_SRC = "/pane-apps/specs/index.html";
22
+ const KEYS_URL = "/specs/keys";
23
+
24
+ // What the shell and the frame call each other's messages. Namespaced because a frame may be talking to
25
+ // something else of its own, and every message is checked against the frame it must come from.
26
+ const HANDOVER = "specs:credentials";
27
+ const CHANGE = "specs:change";
28
+ const OPEN = "specs:open";
29
+ const DELIVER = "specs:deliver";
30
+ const WAITING = "specs:waiting";
31
+ const DELIVERED = "specs:delivered";
32
+
33
+ // How many times a frame that navigates itself is rebuilt before the tab says so instead. A client that keeps
34
+ // leaving the document it was given is broken or is being driven, and either way rebuilding it for ever is a
35
+ // spinning window rather than a recovery.
36
+ const MAX_REBUILDS = 3;
37
+
38
+ let frame = null;
39
+ let handed = false;
40
+ let rebuilds = 0;
41
+ let credentials = null;
42
+ let asking = false;
43
+ let failure = null;
44
+ // How many things are waiting for the user inside the tool. Kept here rather than in the frame, because the
45
+ // mark has to hold while another tool is open and the frame is not being drawn.
46
+ let waiting = 0;
47
+
48
+ /** Fetch the frame's credentials. The shell holds the master key; this is the only route it opens for Specs. */
49
+ async function fetchCredentials() {
50
+ if (asking) return;
51
+ asking = true;
52
+ try {
53
+ const response = await fetch(KEYS_URL, { headers: { [KEY_HEADER]: WEB_KEY }, cache: "no-store" });
54
+ if (!response.ok) throw new Error(String(response.status));
55
+ const answer = await response.json();
56
+ if (typeof answer?.key !== "string" || typeof answer?.raw !== "string") throw new Error("no credentials");
57
+ credentials = answer;
58
+ failure = null;
59
+ } catch {
60
+ failure = "The Specs tool could not be reached. The next time this tab is opened it tries again.";
61
+ } finally {
62
+ asking = false;
63
+ }
64
+ // Drawn whether the tab is open or not. The panel is off the screen when it is not, so nothing is shown
65
+ // either way, but this is the step that puts the frame up - and the tool has to be running to say what
66
+ // is waiting for the user, wherever in the pane that user happens to be.
67
+ render();
68
+ }
69
+
70
+ /** Put a fresh frame in the panel, replacing whatever was there. */
71
+ function build() {
72
+ const next = document.createElement("iframe");
73
+ next.className = "tool-frame";
74
+ // The whole boundary in one attribute: scripts and forms, and no same-origin, so the document has an
75
+ // opaque origin and nothing of this window is reachable from inside it - not the DOM, not storage, and
76
+ // not the URL that carries the master key. Set before the src, since the sandbox of a frame that already
77
+ // has a document is not what a later attribute says it is.
78
+ next.setAttribute("sandbox", "allow-scripts allow-forms");
79
+ // A frame inherits its embedder's referrer, and this window's URL carries the master key - both as
80
+ // `document.referrer` inside the frame and as the `Referer` header on everything it loads.
81
+ next.setAttribute("referrerpolicy", "no-referrer");
82
+ next.setAttribute("title", "Specs");
83
+ next.addEventListener("load", () => onLoad(next));
84
+ next.src = FRAME_SRC;
85
+ frame = next;
86
+ handed = false;
87
+ toolSpecs.replaceChildren(next);
88
+ }
89
+
90
+ /** The frame finished loading a document. The first one is the client; a second one is a navigation. */
91
+ function onLoad(loaded) {
92
+ if (loaded !== frame) return;
93
+ if (!handed) {
94
+ handed = true;
95
+ loaded.contentWindow?.postMessage({ t: HANDOVER, key: credentials.key, raw: credentials.raw }, "*");
96
+ return;
97
+ }
98
+ if (rebuilds >= MAX_REBUILDS) {
99
+ frame = null;
100
+ failure = "The Specs tool kept leaving the page it was given, so it was stopped.";
101
+ render();
102
+ return;
103
+ }
104
+ rebuilds += 1;
105
+ build();
106
+ }
107
+
108
+ /** One sentence in the middle of the panel: what the tab is doing, or why it is doing nothing. */
109
+ function say(line) {
110
+ const wrap = document.createElement("div");
111
+ wrap.className = "pane-intro";
112
+ const title = document.createElement("h2");
113
+ title.textContent = "Specs";
114
+ const lede = document.createElement("p");
115
+ lede.textContent = line;
116
+ wrap.append(title, lede);
117
+ toolSpecs.replaceChildren(wrap);
118
+ }
119
+
120
+ function render() {
121
+ // The tab always has something to show, so it never asks the pane to keep out of the way. What is on it
122
+ // is what the tool last said is waiting for the user, which holds while another tool is open.
123
+ noteTool(SPECS, { unread: waiting, empty: false });
124
+ if (failure !== null) {
125
+ // Said once and then forgotten, so coming back to the tab is what tries again.
126
+ const line = failure;
127
+ failure = null;
128
+ frame = null;
129
+ say(line);
130
+ return;
131
+ }
132
+ // A frame that is up is left alone: it holds the tool's whole state, and rebuilding it would be a reload
133
+ // every time the user came back to the tab.
134
+ if (frame !== null) return;
135
+ if (credentials === null) {
136
+ say("Opening the specs in this repository.");
137
+ fetchCredentials();
138
+ return;
139
+ }
140
+ build();
141
+ }
142
+
143
+ /** The disk moved under a spec. Passed straight in: the frame decides what is worth re-reading. */
144
+ export function applySpecs(msg) {
145
+ if (frame === null || !handed) return;
146
+ frame.contentWindow?.postMessage({ t: CHANGE, change: msg.change }, "*");
147
+ }
148
+
149
+ /**
150
+ * Whether a message is the tool asking for something, rather than anything else on this window.
151
+ *
152
+ * Two checks, and each one holds without the other. The sender must be the frame's own window, which refuses
153
+ * a workspace document nested inside the tool - it is a frame of its own and can post here. And the message
154
+ * must carry the Specs key, which only the document this shell loaded ever received, so a document that
155
+ * navigated the frame passes the window check and fails this one.
156
+ */
157
+ function fromTool(event, message) {
158
+ if (frame === null || !handed || credentials === null) return false;
159
+ if (event.source !== frame.contentWindow) return false;
160
+ return typeof message.key === "string" && message.key === credentials.key;
161
+ }
162
+
163
+ // A link the tool will not follow itself. The frame has to stay the document this shell put in it, since a
164
+ // navigation would keep the window identity every check here rests on, so an outside link is opened as a
165
+ // window of this interface instead - the web alone, and with nothing of this window carried into it.
166
+ window.addEventListener("message", (event) => {
167
+ const message = event.data;
168
+ if (!message || typeof message !== "object" || message.t !== OPEN) return;
169
+ if (!fromTool(event, message)) return;
170
+ let url;
171
+ try {
172
+ url = new URL(String(message.url));
173
+ } catch {
174
+ return;
175
+ }
176
+ if (url.protocol !== "http:" && url.protocol !== "https:") return;
177
+ window.open(url.href, "_blank", "noopener,noreferrer");
178
+ });
179
+
180
+ // The tool saying what it is waiting on. The count is the tool's own, and the mark it puts on the tab is the
181
+ // shell's - which is what makes something awaiting an answer visible from any other tool in the pane.
182
+ window.addEventListener("message", (event) => {
183
+ const message = event.data;
184
+ if (!message || typeof message !== "object" || message.t !== WAITING) return;
185
+ if (!fromTool(event, message)) return;
186
+ const count = Number(message.count);
187
+ waiting = Number.isFinite(count) && count > 0 ? Math.min(Math.floor(count), 99) : 0;
188
+ noteTool(SPECS, { unread: waiting, empty: false });
189
+ });
190
+
191
+ // A delivery: the tool telling the agent beside it that something arrived. It rides this window's own socket,
192
+ // so the server resolves the session from the socket the way every other pane frame does, and nothing here
193
+ // or in the frame names one. What travels is the kind of event, a line of detail, the file the tool wrote,
194
+ // and whether the user agreed to start a session for it.
195
+ window.addEventListener("message", (event) => {
196
+ const message = event.data;
197
+ if (!message || typeof message !== "object" || message.t !== DELIVER) return;
198
+ if (!fromTool(event, message)) return;
199
+ sendFrame({
200
+ t: DELIVER,
201
+ event: String(message.event ?? ""),
202
+ detail: String(message.detail ?? ""),
203
+ path: String(message.path ?? ""),
204
+ create: message.create === true,
205
+ });
206
+ });
207
+
208
+ /** What the server made of the last delivery, passed back to the frame that asked for it. */
209
+ export function applySpecsDelivery(msg) {
210
+ if (frame === null || !handed) return;
211
+ frame.contentWindow?.postMessage({ t: DELIVERED, ok: msg.ok === true, error: msg.error ?? "" }, "*");
212
+ }
213
+
214
+ registerTool(SPECS, render);
215
+
216
+ // The frame is built at startup rather than the first time the tab is opened, because the mark on the tab is
217
+ // the tool's to put there and the tool cannot say anything until it is running. Waiting for the first open
218
+ // would make "something is waiting for you" visible only to a user who already went and looked, which is the
219
+ // one reader who did not need telling - and a window reopened on another tool would show nothing at all.
220
+ // Nothing is drawn by this: the panel is off the screen until its tab is picked, and the frame holds the
221
+ // tool's state from here on, so opening the tab shows what is already there instead of loading it then.
222
+ render();
@@ -40,7 +40,7 @@ export function labelOf(sid, fallback) {
40
40
  return sessions.find((entry) => entry.id === sid)?.label ?? fallback;
41
41
  }
42
42
 
43
- // The key that came with the URL. The server mints a new one every time it starts and refuses everything
43
+ // The key that came with the URL. The server creates a new one every time it starts and refuses everything
44
44
  // without it, so this is also what goes stale: a window left open across a restart still holds the old key,
45
45
  // which is the case the curtain explains rather than reconnecting forever.
46
46
  export const WEB_KEY = new URLSearchParams(location.search).get("k") ?? "";
@@ -171,7 +171,7 @@ function renameEditor(entry) {
171
171
  editor.className = "rename";
172
172
  editor.value = editingValue;
173
173
  editor.spellcheck = false;
174
- // A soft cap for the field; the server trims to its own limit and strips control characters.
174
+ // A soft limit for the field; the server trims to its own limit and strips control characters.
175
175
  editor.maxLength = 60;
176
176
  editor.addEventListener("click", (event) => event.stopPropagation());
177
177
  editor.addEventListener("dblclick", (event) => event.stopPropagation());
@@ -87,10 +87,18 @@ term.onData((data) => {
87
87
  // Keep the PTY's window size in step with the rendered terminal. Debounced: a resize storm
88
88
  // (layout settling, pane drag) collapses to one fit + one resize, which cuts down the redraw
89
89
  // artifacts the TUI shows when width changes mid-render.
90
+ //
91
+ // A terminal with no width on the screen is not a narrow terminal, and the PTY is not told about one.
92
+ // The pane at its full width leaves the row nothing, and the fit would answer with its own floor of two
93
+ // columns: the agent's TUI would take that as a real window, reflow its frame and its scrollback into
94
+ // two columns, and keep the mangled scrollback after the pane came back. Holding the last real size
95
+ // instead is what makes "squeezed out rather than hidden" true for the process as well as the layout -
96
+ // nothing is sent while it is out of the row, and the next pass sends the width it returns at.
90
97
  let resizeTimer = null;
91
98
  const resizeObserver = new ResizeObserver(() => {
92
99
  if (resizeTimer) clearTimeout(resizeTimer);
93
100
  resizeTimer = setTimeout(() => {
101
+ if (termEl.clientWidth === 0 || termEl.clientHeight === 0) return;
94
102
  try {
95
103
  fit.fit();
96
104
  sendResize();