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.
- package/LICENSE +53 -14
- package/README.md +28 -8
- 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 -210
- 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 -261
- 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 -36
- 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 +4 -4
- package/templates/Dockerfile +12 -19
- 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 +3 -2
- package/templates/pane-apps/server/reviews.mjs +20 -0
- package/templates/pane-apps/server/specs.mjs +5 -1588
- 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 +223 -27
- package/templates/skills/dx-spec/references/principles-template.md +2 -0
- package/templates/skills/dx-spec/references/review-guide.md +4 -50
- package/templates/skills/dx-spec/references/spec-guide.md +24 -4
- package/templates/skills/dx-spec/references/stages.md +143 -58
- package/templates/skills/dx-spec-execute/SKILL.md +148 -35
- package/templates/startup.mjs +10 -4
- package/templates/tool-helper.sh +166 -0
- package/templates/webterm/README.md +60 -26
- package/templates/webterm/color.js +61 -0
- package/templates/webterm/config.js +35 -4
- 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/connection.js +9 -0
- package/templates/webterm/public/app/dom.js +2 -0
- package/templates/webterm/public/app/frames.js +51 -17
- package/templates/webterm/public/app/main.js +22 -8
- 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 +199 -134
- package/templates/webterm/public/app/pane.js +33 -24
- package/templates/webterm/public/app/reviews-host.js +15 -0
- package/templates/webterm/public/app/specs-host.js +12 -218
- package/templates/webterm/public/app/state.js +5 -0
- package/templates/webterm/public/app/tabs.js +6 -2
- 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 +17 -3
- package/templates/webterm/public/styles.css +143 -21
- package/templates/webterm/server.js +198 -150
- 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 +70 -7
- package/templates/context/context-usage.md +0 -1
- package/templates/context-usage.sh +0 -266
- package/templates/pane-apps/client/assets/specs-B1970L17.css +0 -1
- package/templates/pane-apps/client/assets/specs-cEee_SPn.js +0 -23
- package/templates/skills/dx-spec-config/SKILL.md +0 -313
- package/templates/skills/dx-spec-config/references/principles-template.md +0 -12
- package/templates/specs.sh +0 -106
- 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 {
|
|
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
|
|
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
|
|
623
|
+
// --- The pane's tools --------------------------------------------------------------------------------------------------------------------
|
|
623
624
|
//
|
|
624
|
-
//
|
|
625
|
-
//
|
|
626
|
-
//
|
|
627
|
-
//
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
|
636
|
-
// session, and two windows reading the same spec both need to know.
|
|
637
|
-
function
|
|
638
|
-
for (const client of clients) send(client, { t:
|
|
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
|
-
// ---
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
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
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
(req, res) => {
|
|
949
|
-
const
|
|
950
|
-
|
|
951
|
-
|
|
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
|
-
|
|
962
|
-
|
|
963
|
-
|
|
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
|
|
989
|
+
// --- The tools' deliveries ---------------------------------------------------------------------------------------------------------------
|
|
968
990
|
//
|
|
969
|
-
// Everything
|
|
970
|
-
//
|
|
971
|
-
//
|
|
972
|
-
//
|
|
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
|
|
975
|
-
// a few sentences is typed and never what it says; the detail is the
|
|
976
|
-
// and is reduced before it goes anywhere near a terminal; and the path
|
|
977
|
-
// wrote, which the mount decides.
|
|
978
|
-
|
|
979
|
-
// What a
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
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
|
-
|
|
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 ((
|
|
1001
|
-
return `this session has had the ${
|
|
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
|
|
1007
|
-
|
|
1008
|
-
|
|
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
|
|
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
|
|
1021
|
-
const
|
|
1022
|
-
|
|
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 >
|
|
1025
|
-
return { ok: false, error: `that message is longer than the ${
|
|
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 =
|
|
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 ?
|
|
1032
|
-
const
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
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]
|
|
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
|
|
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
|
|
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:
|
|
1093
|
+
send(ws, { t: answered, ...typeToolLine(table, session, msg) });
|
|
1062
1094
|
return;
|
|
1063
1095
|
}
|
|
1064
1096
|
if (msg.create !== true) {
|
|
1065
|
-
send(ws, { t:
|
|
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:
|
|
1103
|
+
send(ws, { t: answered, ok: false, error: "a session could not be started" });
|
|
1072
1104
|
return;
|
|
1073
1105
|
}
|
|
1074
|
-
send(ws, { t:
|
|
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 "
|
|
1382
|
-
//
|
|
1383
|
-
// the session this window is driving, and the frame carries no id for it to reach another.
|
|
1384
|
-
|
|
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";
|