chamba 0.6.0 → 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/commands/dev.js +11 -11
- package/dist/commands/settings.js +1 -1
- package/dist/lib/agent-context.js +33 -7
- package/dist/lib/chamba-yaml.js +1 -1
- package/dist/lib/constants.js +4 -4
- package/dist/lib/dockerfile-builder.js +2 -1
- package/dist/lib/ports.js +4 -4
- package/dist/lib/safe-rm.js +13 -3
- package/dist/lib/webterm.js +7 -7
- package/package.json +3 -5
- package/templates/Dockerfile +20 -1
- package/templates/context/web-pane-craft.md +1 -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/chamba-statusline/SKILL.md +1 -1
- 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 +50 -14
- package/templates/webterm/artifacts.js +11 -11
- package/templates/webterm/config.js +52 -9
- package/templates/webterm/conversation.js +3 -3
- package/templates/webterm/pane.js +14 -3
- package/templates/webterm/proc.js +1 -1
- package/templates/webterm/public/app/alerts.js +5 -5
- package/templates/webterm/public/app/composer.js +5 -2
- package/templates/webterm/public/app/connection.js +2 -2
- package/templates/webterm/public/app/dictation.js +1 -1
- package/templates/webterm/public/app/dom.js +13 -5
- package/templates/webterm/public/app/frames.js +8 -1
- package/templates/webterm/public/app/main.js +8 -2
- package/templates/webterm/public/app/new-session.js +1 -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/state.js +1 -1
- package/templates/webterm/public/app/tabs.js +1 -1
- 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 +300 -11
- package/templates/webterm/sessions.js +7 -7
- package/templates/webterm/snapshot.js +2 -2
- package/templates/webterm/specs.js +358 -0
- package/templates/webterm/tool-document.js +67 -0
- package/templates/webterm/typed-line.js +85 -0
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// The window renders the live TUI (xterm.js) and forwards keystrokes; a rich composer uploads
|
|
7
7
|
// pasted images to /tmp/uploads and injects the composed message as one bracketed paste.
|
|
8
8
|
// Sessions belong to the server, not to the socket: see sessions.js for what that buys.
|
|
9
|
-
// Every route that carries the relay is gated by the key the URL holds (?k=),
|
|
9
|
+
// Every route that carries the relay is gated by the key the URL holds (?k=), created fresh at every start.
|
|
10
10
|
// Auth and sandbox are inherited: the spawned CLI sees the same agent config dirs and the same
|
|
11
11
|
// container isolation it has in the terminal. Nothing here touches credentials.
|
|
12
12
|
|
|
@@ -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);
|
|
@@ -176,7 +188,7 @@ function publishAgent() {
|
|
|
176
188
|
* The directories the picker offers. Bounded on purpose (see DIR_SCAN_* in config.js): a deep walk of a
|
|
177
189
|
* real workspace is slow and the list would be unreadable anyway. Dot directories and the skip list are
|
|
178
190
|
* never descended into, and neither are symlinks - a link out of the workspace would list paths that the
|
|
179
|
-
* picker then refuses. Breadth-first, so a
|
|
191
|
+
* picker then refuses. Breadth-first, so a limit that bites drops the deepest entries rather than a whole
|
|
180
192
|
* branch, and it reports the cut so the caller can say the list is partial.
|
|
181
193
|
*/
|
|
182
194
|
function scanDirs() {
|
|
@@ -213,7 +225,7 @@ function scanDirs() {
|
|
|
213
225
|
// --- The key gate ------------------------------------------------------------------------------------------------------------------------
|
|
214
226
|
|
|
215
227
|
// Publish the live key where the things that print the URL can read it: the `webterm` launcher, the
|
|
216
|
-
// container greeting, and chamba on the host before it
|
|
228
|
+
// container greeting, and chamba on the host before it checks /status. Called once the port is bound and
|
|
217
229
|
// never before - a second server that loses the bind must not leave its key behind as if it had won.
|
|
218
230
|
// Owner-only, and chmod'ed after the write because the mode above applies to a file being created rather
|
|
219
231
|
// than to one that already exists.
|
|
@@ -346,8 +358,66 @@ 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
|
-
// Reject non-image types up front;
|
|
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) => {
|
|
352
422
|
const contentType = String(req.headers["content-type"] || "")
|
|
353
423
|
.split(";")[0]
|
|
@@ -489,7 +559,14 @@ function paneSessions() {
|
|
|
489
559
|
const session = registry.get(entry.id);
|
|
490
560
|
const pid = Number(session?.term?.pid);
|
|
491
561
|
if (!session || !Number.isInteger(pid)) continue;
|
|
492
|
-
live.push({
|
|
562
|
+
live.push({
|
|
563
|
+
sid: session.id,
|
|
564
|
+
agent: session.agent,
|
|
565
|
+
cwd: session.cwd,
|
|
566
|
+
pid,
|
|
567
|
+
since: session.createdAt,
|
|
568
|
+
resumedFrom: resumedBy.get(pid) ?? null,
|
|
569
|
+
});
|
|
493
570
|
}
|
|
494
571
|
return live;
|
|
495
572
|
}
|
|
@@ -542,6 +619,25 @@ function sessionForPid(pid) {
|
|
|
542
619
|
return null;
|
|
543
620
|
}
|
|
544
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
|
+
|
|
545
641
|
// --- The status strip --------------------------------------------------------------------------------------------------------------------
|
|
546
642
|
//
|
|
547
643
|
// A claude session's own numbers, above the composer: the model, the context it is holding, what is left of
|
|
@@ -799,6 +895,185 @@ function nudge(session, title, path) {
|
|
|
799
895
|
pasteToSession(session, `[web pane] Feedback submitted on "${quoted}" - read ${homeLabel(path)}`);
|
|
800
896
|
}
|
|
801
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
|
+
|
|
802
1077
|
// --- Resume, once per container start ----------------------------------------------------------------------------------------------------
|
|
803
1078
|
|
|
804
1079
|
// PID 1 is CONTAINER_KEEP_ALIVE, so it starts when the container does and its start time is a different
|
|
@@ -865,13 +1140,19 @@ function resumeArgv(agent, cwd) {
|
|
|
865
1140
|
// auth flows through. The first session after a container start continues the most recent conversation;
|
|
866
1141
|
// every later session starts fresh, which is what the user wants once mid-work.
|
|
867
1142
|
// AGENT_ARGS belong to the agent the launcher named, so any other agent is spawned bare.
|
|
1143
|
+
// Which conversation id a session's agent was told to resume, by the PTY leader's pid. Claude gives a
|
|
1144
|
+
// resumed conversation a fresh id and records no link back to the old one, so the command built here is the
|
|
1145
|
+
// only witness - the pane reads this to carry the old id's pages into the new one. One resume per container
|
|
1146
|
+
// start, so this holds at most one entry that matters.
|
|
1147
|
+
const resumedBy = new Map();
|
|
1148
|
+
|
|
868
1149
|
function spawnAgent({ cwd, agent }) {
|
|
869
1150
|
const resume = resumeArgv(agent, cwd);
|
|
870
1151
|
const [spawnCmd, ...baseArgs] = resume ?? [agent, ...(agent === DEFAULT_AGENT ? AGENT_ARGS : [])];
|
|
871
1152
|
if (resume) console.log(`[webterm] resuming most recent conversation: ${resume.join(" ")}`);
|
|
872
1153
|
// Append the browser-awareness flag for claude (fresh or resumed); every other agent is untouched.
|
|
873
1154
|
const spawnArgs = agentSpawnArgv(spawnCmd, baseArgs);
|
|
874
|
-
|
|
1155
|
+
const term = pty.spawn(spawnCmd, spawnArgs, {
|
|
875
1156
|
name: "xterm-256color",
|
|
876
1157
|
cols: 80,
|
|
877
1158
|
rows: 24,
|
|
@@ -882,6 +1163,9 @@ function spawnAgent({ cwd, agent }) {
|
|
|
882
1163
|
// var is the one answer all three can read.
|
|
883
1164
|
env: { ...process.env, CHAMBA_WEB_SESSION: "1" },
|
|
884
1165
|
});
|
|
1166
|
+
// Only a resume-by-id names a conversation to carry pages from; "--continue" style resumes name nothing.
|
|
1167
|
+
if (resume?.[1] === "--resume" && typeof resume[2] === "string") resumedBy.set(term.pid, resume[2]);
|
|
1168
|
+
return term;
|
|
885
1169
|
}
|
|
886
1170
|
|
|
887
1171
|
// --- Frames from a window ----------------------------------------------------------------------------------------------------------------
|
|
@@ -1021,7 +1305,7 @@ function handleFrame(ws, msg) {
|
|
|
1021
1305
|
greet(ws, sid, msg.fresh === true);
|
|
1022
1306
|
return;
|
|
1023
1307
|
case "ping":
|
|
1024
|
-
// Liveness
|
|
1308
|
+
// Liveness check from a window that just woke up: an answer proves the socket really works,
|
|
1025
1309
|
// so it can reconnect at once instead of waiting for TCP to give up.
|
|
1026
1310
|
send(ws, { t: "pong" });
|
|
1027
1311
|
return;
|
|
@@ -1094,6 +1378,11 @@ function handleFrame(ws, msg) {
|
|
|
1094
1378
|
case "paste":
|
|
1095
1379
|
if (typeof msg.data === "string") paste(ws, msg.data);
|
|
1096
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;
|
|
1097
1386
|
case "resize":
|
|
1098
1387
|
resize(ws, msg.cols, msg.rows);
|
|
1099
1388
|
return;
|
|
@@ -1141,16 +1430,16 @@ setInterval(checkUploads, CHECK_INTERVAL_MS).unref();
|
|
|
1141
1430
|
// actually changed.
|
|
1142
1431
|
setInterval(() => registry.tick(), WORK_TICK_MS).unref();
|
|
1143
1432
|
|
|
1144
|
-
// The pane's own
|
|
1433
|
+
// The pane's own scan, on the same principle: one pass over the live sessions asks the two questions that
|
|
1145
1434
|
// are about time rather than about an event - has this agent written down which conversation it is having
|
|
1146
1435
|
// yet, and has anything been written into its pane directory that did not come through the publish route.
|
|
1147
1436
|
setInterval(() => pane.sweep(), PANE_SCAN_MS).unref();
|
|
1148
1437
|
|
|
1149
|
-
// The strip's
|
|
1438
|
+
// The strip's scan, for the same reason: a snapshot file is written by a shell script into a bind mount, so
|
|
1150
1439
|
// the only way to know it moved is to look.
|
|
1151
1440
|
setInterval(() => sweepStatus(), STATUS_SCAN_MS).unref();
|
|
1152
1441
|
|
|
1153
|
-
// Keepalive
|
|
1442
|
+
// Keepalive pass. A window that stops answering is terminated, which releases the session it was
|
|
1154
1443
|
// driving so the window that comes back can pick it up without a takeover prompt. Sessions themselves
|
|
1155
1444
|
// are never touched here - a dead socket says nothing about whether a conversation is worth keeping.
|
|
1156
1445
|
setInterval(() => {
|
|
@@ -1185,7 +1474,7 @@ server.listen(PORT, "0.0.0.0", () => {
|
|
|
1185
1474
|
if (defaultCwdRefused) {
|
|
1186
1475
|
console.warn(`[webterm] ignored WEBTERM_CWD="${WEBTERM_CWD_RAW}": not a directory inside ${WORKSPACE_ROOT}`);
|
|
1187
1476
|
}
|
|
1188
|
-
// Publish the default agent for the `webterm` launcher: a port
|
|
1477
|
+
// Publish the default agent for the `webterm` launcher: a port check proves something is listening, not
|
|
1189
1478
|
// what it runs. Written after listen so the file only exists once the port is really bound.
|
|
1190
1479
|
publishAgent();
|
|
1191
1480
|
});
|
|
@@ -88,7 +88,7 @@ export const WORK_TICK_MS = 300;
|
|
|
88
88
|
export const MAX_NAME_LENGTH = 40;
|
|
89
89
|
|
|
90
90
|
// Turn a raw name from the client into what gets stored: control characters (newlines, tabs, the lot)
|
|
91
|
-
// stripped so a name is always one clean line, trimmed, and cut to the
|
|
91
|
+
// stripped so a name is always one clean line, trimmed, and cut to the length limit. An empty result means
|
|
92
92
|
// "no name" - the session falls back to its default label. Filtering by code point rather than a regex
|
|
93
93
|
// keeps the source free of literal control characters.
|
|
94
94
|
export function cleanName(raw) {
|
|
@@ -115,8 +115,8 @@ function isOpen(socket) {
|
|
|
115
115
|
* Create the session registry.
|
|
116
116
|
*
|
|
117
117
|
* - `spawn({ cwd })` returns a PTY-like object: { onData, onExit, write, resize, kill }.
|
|
118
|
-
* - `maxSessions`
|
|
119
|
-
* - `maxBuffer`
|
|
118
|
+
* - `maxSessions` limits how many agents may be alive at once. This is about memory, not correctness.
|
|
119
|
+
* - `maxBuffer` limits the replay buffer kept per session (bytes).
|
|
120
120
|
* - `onEvent(event)` is called with { t: "replay" | "out" | "taken" | "exit" | "changed", ... }.
|
|
121
121
|
* Events that target one window carry that window's socket as `client`; "changed" means the session
|
|
122
122
|
* list moved and every window needs to hear about it.
|
|
@@ -305,9 +305,9 @@ export function createRegistry({ spawn, maxSessions, maxBuffer, onEvent }) {
|
|
|
305
305
|
/**
|
|
306
306
|
* Move every session's working state on by one step, and raise the alert on the ones that just stopped.
|
|
307
307
|
* Called on a timer by the server rather than driven by a timer per session: the state is a function of
|
|
308
|
-
* "how long output has been running", so one
|
|
308
|
+
* "how long output has been running", so one pass answers it for every session, and a registry with no
|
|
309
309
|
* timers of its own stays testable by calling this by hand. Every flip is announced here, so the whole
|
|
310
|
-
*
|
|
310
|
+
* pass costs at most one broadcast however many sessions moved.
|
|
311
311
|
*
|
|
312
312
|
* Every ending raises the alert, whoever is watching and whatever they were doing a moment ago. Whether it
|
|
313
313
|
* is also worth a sound is settled afterwards, by whether anyone touches the session - see "Are you there?".
|
|
@@ -372,7 +372,7 @@ export function createRegistry({ spawn, maxSessions, maxBuffer, onEvent }) {
|
|
|
372
372
|
* and how one is written for the browser belong to the server, and the registry only carries the values -
|
|
373
373
|
* the command and path to the PTY, the labels to the bar.
|
|
374
374
|
*
|
|
375
|
-
* { ok: true, session } or
|
|
375
|
+
* { ok: true, session }, or a refusal with the session-limit error code when the limit is reached.
|
|
376
376
|
*/
|
|
377
377
|
function create({ cwd, cwdLabel, agent } = {}) {
|
|
378
378
|
if (sessions.size >= maxSessions) return { ok: false, error: "cap" };
|
|
@@ -384,7 +384,7 @@ export function createRegistry({ spawn, maxSessions, maxBuffer, onEvent }) {
|
|
|
384
384
|
// Named after what it runs, since the bar can hold several different agents at once.
|
|
385
385
|
label: `${agent} ${seq}`,
|
|
386
386
|
// A user-chosen label, or null to fall back to `label`. The number in `label` is always kept,
|
|
387
|
-
// so clearing the name shows "claude 7" again and the tooltip can still
|
|
387
|
+
// so clearing the name shows "claude 7" again and the tooltip can still show it.
|
|
388
388
|
name: null,
|
|
389
389
|
agent,
|
|
390
390
|
colorIndex: (seq - 1) % PALETTE_SIZE,
|
|
@@ -28,7 +28,7 @@ import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
|
28
28
|
import { join } from "node:path";
|
|
29
29
|
import { isSameProcess, isSelfOrDescendant } from "./proc.js";
|
|
30
30
|
|
|
31
|
-
// The largest a snapshot can be and still be read. The script writes a few hundred bytes; the
|
|
31
|
+
// The largest a snapshot can be and still be read. The script writes a few hundred bytes; the limit only keeps
|
|
32
32
|
// an unrelated file that happens to sit in the directory from being pulled into memory.
|
|
33
33
|
const MAX_SNAPSHOT_BYTES = 64 * 1024;
|
|
34
34
|
|
|
@@ -45,7 +45,7 @@ export function writesSnapshots(agent) {
|
|
|
45
45
|
return agent === "claude";
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
/** The snapshot files in `dir`, newest first and
|
|
48
|
+
/** The snapshot files in `dir`, newest first and limited in number. A directory that is not there contributes nothing. */
|
|
49
49
|
function snapshotFiles(dir) {
|
|
50
50
|
let entries;
|
|
51
51
|
try {
|