chamba 0.7.0 → 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 (120) hide show
  1. package/LICENSE +53 -14
  2. package/README.md +28 -8
  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 -210
  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 -261
  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 -36
  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 +4 -4
  28. package/templates/Dockerfile +12 -19
  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 +3 -2
  68. package/templates/pane-apps/server/reviews.mjs +20 -0
  69. package/templates/pane-apps/server/specs.mjs +5 -1588
  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 +223 -27
  74. package/templates/skills/dx-spec/references/principles-template.md +2 -0
  75. package/templates/skills/dx-spec/references/review-guide.md +4 -50
  76. package/templates/skills/dx-spec/references/spec-guide.md +24 -4
  77. package/templates/skills/dx-spec/references/stages.md +143 -58
  78. package/templates/skills/dx-spec-execute/SKILL.md +148 -35
  79. package/templates/startup.mjs +10 -4
  80. package/templates/tool-helper.sh +166 -0
  81. package/templates/webterm/README.md +60 -26
  82. package/templates/webterm/color.js +61 -0
  83. package/templates/webterm/config.js +35 -4
  84. package/templates/webterm/context/claude.md +0 -1
  85. package/templates/webterm/public/app/alerts.js +4 -4
  86. package/templates/webterm/public/app/anchored.js +81 -0
  87. package/templates/webterm/public/app/connection.js +9 -0
  88. package/templates/webterm/public/app/dom.js +2 -0
  89. package/templates/webterm/public/app/frames.js +51 -17
  90. package/templates/webterm/public/app/main.js +22 -8
  91. package/templates/webterm/public/app/new-session.js +13 -41
  92. package/templates/webterm/public/app/palette.js +58 -0
  93. package/templates/webterm/public/app/pane-shape.js +167 -0
  94. package/templates/webterm/public/app/pane-shell.js +199 -134
  95. package/templates/webterm/public/app/pane.js +33 -24
  96. package/templates/webterm/public/app/reviews-host.js +15 -0
  97. package/templates/webterm/public/app/specs-host.js +12 -218
  98. package/templates/webterm/public/app/state.js +5 -0
  99. package/templates/webterm/public/app/tabs.js +6 -2
  100. package/templates/webterm/public/app/theme.js +75 -27
  101. package/templates/webterm/public/app/tool-host.js +302 -0
  102. package/templates/webterm/public/app/workspace-color.js +192 -0
  103. package/templates/webterm/public/index.html +17 -3
  104. package/templates/webterm/public/styles.css +143 -21
  105. package/templates/webterm/server.js +198 -150
  106. package/templates/webterm/tools/commands.js +61 -0
  107. package/templates/webterm/tools/index.js +25 -0
  108. package/templates/webterm/tools/mount.js +337 -0
  109. package/templates/webterm/tools/paths.js +34 -0
  110. package/templates/webterm/tools/reviews.js +133 -0
  111. package/templates/webterm/tools/specs.js +154 -0
  112. package/templates/webterm/typed-line.js +70 -7
  113. package/templates/context/context-usage.md +0 -1
  114. package/templates/context-usage.sh +0 -266
  115. package/templates/pane-apps/client/assets/specs-B1970L17.css +0 -1
  116. package/templates/pane-apps/client/assets/specs-cEee_SPn.js +0 -23
  117. package/templates/skills/dx-spec-config/SKILL.md +0 -313
  118. package/templates/skills/dx-spec-config/references/principles-template.md +0 -12
  119. package/templates/specs.sh +0 -106
  120. package/templates/webterm/specs.js +0 -358
