chamba 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -6
- package/dist/lib/agent-context.js +33 -7
- package/dist/lib/dockerfile-builder.js +2 -1
- package/dist/lib/safe-rm.js +13 -3
- package/package.json +3 -3
- package/templates/Dockerfile +20 -1
- package/templates/pane-apps/client/assets/specs-B1970L17.css +1 -0
- package/templates/pane-apps/client/assets/specs-cEee_SPn.js +23 -0
- package/templates/pane-apps/client/specs/index.html +13 -0
- package/templates/pane-apps/server/specs.mjs +1588 -0
- package/templates/skills/dx-spec/SKILL.md +365 -0
- package/templates/skills/dx-spec/references/imagination-guide.md +140 -0
- package/templates/skills/dx-spec/references/review-guide.md +173 -0
- package/templates/skills/dx-spec/references/spec-guide.md +125 -0
- package/templates/skills/dx-spec/references/stages.md +399 -0
- package/templates/skills/dx-spec-config/SKILL.md +313 -0
- package/templates/skills/dx-spec-config/references/principles-template.md +12 -0
- package/templates/skills/dx-spec-execute/SKILL.md +324 -0
- package/templates/specs.sh +106 -0
- package/templates/webterm/README.md +42 -6
- package/templates/webterm/config.js +43 -0
- package/templates/webterm/public/app/composer.js +4 -1
- package/templates/webterm/public/app/dom.js +13 -5
- package/templates/webterm/public/app/frames.js +7 -0
- package/templates/webterm/public/app/main.js +7 -1
- package/templates/webterm/public/app/pane-shell.js +315 -0
- package/templates/webterm/public/app/pane.js +58 -183
- package/templates/webterm/public/app/specs-host.js +222 -0
- package/templates/webterm/public/app/terminal.js +8 -0
- package/templates/webterm/public/index.html +51 -27
- package/templates/webterm/public/styles.css +144 -30
- package/templates/webterm/server.js +273 -0
- package/templates/webterm/specs.js +358 -0
- package/templates/webterm/tool-document.js +67 -0
- package/templates/webterm/typed-line.js +85 -0
|
@@ -46,7 +46,11 @@ import {
|
|
|
46
46
|
MAX_PANE_BYTES,
|
|
47
47
|
MAX_PANE_FILES,
|
|
48
48
|
MAX_SESSIONS,
|
|
49
|
+
MAX_SPECS_BYTES,
|
|
50
|
+
MAX_SPECS_LINE_LENGTH,
|
|
51
|
+
MAX_SPECS_LINES_PER_SESSION,
|
|
49
52
|
MAX_UPLOAD_BYTES,
|
|
53
|
+
PANE_APPS_CLIENT_DIR,
|
|
50
54
|
PANE_DIR,
|
|
51
55
|
PANE_SCAN_MS,
|
|
52
56
|
PASTE_END,
|
|
@@ -54,6 +58,11 @@ import {
|
|
|
54
58
|
PORT,
|
|
55
59
|
RESUME_STAMP,
|
|
56
60
|
resolveWorkspacePath,
|
|
61
|
+
SPECS_KEY,
|
|
62
|
+
SPECS_LINE_MIN_INTERVAL_MS,
|
|
63
|
+
SPECS_NEW_SESSION_DELAY_MS,
|
|
64
|
+
SPECS_RAW_TOKEN,
|
|
65
|
+
SPECS_ROOT,
|
|
57
66
|
STATE_FILE,
|
|
58
67
|
STATUS_SCAN_MS,
|
|
59
68
|
STOP_ANNOUNCE_MS,
|
|
@@ -72,6 +81,9 @@ import { isSelfOrDescendant, processStart } from "./proc.js";
|
|
|
72
81
|
import { resumeArgvFor } from "./resume.js";
|
|
73
82
|
import { createRegistry, WORK_TICK_MS } from "./sessions.js";
|
|
74
83
|
import { statusFor, writesSnapshots } from "./snapshot.js";
|
|
84
|
+
import { CLIENT_PREFIX, createSpecsModule, mountSpecs, SPECS_AGENT_PATH, SPECS_KEYS_PATH, SPECS_PREFIX } from "./specs.js";
|
|
85
|
+
import { documentPolicy, toolDocumentFor, withNonce } from "./tool-document.js";
|
|
86
|
+
import { framedLine, reduceLine } from "./typed-line.js";
|
|
75
87
|
|
|
76
88
|
// node-pty is a native CommonJS addon; load it through createRequire under ESM.
|
|
77
89
|
const require = createRequire(import.meta.url);
|
|
@@ -346,6 +358,64 @@ app.use("/vendor/xterm", express.static(join(import.meta.dirname, "node_modules"
|
|
|
346
358
|
app.use("/vendor/xterm", express.static(join(import.meta.dirname, "node_modules", "@xterm", "xterm", "lib")));
|
|
347
359
|
app.use("/vendor/xterm-fit", express.static(join(import.meta.dirname, "node_modules", "@xterm", "addon-fit", "lib")));
|
|
348
360
|
|
|
361
|
+
// The pane's tool clients, built elsewhere and baked beside this directory. Open like the statics above, and
|
|
362
|
+
// CORS-readable on top of it: a tool renders in a frame with an opaque origin, so it fetches its own module
|
|
363
|
+
// scripts cross-origin and a browser will not run them without being told they may be read. Nothing here is
|
|
364
|
+
// secret and nothing here drives anything - the data behind a tool is gated separately, in specs.js.
|
|
365
|
+
//
|
|
366
|
+
// `frame-ancestors` is the one thing that is not open. A tool's document is only ever framed by this
|
|
367
|
+
// interface, and framed by it inside a sandbox; a page somewhere else could otherwise frame the same document
|
|
368
|
+
// without one and run it as an ordinary document on this origin.
|
|
369
|
+
//
|
|
370
|
+
// The document of a tool is served ahead of the statics, under a policy of its own: it is the one file here
|
|
371
|
+
// that runs anything, and the frame it runs in has an opaque origin, where a policy written with `'self'`
|
|
372
|
+
// names nobody. See tool-document.js.
|
|
373
|
+
// Which origin the policy names has to come from the request. The browser reaches this server through a
|
|
374
|
+
// port the host published, and the container is not told which one, so `Host` is the only place the origin
|
|
375
|
+
// the document actually loaded from is written down. That makes it the caller's word, and it goes into a
|
|
376
|
+
// response header, so it is taken only in the shape an authority has: a host and an optional port, and
|
|
377
|
+
// nothing that could end the header or name a scheme. Anything else falls back to the address inside the
|
|
378
|
+
// container, which names no outside origin and so grants nothing.
|
|
379
|
+
const AUTHORITY = /^[A-Za-z0-9._-]+(:\d{1,5})?$|^\[[0-9A-Fa-f:.]+\](:\d{1,5})?$/;
|
|
380
|
+
|
|
381
|
+
function originOf(req) {
|
|
382
|
+
const host = req.get("host") ?? "";
|
|
383
|
+
return AUTHORITY.test(host) ? `http://${host}` : `http://127.0.0.1:${PORT}`;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// The wildcard is express 4's, which is what this directory's package.json pins. Express 5 spells the same
|
|
387
|
+
// thing `/*splat`, and that spelling compiles here to a route that ends in the literal word - it matches
|
|
388
|
+
// nothing anyone loads, and the document falls through to the statics below with no policy on it at all.
|
|
389
|
+
// The failure is silent, which is why a test issues a real request through a real express instead of
|
|
390
|
+
// reading this line.
|
|
391
|
+
app.get(`${CLIENT_PREFIX}/*`, (req, res, next) => {
|
|
392
|
+
const document = toolDocumentFor(req.path);
|
|
393
|
+
if (!document) return next();
|
|
394
|
+
let html;
|
|
395
|
+
try {
|
|
396
|
+
html = readFileSync(join(PANE_APPS_CLIENT_DIR, document), "utf8");
|
|
397
|
+
} catch {
|
|
398
|
+
return next();
|
|
399
|
+
}
|
|
400
|
+
const nonce = randomBytes(16).toString("base64");
|
|
401
|
+
res.set("Access-Control-Allow-Origin", "*");
|
|
402
|
+
res.set("Content-Security-Policy", documentPolicy(nonce, originOf(req)));
|
|
403
|
+
res.set("X-Content-Type-Options", "nosniff");
|
|
404
|
+
// A nonce is good for one response, so the document it sits in is never a document to keep.
|
|
405
|
+
res.set("Cache-Control", "no-store");
|
|
406
|
+
res.type("html").send(withNonce(html, nonce));
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
app.use(
|
|
410
|
+
CLIENT_PREFIX,
|
|
411
|
+
express.static(PANE_APPS_CLIENT_DIR, {
|
|
412
|
+
setHeaders: (res) => {
|
|
413
|
+
res.set("Access-Control-Allow-Origin", "*");
|
|
414
|
+
res.set("Content-Security-Policy", "frame-ancestors 'self'");
|
|
415
|
+
},
|
|
416
|
+
}),
|
|
417
|
+
);
|
|
418
|
+
|
|
349
419
|
// Accept a raw image body (the client POSTs the pasted/dropped blob with its Content-Type).
|
|
350
420
|
// Reject non-image types up front; limit the size so a bad request cannot fill the disk.
|
|
351
421
|
app.post("/upload", requireKey, express.raw({ type: () => true, limit: MAX_UPLOAD_BYTES }), (req, res) => {
|
|
@@ -549,6 +619,25 @@ function sessionForPid(pid) {
|
|
|
549
619
|
return null;
|
|
550
620
|
}
|
|
551
621
|
|
|
622
|
+
// --- The Specs tool ----------------------------------------------------------------------------------------------------------------------
|
|
623
|
+
//
|
|
624
|
+
// The first of the pane's tools, mounted the way the pane store is: something built elsewhere is handed what
|
|
625
|
+
// only this process can give it - where to look, and who to tell when the disk moves - and its answers are
|
|
626
|
+
// registered behind gates it does not hold itself. It is not a process and not a port: a failure inside it is
|
|
627
|
+
// one request answered with a status, and every terminal in this container carries on.
|
|
628
|
+
|
|
629
|
+
const specs = mountSpecs({
|
|
630
|
+
module: createSpecsModule({ root: SPECS_ROOT, onChange: (change) => broadcastSpecs(change) }),
|
|
631
|
+
key: SPECS_KEY,
|
|
632
|
+
rawToken: SPECS_RAW_TOKEN,
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
// A spec directory moved. Every window hears it, not one: a spec belongs to the repository rather than to a
|
|
636
|
+
// session, and two windows reading the same spec both need to know.
|
|
637
|
+
function broadcastSpecs(change) {
|
|
638
|
+
for (const client of clients) send(client, { t: "specs", change });
|
|
639
|
+
}
|
|
640
|
+
|
|
552
641
|
// --- The status strip --------------------------------------------------------------------------------------------------------------------
|
|
553
642
|
//
|
|
554
643
|
// A claude session's own numbers, above the composer: the model, the context it is holding, what is left of
|
|
@@ -806,6 +895,185 @@ function nudge(session, title, path) {
|
|
|
806
895
|
pasteToSession(session, `[web pane] Feedback submitted on "${quoted}" - read ${homeLabel(path)}`);
|
|
807
896
|
}
|
|
808
897
|
|
|
898
|
+
// --- Specs routes ------------------------------------------------------------------------------------------------------------------------
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* The scoped credentials, for the shell alone. It holds the master key already, so nothing new is exposed by
|
|
902
|
+
* telling it these; what matters is where they go next - the shell posts the key into the tool's frame once,
|
|
903
|
+
* and the frame presents it as a header. Behind the pane's gate, which is the master key in a header and
|
|
904
|
+
* never in a URL, because a URL that carries it is a URL a document can read itself out of.
|
|
905
|
+
*/
|
|
906
|
+
app.get(SPECS_KEYS_PATH, requirePaneKey, (_req, res) => {
|
|
907
|
+
res.json({ key: SPECS_KEY, raw: specs.rawBase });
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* The `specs` helper's door: the agent's half of the coordination channel.
|
|
912
|
+
*
|
|
913
|
+
* The gate is the master key, which every helper route takes and which nothing outside the container has.
|
|
914
|
+
* The session behind it is a claim rather than a second gate: the helper sends its own pid, this walks up
|
|
915
|
+
* the process tree to see whether it lands in a session this server started, and any process in the
|
|
916
|
+
* container could send another process's pid instead - the same rule `webpane` publishes under, and its
|
|
917
|
+
* docstring says the same of itself. What that buys is a helper run outside a web session getting a clear
|
|
918
|
+
* answer rather than a silent one, and it is not asked to buy more: the session is used for nothing else,
|
|
919
|
+
* because a verb moves files in a spec directory, which belongs to the repository rather than to a terminal.
|
|
920
|
+
*/
|
|
921
|
+
app.post(SPECS_AGENT_PATH, requirePaneKey, express.json({ limit: MAX_SPECS_BYTES }), (req, res) => {
|
|
922
|
+
const session = sessionForPid(Number(req.headers["x-specs-pid"]));
|
|
923
|
+
if (!session) {
|
|
924
|
+
res.status(409).json({ error: "not running inside a web session - there is no pane to tell" });
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
const body = req.body && typeof req.body === "object" ? req.body : {};
|
|
928
|
+
const answer = specs.agent({ verb: body.verb, entry: body.spec, payload: body.payload });
|
|
929
|
+
res.status(answer.status).json(answer.body);
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
// The body of a Specs request, read only for a request the mount says may carry one. Reading it is itself
|
|
933
|
+
// something a caller gets to make this process do - hold the bytes, decode them - so it sits behind the same
|
|
934
|
+
// key as the route, rather than in front of it where every other body route in this file would not put it.
|
|
935
|
+
const specsBody = express.text({ type: () => true, limit: MAX_SPECS_BYTES });
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* Everything else the tool answers. One delegation and no decision: specs.js takes the request as plain data
|
|
939
|
+
* and gives back the status, the headers and the body, and it never throws, so a Specs failure is a view that
|
|
940
|
+
* says so rather than a process that went down.
|
|
941
|
+
*/
|
|
942
|
+
app.all(
|
|
943
|
+
`${SPECS_PREFIX}/*`,
|
|
944
|
+
(req, res, next) => {
|
|
945
|
+
if (specs.takesBody(req.method, req.path, req.headers)) specsBody(req, res, next);
|
|
946
|
+
else next();
|
|
947
|
+
},
|
|
948
|
+
(req, res) => {
|
|
949
|
+
const answer = specs.handle({
|
|
950
|
+
method: req.method,
|
|
951
|
+
path: req.path,
|
|
952
|
+
query: req.query,
|
|
953
|
+
headers: req.headers,
|
|
954
|
+
body: req.body,
|
|
955
|
+
});
|
|
956
|
+
res.status(answer.status).set(answer.headers);
|
|
957
|
+
if (answer.body === null) {
|
|
958
|
+
res.end();
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
// A workspace file goes out as its own bytes, under the type the tool decided; the rest is JSON.
|
|
962
|
+
if (Buffer.isBuffer(answer.body)) res.send(answer.body);
|
|
963
|
+
else res.json(answer.body);
|
|
964
|
+
},
|
|
965
|
+
);
|
|
966
|
+
|
|
967
|
+
// --- The Specs delivery ------------------------------------------------------------------------------------------------------------------
|
|
968
|
+
//
|
|
969
|
+
// Everything the pane sends the agent lands as a file plus one typed line. The file is written through the
|
|
970
|
+
// data routes above, by the tool; this is the line. It rides the window's own socket, so the session it
|
|
971
|
+
// reaches is the session that window is driving - the tool never names one, and there is no id on the frame
|
|
972
|
+
// for it to name.
|
|
973
|
+
//
|
|
974
|
+
// The words of the line are this server's. The event is a key into the table below, so a tool picks which of
|
|
975
|
+
// a few sentences is typed and never what it says; the detail is the one part that came from somewhere else
|
|
976
|
+
// and is reduced before it goes anywhere near a terminal; and the path has to be a file the tool itself
|
|
977
|
+
// wrote, which the mount decides.
|
|
978
|
+
|
|
979
|
+
// What a delivery may say it is. A key that is not here is not a delivery.
|
|
980
|
+
const SPECS_EVENTS = {
|
|
981
|
+
annotations: "Annotations sent",
|
|
982
|
+
intake: "A new spec was filed",
|
|
983
|
+
answers: "A round of answers was submitted",
|
|
984
|
+
gate: "A gate was answered",
|
|
985
|
+
brief: "Pick up this spec",
|
|
986
|
+
};
|
|
987
|
+
|
|
988
|
+
// The gap and the total, per session, for as long as this container runs. Held here rather than in the
|
|
989
|
+
// registry because they are about this one channel: a session that is closed and gone takes its count with
|
|
990
|
+
// it, which is the same span the resume marker uses.
|
|
991
|
+
const lastSpecsLine = new Map();
|
|
992
|
+
const specsLineCount = new Map();
|
|
993
|
+
|
|
994
|
+
/** Whether this session has had as much of this channel as it may have, and why. */
|
|
995
|
+
function specsLimited(sid) {
|
|
996
|
+
const now = Date.now();
|
|
997
|
+
if (now - (lastSpecsLine.get(sid) ?? 0) < SPECS_LINE_MIN_INTERVAL_MS) {
|
|
998
|
+
return "that was just delivered - give it a moment";
|
|
999
|
+
}
|
|
1000
|
+
if ((specsLineCount.get(sid) ?? 0) >= MAX_SPECS_LINES_PER_SESSION) {
|
|
1001
|
+
return `this session has had the ${MAX_SPECS_LINES_PER_SESSION} deliveries it may have`;
|
|
1002
|
+
}
|
|
1003
|
+
return null;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
function specsDelivered(sid) {
|
|
1007
|
+
lastSpecsLine.set(sid, Date.now());
|
|
1008
|
+
specsLineCount.set(sid, (specsLineCount.get(sid) ?? 0) + 1);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* Type one Specs line into a session, or say why not.
|
|
1013
|
+
*
|
|
1014
|
+
* The length is checked before the reduction, so an over-limit detail is refused with a reason rather than
|
|
1015
|
+
* cut into half a sentence - which is the one thing a silent trim would make unreadable.
|
|
1016
|
+
*
|
|
1017
|
+
* `after` is for a session this delivery has just started: the line waits for the agent to be there to read
|
|
1018
|
+
* it, and the delivery is answered now, because the session did start and the line will follow.
|
|
1019
|
+
*/
|
|
1020
|
+
function typeSpecsLine(session, msg, after = 0) {
|
|
1021
|
+
const event = SPECS_EVENTS[typeof msg.event === "string" ? msg.event : ""];
|
|
1022
|
+
if (!event) return { ok: false, error: "that is not something the Specs tool delivers" };
|
|
1023
|
+
const detail = typeof msg.detail === "string" ? msg.detail : "";
|
|
1024
|
+
if (detail.length > MAX_SPECS_LINE_LENGTH) {
|
|
1025
|
+
return { ok: false, error: `that message is longer than the ${MAX_SPECS_LINE_LENGTH} characters a delivery may carry` };
|
|
1026
|
+
}
|
|
1027
|
+
const limited = specsLimited(session.id);
|
|
1028
|
+
if (limited) return { ok: false, error: limited };
|
|
1029
|
+
// A path is optional - a briefing names no file - and one that is not a file this tool wrote is dropped
|
|
1030
|
+
// rather than refused: the line still reads, and it names nothing that is not there.
|
|
1031
|
+
const absolute = msg.path ? specs.fileFor(msg.path) : null;
|
|
1032
|
+
const line = framedLine({
|
|
1033
|
+
tag: "specs",
|
|
1034
|
+
event,
|
|
1035
|
+
detail: reduceLine(detail, MAX_SPECS_LINE_LENGTH),
|
|
1036
|
+
path: absolute === null ? "" : homeLabel(absolute),
|
|
1037
|
+
});
|
|
1038
|
+
specsDelivered(session.id);
|
|
1039
|
+
if (after > 0) {
|
|
1040
|
+
// Only if that session is still there when the wait is over.
|
|
1041
|
+
setTimeout(() => {
|
|
1042
|
+
const still = registry.get(session.id);
|
|
1043
|
+
if (still) pasteToSession(still, line);
|
|
1044
|
+
}, after).unref();
|
|
1045
|
+
} else {
|
|
1046
|
+
pasteToSession(session, line);
|
|
1047
|
+
}
|
|
1048
|
+
console.log(`[webterm] specs: delivered to session "${session.name || session.label}"`);
|
|
1049
|
+
return { ok: true };
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/**
|
|
1053
|
+
* A delivery from the Specs tool, arriving over the shell's own socket.
|
|
1054
|
+
*
|
|
1055
|
+
* With no session on this window there is nobody to tell, and the tool has already asked the user whether to
|
|
1056
|
+
* start one: `create` is that answer, and the briefing becomes the first line the new agent reads.
|
|
1057
|
+
*/
|
|
1058
|
+
function deliverSpecs(ws, msg) {
|
|
1059
|
+
const session = registry.sessionFor(ws);
|
|
1060
|
+
if (session) {
|
|
1061
|
+
send(ws, { t: "specs:delivered", ...typeSpecsLine(session, msg) });
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
if (msg.create !== true) {
|
|
1065
|
+
send(ws, { t: "specs:delivered", ok: false, error: "no-session" });
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
newSession(ws);
|
|
1069
|
+
const started = registry.sessionFor(ws);
|
|
1070
|
+
if (!started) {
|
|
1071
|
+
send(ws, { t: "specs:delivered", ok: false, error: "a session could not be started" });
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
send(ws, { t: "specs:delivered", ...typeSpecsLine(started, msg, SPECS_NEW_SESSION_DELAY_MS) });
|
|
1075
|
+
}
|
|
1076
|
+
|
|
809
1077
|
// --- Resume, once per container start ----------------------------------------------------------------------------------------------------
|
|
810
1078
|
|
|
811
1079
|
// PID 1 is CONTAINER_KEEP_ALIVE, so it starts when the container does and its start time is a different
|
|
@@ -1110,6 +1378,11 @@ function handleFrame(ws, msg) {
|
|
|
1110
1378
|
case "paste":
|
|
1111
1379
|
if (typeof msg.data === "string") paste(ws, msg.data);
|
|
1112
1380
|
return;
|
|
1381
|
+
case "specs:deliver":
|
|
1382
|
+
// The pane's tool telling the agent something arrived. Like the pane's own frames, it reaches
|
|
1383
|
+
// the session this window is driving, and the frame carries no id for it to reach another.
|
|
1384
|
+
deliverSpecs(ws, msg);
|
|
1385
|
+
return;
|
|
1113
1386
|
case "resize":
|
|
1114
1387
|
resize(ws, msg.cols, msg.rows);
|
|
1115
1388
|
return;
|