chamba 0.6.1 → 0.8.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 (118) hide show
  1. package/LICENSE +53 -14
  2. package/README.md +38 -11
  3. package/dist/commands/advanced.js +7 -278
  4. package/dist/commands/dev.js +11 -612
  5. package/dist/commands/doctor.js +1 -29
  6. package/dist/commands/menu.js +1 -80
  7. package/dist/commands/onboard.js +6 -230
  8. package/dist/commands/settings.js +15 -349
  9. package/dist/lib/agent-commands.js +2 -0
  10. package/dist/lib/agent-context.js +6 -184
  11. package/dist/lib/browser.js +1 -40
  12. package/dist/lib/chamba-yaml.js +10 -191
  13. package/dist/lib/constants.js +1 -125
  14. package/dist/lib/dockerfile-builder.js +41 -260
  15. package/dist/lib/env.js +2 -78
  16. package/dist/lib/git-env.js +1 -21
  17. package/dist/lib/global-config.js +4 -66
  18. package/dist/lib/pnpm-store.js +1 -19
  19. package/dist/lib/ports.js +1 -210
  20. package/dist/lib/safe-rm.js +1 -26
  21. package/dist/lib/sessions.js +2 -34
  22. package/dist/lib/shadows.js +1 -176
  23. package/dist/lib/skills.js +2 -0
  24. package/dist/lib/templates.js +1 -0
  25. package/dist/lib/webterm.js +1 -305
  26. package/dist/lib/workspace-identity.js +4 -260
  27. package/package.json +6 -6
  28. package/templates/Dockerfile +24 -12
  29. package/templates/claude-statusline.sh +5 -7
  30. package/templates/context/baseline.md +2 -0
  31. package/templates/pane-apps/client/assets/c-BNa-DeUl.json +3552 -0
  32. package/templates/pane-apps/client/assets/cpp-B-T4NRoH.json +20239 -0
  33. package/templates/pane-apps/client/assets/csharp-BbxHChhA.json +6010 -0
  34. package/templates/pane-apps/client/assets/css-CWCxuwmz.json +1860 -0
  35. package/templates/pane-apps/client/assets/diff-B-2N89g8.json +154 -0
  36. package/templates/pane-apps/client/assets/docker-DWTvYW26.json +103 -0
  37. package/templates/pane-apps/client/assets/files-D0nJgFAA.js +12 -0
  38. package/templates/pane-apps/client/assets/files-h0K1vZyQ.css +1 -0
  39. package/templates/pane-apps/client/assets/go-B2hyYeNg.json +3156 -0
  40. package/templates/pane-apps/client/assets/graphql-Z6Id2aNO.json +1292 -0
  41. package/templates/pane-apps/client/assets/html-BtzvlIfu.json +2627 -0
  42. package/templates/pane-apps/client/assets/ini-Bn3Twap1.json +108 -0
  43. package/templates/pane-apps/client/assets/java-C7t2dwLO.json +1885 -0
  44. package/templates/pane-apps/client/assets/javascript-DuC2v0C0.json +5996 -0
  45. package/templates/pane-apps/client/assets/json-CwdkWeV5.json +206 -0
  46. package/templates/pane-apps/client/assets/jsonc-MYWDbTU3.json +206 -0
  47. package/templates/pane-apps/client/assets/jsx-Cn8EZuIC.json +5996 -0
  48. package/templates/pane-apps/client/assets/kotlin-CFNOz1gw.json +580 -0
  49. package/templates/pane-apps/client/assets/make-BVnlFCcl.json +629 -0
  50. package/templates/pane-apps/client/assets/markdown-B4Xk2WM_.json +3263 -0
  51. package/templates/pane-apps/client/assets/php-CAQzQmmI.json +4188 -0
  52. package/templates/pane-apps/client/assets/python-CYQee8m4.json +4174 -0
  53. package/templates/pane-apps/client/assets/reviews-BsoGZ35r.css +1 -0
  54. package/templates/pane-apps/client/assets/reviews-iW0Mbn4e.js +153 -0
  55. package/templates/pane-apps/client/assets/ruby-D7R58tus.json +3114 -0
  56. package/templates/pane-apps/client/assets/rust-BFBbFiei.json +1103 -0
  57. package/templates/pane-apps/client/assets/scss-TQRhBkPG.json +1874 -0
  58. package/templates/pane-apps/client/assets/shellscript-DieAkC4W.json +2375 -0
  59. package/templates/pane-apps/client/assets/specs-D9z3neR-.js +13 -0
  60. package/templates/pane-apps/client/assets/sql-Bxd030LV.json +634 -0
  61. package/templates/pane-apps/client/assets/toml-ChrKGzsV.json +430 -0
  62. package/templates/pane-apps/client/assets/tsx-D2e90MFw.json +5996 -0
  63. package/templates/pane-apps/client/assets/typescript-DonXQ_n1.json +5747 -0
  64. package/templates/pane-apps/client/assets/xml-B17r2RXF.json +382 -0
  65. package/templates/pane-apps/client/assets/yaml-CKn4h1zy.json +627 -0
  66. package/templates/pane-apps/client/reviews/index.html +15 -0
  67. package/templates/pane-apps/client/specs/index.html +14 -0
  68. package/templates/pane-apps/server/reviews.mjs +20 -0
  69. package/templates/pane-apps/server/specs.mjs +5 -0
  70. package/templates/skills/chamba-statusline/SKILL.md +7 -19
  71. package/templates/skills/dx-review/SKILL.md +123 -0
  72. package/templates/skills/dx-review/references/acts.md +162 -0
  73. package/templates/skills/dx-spec/SKILL.md +561 -0
  74. package/templates/skills/dx-spec/references/imagination-guide.md +140 -0
  75. package/templates/skills/dx-spec/references/principles-template.md +2 -0
  76. package/templates/skills/dx-spec/references/review-guide.md +127 -0
  77. package/templates/skills/dx-spec/references/spec-guide.md +145 -0
  78. package/templates/skills/dx-spec/references/stages.md +484 -0
  79. package/templates/skills/dx-spec-execute/SKILL.md +437 -0
  80. package/templates/startup.mjs +10 -4
  81. package/templates/tool-helper.sh +166 -0
  82. package/templates/webterm/README.md +79 -9
  83. package/templates/webterm/color.js +61 -0
  84. package/templates/webterm/config.js +74 -0
  85. package/templates/webterm/context/claude.md +0 -1
  86. package/templates/webterm/public/app/alerts.js +4 -4
  87. package/templates/webterm/public/app/anchored.js +81 -0
  88. package/templates/webterm/public/app/composer.js +4 -1
  89. package/templates/webterm/public/app/connection.js +9 -0
  90. package/templates/webterm/public/app/dom.js +15 -5
  91. package/templates/webterm/public/app/frames.js +51 -10
  92. package/templates/webterm/public/app/main.js +26 -6
  93. package/templates/webterm/public/app/new-session.js +13 -41
  94. package/templates/webterm/public/app/palette.js +58 -0
  95. package/templates/webterm/public/app/pane-shape.js +167 -0
  96. package/templates/webterm/public/app/pane-shell.js +380 -0
  97. package/templates/webterm/public/app/pane.js +77 -193
  98. package/templates/webterm/public/app/reviews-host.js +15 -0
  99. package/templates/webterm/public/app/specs-host.js +16 -0
  100. package/templates/webterm/public/app/state.js +5 -0
  101. package/templates/webterm/public/app/tabs.js +6 -2
  102. package/templates/webterm/public/app/terminal.js +8 -0
  103. package/templates/webterm/public/app/theme.js +75 -27
  104. package/templates/webterm/public/app/tool-host.js +302 -0
  105. package/templates/webterm/public/app/workspace-color.js +192 -0
  106. package/templates/webterm/public/index.html +68 -30
  107. package/templates/webterm/public/styles.css +270 -34
  108. package/templates/webterm/server.js +321 -0
  109. package/templates/webterm/tool-document.js +67 -0
  110. package/templates/webterm/tools/commands.js +61 -0
  111. package/templates/webterm/tools/index.js +25 -0
  112. package/templates/webterm/tools/mount.js +337 -0
  113. package/templates/webterm/tools/paths.js +34 -0
  114. package/templates/webterm/tools/reviews.js +133 -0
  115. package/templates/webterm/tools/specs.js +154 -0
  116. package/templates/webterm/typed-line.js +148 -0
  117. package/templates/context/context-usage.md +0 -1
  118. package/templates/context-usage.sh +0 -266