@@ -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,
@@ -46,9 +48,6 @@ import {
46
48
  MAX_PANE_BYTES,
47
49
  MAX_PANE_FILES,
48
50
  MAX_SESSIONS,
49
- MAX_SPECS_BYTES,
50
- MAX_SPECS_LINE_LENGTH,
51
- MAX_SPECS_LINES_PER_SESSION,
52
51
  MAX_UPLOAD_BYTES,
53
52
  PANE_APPS_CLIENT_DIR,
54
53
  PANE_DIR,
@@ -58,11 +57,6 @@ import {
58
57
  PORT,
59
58
  RESUME_STAMP,
60
59
  resolveWorkspacePath,
61
- SPECS_KEY,
62
- SPECS_LINE_MIN_INTERVAL_MS,
63
- SPECS_NEW_SESSION_DELAY_MS,
64
- SPECS_RAW_TOKEN,
65
- SPECS_ROOT,
66
60
  STATE_FILE,
67
61
  STATUS_SCAN_MS,
68
62
  STOP_ANNOUNCE_MS,
@@ -81,9 +75,9 @@ import { isSelfOrDescendant, processStart } from "./proc.js";
81
75
  import { resumeArgvFor } from "./resume.js";
82
76
  import { createRegistry, WORK_TICK_MS } from "./sessions.js";
83
77
  import { statusFor, writesSnapshots } from "./snapshot.js";
84
- import { CLIENT_PREFIX, createSpecsModule, mountSpecs, SPECS_AGENT_PATH, SPECS_KEYS_PATH, SPECS_PREFIX } from "./specs.js";
85
78
  import { documentPolicy, toolDocumentFor, withNonce } from "./tool-document.js";
86
- import { framedLine, reduceLine } from "./typed-line.js";
79
+ import { CLIENT_PREFIX, commandFor, commandsOf, mountTool, TOOLS, toolPaths } from "./tools/index.js";
80
+ import { commandLine, framedLine, reduceLine } from "./typed-line.js";
87
81
 
88
82
  // node-pty is a native CommonJS addon; load it through createRequire under ESM.
89
83
  const require = createRequire(import.meta.url);
@@ -173,6 +167,10 @@ function sessionAgent(raw) {
173
167
  return isKnownAgent(raw) ? raw : null;
174
168
  }
175
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
+
176
174
  // The one-line file that names the default agent, for the `webterm` launcher to read back. Written when the
177
175
  // port is bound and again whenever the default moves, so a second `webterm <agent>` can say what is live.
178
176
  function publishAgent() {
@@ -361,7 +359,7 @@ app.use("/vendor/xterm-fit", express.static(join(import.meta.dirname, "node_modu
361
359
  // The pane's tool clients, built elsewhere and baked beside this directory. Open like the statics above, and
362
360
  // CORS-readable on top of it: a tool renders in a frame with an opaque origin, so it fetches its own module
363
361
  // 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.
362
+ // secret and nothing here drives anything - the data behind a tool is gated separately, in tools/mount.js.
365
363
  //
366
364
  // `frame-ancestors` is the one thing that is not open. A tool's document is only ever framed by this
367
365
  // interface, and framed by it inside a sandbox; a page somewhere else could otherwise frame the same document
@@ -500,6 +498,9 @@ function broadcastSessions() {
500
498
  agent: defaultAgent,
501
499
  agents: AGENTS,
502
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,
503
504
  max: MAX_SESSIONS,
504
505
  defaultCwd: workspaceLabel(defaultCwd),
505
506
  list,
@@ -619,23 +620,33 @@ function sessionForPid(pid) {
619
620
  return null;
620
621
  }
621
622
 
622
- // --- The Specs tool ----------------------------------------------------------------------------------------------------------------------
623
+ // --- The pane's tools --------------------------------------------------------------------------------------------------------------------
623
624
  //
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
- });
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
+ }
634
645
 
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 });
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 });
639
650
  }
640
651
 
641
652
  // --- The status strip --------------------------------------------------------------------------------------------------------------------
@@ -895,121 +906,130 @@ function nudge(session, title, path) {
895
906
  pasteToSession(session, `[web pane] Feedback submitted on "${quoted}" - read ${homeLabel(path)}`);
896
907
  }
897
908
 
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 });
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
+ });
936
928
 
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();
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" });
959
944
  return;
960
945
  }
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
- );
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
+ }
966
988
 
967
- // --- The Specs delivery ------------------------------------------------------------------------------------------------------------------
989
+ // --- The tools' deliveries ---------------------------------------------------------------------------------------------------------------
968
990
  //
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.
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.
973
995
  //
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
+ // 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) {
996
1014
  const now = Date.now();
