chamba 0.6.1 → 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 (35) hide show
  1. package/README.md +13 -6
  2. package/dist/lib/agent-context.js +33 -7
  3. package/dist/lib/dockerfile-builder.js +2 -1
  4. package/dist/lib/safe-rm.js +13 -3
  5. package/package.json +3 -3
  6. package/templates/Dockerfile +20 -1
  7. package/templates/pane-apps/client/assets/specs-B1970L17.css +1 -0
  8. package/templates/pane-apps/client/assets/specs-cEee_SPn.js +23 -0
  9. package/templates/pane-apps/client/specs/index.html +13 -0
  10. package/templates/pane-apps/server/specs.mjs +1588 -0
  11. package/templates/skills/dx-spec/SKILL.md +365 -0
  12. package/templates/skills/dx-spec/references/imagination-guide.md +140 -0
  13. package/templates/skills/dx-spec/references/review-guide.md +173 -0
  14. package/templates/skills/dx-spec/references/spec-guide.md +125 -0
  15. package/templates/skills/dx-spec/references/stages.md +399 -0
  16. package/templates/skills/dx-spec-config/SKILL.md +313 -0
  17. package/templates/skills/dx-spec-config/references/principles-template.md +12 -0
  18. package/templates/skills/dx-spec-execute/SKILL.md +324 -0
  19. package/templates/specs.sh +106 -0
  20. package/templates/webterm/README.md +42 -6
  21. package/templates/webterm/config.js +43 -0
  22. package/templates/webterm/public/app/composer.js +4 -1
  23. package/templates/webterm/public/app/dom.js +13 -5
  24. package/templates/webterm/public/app/frames.js +7 -0
  25. package/templates/webterm/public/app/main.js +7 -1
  26. package/templates/webterm/public/app/pane-shell.js +315 -0
  27. package/templates/webterm/public/app/pane.js +58 -183
  28. package/templates/webterm/public/app/specs-host.js +222 -0
  29. package/templates/webterm/public/app/terminal.js +8 -0
  30. package/templates/webterm/public/index.html +51 -27
  31. package/templates/webterm/public/styles.css +144 -30
  32. package/templates/webterm/server.js +273 -0
  33. package/templates/webterm/specs.js +358 -0
  34. package/templates/webterm/tool-document.js +67 -0
  35. package/templates/webterm/typed-line.js +85 -0
@@ -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);
@@ -346,6 +358,64 @@ 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
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) => {
@@ -549,6 +619,25 @@ function sessionForPid(pid) {
549
619
  return null;
550
620
  }
551
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
+
552
641
  // --- The status strip --------------------------------------------------------------------------------------------------------------------
553
642
  //
554
643
  // A claude session's own numbers, above the composer: the model, the context it is holding, what is left of
@@ -806,6 +895,185 @@ function nudge(session, title, path) {
806
895
  pasteToSession(session, `[web pane] Feedback submitted on "${quoted}" - read ${homeLabel(path)}`);
807
896
  }
808
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
+
809
1077
  // --- Resume, once per container start ----------------------------------------------------------------------------------------------------
810
1078
 
811
1079
  // PID 1 is CONTAINER_KEEP_ALIVE, so it starts when the container does and its start time is a different
@@ -1110,6 +1378,11 @@ function handleFrame(ws, msg) {
1110
1378
  case "paste":
1111
1379
  if (typeof msg.data === "string") paste(ws, msg.data);
1112
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;
1113
1386
  case "resize":
1114
1387
  resize(ws, msg.cols, msg.rows);
1115
1388
  return;