@@ -18,6 +18,7 @@ import { join, resolve, sep } from "node:path";
18
18
  import express from "express";
19
19
  import { WebSocketServer } from "ws";
20
20
  import { cleanLine, cleanSubmission, createPaneStore, isPageId } from "./artifacts.js";
21
+ import { readColor, storeColor } from "./color.js";
21
22
  import {
22
23
  AGENT_ARGS,
23
24
  AGENT_STORES,
@@ -25,6 +26,7 @@ import {
25
26
  agentSpawnArgv,
26
27
  CHECK_INTERVAL_MS,
27
28
  CLIENT_PING_INTERVAL_MS,
29
+ COLOR_FILE,
28
30
  DEFAULT_AGENT,
29
31
  DIR_SCAN_DEPTH,
30
32
  DIR_SCAN_MAX,
@@ -47,6 +49,7 @@ import {
47
49
  MAX_PANE_FILES,
48
50
  MAX_SESSIONS,
49
51
  MAX_UPLOAD_BYTES,
52
+ PANE_APPS_CLIENT_DIR,
50
53
  PANE_DIR,
51
54
  PANE_SCAN_MS,
52
55
  PASTE_END,
@@ -72,6 +75,9 @@ import { isSelfOrDescendant, processStart } from "./proc.js";
72
75
  import { resumeArgvFor } from "./resume.js";
73
76
  import { createRegistry, WORK_TICK_MS } from "./sessions.js";
74
77
  import { statusFor, writesSnapshots } from "./snapshot.js";
78
+ import { documentPolicy, toolDocumentFor, withNonce } from "./tool-document.js";
79
+ import { CLIENT_PREFIX, commandFor, commandsOf, mountTool, TOOLS, toolPaths } from "./tools/index.js";
80
+ import { commandLine, framedLine, reduceLine } from "./typed-line.js";
75
81
 
76
82
  // node-pty is a native CommonJS addon; load it through createRequire under ESM.
77
83
  const require = createRequire(import.meta.url);
@@ -161,6 +167,10 @@ function sessionAgent(raw) {
161
167
  return isKnownAgent(raw) ? raw : null;
162
168
  }
163
169
 
170
+ // The accent colour this workspace is wearing, read once at start and moved by the "color" frame. "" means
171
+ // nothing was picked, and the browser works one out from the published port.
172
+ let chosenColor = readColor(COLOR_FILE);
173
+
164
174
  // The one-line file that names the default agent, for the `webterm` launcher to read back. Written when the
165
175
  // port is bound and again whenever the default moves, so a second `webterm <agent>` can say what is live.
166
176
  function publishAgent() {
@@ -346,6 +356,64 @@ app.use("/vendor/xterm", express.static(join(import.meta.dirname, "node_modules"
346
356
  app.use("/vendor/xterm", express.static(join(import.meta.dirname, "node_modules", "@xterm", "xterm", "lib")));
347
357
  app.use("/vendor/xterm-fit", express.static(join(import.meta.dirname, "node_modules", "@xterm", "addon-fit", "lib")));
348
358
 
359
+ // The pane's tool clients, built elsewhere and baked beside this directory. Open like the statics above, and
360
+ // CORS-readable on top of it: a tool renders in a frame with an opaque origin, so it fetches its own module
361
+ // scripts cross-origin and a browser will not run them without being told they may be read. Nothing here is
362
+ // secret and nothing here drives anything - the data behind a tool is gated separately, in tools/mount.js.
363
+ //
364
+ // `frame-ancestors` is the one thing that is not open. A tool's document is only ever framed by this
365
+ // interface, and framed by it inside a sandbox; a page somewhere else could otherwise frame the same document
366
+ // without one and run it as an ordinary document on this origin.
367
+ //
368
+ // The document of a tool is served ahead of the statics, under a policy of its own: it is the one file here
369
+ // that runs anything, and the frame it runs in has an opaque origin, where a policy written with `'self'`
370
+ // names nobody. See tool-document.js.
371
+ // Which origin the policy names has to come from the request. The browser reaches this server through a
372
+ // port the host published, and the container is not told which one, so `Host` is the only place the origin
373
+ // the document actually loaded from is written down. That makes it the caller's word, and it goes into a
374
+ // response header, so it is taken only in the shape an authority has: a host and an optional port, and
375
+ // nothing that could end the header or name a scheme. Anything else falls back to the address inside the
376
+ // container, which names no outside origin and so grants nothing.
377
+ const AUTHORITY = /^[A-Za-z0-9._-]+(:\d{1,5})?$|^\[[0-9A-Fa-f:.]+\](:\d{1,5})?$/;
378
+
379
+ function originOf(req) {
380
+ const host = req.get("host") ?? "";
381
+ return AUTHORITY.test(host) ? `http://${host}` : `http://127.0.0.1:${PORT}`;
382
+ }
383
+
384
+ // The wildcard is express 4's, which is what this directory's package.json pins. Express 5 spells the same
385
+ // thing `/*splat`, and that spelling compiles here to a route that ends in the literal word - it matches
386
+ // nothing anyone loads, and the document falls through to the statics below with no policy on it at all.
387
+ // The failure is silent, which is why a test issues a real request through a real express instead of
388
+ // reading this line.
389
+ app.get(`${CLIENT_PREFIX}/*`, (req, res, next) => {
390
+ const document = toolDocumentFor(req.path);
391
+ if (!document) return next();
392
+ let html;
393
+ try {
394
+ html = readFileSync(join(PANE_APPS_CLIENT_DIR, document), "utf8");
395
+ } catch {
396
+ return next();
397
+ }
398
+ const nonce = randomBytes(16).toString("base64");
399
+ res.set("Access-Control-Allow-Origin", "*");
400
+ res.set("Content-Security-Policy", documentPolicy(nonce, originOf(req)));
401
+ res.set("X-Content-Type-Options", "nosniff");
402
+ // A nonce is good for one response, so the document it sits in is never a document to keep.
403
+ res.set("Cache-Control", "no-store");
404
+ res.type("html").send(withNonce(html, nonce));
405
+ });
406
+
407
+ app.use(
408
+ CLIENT_PREFIX,
409
+ express.static(PANE_APPS_CLIENT_DIR, {
410
+ setHeaders: (res) => {
411
+ res.set("Access-Control-Allow-Origin", "*");
412
+ res.set("Content-Security-Policy", "frame-ancestors 'self'");
413
+ },
414
+ }),
415
+ );
416
+
349
417
  // Accept a raw image body (the client POSTs the pasted/dropped blob with its Content-Type).
350
418
  // Reject non-image types up front; limit the size so a bad request cannot fill the disk.
351
419
  app.post("/upload", requireKey, express.raw({ type: () => true, limit: MAX_UPLOAD_BYTES }), (req, res) => {
@@ -430,6 +498,9 @@ function broadcastSessions() {
430
498
  agent: defaultAgent,
431
499
  agents: AGENTS,
432
500
  workspace: WORKSPACE,
501
+ // The accent colour, on the one frame that already reaches every window: a change has to travel
502
+ // to all of them either way, so this closes the door rather than adding a second one.
503
+ color: chosenColor,
433
504
  max: MAX_SESSIONS,
434
505
  defaultCwd: workspaceLabel(defaultCwd),
435
506
  list,
@@ -549,6 +620,35 @@ function sessionForPid(pid) {
549
620
  return null;
550
621
  }
551
622
 
623
+ // --- The pane's tools --------------------------------------------------------------------------------------------------------------------
624
+ //
625
+ // Each one is mounted the way the pane store is: something built elsewhere is handed what only this process
626
+ // can give it - where to look, and who to tell when the disk moves - and its answers are registered behind
627
+ // gates it does not hold itself. None of them is a process or a port: a failure inside one is one request
628
+ // answered with a status, and every terminal in this container carries on.
629
+ //
630
+ // Nothing below names a tool. The list in `tools/index.js` is what is mounted, routed, broadcast and typed
631
+ // from, so a third tool is a row there and a table beside it rather than an edit in this file.
632
+
633
+ const mounted = new Map();
634
+ for (const table of TOOLS) {
635
+ mounted.set(
636
+ table.id,
637
+ mountTool({
638
+ table,
639
+ module: table.create({ onChange: (change) => broadcastTool(table.id, change) }),
640
+ key: table.key,
641
+ rawToken: table.rawToken,
642
+ }),
643
+ );
644
+ }
645
+
646
+ // A piece of work moved on disk. Every window hears it, not one: what a tool serves belongs to the repository
647
+ // rather than to a session, and two windows reading the same spec or the same review both need to know.
648
+ function broadcastTool(id, change) {
649
+ for (const client of clients) send(client, { t: id, change });
650
+ }
651
+
552
652
  // --- The status strip --------------------------------------------------------------------------------------------------------------------
553
653
  //
554
654
  // A claude session's own numbers, above the composer: the model, the context it is holding, what is left of
@@ -806,6 +906,206 @@ function nudge(session, title, path) {
806
906
  pasteToSession(session, `[web pane] Feedback submitted on "${quoted}" - read ${homeLabel(path)}`);
807
907
  }
808
908
 
909
+ // --- The tools' routes -------------------------------------------------------------------------------------------------------------------
910
+ //
911
+ // Three routes for each tool, registered from its own row: the credentials the shell hands into the frame,
912
+ // the helper's door, and everything the tool itself answers. Every rule about who may ask is in the mount, so
913
+ // what is here is one delegation per kind and no decision.
914
+
915
+ for (const table of TOOLS) {
916
+ const tool = mounted.get(table.id);
917
+ const paths = toolPaths(table.id);
918
+
919
+ /**
920
+ * The scoped credentials, for the shell alone. It holds the master key already, so nothing new is exposed
921
+ * by telling it these; what matters is where they go next - the shell posts the key into the tool's frame
922
+ * once, and the frame presents it as a header. Behind the pane's gate, which is the master key in a header
923
+ * and never in a URL, because a URL that carries it is a URL a document can read itself out of.
924
+ */
925
+ app.get(paths.keys, requirePaneKey, (_req, res) => {
926
+ res.json({ key: table.key, raw: tool.rawBase });
927
+ });
928
+
929
+ /**
930
+ * The tool helper's door: the agent's half of the coordination channel.
931
+ *
932
+ * The gate is the master key, which every helper route takes and which nothing outside the container has.
933
+ * The session behind it is a claim rather than a second gate: the helper sends its own pid, this walks up
934
+ * the process tree to see whether it lands in a session this server started, and any process in the
935
+ * container could send another process's pid instead - the same rule `webpane` publishes under, and its
936
+ * docstring says the same of itself. What that buys is a helper run outside a web session getting a clear
937
+ * answer rather than a silent one, and it is not asked to buy more: the session is used for nothing else,
938
+ * because a verb moves files in a directory of the repository rather than of a terminal.
939
+ */
940
+ app.post(paths.agent, requirePaneKey, express.json({ limit: table.limits.bytes }), async (req, res) => {
941
+ const session = sessionForPid(Number(req.headers[paths.pidHeader]));
942
+ if (!session) {
943
+ res.status(409).json({ error: "not running inside a web session - there is no pane to tell" });
944
+ return;
945
+ }
946
+ const body = req.body && typeof req.body === "object" ? req.body : {};
947
+ const answer = await tool.agent({ verb: body.verb, entry: body.entry, payload: body.payload });
948
+ res.status(answer.status).json(answer.body);
949
+ });
950
+
951
+ // The body of a tool's request, read only for a request the mount says may carry one. Reading it is
952
+ // itself something a caller gets to make this process do - hold the bytes, decode them - so it sits
953
+ // behind the same key as the route, rather than in front of it where every other body route in this file
954
+ // would not put it.
955
+ const body = express.text({ type: () => true, limit: table.limits.bytes });
956
+
957
+ /**
958
+ * Everything else the tool answers. One delegation and no decision: the mount takes the request as plain
959
+ * data and answers with the status, the headers and the body, and it neither throws nor rejects, so a
960
+ * tool's failure is a view that says so rather than a process that went down. Awaited because a module
961
+ * may compute its answer - a review asks git for one - and the mount settles that before it answers.
962
+ */
963
+ app.all(
964
+ `${paths.prefix}/*`,
965
+ (req, res, next) => {
966
+ if (tool.takesBody(req.method, req.path, req.headers)) body(req, res, next);
967
+ else next();
968
+ },
969
+ async (req, res) => {
970
+ const answer = await tool.handle({
971
+ method: req.method,
972
+ path: req.path,
973
+ query: req.query,
974
+ headers: req.headers,
975
+ body: req.body,
976
+ });
977
+ res.status(answer.status).set(answer.headers);
978
+ if (answer.body === null) {
979
+ res.end();
980
+ return;
981
+ }
982
+ // A workspace file goes out as its own bytes, under the type the tool decided; the rest is JSON.
983
+ if (Buffer.isBuffer(answer.body)) res.send(answer.body);
984
+ else res.json(answer.body);
985
+ },
986
+ );
987
+ }
988
+
989
+ // --- The tools' deliveries ---------------------------------------------------------------------------------------------------------------
990
+ //
991
+ // Everything a tool sends the agent lands as a file plus one typed line. The file is written through the data
992
+ // routes above, by the tool; this is the line. It rides the window's own socket, so the session it reaches is
993
+ // the session that window is driving - a tool never names one, and there is no id on the frame for it to
994
+ // name.
995
+ //
996
+ // The words of the line are this server's. The event is a key into the tool's own tables, so a tool picks
997
+ // which of a few sentences - or which of its commands - is typed, and never what it says; the detail is the
998
+ // one part that came from somewhere else and is reduced before it goes anywhere near a terminal; and the path
999
+ // has to be a file the tool itself wrote, which the mount decides.
1000
+ //
1001
+ // What a tool may say is in its table: `events` for the sentences and `commands.js` for the commands. A
1002
+ // sentence carries information to an agent already inside the flow, and anything that starts a flow is a
1003
+ // command, so no framed sentence ever reaches an agent that is not allowed to act on it.
1004
+
1005
+ // The gap and the total, per tool and per session, for as long as this container runs. Held here rather than
1006
+ // in the registry because they are about this one channel: a session that is closed and gone takes its count
1007
+ // with it, which is the same span the resume marker uses. Counted per tool, so one tool in a loop cannot
1008
+ // spend another's share of a session.
1009
+ const lastToolLine = new Map();
1010
+ const toolLineCount = new Map();
1011
+
1012
+ /** Whether this session has had as much of one tool's channel as it may have, and why. */
1013
+ function toolLimited(table, sid) {
1014
+ const now = Date.now();
1015
+ const at = `${table.id}\n${sid}`;
1016
+ if (now - (lastToolLine.get(at) ?? 0) < table.limits.interval) {
1017
+ return "that was just delivered - give it a moment";
1018
+ }
1019
+ if ((toolLineCount.get(at) ?? 0) >= table.limits.perSession) {
1020
+ return `this session has had the ${table.limits.perSession} deliveries it may have`;
1021
+ }
1022
+ return null;
1023
+ }
1024
+
1025
+ function toolDelivered(table, sid) {
1026
+ const at = `${table.id}\n${sid}`;
1027
+ lastToolLine.set(at, Date.now());
1028
+ toolLineCount.set(at, (toolLineCount.get(at) ?? 0) + 1);
1029
+ }
1030
+
1031
+ /**
1032
+ * Type one line from one tool into a session, or say why not.
1033
+ *
1034
+ * The length is checked before the reduction, so an over-limit detail is refused with a reason rather than
1035
+ * cut into half a sentence - which is the one thing a silent trim would make unreadable.
1036
+ *
1037
+ * `after` is for a session this delivery has just started: the line waits for the agent to be there to read
1038
+ * it, and the delivery is answered now, because the session did start and the line will follow.
1039
+ */
1040
+ function typeToolLine(table, session, msg, after = 0) {
1041
+ const tool = mounted.get(table.id);
1042
+ const key = typeof msg.event === "string" ? msg.event : "";
1043
+ // Read as the table's own entries and never as whatever an object inherits: the key came from the client,
1044
+ // so a name like `constructor` would otherwise index a function and pass for a delivery.
1045
+ const event = Object.hasOwn(table.events, key) ? table.events[key] : null;
1046
+ const commanded = Object.hasOwn(commandsOf(table.id), key);
1047
+ if (!event && !commanded) return { ok: false, error: `that is not something the ${table.label} tool delivers` };
1048
+ // An agent with no spelling for this command gets no delivery. Both other ways out break the typed-line
1049
+ // boundary: one agent's command word typed into another's session is a sentence that names a command, and
1050
+ // a framed sentence may never start a flow.
1051
+ const command = commanded ? commandFor(table.id, key, session.agent) : null;
1052
+ if (commanded && !command) {
1053
+ return { ok: false, error: `this button has no spelling for a ${session.agent} session` };
1054
+ }
1055
+ const detail = typeof msg.detail === "string" ? msg.detail : "";
1056
+ if (detail.length > table.limits.line) {
1057
+ return { ok: false, error: `that message is longer than the ${table.limits.line} characters a delivery may carry` };
1058
+ }
1059
+ const limited = toolLimited(table, session.id);
1060
+ if (limited) return { ok: false, error: limited };
1061
+ // A path is optional - a briefing names no file - and one that is not a file this tool wrote is dropped
1062
+ // rather than refused: the line still reads, and it names nothing that is not there.
1063
+ const absolute = msg.path ? tool.fileFor(msg.path) : null;
1064
+ const said = reduceLine(detail, table.limits.line, session.agent);
1065
+ // A command names no file: it opens on the work itself, and the skill it starts reads what it needs.
1066
+ const line = command
1067
+ ? commandLine(command, said)
1068
+ : framedLine({ tag: table.id, event, detail: said, path: absolute === null ? "" : homeLabel(absolute), agent: session.agent });
1069
+ toolDelivered(table, session.id);
1070
+ if (after > 0) {
1071
+ // Only if that session is still there when the wait is over.
1072
+ setTimeout(() => {
1073
+ const still = registry.get(session.id);
1074
+ if (still) pasteToSession(still, line);
1075
+ }, after).unref();
1076
+ } else {
1077
+ pasteToSession(session, line);
1078
+ }
1079
+ console.log(`[webterm] ${table.id}: delivered to session "${session.name || session.label}"`);
1080
+ return { ok: true };
1081
+ }
1082
+
1083
+ /**
1084
+ * A delivery from a tool, arriving over the shell's own socket.
1085
+ *
1086
+ * With no session on this window there is nobody to tell, and the tool has already asked the user whether to
1087
+ * start one: `create` is that answer, and the briefing becomes the first line the new agent reads.
1088
+ */
1089
+ function deliverTool(table, ws, msg) {
1090
+ const answered = `${table.id}:delivered`;
1091
+ const session = registry.sessionFor(ws);
1092
+ if (session) {
1093
+ send(ws, { t: answered, ...typeToolLine(table, session, msg) });
1094
+ return;
1095
+ }
1096
+ if (msg.create !== true) {
1097
+ send(ws, { t: answered, ok: false, error: "no-session" });
1098
+ return;
1099
+ }
1100
+ newSession(ws);
1101
+ const started = registry.sessionFor(ws);
1102
+ if (!started) {
1103
+ send(ws, { t: answered, ok: false, error: "a session could not be started" });
1104
+ return;
1105
+ }
1106
+ send(ws, { t: answered, ...typeToolLine(table, started, msg, table.limits.newSession) });
1107
+ }
1108
+
809
1109
  // --- Resume, once per container start ----------------------------------------------------------------------------------------------------
810
1110
 
811
1111
  // PID 1 is CONTAINER_KEEP_ALIVE, so it starts when the container does and its start time is a different
@@ -1059,6 +1359,19 @@ function handleFrame(ws, msg) {
1059
1359
  if (attached) sendStatus(attached, { force: true });
1060
1360
  return;
1061
1361
  }
1362
+ case "color": {
1363
+ // The palette lives in the browser, so the id is opaque here and only its size and shape are
1364
+ // checked. A refused frame writes nothing and broadcasts nothing, and the window that sent it is
1365
+ // put right by the next sessions frame it receives.
1366
+ const applied = storeColor(COLOR_FILE, msg.color);
1367
+ if (applied === null) {
1368
+ console.warn("[webterm] refused a colour id");
1369
+ return;
1370
+ }
1371
+ chosenColor = applied;
1372
+ broadcastSessions();
1373
+ return;
1374
+ }
1062
1375
  case "seen":
1063
1376
  // The user did something in this window - a click, a scroll, a key. It changes nothing about who
1064
1377
  // drives what, only whether the next ending of the session it is driving is worth a sound. Typing
@@ -1110,6 +1423,14 @@ function handleFrame(ws, msg) {
1110
1423
  case "paste":
1111
1424
  if (typeof msg.data === "string") paste(ws, msg.data);
1112
1425
  return;
1426
+ case "tool:deliver": {
1427
+ // A tool in the pane telling the agent something arrived. Like the pane's own frames, it reaches
1428
+ // the session this window is driving, and the frame carries no id for it to reach another. The
1429
+ // tool it came from is a name on the frame, and a name that is not a tool's is not a delivery.
1430
+ const table = TOOLS.find((one) => one.id === msg.tool);
1431
+ if (table) deliverTool(table, ws, msg);
1432
+ return;
1433
+ }
1113
1434
  case "resize":
1114
1435
  resize(ws, msg.cols, msg.rows);
1115
1436
  return;
@@ -0,0 +1,67 @@
1
+ // =========================================================================================================================================
2
+ // tool-document.js - The policy a tool's own document is served under.
3
+ //
4
+ // A tool client runs in a frame that is sandboxed without allow-same-origin, so its document has an opaque
5
+ // origin. That is what keeps a bug in spec rendering away from the key that types into terminals, and it is
6
+ // also why this document cannot be served under the policy everything else on this origin is: `'self'` names
7
+ // the document's own origin, an opaque origin is nobody, and a directive that names it matches nothing at
8
+ // all. A policy written with `'self'` in it therefore does not tighten this document - it turns its own
9
+ // scripts off.
10
+ //
11
+ // So the execution directives name no origin. What may run is named by a nonce this server puts on the two
12
+ // elements it built the document with, and the directives that decide where bytes may be fetched from name
13
+ // the origin the document was loaded from - which is this server, and which is where the tool's assets, its
14
+ // data routes and its jailed workspace files all live.
15
+ //
16
+ // Everything here is a pure function of its inputs. server.js reads the file, makes the nonce, and sends the
17
+ // answer; the rules are decided here, where a test can run them.
18
+ // =========================================================================================================================================
19
+
20
+ // Where the built tool clients are served from, and the one document each tool has. The tool name is spelled
21
+ // out rather than taken from the URL, so no path a caller writes ever reaches the filesystem through here.
22
+ const DOCUMENT_PATH = /^\/pane-apps\/([a-z0-9-]+)\/index\.html$/;
23
+
24
+ /**
25
+ * The document a URL asks for, as a path relative to the built clients directory, or null when the URL asks
26
+ * for something else - an asset, a stylesheet, a name that is not a tool.
27
+ */
28
+ export function toolDocumentFor(urlPath) {
29
+ const asked = DOCUMENT_PATH.exec(String(urlPath || ""));
30
+ return asked ? `${asked[1]}/index.html` : null;
31
+ }
32
+
33
+ /**
34
+ * The document with its nonce on it.
35
+ *
36
+ * The build emits one module script and one stylesheet link, and both have to carry the nonce, because the
37
+ * policy below names nothing else that may run or style. This is a rewrite of a file this build made, not of
38
+ * anything a workspace wrote: no content from the served root passes through here.
39
+ */
40
+ export function withNonce(html, nonce) {
41
+ return String(html).replace(/<(script|link)\b/gi, (tag) => `${tag} nonce="${nonce}"`);
42
+ }
43
+
44
+ /**
45
+ * The policy the document is sent under.
46
+ *
47
+ * `default-src 'none'` is the floor, so anything not named below is refused. What runs is named by the nonce
48
+ * alone. What is fetched is named by `origin` - the scheme and host the document was loaded from - because
49
+ * the client's own assets, the tool's data routes and the jailed workspace files are all served from here,
50
+ * and an opaque origin has no `'self'` to say that with. `base-uri` and `form-action` are named on their own,
51
+ * since neither one falls back to `default-src`.
52
+ */
53
+ export function documentPolicy(nonce, origin) {
54
+ return [
55
+ "default-src 'none'",
56
+ `script-src 'nonce-${nonce}'`,
57
+ `style-src-elem 'nonce-${nonce}'`,
58
+ `img-src ${origin}`,
59
+ `connect-src ${origin}`,
60
+ `frame-src ${origin}`,
61
+ "base-uri 'none'",
62
+ "form-action 'none'",
63
+ // The document is framed by this interface and by nothing else. Without it a page elsewhere could
64
+ // frame the same document with no sandbox and run it as an ordinary document on this origin.
65
+ "frame-ancestors 'self'",
66
+ ].join("; ");
67
+ }
@@ -0,0 +1,61 @@
1
+ // =========================================================================================================================================
2
+ // tools/commands.js - The commands a tool's button types, spelled for each agent.
3
+ //
4
+ // A button on a tab that starts a skill types the command the user would type, because a skill only the user may start may never
5
+ // start from a framed sentence. Each agent spells that command its own way, so one row here is one event, and one cell is what that
6
+ // event types into a session of that agent.
7
+ //
8
+ // The table is read from two sides. The web interface's server reads it to type the line, and chamba's host-side injector reads it
9
+ // to write opencode's command files - the files that make opencode's spelling resolve at all. One table, so the word a button types
10
+ // and the word the container answers to cannot come to differ.
11
+ //
12
+ // Nothing here imports anything. The injector runs on the host, where the interface's own config does not belong.
13
+ // =========================================================================================================================================
14
+
15
+ // claude resolves `/name` from a skill's own frontmatter. codex takes `$name`, with the argument on the same line. opencode
16
+ // resolves `/name` from a command file, which is why the injector ships one per word below.
17
+ //
18
+ // One block per tool, and the event names are that tool's own: a delivery names its tool and its event, and the two together are
19
+ // what picks a row.
20
+ export const TOOL_COMMANDS = {
21
+ specs: {
22
+ brief: { claude: "/dx-spec", codex: "$dx-spec", opencode: "/dx-spec" },
23
+ execute: { claude: "/dx-spec-execute", codex: "$dx-spec-execute", opencode: "/dx-spec-execute" },
24
+ },
25
+ // One word for every act that starts the agent on a review - filing one, sending feedback, syncing, and asking it to carry on.
26
+ // The skill reads the review's state to know which of them it was woken for, so the line never says.
27
+ reviews: {
28
+ review: { claude: "/dx-review", codex: "$dx-review", opencode: "/dx-review" },
29
+ },
30
+ };
31
+
32
+ // The skill each command word starts. opencode's command file is a prompt rather than a skill, so it has to name the skill it
33
+ // means, and this is where that name comes from.
34
+ export const TOOL_COMMAND_SKILLS = {
35
+ specs: {
36
+ brief: "dx-spec",
37
+ execute: "dx-spec-execute",
38
+ },
39
+ reviews: {
40
+ review: "dx-review",
41
+ },
42
+ };
43
+
44
+ /** The rows one tool types from, or an empty table for a name that is not a tool. */
45
+ export function commandsOf(tool) {
46
+ return Object.hasOwn(TOOL_COMMANDS, tool) ? TOOL_COMMANDS[tool] : {};
47
+ }
48
+
49
+ /** The skill one tool's word starts, or null. */
50
+ export function commandSkill(tool, event) {
51
+ const rows = Object.hasOwn(TOOL_COMMAND_SKILLS, tool) ? TOOL_COMMAND_SKILLS[tool] : {};
52
+ return Object.hasOwn(rows, event) ? rows[event] : null;
53
+ }
54
+
55
+ /** The spelling for one tool's event in one agent's session, or null when that agent has none. */
56
+ export function commandFor(tool, event, agent) {
57
+ const rows = commandsOf(tool);
58
+ if (!Object.hasOwn(rows, event)) return null;
59
+ const row = rows[event];
60
+ return Object.hasOwn(row, agent) ? row[agent] : null;
61
+ }
@@ -0,0 +1,25 @@
1
+ // =========================================================================================================================================
2
+ // tools/index.js - The tools this pane hosts.
3
+ //
4
+ // One list, and everything about a tool is derived from its row: what is mounted, what is routed, what is broadcast, what a button
5
+ // types and how much of the typing channel one session gets. server.js reads the list and never a tool's name, so a third tool is
6
+ // a row here and a table beside it.
7
+ //
8
+ // The order is the order the tabs are drawn in, which is the pane's own list in `public/app/pane-shell.js`. The two lists are
9
+ // checked against each other by the interface's tests rather than derived from one another, because the pane is drawn in the
10
+ // browser and this file is read in the server.
11
+ // =========================================================================================================================================
12
+
13
+ import { REVIEWS_TABLE } from "./reviews.js";
14
+ import { SPECS_TABLE } from "./specs.js";
15
+
16
+ export const TOOLS = [SPECS_TABLE, REVIEWS_TABLE];
17
+
18
+ /** The table of one tool, or undefined. Asked with `find`, so a name that is not a tool's is not one. */
19
+ export function toolTable(id) {
20
+ return TOOLS.find((table) => table.id === id);
21
+ }
22
+
23
+ export { commandFor, commandSkill, commandsOf, TOOL_COMMAND_SKILLS, TOOL_COMMANDS } from "./commands.js";
24
+ export { mountTool } from "./mount.js";
25
+ export { CLIENT_PREFIX, toolPaths } from "./paths.js";