997
- if (now - (lastSpecsLine.get(sid) ?? 0) < SPECS_LINE_MIN_INTERVAL_MS) {
1015
+ const at = `${table.id}\n${sid}`;
1016
+ if (now - (lastToolLine.get(at) ?? 0) < table.limits.interval) {
998
1017
  return "that was just delivered - give it a moment";
999
1018
  }
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`;
1019
+ if ((toolLineCount.get(at) ?? 0) >= table.limits.perSession) {
1020
+ return `this session has had the ${table.limits.perSession} deliveries it may have`;
1002
1021
  }
1003
1022
  return null;
1004
1023
  }
1005
1024
 
1006
- function specsDelivered(sid) {
1007
- lastSpecsLine.set(sid, Date.now());
1008
- specsLineCount.set(sid, (specsLineCount.get(sid) ?? 0) + 1);
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);
1009
1029
  }
1010
1030
 
1011
1031
  /**
1012
- * Type one Specs line into a session, or say why not.
1032
+ * Type one line from one tool into a session, or say why not.
1013
1033
  *
1014
1034
  * The length is checked before the reduction, so an over-limit detail is refused with a reason rather than
1015
1035
  * cut into half a sentence - which is the one thing a silent trim would make unreadable.
@@ -1017,25 +1037,36 @@ function specsDelivered(sid) {
1017
1037
  * `after` is for a session this delivery has just started: the line waits for the agent to be there to read
1018
1038
  * it, and the delivery is answered now, because the session did start and the line will follow.
1019
1039
  */
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" };
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
+ }
1023
1055
  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` };
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` };
1026
1058
  }
1027
- const limited = specsLimited(session.id);
1059
+ const limited = toolLimited(table, session.id);
1028
1060
  if (limited) return { ok: false, error: limited };
1029
1061
  // A path is optional - a briefing names no file - and one that is not a file this tool wrote is dropped
1030
1062
  // 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);
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);
1039
1070
  if (after > 0) {
1040
1071
  // Only if that session is still there when the wait is over.
1041
1072
  setTimeout(() => {
@@ -1045,33 +1076,34 @@ function typeSpecsLine(session, msg, after = 0) {
1045
1076
  } else {
1046
1077
  pasteToSession(session, line);
1047
1078
  }
1048
- console.log(`[webterm] specs: delivered to session "${session.name || session.label}"`);
1079
+ console.log(`[webterm] ${table.id}: delivered to session "${session.name || session.label}"`);
1049
1080
  return { ok: true };
1050
1081
  }
1051
1082
 
1052
1083
  /**
1053
- * A delivery from the Specs tool, arriving over the shell's own socket.
1084
+ * A delivery from a tool, arriving over the shell's own socket.
1054
1085
  *
1055
1086
  * With no session on this window there is nobody to tell, and the tool has already asked the user whether to
1056
1087
  * start one: `create` is that answer, and the briefing becomes the first line the new agent reads.
1057
1088
  */
1058
- function deliverSpecs(ws, msg) {
1089
+ function deliverTool(table, ws, msg) {
1090
+ const answered = `${table.id}:delivered`;
1059
1091
  const session = registry.sessionFor(ws);
1060
1092
  if (session) {
1061
- send(ws, { t: "specs:delivered", ...typeSpecsLine(session, msg) });
1093
+ send(ws, { t: answered, ...typeToolLine(table, session, msg) });
1062
1094
  return;
1063
1095
  }
1064
1096
  if (msg.create !== true) {
1065
- send(ws, { t: "specs:delivered", ok: false, error: "no-session" });
1097
+ send(ws, { t: answered, ok: false, error: "no-session" });
1066
1098
  return;
1067
1099
  }
1068
1100
  newSession(ws);
1069
1101
  const started = registry.sessionFor(ws);
1070
1102
  if (!started) {
1071
- send(ws, { t: "specs:delivered", ok: false, error: "a session could not be started" });
1103
+ send(ws, { t: answered, ok: false, error: "a session could not be started" });
1072
1104
  return;
1073
1105
  }
1074
- send(ws, { t: "specs:delivered", ...typeSpecsLine(started, msg, SPECS_NEW_SESSION_DELAY_MS) });
1106
+ send(ws, { t: answered, ...typeToolLine(table, started, msg, table.limits.newSession) });
1075
1107
  }
1076
1108
 
1077
1109
  // --- Resume, once per container start ----------------------------------------------------------------------------------------------------
@@ -1327,6 +1359,19 @@ function handleFrame(ws, msg) {
1327
1359
  if (attached) sendStatus(attached, { force: true });
1328
1360
  return;
1329
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
+ }
1330
1375
  case "seen":
1331
1376
  // The user did something in this window - a click, a scroll, a key. It changes nothing about who
1332
1377
  // drives what, only whether the next ending of the session it is driving is worth a sound. Typing
@@ -1378,11 +1423,14 @@ function handleFrame(ws, msg) {
1378
1423
  case "paste":
1379
1424
  if (typeof msg.data === "string") paste(ws, msg.data);
1380
1425
  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);
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);
1385
1432
  return;
1433
+ }
1386
1434
  case "resize":
1387
1435
  resize(ws, msg.cols, msg.rows);
1388
1436
  return;
@@ -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";