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.
- package/LICENSE +53 -14
- package/README.md +38 -11
- package/dist/commands/advanced.js +7 -278
- package/dist/commands/dev.js +11 -612
- package/dist/commands/doctor.js +1 -29
- package/dist/commands/menu.js +1 -80
- package/dist/commands/onboard.js +6 -230
- package/dist/commands/settings.js +15 -349
- package/dist/lib/agent-commands.js +2 -0
- package/dist/lib/agent-context.js +6 -184
- package/dist/lib/browser.js +1 -40
- package/dist/lib/chamba-yaml.js +10 -191
- package/dist/lib/constants.js +1 -125
- package/dist/lib/dockerfile-builder.js +41 -260
- package/dist/lib/env.js +2 -78
- package/dist/lib/git-env.js +1 -21
- package/dist/lib/global-config.js +4 -66
- package/dist/lib/pnpm-store.js +1 -19
- package/dist/lib/ports.js +1 -210
- package/dist/lib/safe-rm.js +1 -26
- package/dist/lib/sessions.js +2 -34
- package/dist/lib/shadows.js +1 -176
- package/dist/lib/skills.js +2 -0
- package/dist/lib/templates.js +1 -0
- package/dist/lib/webterm.js +1 -305
- package/dist/lib/workspace-identity.js +4 -260
- package/package.json +6 -6
- package/templates/Dockerfile +24 -12
- package/templates/claude-statusline.sh +5 -7
- package/templates/context/baseline.md +2 -0
- package/templates/pane-apps/client/assets/c-BNa-DeUl.json +3552 -0
- package/templates/pane-apps/client/assets/cpp-B-T4NRoH.json +20239 -0
- package/templates/pane-apps/client/assets/csharp-BbxHChhA.json +6010 -0
- package/templates/pane-apps/client/assets/css-CWCxuwmz.json +1860 -0
- package/templates/pane-apps/client/assets/diff-B-2N89g8.json +154 -0
- package/templates/pane-apps/client/assets/docker-DWTvYW26.json +103 -0
- package/templates/pane-apps/client/assets/files-D0nJgFAA.js +12 -0
- package/templates/pane-apps/client/assets/files-h0K1vZyQ.css +1 -0
- package/templates/pane-apps/client/assets/go-B2hyYeNg.json +3156 -0
- package/templates/pane-apps/client/assets/graphql-Z6Id2aNO.json +1292 -0
- package/templates/pane-apps/client/assets/html-BtzvlIfu.json +2627 -0
- package/templates/pane-apps/client/assets/ini-Bn3Twap1.json +108 -0
- package/templates/pane-apps/client/assets/java-C7t2dwLO.json +1885 -0
- package/templates/pane-apps/client/assets/javascript-DuC2v0C0.json +5996 -0
- package/templates/pane-apps/client/assets/json-CwdkWeV5.json +206 -0
- package/templates/pane-apps/client/assets/jsonc-MYWDbTU3.json +206 -0
- package/templates/pane-apps/client/assets/jsx-Cn8EZuIC.json +5996 -0
- package/templates/pane-apps/client/assets/kotlin-CFNOz1gw.json +580 -0
- package/templates/pane-apps/client/assets/make-BVnlFCcl.json +629 -0
- package/templates/pane-apps/client/assets/markdown-B4Xk2WM_.json +3263 -0
- package/templates/pane-apps/client/assets/php-CAQzQmmI.json +4188 -0
- package/templates/pane-apps/client/assets/python-CYQee8m4.json +4174 -0
- package/templates/pane-apps/client/assets/reviews-BsoGZ35r.css +1 -0
- package/templates/pane-apps/client/assets/reviews-iW0Mbn4e.js +153 -0
- package/templates/pane-apps/client/assets/ruby-D7R58tus.json +3114 -0
- package/templates/pane-apps/client/assets/rust-BFBbFiei.json +1103 -0
- package/templates/pane-apps/client/assets/scss-TQRhBkPG.json +1874 -0
- package/templates/pane-apps/client/assets/shellscript-DieAkC4W.json +2375 -0
- package/templates/pane-apps/client/assets/specs-D9z3neR-.js +13 -0
- package/templates/pane-apps/client/assets/sql-Bxd030LV.json +634 -0
- package/templates/pane-apps/client/assets/toml-ChrKGzsV.json +430 -0
- package/templates/pane-apps/client/assets/tsx-D2e90MFw.json +5996 -0
- package/templates/pane-apps/client/assets/typescript-DonXQ_n1.json +5747 -0
- package/templates/pane-apps/client/assets/xml-B17r2RXF.json +382 -0
- package/templates/pane-apps/client/assets/yaml-CKn4h1zy.json +627 -0
- package/templates/pane-apps/client/reviews/index.html +15 -0
- package/templates/pane-apps/client/specs/index.html +14 -0
- package/templates/pane-apps/server/reviews.mjs +20 -0
- package/templates/pane-apps/server/specs.mjs +5 -0
- package/templates/skills/chamba-statusline/SKILL.md +7 -19
- package/templates/skills/dx-review/SKILL.md +123 -0
- package/templates/skills/dx-review/references/acts.md +162 -0
- package/templates/skills/dx-spec/SKILL.md +561 -0
- package/templates/skills/dx-spec/references/imagination-guide.md +140 -0
- package/templates/skills/dx-spec/references/principles-template.md +2 -0
- package/templates/skills/dx-spec/references/review-guide.md +127 -0
- package/templates/skills/dx-spec/references/spec-guide.md +145 -0
- package/templates/skills/dx-spec/references/stages.md +484 -0
- package/templates/skills/dx-spec-execute/SKILL.md +437 -0
- package/templates/startup.mjs +10 -4
- package/templates/tool-helper.sh +166 -0
- package/templates/webterm/README.md +79 -9
- package/templates/webterm/color.js +61 -0
- package/templates/webterm/config.js +74 -0
- package/templates/webterm/context/claude.md +0 -1
- package/templates/webterm/public/app/alerts.js +4 -4
- package/templates/webterm/public/app/anchored.js +81 -0
- package/templates/webterm/public/app/composer.js +4 -1
- package/templates/webterm/public/app/connection.js +9 -0
- package/templates/webterm/public/app/dom.js +15 -5
- package/templates/webterm/public/app/frames.js +51 -10
- package/templates/webterm/public/app/main.js +26 -6
- package/templates/webterm/public/app/new-session.js +13 -41
- package/templates/webterm/public/app/palette.js +58 -0
- package/templates/webterm/public/app/pane-shape.js +167 -0
- package/templates/webterm/public/app/pane-shell.js +380 -0
- package/templates/webterm/public/app/pane.js +77 -193
- package/templates/webterm/public/app/reviews-host.js +15 -0
- package/templates/webterm/public/app/specs-host.js +16 -0
- package/templates/webterm/public/app/state.js +5 -0
- package/templates/webterm/public/app/tabs.js +6 -2
- package/templates/webterm/public/app/terminal.js +8 -0
- package/templates/webterm/public/app/theme.js +75 -27
- package/templates/webterm/public/app/tool-host.js +302 -0
- package/templates/webterm/public/app/workspace-color.js +192 -0
- package/templates/webterm/public/index.html +68 -30
- package/templates/webterm/public/styles.css +270 -34
- package/templates/webterm/server.js +321 -0
- package/templates/webterm/tool-document.js +67 -0
- package/templates/webterm/tools/commands.js +61 -0
- package/templates/webterm/tools/index.js +25 -0
- package/templates/webterm/tools/mount.js +337 -0
- package/templates/webterm/tools/paths.js +34 -0
- package/templates/webterm/tools/reviews.js +133 -0
- package/templates/webterm/tools/specs.js +154 -0
- package/templates/webterm/typed-line.js +148 -0
- package/templates/context/context-usage.md +0 -1
- 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";
|