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
@@ -6,7 +6,7 @@
6
6
  // The window renders the live TUI (xterm.js) and forwards keystrokes; a rich composer uploads
7
7
  // pasted images to /tmp/uploads and injects the composed message as one bracketed paste.
8
8
  // Sessions belong to the server, not to the socket: see sessions.js for what that buys.
9
- // Every route that carries the relay is gated by the key the URL holds (?k=), minted fresh at every start.
9
+ // Every route that carries the relay is gated by the key the URL holds (?k=), created fresh at every start.
10
10
  // Auth and sandbox are inherited: the spawned CLI sees the same agent config dirs and the same
11
11
  // container isolation it has in the terminal. Nothing here touches credentials.
12
12
 
@@ -46,7 +46,11 @@ import {
46
46
  MAX_PANE_BYTES,
47
47
  MAX_PANE_FILES,
48
48
  MAX_SESSIONS,
49
+ MAX_SPECS_BYTES,
50
+ MAX_SPECS_LINE_LENGTH,
51
+ MAX_SPECS_LINES_PER_SESSION,
49
52
  MAX_UPLOAD_BYTES,
53
+ PANE_APPS_CLIENT_DIR,
50
54
  PANE_DIR,
51
55
  PANE_SCAN_MS,
52
56
  PASTE_END,
@@ -54,6 +58,11 @@ import {
54
58
  PORT,
55
59
  RESUME_STAMP,
56
60
  resolveWorkspacePath,
61
+ SPECS_KEY,
62
+ SPECS_LINE_MIN_INTERVAL_MS,
63
+ SPECS_NEW_SESSION_DELAY_MS,
64
+ SPECS_RAW_TOKEN,
65
+ SPECS_ROOT,
57
66
  STATE_FILE,
58
67
  STATUS_SCAN_MS,
59
68
  STOP_ANNOUNCE_MS,
@@ -72,6 +81,9 @@ import { isSelfOrDescendant, processStart } from "./proc.js";
72
81
  import { resumeArgvFor } from "./resume.js";
73
82
  import { createRegistry, WORK_TICK_MS } from "./sessions.js";
74
83
  import { statusFor, writesSnapshots } from "./snapshot.js";
84
+ import { CLIENT_PREFIX, createSpecsModule, mountSpecs, SPECS_AGENT_PATH, SPECS_KEYS_PATH, SPECS_PREFIX } from "./specs.js";
85
+ import { documentPolicy, toolDocumentFor, withNonce } from "./tool-document.js";
86
+ import { framedLine, reduceLine } from "./typed-line.js";
75
87
 
76
88
  // node-pty is a native CommonJS addon; load it through createRequire under ESM.
77
89
  const require = createRequire(import.meta.url);
@@ -176,7 +188,7 @@ function publishAgent() {
176
188
  * The directories the picker offers. Bounded on purpose (see DIR_SCAN_* in config.js): a deep walk of a
177
189
  * real workspace is slow and the list would be unreadable anyway. Dot directories and the skip list are
178
190
  * never descended into, and neither are symlinks - a link out of the workspace would list paths that the
179
- * picker then refuses. Breadth-first, so a cap that bites drops the deepest entries rather than a whole
191
+ * picker then refuses. Breadth-first, so a limit that bites drops the deepest entries rather than a whole
180
192
  * branch, and it reports the cut so the caller can say the list is partial.
181
193
  */
182
194
  function scanDirs() {
@@ -213,7 +225,7 @@ function scanDirs() {
213
225
  // --- The key gate ------------------------------------------------------------------------------------------------------------------------
214
226
 
215
227
  // Publish the live key where the things that print the URL can read it: the `webterm` launcher, the
216
- // container greeting, and chamba on the host before it probes /status. Called once the port is bound and
228
+ // container greeting, and chamba on the host before it checks /status. Called once the port is bound and
217
229
  // never before - a second server that loses the bind must not leave its key behind as if it had won.
218
230
  // Owner-only, and chmod'ed after the write because the mode above applies to a file being created rather
219
231
  // than to one that already exists.
@@ -346,8 +358,66 @@ app.use("/vendor/xterm", express.static(join(import.meta.dirname, "node_modules"
346
358
  app.use("/vendor/xterm", express.static(join(import.meta.dirname, "node_modules", "@xterm", "xterm", "lib")));
347
359
  app.use("/vendor/xterm-fit", express.static(join(import.meta.dirname, "node_modules", "@xterm", "addon-fit", "lib")));
348
360
 
361
+ // The pane's tool clients, built elsewhere and baked beside this directory. Open like the statics above, and
362
+ // CORS-readable on top of it: a tool renders in a frame with an opaque origin, so it fetches its own module
363
+ // scripts cross-origin and a browser will not run them without being told they may be read. Nothing here is
364
+ // secret and nothing here drives anything - the data behind a tool is gated separately, in specs.js.
365
+ //
366
+ // `frame-ancestors` is the one thing that is not open. A tool's document is only ever framed by this
367
+ // interface, and framed by it inside a sandbox; a page somewhere else could otherwise frame the same document
368
+ // without one and run it as an ordinary document on this origin.
369
+ //
370
+ // The document of a tool is served ahead of the statics, under a policy of its own: it is the one file here
371
+ // that runs anything, and the frame it runs in has an opaque origin, where a policy written with `'self'`
372
+ // names nobody. See tool-document.js.
373
+ // Which origin the policy names has to come from the request. The browser reaches this server through a
374
+ // port the host published, and the container is not told which one, so `Host` is the only place the origin
375
+ // the document actually loaded from is written down. That makes it the caller's word, and it goes into a
376
+ // response header, so it is taken only in the shape an authority has: a host and an optional port, and
377
+ // nothing that could end the header or name a scheme. Anything else falls back to the address inside the
378
+ // container, which names no outside origin and so grants nothing.
379
+ const AUTHORITY = /^[A-Za-z0-9._-]+(:\d{1,5})?$|^\[[0-9A-Fa-f:.]+\](:\d{1,5})?$/;
380
+
381
+ function originOf(req) {
382
+ const host = req.get("host") ?? "";
383
+ return AUTHORITY.test(host) ? `http://${host}` : `http://127.0.0.1:${PORT}`;
384
+ }
385
+
386
+ // The wildcard is express 4's, which is what this directory's package.json pins. Express 5 spells the same
387
+ // thing `/*splat`, and that spelling compiles here to a route that ends in the literal word - it matches
388
+ // nothing anyone loads, and the document falls through to the statics below with no policy on it at all.
389
+ // The failure is silent, which is why a test issues a real request through a real express instead of
390
+ // reading this line.
391
+ app.get(`${CLIENT_PREFIX}/*`, (req, res, next) => {
392
+ const document = toolDocumentFor(req.path);
393
+ if (!document) return next();
394
+ let html;
395
+ try {
396
+ html = readFileSync(join(PANE_APPS_CLIENT_DIR, document), "utf8");
397
+ } catch {
398
+ return next();
399
+ }
400
+ const nonce = randomBytes(16).toString("base64");
401
+ res.set("Access-Control-Allow-Origin", "*");
402
+ res.set("Content-Security-Policy", documentPolicy(nonce, originOf(req)));
403
+ res.set("X-Content-Type-Options", "nosniff");
404
+ // A nonce is good for one response, so the document it sits in is never a document to keep.
405
+ res.set("Cache-Control", "no-store");
406
+ res.type("html").send(withNonce(html, nonce));
407
+ });
408
+
409
+ app.use(
410
+ CLIENT_PREFIX,
411
+ express.static(PANE_APPS_CLIENT_DIR, {
412
+ setHeaders: (res) => {
413
+ res.set("Access-Control-Allow-Origin", "*");
414
+ res.set("Content-Security-Policy", "frame-ancestors 'self'");
415
+ },
416
+ }),
417
+ );
418
+
349
419
  // Accept a raw image body (the client POSTs the pasted/dropped blob with its Content-Type).
350
- // Reject non-image types up front; cap the size so a bad request cannot fill the disk.
420
+ // Reject non-image types up front; limit the size so a bad request cannot fill the disk.
351
421
  app.post("/upload", requireKey, express.raw({ type: () => true, limit: MAX_UPLOAD_BYTES }), (req, res) => {
352
422
  const contentType = String(req.headers["content-type"] || "")
353
423
  .split(";")[0]
@@ -489,7 +559,14 @@ function paneSessions() {
489
559
  const session = registry.get(entry.id);
490
560
  const pid = Number(session?.term?.pid);
491
561
  if (!session || !Number.isInteger(pid)) continue;
492
- live.push({ sid: session.id, agent: session.agent, cwd: session.cwd, pid, since: session.createdAt });
562
+ live.push({
563
+ sid: session.id,
564
+ agent: session.agent,
565
+ cwd: session.cwd,
566
+ pid,
567
+ since: session.createdAt,
568
+ resumedFrom: resumedBy.get(pid) ?? null,
569
+ });
493
570
  }
494
571
  return live;
495
572
  }
@@ -542,6 +619,25 @@ function sessionForPid(pid) {
542
619
  return null;
543
620
  }
544
621
 
622
+ // --- The Specs tool ----------------------------------------------------------------------------------------------------------------------
623
+ //
624
+ // The first of the pane's tools, mounted the way the pane store is: something built elsewhere is handed what
625
+ // only this process can give it - where to look, and who to tell when the disk moves - and its answers are
626
+ // registered behind gates it does not hold itself. It is not a process and not a port: a failure inside it is
627
+ // one request answered with a status, and every terminal in this container carries on.
628
+
629
+ const specs = mountSpecs({
630
+ module: createSpecsModule({ root: SPECS_ROOT, onChange: (change) => broadcastSpecs(change) }),
631
+ key: SPECS_KEY,
632
+ rawToken: SPECS_RAW_TOKEN,
633
+ });
634
+
635
+ // A spec directory moved. Every window hears it, not one: a spec belongs to the repository rather than to a
636
+ // session, and two windows reading the same spec both need to know.
637
+ function broadcastSpecs(change) {
638
+ for (const client of clients) send(client, { t: "specs", change });
639
+ }
640
+
545
641
  // --- The status strip --------------------------------------------------------------------------------------------------------------------
546
642
  //
547
643
  // A claude session's own numbers, above the composer: the model, the context it is holding, what is left of
@@ -799,6 +895,185 @@ function nudge(session, title, path) {
799
895
  pasteToSession(session, `[web pane] Feedback submitted on "${quoted}" - read ${homeLabel(path)}`);
800
896
  }
801
897
 
898
+ // --- Specs routes ------------------------------------------------------------------------------------------------------------------------
899
+
900
+ /**
901
+ * The scoped credentials, for the shell alone. It holds the master key already, so nothing new is exposed by
902
+ * telling it these; what matters is where they go next - the shell posts the key into the tool's frame once,
903
+ * and the frame presents it as a header. Behind the pane's gate, which is the master key in a header and
904
+ * never in a URL, because a URL that carries it is a URL a document can read itself out of.
905
+ */
906
+ app.get(SPECS_KEYS_PATH, requirePaneKey, (_req, res) => {
907
+ res.json({ key: SPECS_KEY, raw: specs.rawBase });
908
+ });
909
+
910
+ /**
911
+ * The `specs` helper's door: the agent's half of the coordination channel.
912
+ *
913
+ * The gate is the master key, which every helper route takes and which nothing outside the container has.
914
+ * The session behind it is a claim rather than a second gate: the helper sends its own pid, this walks up
915
+ * the process tree to see whether it lands in a session this server started, and any process in the
916
+ * container could send another process's pid instead - the same rule `webpane` publishes under, and its
917
+ * docstring says the same of itself. What that buys is a helper run outside a web session getting a clear
918
+ * answer rather than a silent one, and it is not asked to buy more: the session is used for nothing else,
919
+ * because a verb moves files in a spec directory, which belongs to the repository rather than to a terminal.
920
+ */
921
+ app.post(SPECS_AGENT_PATH, requirePaneKey, express.json({ limit: MAX_SPECS_BYTES }), (req, res) => {
922
+ const session = sessionForPid(Number(req.headers["x-specs-pid"]));
923
+ if (!session) {
924
+ res.status(409).json({ error: "not running inside a web session - there is no pane to tell" });
925
+ return;
926
+ }
927
+ const body = req.body && typeof req.body === "object" ? req.body : {};
928
+ const answer = specs.agent({ verb: body.verb, entry: body.spec, payload: body.payload });
929
+ res.status(answer.status).json(answer.body);
930
+ });
931
+
932
+ // The body of a Specs request, read only for a request the mount says may carry one. Reading it is itself
933
+ // something a caller gets to make this process do - hold the bytes, decode them - so it sits behind the same
934
+ // key as the route, rather than in front of it where every other body route in this file would not put it.
935
+ const specsBody = express.text({ type: () => true, limit: MAX_SPECS_BYTES });
936
+
937
+ /**
938
+ * Everything else the tool answers. One delegation and no decision: specs.js takes the request as plain data
939
+ * and gives back the status, the headers and the body, and it never throws, so a Specs failure is a view that
940
+ * says so rather than a process that went down.
941
+ */
942
+ app.all(
943
+ `${SPECS_PREFIX}/*`,
944
+ (req, res, next) => {
945
+ if (specs.takesBody(req.method, req.path, req.headers)) specsBody(req, res, next);
946
+ else next();
947
+ },
948
+ (req, res) => {
949
+ const answer = specs.handle({
950
+ method: req.method,
951
+ path: req.path,
952
+ query: req.query,
953
+ headers: req.headers,
954
+ body: req.body,
955
+ });
956
+ res.status(answer.status).set(answer.headers);
957
+ if (answer.body === null) {
958
+ res.end();
959
+ return;
960
+ }
961
+ // A workspace file goes out as its own bytes, under the type the tool decided; the rest is JSON.
962
+ if (Buffer.isBuffer(answer.body)) res.send(answer.body);
963
+ else res.json(answer.body);
964
+ },
965
+ );
966
+
967
+ // --- The Specs delivery ------------------------------------------------------------------------------------------------------------------
968
+ //
969
+ // Everything the pane sends the agent lands as a file plus one typed line. The file is written through the
970
+ // data routes above, by the tool; this is the line. It rides the window's own socket, so the session it
971
+ // reaches is the session that window is driving - the tool never names one, and there is no id on the frame
972
+ // for it to name.
973
+ //
974
+ // The words of the line are this server's. The event is a key into the table below, so a tool picks which of
975
+ // a few sentences is typed and never what it says; the detail is the one part that came from somewhere else
976
+ // and is reduced before it goes anywhere near a terminal; and the path has to be a file the tool itself
977
+ // wrote, which the mount decides.
978
+
979
+ // What a delivery may say it is. A key that is not here is not a delivery.
980
+ const SPECS_EVENTS = {
981
+ annotations: "Annotations sent",
982
+ intake: "A new spec was filed",
983
+ answers: "A round of answers was submitted",
984
+ gate: "A gate was answered",
985
+ brief: "Pick up this spec",
986
+ };
987
+
988
+ // The gap and the total, per session, for as long as this container runs. Held here rather than in the
989
+ // registry because they are about this one channel: a session that is closed and gone takes its count with
990
+ // it, which is the same span the resume marker uses.
991
+ const lastSpecsLine = new Map();
992
+ const specsLineCount = new Map();
993
+
994
+ /** Whether this session has had as much of this channel as it may have, and why. */
995
+ function specsLimited(sid) {
996
+ const now = Date.now();
997
+ if (now - (lastSpecsLine.get(sid) ?? 0) < SPECS_LINE_MIN_INTERVAL_MS) {
998
+ return "that was just delivered - give it a moment";
999
+ }
1000
+ if ((specsLineCount.get(sid) ?? 0) >= MAX_SPECS_LINES_PER_SESSION) {
1001
+ return `this session has had the ${MAX_SPECS_LINES_PER_SESSION} deliveries it may have`;
1002
+ }
1003
+ return null;
1004
+ }
1005
+
1006
+ function specsDelivered(sid) {
1007
+ lastSpecsLine.set(sid, Date.now());
1008
+ specsLineCount.set(sid, (specsLineCount.get(sid) ?? 0) + 1);
1009
+ }
1010
+
1011
+ /**
1012
+ * Type one Specs line into a session, or say why not.
1013
+ *
1014
+ * The length is checked before the reduction, so an over-limit detail is refused with a reason rather than
1015
+ * cut into half a sentence - which is the one thing a silent trim would make unreadable.
1016
+ *
1017
+ * `after` is for a session this delivery has just started: the line waits for the agent to be there to read
1018
+ * it, and the delivery is answered now, because the session did start and the line will follow.
1019
+ */
1020
+ function typeSpecsLine(session, msg, after = 0) {
1021
+ const event = SPECS_EVENTS[typeof msg.event === "string" ? msg.event : ""];
1022
+ if (!event) return { ok: false, error: "that is not something the Specs tool delivers" };
1023
+ const detail = typeof msg.detail === "string" ? msg.detail : "";
1024
+ if (detail.length > MAX_SPECS_LINE_LENGTH) {
1025
+ return { ok: false, error: `that message is longer than the ${MAX_SPECS_LINE_LENGTH} characters a delivery may carry` };
1026
+ }
1027
+ const limited = specsLimited(session.id);
1028
+ if (limited) return { ok: false, error: limited };
1029
+ // A path is optional - a briefing names no file - and one that is not a file this tool wrote is dropped
1030
+ // rather than refused: the line still reads, and it names nothing that is not there.
1031
+ const absolute = msg.path ? specs.fileFor(msg.path) : null;
1032
+ const line = framedLine({
1033
+ tag: "specs",
1034
+ event,
1035
+ detail: reduceLine(detail, MAX_SPECS_LINE_LENGTH),
1036
+ path: absolute === null ? "" : homeLabel(absolute),
1037
+ });
1038
+ specsDelivered(session.id);
1039
+ if (after > 0) {
1040
+ // Only if that session is still there when the wait is over.
1041
+ setTimeout(() => {
1042
+ const still = registry.get(session.id);
1043
+ if (still) pasteToSession(still, line);
1044
+ }, after).unref();
1045
+ } else {
1046
+ pasteToSession(session, line);
1047
+ }
1048
+ console.log(`[webterm] specs: delivered to session "${session.name || session.label}"`);
1049
+ return { ok: true };
1050
+ }
1051
+
1052
+ /**
1053
+ * A delivery from the Specs tool, arriving over the shell's own socket.
1054
+ *
1055
+ * With no session on this window there is nobody to tell, and the tool has already asked the user whether to
1056
+ * start one: `create` is that answer, and the briefing becomes the first line the new agent reads.
1057
+ */
1058
+ function deliverSpecs(ws, msg) {
1059
+ const session = registry.sessionFor(ws);
1060
+ if (session) {
1061
+ send(ws, { t: "specs:delivered", ...typeSpecsLine(session, msg) });
1062
+ return;
1063
+ }
1064
+ if (msg.create !== true) {
1065
+ send(ws, { t: "specs:delivered", ok: false, error: "no-session" });
1066
+ return;
1067
+ }
1068
+ newSession(ws);
1069
+ const started = registry.sessionFor(ws);
1070
+ if (!started) {
1071
+ send(ws, { t: "specs:delivered", ok: false, error: "a session could not be started" });
1072
+ return;
1073
+ }
1074
+ send(ws, { t: "specs:delivered", ...typeSpecsLine(started, msg, SPECS_NEW_SESSION_DELAY_MS) });
1075
+ }
1076
+
802
1077
  // --- Resume, once per container start ----------------------------------------------------------------------------------------------------
803
1078
 
804
1079
  // PID 1 is CONTAINER_KEEP_ALIVE, so it starts when the container does and its start time is a different
@@ -865,13 +1140,19 @@ function resumeArgv(agent, cwd) {
865
1140
  // auth flows through. The first session after a container start continues the most recent conversation;
866
1141
  // every later session starts fresh, which is what the user wants once mid-work.
867
1142
  // AGENT_ARGS belong to the agent the launcher named, so any other agent is spawned bare.
1143
+ // Which conversation id a session's agent was told to resume, by the PTY leader's pid. Claude gives a
1144
+ // resumed conversation a fresh id and records no link back to the old one, so the command built here is the
1145
+ // only witness - the pane reads this to carry the old id's pages into the new one. One resume per container
1146
+ // start, so this holds at most one entry that matters.
1147
+ const resumedBy = new Map();
1148
+
868
1149
  function spawnAgent({ cwd, agent }) {
869
1150
  const resume = resumeArgv(agent, cwd);
870
1151
  const [spawnCmd, ...baseArgs] = resume ?? [agent, ...(agent === DEFAULT_AGENT ? AGENT_ARGS : [])];
871
1152
  if (resume) console.log(`[webterm] resuming most recent conversation: ${resume.join(" ")}`);
872
1153
  // Append the browser-awareness flag for claude (fresh or resumed); every other agent is untouched.
873
1154
  const spawnArgs = agentSpawnArgv(spawnCmd, baseArgs);
874
- return pty.spawn(spawnCmd, spawnArgs, {
1155
+ const term = pty.spawn(spawnCmd, spawnArgs, {
875
1156
  name: "xterm-256color",
876
1157
  cols: 80,
877
1158
  rows: 24,
@@ -882,6 +1163,9 @@ function spawnAgent({ cwd, agent }) {
882
1163
  // var is the one answer all three can read.
883
1164
  env: { ...process.env, CHAMBA_WEB_SESSION: "1" },
884
1165
  });
1166
+ // Only a resume-by-id names a conversation to carry pages from; "--continue" style resumes name nothing.
1167
+ if (resume?.[1] === "--resume" && typeof resume[2] === "string") resumedBy.set(term.pid, resume[2]);
1168
+ return term;
885
1169
  }
886
1170
 
887
1171
  // --- Frames from a window ----------------------------------------------------------------------------------------------------------------
@@ -1021,7 +1305,7 @@ function handleFrame(ws, msg) {
1021
1305
  greet(ws, sid, msg.fresh === true);
1022
1306
  return;
1023
1307
  case "ping":
1024
- // Liveness probe from a window that just woke up: an answer proves the socket really works,
1308
+ // Liveness check from a window that just woke up: an answer proves the socket really works,
1025
1309
  // so it can reconnect at once instead of waiting for TCP to give up.
1026
1310
  send(ws, { t: "pong" });
1027
1311
  return;
@@ -1094,6 +1378,11 @@ function handleFrame(ws, msg) {
1094
1378
  case "paste":
1095
1379
  if (typeof msg.data === "string") paste(ws, msg.data);
1096
1380
  return;
1381
+ case "specs:deliver":
1382
+ // The pane's tool telling the agent something arrived. Like the pane's own frames, it reaches
1383
+ // the session this window is driving, and the frame carries no id for it to reach another.
1384
+ deliverSpecs(ws, msg);
1385
+ return;
1097
1386
  case "resize":
1098
1387
  resize(ws, msg.cols, msg.rows);
1099
1388
  return;
@@ -1141,16 +1430,16 @@ setInterval(checkUploads, CHECK_INTERVAL_MS).unref();
1141
1430
  // actually changed.
1142
1431
  setInterval(() => registry.tick(), WORK_TICK_MS).unref();
1143
1432
 
1144
- // The pane's own sweep, on the same principle: one pass over the live sessions asks the two questions that
1433
+ // The pane's own scan, on the same principle: one pass over the live sessions asks the two questions that
1145
1434
  // are about time rather than about an event - has this agent written down which conversation it is having
1146
1435
  // yet, and has anything been written into its pane directory that did not come through the publish route.
1147
1436
  setInterval(() => pane.sweep(), PANE_SCAN_MS).unref();
1148
1437
 
1149
- // The strip's sweep, for the same reason: a snapshot file is written by a shell script into a bind mount, so
1438
+ // The strip's scan, for the same reason: a snapshot file is written by a shell script into a bind mount, so
1150
1439
  // the only way to know it moved is to look.
1151
1440
  setInterval(() => sweepStatus(), STATUS_SCAN_MS).unref();
1152
1441
 
1153
- // Keepalive sweep. A window that stops answering is terminated, which releases the session it was
1442
+ // Keepalive pass. A window that stops answering is terminated, which releases the session it was
1154
1443
  // driving so the window that comes back can pick it up without a takeover prompt. Sessions themselves
1155
1444
  // are never touched here - a dead socket says nothing about whether a conversation is worth keeping.
1156
1445
  setInterval(() => {
@@ -1185,7 +1474,7 @@ server.listen(PORT, "0.0.0.0", () => {
1185
1474
  if (defaultCwdRefused) {
1186
1475
  console.warn(`[webterm] ignored WEBTERM_CWD="${WEBTERM_CWD_RAW}": not a directory inside ${WORKSPACE_ROOT}`);
1187
1476
  }
1188
- // Publish the default agent for the `webterm` launcher: a port probe proves something is listening, not
1477
+ // Publish the default agent for the `webterm` launcher: a port check proves something is listening, not
1189
1478
  // what it runs. Written after listen so the file only exists once the port is really bound.
1190
1479
  publishAgent();
1191
1480
  });
@@ -88,7 +88,7 @@ export const WORK_TICK_MS = 300;
88
88
  export const MAX_NAME_LENGTH = 40;
89
89
 
90
90
  // Turn a raw name from the client into what gets stored: control characters (newlines, tabs, the lot)
91
- // stripped so a name is always one clean line, trimmed, and cut to the cap. An empty result means
91
+ // stripped so a name is always one clean line, trimmed, and cut to the length limit. An empty result means
92
92
  // "no name" - the session falls back to its default label. Filtering by code point rather than a regex
93
93
  // keeps the source free of literal control characters.
94
94
  export function cleanName(raw) {
@@ -115,8 +115,8 @@ function isOpen(socket) {
115
115
  * Create the session registry.
116
116
  *
117
117
  * - `spawn({ cwd })` returns a PTY-like object: { onData, onExit, write, resize, kill }.
118
- * - `maxSessions` caps how many agents may be alive at once. This is about memory, not correctness.
119
- * - `maxBuffer` caps the replay buffer kept per session (bytes).
118
+ * - `maxSessions` limits how many agents may be alive at once. This is about memory, not correctness.
119
+ * - `maxBuffer` limits the replay buffer kept per session (bytes).
120
120
  * - `onEvent(event)` is called with { t: "replay" | "out" | "taken" | "exit" | "changed", ... }.
121
121
  * Events that target one window carry that window's socket as `client`; "changed" means the session
122
122
  * list moved and every window needs to hear about it.
@@ -305,9 +305,9 @@ export function createRegistry({ spawn, maxSessions, maxBuffer, onEvent }) {
305
305
  /**
306
306
  * Move every session's working state on by one step, and raise the alert on the ones that just stopped.
307
307
  * Called on a timer by the server rather than driven by a timer per session: the state is a function of
308
- * "how long output has been running", so one sweep answers it for every session, and a registry with no
308
+ * "how long output has been running", so one pass answers it for every session, and a registry with no
309
309
  * timers of its own stays testable by calling this by hand. Every flip is announced here, so the whole
310
- * sweep costs at most one broadcast however many sessions moved.
310
+ * pass costs at most one broadcast however many sessions moved.
311
311
  *
312
312
  * Every ending raises the alert, whoever is watching and whatever they were doing a moment ago. Whether it
313
313
  * is also worth a sound is settled afterwards, by whether anyone touches the session - see "Are you there?".
@@ -372,7 +372,7 @@ export function createRegistry({ spawn, maxSessions, maxBuffer, onEvent }) {
372
372
  * and how one is written for the browser belong to the server, and the registry only carries the values -
373
373
  * the command and path to the PTY, the labels to the bar.
374
374
  *
375
- * { ok: true, session } or { ok: false, error: "cap" } when the limit is reached.
375
+ * { ok: true, session }, or a refusal with the session-limit error code when the limit is reached.
376
376
  */
377
377
  function create({ cwd, cwdLabel, agent } = {}) {
378
378
  if (sessions.size >= maxSessions) return { ok: false, error: "cap" };
@@ -384,7 +384,7 @@ export function createRegistry({ spawn, maxSessions, maxBuffer, onEvent }) {
384
384
  // Named after what it runs, since the bar can hold several different agents at once.
385
385
  label: `${agent} ${seq}`,
386
386
  // A user-chosen label, or null to fall back to `label`. The number in `label` is always kept,
387
- // so clearing the name shows "claude 7" again and the tooltip can still surface it.
387
+ // so clearing the name shows "claude 7" again and the tooltip can still show it.
388
388
  name: null,
389
389
  agent,
390
390
  colorIndex: (seq - 1) % PALETTE_SIZE,
@@ -28,7 +28,7 @@ import { readdirSync, readFileSync, statSync } from "node:fs";
28
28
  import { join } from "node:path";
29
29
  import { isSameProcess, isSelfOrDescendant } from "./proc.js";
30
30
 
31
- // The largest a snapshot can be and still be read. The script writes a few hundred bytes; the cap only keeps
31
+ // The largest a snapshot can be and still be read. The script writes a few hundred bytes; the limit only keeps
32
32
  // an unrelated file that happens to sit in the directory from being pulled into memory.
33
33
  const MAX_SNAPSHOT_BYTES = 64 * 1024;
34
34
 
@@ -45,7 +45,7 @@ export function writesSnapshots(agent) {
45
45
  return agent === "claude";
46
46
  }
47
47
 
48
- /** The snapshot files in `dir`, newest first and capped. A directory that is not there contributes nothing. */
48
+ /** The snapshot files in `dir`, newest first and limited in number. A directory that is not there contributes nothing. */
49
49
  function snapshotFiles(dir) {
50
50
  let entries;
51
51
  try {