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
package/README.md
CHANGED
|
@@ -36,6 +36,7 @@ The optional fields - `shadow_paths`, `env`, `ports`, `profiles` - are all descr
|
|
|
36
36
|
- **Hidden paths.** Shadow paths (`node_modules`, `.env*` by default) are overlaid with empty container-local copies, so an agent never reads the real thing.
|
|
37
37
|
- **The current CLIs.** claude, opencode and codex are kept on their latest versions, each with its config directory bind-mounted to the workspace cache so conversations and memory survive rebuilds.
|
|
38
38
|
- **A web interface.** The browser front-end relays the real agent TUI over a websocket - every session on one page, several agents at once, images and dictation. It starts with the container. Claude sessions also get a status strip above the composer: model, context used, quota left.
|
|
39
|
+
- **A pane of tools beside the terminal.** Pages, where an agent publishes a page for you to read or a form for you to answer, and Specs, where a piece of work is read, annotated and steered. An agent reaches Specs with the `specs` helper on its path, and the `dx-spec` skills teach it the craft.
|
|
39
40
|
|
|
40
41
|
The menu has two doors into that container.
|
|
41
42
|
**Web interface** brings it up, opens the interface in your browser, and hands your terminal straight back - the work happens in the tab.
|
|
@@ -43,15 +44,21 @@ The menu has two doors into that container.
|
|
|
43
44
|
|
|
44
45
|
## The web pane
|
|
45
46
|
|
|
46
|
-
Beside the terminal is a pane
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
A page can also ask.
|
|
50
|
-
A form marked `data-feedback` is wired up by the interface, and what you submit is written as JSON next to the page, with one line typed into the agent's terminal saying where to read it.
|
|
47
|
+
Beside the terminal is a pane, there from the first second of a session, and the pane holds tools.
|
|
48
|
+
One tab each, and the width controls beside them: drag the divider, take the whole window, or fold the pane into the spine at its edge.
|
|
51
49
|
|
|
50
|
+
**Pages** is where an agent publishes.
|
|
51
|
+
`webpane report.html --title "Port rework"` puts a standalone HTML file in the pane as a chip: a plan, a comparison table, a long answer, anything worth reading at your own pace instead of scrolling back for.
|
|
52
|
+
A page can also ask - a form marked `data-feedback` is wired up by the interface, and what you submit is written as JSON next to the page, with one line typed into the agent's terminal saying where to read it.
|
|
52
53
|
Pages are kept, one directory per conversation, so resuming a conversation opens with them again.
|
|
53
54
|
They live under `~/.chamba/workspaces/<id>/agents/webpane/` on the host, and nothing deletes them.
|
|
54
55
|
|
|
56
|
+
**Specs** is where a piece of work is read and steered.
|
|
57
|
+
It renders the specs directory of your repository: the work, its artifacts, and where each piece stands.
|
|
58
|
+
Read an artifact as one continuous document, select any part of it to comment on or ask for its removal, and answer what the run asks - a gate on a screen, a round of questions, a decision that needs your judgment - as a form beside the thing it is about.
|
|
59
|
+
Everything is a file in your repository, so two windows agree, a session that ends loses nothing, and git sees the work.
|
|
60
|
+
The agent's half is `specs` on its path inside the container, and the `dx-spec` skills carry the craft: `/dx-spec` builds a specification, `/dx-spec-execute` runs its plan, and `/dx-spec-config` keeps the settings beside them.
|
|
61
|
+
|
|
55
62
|
## Where state lives
|
|
56
63
|
|
|
57
64
|
`chamba.yaml` lives in the workspace and can be committed.
|
|
@@ -62,7 +69,7 @@ Everything else is on your machine under `~/.chamba/`: one directory per workspa
|
|
|
62
69
|
```bash
|
|
63
70
|
pnpm install
|
|
64
71
|
pnpm start # run the CLI from source (host only - Docker is not available in the dev container)
|
|
65
|
-
pnpm check # typecheck, lint, repo checks, tests
|
|
72
|
+
pnpm check # build, typecheck, lint, repo checks, tests
|
|
66
73
|
pnpm release # check, bump, commit and publish to npm
|
|
67
74
|
```
|
|
68
75
|
|
package/dist/commands/dev.js
CHANGED
|
@@ -116,7 +116,7 @@ export async function startContainer(opts) {
|
|
|
116
116
|
const stdio = quiet ? "pipe" : ["ignore", "ignore", "inherit"];
|
|
117
117
|
// --- Published ports fingerprint (static config, no host I/O here) ----------------------------------------------------------------------
|
|
118
118
|
// Ports are declared, not resolved, so the label is a pure function of the config. Host availability is
|
|
119
|
-
//
|
|
119
|
+
// checked just-in-time on the create path (assertHostPortsAvailable), never on resume/connect.
|
|
120
120
|
const currentPortsLabel = portsLabel(portMappings);
|
|
121
121
|
// --- Env fingerprint (inline vars + resolved file contents) -----------------------------------------------------------------------------
|
|
122
122
|
// Covers inline vars and the contents of every existing env file, so editing either recreates the container.
|
|
@@ -243,8 +243,8 @@ export async function startContainer(opts) {
|
|
|
243
243
|
outro("Failed to build container image.");
|
|
244
244
|
process.exit(buildResult.status);
|
|
245
245
|
}
|
|
246
|
-
// Minutes can pass between run()
|
|
247
|
-
// recreate confirm, the image build), so re-
|
|
246
|
+
// Minutes can pass between run() checking the web port and this create path (profile selection, a
|
|
247
|
+
// recreate confirm, the image build), so re-check it here. A port taken in the meantime drops the
|
|
248
248
|
// mapping and the session continues without the interface. Only chamba.yaml ports are
|
|
249
249
|
// allowed to fail a session, which is what assertHostPortsAvailable below is for.
|
|
250
250
|
let published = portMappings;
|
|
@@ -255,8 +255,8 @@ export async function startContainer(opts) {
|
|
|
255
255
|
published = portMappings.filter((m) => m.host !== webPort);
|
|
256
256
|
publishedWebPort = undefined;
|
|
257
257
|
}
|
|
258
|
-
// Ports are static config, so
|
|
259
|
-
// every path that reaches here, so we never
|
|
258
|
+
// Ports are static config, so check host availability up front. The old container was already removed on
|
|
259
|
+
// every path that reaches here, so we never check our own live port. A taken host port fails clearly and
|
|
260
260
|
// names the entry, before docker run - so no doomed `created` container is left behind on a clash.
|
|
261
261
|
try {
|
|
262
262
|
await assertHostPortsAvailable(published, publishedWebPort);
|
|
@@ -268,7 +268,7 @@ export async function startContainer(opts) {
|
|
|
268
268
|
}
|
|
269
269
|
if (!quiet)
|
|
270
270
|
log.info("Starting dev container...");
|
|
271
|
-
// Only set when the web port survived the re-
|
|
271
|
+
// Only set when the web port survived the re-check above: this gates the greeting's URL line and the
|
|
272
272
|
// webterm launcher. Built here rather than with the other runtime env so a dropped mapping cannot
|
|
273
273
|
// leave a URL behind that nothing is listening on.
|
|
274
274
|
// Never stale either way: the web mapping is part of the ports label written just below, so a moved or
|
|
@@ -298,7 +298,7 @@ export async function startContainer(opts) {
|
|
|
298
298
|
containerName,
|
|
299
299
|
...CONTAINER_KEEP_ALIVE,
|
|
300
300
|
];
|
|
301
|
-
// Capture stderr so the real docker error is re-emitted on failure. A single run - the pre-flight
|
|
301
|
+
// Capture stderr so the real docker error is re-emitted on failure. A single run - the pre-flight check
|
|
302
302
|
// above already rejected any taken host port, so there is no port-race retry loop.
|
|
303
303
|
const runStdio = quiet ? "pipe" : ["ignore", "ignore", "pipe"];
|
|
304
304
|
const runResult = spawnSync("docker", runArgs, { stdio: runStdio });
|
|
@@ -387,7 +387,7 @@ export async function run(packageDir, ctx, options) {
|
|
|
387
387
|
}
|
|
388
388
|
// --- Web agent interface port (sticky per-workspace assignment) ----------------------------------------------------------------------
|
|
389
389
|
// Resolved on every session: the interface has no off switch, it is where the agent runs.
|
|
390
|
-
// Pushing the mapping into portMappings is the whole wiring: publish args, the availability
|
|
390
|
+
// Pushing the mapping into portMappings is the whole wiring: publish args, the availability check,
|
|
391
391
|
// and the LABEL_PORTS recreate fingerprint all pick it up with no arg-builder changes.
|
|
392
392
|
// A session still opens without it, so every problem below drops it for this session and says why - it
|
|
393
393
|
// must never fail a session the way a chamba.yaml port does. The sticky assignment is left alone in all
|
|
@@ -467,7 +467,7 @@ export async function run(packageDir, ctx, options) {
|
|
|
467
467
|
...(options?.noCache !== undefined && { noCache: options.noCache }),
|
|
468
468
|
};
|
|
469
469
|
let startResult = await startContainer(containerOpts);
|
|
470
|
-
// The create path re-
|
|
470
|
+
// The create path re-checks the web port and drops it when something took it while the image built, so
|
|
471
471
|
// from here on the published port is the only one worth talking to.
|
|
472
472
|
webPort = startResult.webPort;
|
|
473
473
|
// --- Stale image check - prompt user to rebuild if image is outdated ------------------------------------------------------------------
|
|
@@ -496,7 +496,7 @@ export async function run(packageDir, ctx, options) {
|
|
|
496
496
|
// Ports are static config, so the notice derives straight from the mappings - no .lock lookup needed.
|
|
497
497
|
// The web mapping is skipped here: its URL is announced by the container greeting, where it is actionable.
|
|
498
498
|
// Matched on the container port, which validatePortsConfig reserves - so it can only ever be chamba's own
|
|
499
|
-
// web mapping, and it is skipped whether or not the port survived the create path's re-
|
|
499
|
+
// web mapping, and it is skipped whether or not the port survived the create path's re-check.
|
|
500
500
|
for (const m of portMappings) {
|
|
501
501
|
if (m.container === WEB_CONTAINER_PORT)
|
|
502
502
|
continue;
|
|
@@ -589,7 +589,7 @@ export async function run(packageDir, ctx, options) {
|
|
|
589
589
|
// is still offered, because those sessions may be finished ones nobody has closed yet.
|
|
590
590
|
if (containerSessionCount(containerName) === 0) {
|
|
591
591
|
// The counts are behind the interface's key, so it is read from the container first - fresh, because
|
|
592
|
-
// a new one is
|
|
592
|
+
// a new one is created at every server start and nothing on the host keeps one.
|
|
593
593
|
const webSessions = (webPort === null ? null : await webSessionInfo(webPort, readWebKey(containerName)))?.sessions ?? 0;
|
|
594
594
|
if (webSessions > 0) {
|
|
595
595
|
const count = webSessions === 1 ? "1 agent session is" : `${webSessions} agent sessions are`;
|
|
@@ -171,7 +171,7 @@ async function gitModeMenu(ctx) {
|
|
|
171
171
|
async function portsMenu(ctx) {
|
|
172
172
|
const range = readWebRange();
|
|
173
173
|
const webPort = readWebPort(ctx.workspaceId);
|
|
174
|
-
// The URL only opens the interface with the key the running server
|
|
174
|
+
// The URL only opens the interface with the key the running server created, so the live one is shown
|
|
175
175
|
// whenever it can be read. With nothing running there is no key to show, and saying where it comes
|
|
176
176
|
// from beats printing half a URL.
|
|
177
177
|
const liveKey = webPort === null ? null : readWebKey(ctx.containerName);
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// Claude Code: https://docs.anthropic.com/en/docs/claude-code
|
|
9
9
|
// OpenCode: https://github.com/opencode-ai/opencode
|
|
10
10
|
// Codex: https://github.com/openai/codex
|
|
11
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
12
12
|
import { dirname, join } from "node:path";
|
|
13
13
|
import { AGENTS_DIR, CLAUDE_STATUSLINE_PATH, CONTAINER_HOME, GIT_MODE, PACKAGE_ROOT } from "./constants.js";
|
|
14
14
|
export const AGENT_MOUNTS = [
|
|
@@ -145,8 +145,12 @@ export function ensureClaudeStatusLine(workspaceDir) {
|
|
|
145
145
|
}
|
|
146
146
|
}
|
|
147
147
|
// --- Claude skills injection -------------------------------------------------------------------------------------------------------------
|
|
148
|
-
//
|
|
149
|
-
//
|
|
148
|
+
// Copies every templates/skills/<name>/ into agents/claude/skills/<name>/, whole. Drop a new directory
|
|
149
|
+
// under templates/skills/ to ship a new skill - no other code changes needed.
|
|
150
|
+
//
|
|
151
|
+
// The directory rather than the one file, because a skill of any size keeps its craft in references/ and
|
|
152
|
+
// points at those files by name. A SKILL.md that reaches a session without them names files that are not
|
|
153
|
+
// there, which is worse than shipping nothing.
|
|
150
154
|
export function injectClaudeSkills(workspaceDir) {
|
|
151
155
|
const templatesSkillsDir = join(PACKAGE_ROOT, "templates", "skills");
|
|
152
156
|
if (!existsSync(templatesSkillsDir))
|
|
@@ -158,11 +162,33 @@ export function injectClaudeSkills(workspaceDir) {
|
|
|
158
162
|
for (const entry of readdirSync(templatesSkillsDir, { withFileTypes: true })) {
|
|
159
163
|
if (!entry.isDirectory())
|
|
160
164
|
continue;
|
|
161
|
-
|
|
162
|
-
if (!existsSync(sourcePath))
|
|
165
|
+
if (!existsSync(join(templatesSkillsDir, entry.name, "SKILL.md")))
|
|
163
166
|
continue;
|
|
164
|
-
|
|
165
|
-
|
|
167
|
+
copySkillDir(join(templatesSkillsDir, entry.name), join(targetSkillsDir, entry.name), vars);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// One skill's files, at whatever depth they sit. Markdown is rendered with the skill variables, since a
|
|
171
|
+
// reference states the same placeholders SKILL.md does; anything else is copied byte for byte.
|
|
172
|
+
function copySkillDir(from, to, vars) {
|
|
173
|
+
for (const entry of readdirSync(from, { withFileTypes: true })) {
|
|
174
|
+
const source = join(from, entry.name);
|
|
175
|
+
const target = join(to, entry.name);
|
|
176
|
+
// A link is copied as what it points at. The container gets a directory rather than a tree of links,
|
|
177
|
+
// and a shared reference brought in as a link still ships - a skill naming a file this walk skipped
|
|
178
|
+
// is exactly the broken skill that copying the whole directory exists to prevent.
|
|
179
|
+
const kind = statSync(source);
|
|
180
|
+
if (kind.isDirectory()) {
|
|
181
|
+
copySkillDir(source, target, vars);
|
|
182
|
+
}
|
|
183
|
+
else if (kind.isFile()) {
|
|
184
|
+
if (entry.name.endsWith(".md")) {
|
|
185
|
+
writeFileEnsuringDir(target, renderTemplate(readFileSync(source, "utf8"), vars));
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
189
|
+
copyFileSync(source, target);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
166
192
|
}
|
|
167
193
|
}
|
|
168
194
|
// --- Inject agent context ----------------------------------------------------------------------------------------------------------------
|
package/dist/lib/chamba-yaml.js
CHANGED
|
@@ -141,7 +141,7 @@ const KNOWN_KEYS = new Set(["workspace_id", "env", "shadow_paths", "profiles", "
|
|
|
141
141
|
/**
|
|
142
142
|
* Attempt to repair an invalid chamba.yaml on disk.
|
|
143
143
|
* Strips unknown fields, fills missing required/optional fields from defaults,
|
|
144
|
-
* and rewrites with
|
|
144
|
+
* and rewrites with standard formatting. Returns a result describing what happened.
|
|
145
145
|
*/
|
|
146
146
|
export function repairChambaYaml(dir) {
|
|
147
147
|
const filePath = join(dir, CHAMBA_YAML);
|
package/dist/lib/constants.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// =========================================================================================================================================
|
|
2
|
-
// src/lib/constants.ts -
|
|
2
|
+
// src/lib/constants.ts - Shared constants used across the chamba codebase
|
|
3
3
|
// =========================================================================================================================================
|
|
4
4
|
import { dirname } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
@@ -100,10 +100,10 @@ export const DEFAULT_WEB_AGENT = "claude";
|
|
|
100
100
|
// loopback-only to a sticky per-workspace host port taken from web_range (host-global setting).
|
|
101
101
|
export const WEB_CONTAINER_PORT = 3899;
|
|
102
102
|
export const WEB_RANGE_DEFAULT = "3900-3999";
|
|
103
|
-
// Where the webterm server publishes the key its URL carries (`/?k=<key>`). A new key is
|
|
103
|
+
// Where the webterm server publishes the key its URL carries (`/?k=<key>`). A new key is created every time
|
|
104
104
|
// that server starts and written here as soon as it has the port, so whoever prints the URL reads it back
|
|
105
105
|
// from this file rather than storing one: the container greeting, the `webterm` launcher, and chamba on the
|
|
106
|
-
// host before it
|
|
106
|
+
// host before it checks /status. Container-side path - the host only ever reads it through `docker exec`.
|
|
107
107
|
export const WEB_KEY_FILE_PATH = "/tmp/webterm.key";
|
|
108
108
|
// Where the container sees this workspace's webterm state dir, bind-mounted from the cache dir above. The
|
|
109
109
|
// one thing in it is the agent file: which agent new web sessions start with, written by the server when
|
|
@@ -116,7 +116,7 @@ export const WEBTERM_AGENT_FILE_PATH = `${CONTAINER_WEBTERM_STATE_DIR}/${WEBTERM
|
|
|
116
116
|
// conversation's pane and the feedback submitted on them. On the host it sits under the cache dir's
|
|
117
117
|
// agents/ dir, which is exactly what Advanced > Clear agent memory removes, so that menu item is the one
|
|
118
118
|
// thing that clears the pane. It is bind-mounted into the container, so the pages outlive the container
|
|
119
|
-
// and a resumed conversation finds its own history again. Nothing
|
|
119
|
+
// and a resumed conversation finds its own history again. Nothing clears it by age.
|
|
120
120
|
export const WEBPANE_DIR = "webpane";
|
|
121
121
|
export const CONTAINER_WEBPANE_DIR = `${CONTAINER_HOME}/.webpane`;
|
|
122
122
|
// Reopening the last conversation is the container's own job: templates/webterm/resume.js reads the agent
|
|
@@ -74,6 +74,7 @@ export const BAKED_TEMPLATE_FILES = [
|
|
|
74
74
|
"npmrc",
|
|
75
75
|
"pnpm-config.yaml",
|
|
76
76
|
"runtime-constants.mjs",
|
|
77
|
+
"specs.sh",
|
|
77
78
|
"startup-git-mode.mjs",
|
|
78
79
|
"startup.mjs",
|
|
79
80
|
"webpane.sh",
|
|
@@ -82,7 +83,7 @@ export const BAKED_TEMPLATE_FILES = [
|
|
|
82
83
|
// Directories (relative to the templates dir) baked into the image via a directory COPY. Hashed
|
|
83
84
|
// recursively by computeBuildHash so editing any file inside triggers a rebuild prompt, with
|
|
84
85
|
// node_modules skipped - a local install there must never flip the hash (it is .dockerignore'd too).
|
|
85
|
-
export const BAKED_TEMPLATE_DIRS = ["webterm"];
|
|
86
|
+
export const BAKED_TEMPLATE_DIRS = ["pane-apps", "webterm"];
|
|
86
87
|
// Relative paths of every regular file under dir (sorted, node_modules subtrees skipped). Missing
|
|
87
88
|
// dirs yield [] for the same reason computeBuildHash tolerates missing files: minimal test contexts.
|
|
88
89
|
function walkTemplateDir(root, prefix = "") {
|
package/dist/lib/ports.js
CHANGED
|
@@ -8,10 +8,10 @@ import { createHash } from "node:crypto";
|
|
|
8
8
|
import { createServer } from "node:net";
|
|
9
9
|
import { WEB_CONTAINER_PORT } from "./constants.js";
|
|
10
10
|
// --- Constants ---------------------------------------------------------------------------------------------------------------------------
|
|
11
|
-
// The CLI runs as a non-root user, so the availability
|
|
11
|
+
// The CLI runs as a non-root user, so the availability check (net bind) cannot bind ports below 1024.
|
|
12
12
|
export const PORT_MIN = 1024;
|
|
13
13
|
export const PORT_MAX = 65535;
|
|
14
|
-
// Publish and
|
|
14
|
+
// Publish and check on loopback only - never 0.0.0.0. Nothing on the LAN may reach these sessions.
|
|
15
15
|
export const PORT_LOOPBACK_HOST = "127.0.0.1";
|
|
16
16
|
// --- Config validation and normalization -------------------------------------------------------------------------------------------------
|
|
17
17
|
/** A single port number is a usable, unprivileged host/container port. */
|
|
@@ -127,7 +127,7 @@ export function parsePublishedPorts(psOutput) {
|
|
|
127
127
|
/**
|
|
128
128
|
* Host ports currently published by any running container. On native Linux with userland-proxy=false no host
|
|
129
129
|
* socket exists (DNAT), so canBind alone would wrongly report a published port as free - this covers that case.
|
|
130
|
-
* Returns an empty set when docker is unavailable (the bind
|
|
130
|
+
* Returns an empty set when docker is unavailable (the bind check still applies).
|
|
131
131
|
*/
|
|
132
132
|
export function dockerPublishedPorts() {
|
|
133
133
|
const result = spawnSync("docker", ["ps", "--format", "{{.Ports}}"], { encoding: "utf8", stdio: "pipe" });
|
|
@@ -151,7 +151,7 @@ export function containerPublishedPorts(containerName) {
|
|
|
151
151
|
}
|
|
152
152
|
/**
|
|
153
153
|
* Check that every mapped host port is free before we create the container. Called only on the create path - the
|
|
154
|
-
* old container has already been removed, so we never
|
|
154
|
+
* old container has already been removed, so we never check our own live port. Throws a clear error naming the taken
|
|
155
155
|
* host port and its entry on the first collision. Doing this here, rather than letting `docker run` fail, buys two
|
|
156
156
|
* things: the error names the offending entry (docker's raw message does not), and no doomed `created` container is
|
|
157
157
|
* left behind on a clash.
|
package/dist/lib/safe-rm.js
CHANGED
|
@@ -7,20 +7,30 @@
|
|
|
7
7
|
import { rmSync } from "node:fs";
|
|
8
8
|
import { homedir, tmpdir } from "node:os";
|
|
9
9
|
import { basename, join, resolve, sep } from "node:path";
|
|
10
|
-
import { CHAMBA_DIR, CHAMBA_YAML, CONTAINER_NAME_PREFIX } from "./constants.js";
|
|
10
|
+
import { CHAMBA_DIR, CHAMBA_YAML, CONTAINER_NAME_PREFIX, PACKAGE_ROOT } from "./constants.js";
|
|
11
11
|
const CHAMBA_HOME = resolve(join(homedir(), CHAMBA_DIR));
|
|
12
12
|
const TEST_TMP_PREFIX = join(tmpdir(), `${CONTAINER_NAME_PREFIX}test-`);
|
|
13
|
+
// chamba's own build outputs, inside the package itself. The build stages the pane apps into the
|
|
14
|
+
// templates tree, and it clears the previous staging before it copies: every asset in that dist is
|
|
15
|
+
// named by its own content hash, so merging would keep every past build's files for ever, and the
|
|
16
|
+
// image build hash covers each of them.
|
|
17
|
+
const BUILD_OUTPUTS = [resolve(join(PACKAGE_ROOT, "dist")), resolve(join(PACKAGE_ROOT, "templates", "pane-apps"))];
|
|
13
18
|
/**
|
|
14
19
|
* Safe wrapper around rmSync. Throws if the path is outside a chamba-owned location:
|
|
15
20
|
* - ~/.chamba/ (workspace caches, agents, shadows, global config)
|
|
16
21
|
* - A file named chamba.yaml (workspace config file in any user workspace root)
|
|
17
22
|
* - <tmpdir>/chamba-test-* (test temp directories)
|
|
23
|
+
* - chamba's own build outputs inside the package (dist/, templates/pane-apps/)
|
|
18
24
|
*/
|
|
19
25
|
export function safeRmSync(path, options) {
|
|
20
26
|
const r = resolve(path);
|
|
21
|
-
const ok = r === CHAMBA_HOME ||
|
|
27
|
+
const ok = r === CHAMBA_HOME ||
|
|
28
|
+
r.startsWith(CHAMBA_HOME + sep) ||
|
|
29
|
+
basename(r) === CHAMBA_YAML ||
|
|
30
|
+
r.startsWith(TEST_TMP_PREFIX) ||
|
|
31
|
+
BUILD_OUTPUTS.some((output) => r === output || r.startsWith(output + sep));
|
|
22
32
|
if (!ok) {
|
|
23
|
-
throw new Error(`safeRmSync: refusing to delete '${r}' - must be under ~/.chamba/, named chamba.yaml,
|
|
33
|
+
throw new Error(`safeRmSync: refusing to delete '${r}' - must be under ~/.chamba/, named chamba.yaml, a test temp dir, or a build output of this package`);
|
|
24
34
|
}
|
|
25
35
|
rmSync(r, options);
|
|
26
36
|
}
|
package/dist/lib/webterm.js
CHANGED
|
@@ -34,7 +34,7 @@ export function nextFreeWebPort(range, taken) {
|
|
|
34
34
|
* Return a workspace's sticky web port, assigning the lowest free port in the range when it has none.
|
|
35
35
|
* An already-assigned port is returned as-is even when it falls outside the range - stickiness wins;
|
|
36
36
|
* out-of-range ports move only when the user changes the range (reassignOutOfRangeWebPorts).
|
|
37
|
-
* This is pure over the .lock files: it never
|
|
37
|
+
* This is pure over the .lock files: it never checks the host, so it can say a port is assigned but not
|
|
38
38
|
* that it can be published. resolveWebPort adds that.
|
|
39
39
|
*/
|
|
40
40
|
export function ensureWebPort(workspaceId, range) {
|
|
@@ -54,7 +54,7 @@ export function ensureWebPort(workspaceId, range) {
|
|
|
54
54
|
* The host port this workspace's web interface should publish this session.
|
|
55
55
|
*
|
|
56
56
|
* `usable(port)` is the caller's test for "can this port carry the interface right now" - it covers both
|
|
57
|
-
* the host-side
|
|
57
|
+
* the host-side check and any host port the workspace already declares in chamba.yaml. When the sticky
|
|
58
58
|
* assignment fails that test the port MOVES and the new one is persisted: sticky means it stays where it
|
|
59
59
|
* was last put, not that it stays somewhere unusable and warns about the same conflict at every start.
|
|
60
60
|
* When nothing in the range qualifies the old assignment is left alone rather than cleared, so the
|
|
@@ -162,10 +162,10 @@ export async function webPortUsable(webPort, containerName) {
|
|
|
162
162
|
}
|
|
163
163
|
/**
|
|
164
164
|
* The key this container's web interface is currently demanding, read from the file its server publishes
|
|
165
|
-
* before binding the port. A new key is
|
|
165
|
+
* before binding the port. A new key is created at every server start and nothing on the host stores one,
|
|
166
166
|
* so it is always read fresh, right before it is used.
|
|
167
167
|
* Returns null when there is nothing to read: no container, no interface running, or a container built
|
|
168
|
-
* before the key existed. Callers then
|
|
168
|
+
* before the key existed. Callers then check without a key, which is exactly what such an older server
|
|
169
169
|
* (which ignores the query parameter) expects.
|
|
170
170
|
*/
|
|
171
171
|
export function readWebKey(containerName) {
|
|
@@ -175,12 +175,12 @@ export function readWebKey(containerName) {
|
|
|
175
175
|
const key = result.stdout?.toString().trim();
|
|
176
176
|
return key ? key : null;
|
|
177
177
|
}
|
|
178
|
-
// The shape the server
|
|
178
|
+
// The shape the server creates: 16 random bytes, hex. The key file is writable by every process in the
|
|
179
179
|
// container, so anything read back from it is checked against this before the host builds a URL out of it
|
|
180
180
|
// and hands that URL to a program. Argv-style spawning already keeps a strange value from being a command;
|
|
181
181
|
// this keeps it from being a URL at all.
|
|
182
182
|
const WEB_KEY_PATTERN = /^[0-9a-f]{32}$/;
|
|
183
|
-
/** Whether a key read back from the container has the shape the server
|
|
183
|
+
/** Whether a key read back from the container has the shape the server creates. */
|
|
184
184
|
export function isWebKey(key) {
|
|
185
185
|
return key !== null && WEB_KEY_PATTERN.test(key);
|
|
186
186
|
}
|
|
@@ -247,7 +247,7 @@ export async function setWebDefaultCwd(webPort, key, workdir) {
|
|
|
247
247
|
// The interface is on loopback in the same machine, so a slow answer means something is wrong, not far.
|
|
248
248
|
const WEB_STATUS_TIMEOUT_MS = 1500;
|
|
249
249
|
/**
|
|
250
|
-
* Whether the interface is serving at all. Any HTTP answer counts, including the 403 a keyless
|
|
250
|
+
* Whether the interface is serving at all. Any HTTP answer counts, including the 403 a keyless check gets:
|
|
251
251
|
* refusing a caller without a key is still proof that the server is up. Used where only liveness matters
|
|
252
252
|
* (did the launch work, is the interface still there), so those paths need no key and no docker exec.
|
|
253
253
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chamba",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Run AI coding agents in a container, from your browser",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"js-yaml": "^4.1.1"
|
|
14
14
|
},
|
|
15
15
|
"devDependencies": {
|
|
16
|
-
"@biomejs/biome": "
|
|
16
|
+
"@biomejs/biome": "2.5.5",
|
|
17
17
|
"@types/js-yaml": "^4.0.9",
|
|
18
18
|
"@types/node": "^25.4.0",
|
|
19
19
|
"tsx": "^4.21.0",
|
|
@@ -23,7 +23,6 @@
|
|
|
23
23
|
"start": "node bin/chamba.js",
|
|
24
24
|
"build": "tsx scripts/build.ts",
|
|
25
25
|
"re:build": "rm -rf dist && pnpm build",
|
|
26
|
-
"prepare": "git config core.hooksPath .githooks",
|
|
27
26
|
"typecheck": "tsc --noEmit",
|
|
28
27
|
"lint": "biome check .",
|
|
29
28
|
"lint:fix": "biome check --write .",
|
|
@@ -31,7 +30,7 @@
|
|
|
31
30
|
"test:docker": "node --import tsx/esm --test 'tests/docker/**/*.test.ts'",
|
|
32
31
|
"test:all": "pnpm test && pnpm test:docker",
|
|
33
32
|
"validate": "pnpm typecheck && pnpm lint && tsx scripts/check.ts && pnpm test",
|
|
34
|
-
"check": "pnpm
|
|
33
|
+
"check": "pnpm re:build && pnpm validate",
|
|
35
34
|
"release": "tsx scripts/release.ts",
|
|
36
35
|
"prepublishOnly": "pnpm re:build"
|
|
37
36
|
},
|
|
@@ -40,7 +39,6 @@
|
|
|
40
39
|
},
|
|
41
40
|
"license": "MIT",
|
|
42
41
|
"author": "Asaf Ratzon",
|
|
43
|
-
"packageManager": "pnpm@11.9.0",
|
|
44
42
|
"keywords": [
|
|
45
43
|
"ai",
|
|
46
44
|
"ai-agent",
|
package/templates/Dockerfile
CHANGED
|
@@ -157,7 +157,17 @@ RUN chmod +x /usr/local/share/chamba/webterm.sh && \
|
|
|
157
157
|
ln -sf /usr/local/share/chamba/webterm.sh /usr/local/bin/webterm
|
|
158
158
|
|
|
159
159
|
# ---------------------------------------------------------------------------
|
|
160
|
-
# Layer 14 - Bake the
|
|
160
|
+
# Layer 14 - Bake the pane apps (the tool UIs the pane hosts, and their servers)
|
|
161
|
+
# One built dist from the private pane-apps package: a client per tool and the
|
|
162
|
+
# module webterm mounts for it. Beside webterm rather than inside it, because
|
|
163
|
+
# webterm resolves it as a sibling and its own npm ci layer stays untouched by
|
|
164
|
+
# a UI change. Nothing to install - each server is bundled with no dependency
|
|
165
|
+
# outside node's own builtins.
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
COPY pane-apps/ /usr/local/share/chamba/pane-apps/
|
|
168
|
+
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
# Layer 15 - Bake the web pane publish helper
|
|
161
171
|
# Publishes an HTML file as a page in the calling session's pane. On PATH via
|
|
162
172
|
# the symlink, so any process inside a session can use it.
|
|
163
173
|
# ---------------------------------------------------------------------------
|
|
@@ -165,6 +175,15 @@ COPY webpane.sh /usr/local/share/chamba/webpane.sh
|
|
|
165
175
|
RUN chmod +x /usr/local/share/chamba/webpane.sh && \
|
|
166
176
|
ln -sf /usr/local/share/chamba/webpane.sh /usr/local/bin/webpane
|
|
167
177
|
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
# Layer 16 - Bake the Specs helper
|
|
180
|
+
# The agent's half of the Specs channel: the verbs that move a piece of work.
|
|
181
|
+
# Gated like webpane, so only a process inside a session may use it.
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
COPY specs.sh /usr/local/share/chamba/specs.sh
|
|
184
|
+
RUN chmod +x /usr/local/share/chamba/specs.sh && \
|
|
185
|
+
ln -sf /usr/local/share/chamba/specs.sh /usr/local/bin/specs
|
|
186
|
+
|
|
168
187
|
WORKDIR /workspace
|
|
169
188
|
|
|
170
189
|
# ---------------------------------------------------------------------------
|
|
@@ -26,7 +26,7 @@ One file, standalone, dark.
|
|
|
26
26
|
|
|
27
27
|
- **Inline everything.** No external stylesheets, scripts or fonts. The page renders in a sandboxed frame with no origin of its own: `localStorage`, cookies and same-origin requests do not work in it, and an external asset only makes the page slower and chattier. Inline `<style>` and `<script>` both work.
|
|
28
28
|
- **Match the interface.** Background `#0d1117`, panels and table headers `#161b22`, borders `#30363d`, text `#e6edf3`, muted text `#8b949e`, and a green accent `#2fe58a` for links, headings you want to lift, and the submit button. System font stack, ~14px, generous line height.
|
|
29
|
-
- **Write it as a document**, not a dashboard: a title, sections, tables where a table is clearer than
|
|
29
|
+
- **Write it as a document**, not a dashboard: a title, sections, tables where a table is clearer than text, and short paragraphs. Keep it small - a couple of hundred kilobytes is plenty. The per-page limit is 2 MB, and a page past it is refused rather than trimmed.
|
|
30
30
|
- Images have to be inline too (a data URI, or an SVG written into the page). A path into the container is not something the browser can fetch.
|
|
31
31
|
|
|
32
32
|
### Asking with a form
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
:root{--bg:#0d1117;--panel:#161b22;--fg:#e6edf3;--muted:#8b949e;--border:#30363d;--accent:#58a6ff;--ok:#3fb950;--warn:#d29922;--sunken:#0b0e13;--sel:#1f6feb33}:root[data-theme=light]{--bg:#fff;--panel:#f6f8fa;--fg:#1f2328;--muted:#656d76;--border:#d0d7de;--accent:#0969da;--ok:#1a7f37;--warn:#9a6700;--sunken:#f6f8fa;--sel:#ddf4ff}*{box-sizing:border-box}html,body,#root{height:100%}body{background:var(--bg);color:var(--fg);margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-size:14px;line-height:1.6}@media (prefers-reduced-motion:no-preference){html{scroll-behavior:smooth}}.workspace{grid-template-columns:minmax(180px,220px) minmax(0,1fr) minmax(220px,280px);height:100%;display:grid}.nav,.rail{background:var(--panel);font-size:12.5px;overflow:auto}.nav{border-right:1px solid var(--border);padding:10px 0 24px}.rail{border-left:1px solid var(--border);flex-direction:column;gap:18px;padding:12px 14px 24px;display:flex}.nav-head,.rail-head{letter-spacing:.06em;text-transform:uppercase;color:var(--muted);margin:12px 0 5px;padding:0 14px;font-size:10.5px;font-weight:700}.rail-head{padding:0}.nav-list{margin:0;padding:0;list-style:none}.nav-list button{text-align:left;width:100%;color:inherit;font:inherit;cursor:pointer;background:0 0;border:none;border-left:2px solid #0000;padding:3px 14px;display:block}.nav-list button:hover{color:var(--accent)}.nav-list button[aria-current]{background:var(--sel);border-left-color:var(--accent)}.nav-list button.nav-artifact{padding-left:22px}.nav-list button.nav-spec{align-items:baseline;gap:6px;display:flex}.nav-spec-name{text-overflow:ellipsis;white-space:nowrap;flex:1;overflow:hidden}.nav-spec-status{color:var(--muted);letter-spacing:.04em;text-transform:uppercase;flex:none;font-size:10px}.nav-spec-awaiting{background:var(--accent);min-width:15px;color:var(--bg);text-align:center;border-radius:7px;flex:none;padding:0 4px;font-size:10px;font-weight:700}.nav-dir{color:var(--muted);letter-spacing:.04em;margin:8px 0 2px;padding:0 14px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:10.5px}.nav-sections{margin:0 0 4px;padding:0;font-size:12px;list-style:none}.nav-sections button{color:var(--muted);padding-left:32px}.nav-sections button[aria-current]{color:var(--fg);font-weight:600}.nav-sections button.is-read{opacity:.55}.nav-changed{color:var(--warn);vertical-align:middle;margin-left:6px;font-size:9px}.doc{padding:20px 40px 96px;overflow:auto}.doc-facts{color:var(--muted);align-items:center;gap:10px;margin-bottom:14px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;display:flex}.doc-section{scroll-margin-top:12px;position:relative}.doc-section.is-read{opacity:.62}.doc-section h2{margin:22px 0 8px;font-size:1.15em}.sec-mark{position:absolute;top:24px;left:-26px}.mark{border:1px solid var(--border);background:var(--bg);color:#0000;cursor:pointer;border-radius:5px;width:20px;height:20px;padding:0;font-size:11px;line-height:1}.doc-section:hover .mark,.mark:focus-visible{color:var(--muted)}.mark[aria-pressed=true]{color:var(--ok);border-color:var(--ok)}.sec-changed{color:var(--warn);margin-left:10px;font-size:11px;font-weight:400}.art-frame{border:1px solid var(--border);background:var(--bg);border-radius:8px;width:100%;height:calc(100vh - 90px)}.art-image img{max-width:100%}.rail-top{justify-content:flex-end;display:flex}.rail-fact{margin:0 0 2px}.rail-changed{color:var(--warn)}.theme-toggle{border:1px solid var(--border);color:var(--muted);font:inherit;cursor:pointer;background:0 0;border-radius:999px;padding:2px 12px;font-size:11px}.notice{color:var(--muted);padding:20px 24px}.notice-failure{border-left:3px solid var(--warn);color:var(--fg)}.muted{color:var(--muted)}.visually-hidden{clip-path:inset(50%);white-space:nowrap;width:1px;height:1px;position:absolute;overflow:hidden}.plain{white-space:pre-wrap;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px;overflow-x:auto}.prose{overflow-wrap:anywhere;font-size:13.5px}.prose h1{margin:18px 0 8px;font-size:1.4em}.prose h2{margin:18px 0 8px;font-size:1.2em}.prose h3{margin:16px 0 6px;font-size:1.08em}.prose h4,.prose h5,.prose h6{color:var(--muted);margin:14px 0 6px;font-size:1em}.prose p{margin:0 0 10px}.prose ul,.prose ol{margin:0 0 10px;padding-left:22px}.prose li{margin:2px 0}.prose li.task{margin-left:-20px;list-style:none}.task-box{border:1px solid var(--muted);vertical-align:baseline;border-radius:3px;width:11px;height:11px;margin-right:8px;display:inline-block}.task-box.is-done{border-color:var(--ok);background:var(--ok)}.prose a{color:var(--accent)}.prose code{background:var(--sunken);border:1px solid var(--border);border-radius:5px;padding:1px 5px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.88em}.prose blockquote{border-left:3px solid var(--border);color:var(--muted);margin:0 0 12px;padding:2px 14px}.prose blockquote>:last-child{margin-bottom:0}.prose hr{border:0;border-top:1px solid var(--border);margin:16px 0}.prose img{max-width:100%}.prose table{border-collapse:collapse;margin:0 0 12px;font-size:.95em;display:block;overflow-x:auto}.prose th,.prose td{border:1px solid var(--border);text-align:left;padding:4px 10px}.prose th{background:var(--sunken)}.prose .cell-left{text-align:left}.prose .cell-center{text-align:center}.prose .cell-right{text-align:right}.prose pre{background:var(--sunken);border:1px solid var(--border);border-radius:8px;margin:0 0 12px;padding:12px;overflow-x:auto}.prose pre code{background:0 0;border:0;padding:0}.annotate{z-index:2;border:1px solid var(--border);background:var(--panel);border-radius:7px;gap:4px;padding:3px 6px;display:flex;position:absolute;box-shadow:0 4px 14px #00000059}.annotate button{color:var(--accent);font:inherit;cursor:pointer;background:0 0;border:none;border-radius:5px;padding:2px 5px;font-size:13px;line-height:1}.annotate button:hover{background:var(--sel)}.annotate-whole{border:1px solid var(--border);color:var(--accent);font:inherit;cursor:pointer;background:0 0;border-radius:999px;padding:0 9px;font-size:10.5px}.queue{margin:0 0 8px;padding:0;list-style:none}.filed{margin:0;padding:0;list-style:none}.filed li{border-bottom:1px solid var(--border);align-items:baseline;gap:6px;padding:3px 0;font-size:11.5px;display:flex}.filed li:last-child{border-bottom:none}.filed-round{flex:none;font-weight:600}.filed-count{color:var(--muted);flex:1}.filed-when{color:var(--muted);flex:none;font-size:10.5px}.queued{border:1px solid var(--border);border-left:3px solid var(--accent);background:var(--bg);border-radius:7px;margin-bottom:7px;padding:7px 9px}.queued.is-remove{border-left-color:var(--warn)}.queued-target{color:var(--muted);margin:0;font-size:11px;font-style:italic}.queued-kind{color:var(--warn);margin:3px 0 0;font-size:11.5px}.queued textarea{background:var(--panel);width:100%;min-height:42px;color:inherit;border:1px solid var(--border);font:inherit;resize:vertical;border-radius:6px;margin-top:4px;padding:5px 7px;font-size:12px}.queued-acts{gap:10px;margin-top:4px;display:flex}.queued-acts button{color:var(--accent);font:inherit;cursor:pointer;background:0 0;border:none;padding:0;font-size:11px}.queued-acts button:disabled{color:var(--muted);cursor:default}.send{background:var(--accent);width:100%;color:var(--bg);font:inherit;cursor:pointer;border:none;border-radius:7px;padding:6px 0;font-size:12px}.send:disabled{background:var(--border);color:var(--muted);cursor:default}.rail-continue{border:1px solid var(--border);color:var(--accent);font:inherit;cursor:pointer;background:0 0;border-radius:7px;margin-top:6px;padding:3px 10px;font-size:11.5px}.dialog-shade{z-index:10;background:#00000080;justify-content:center;align-items:center;display:flex;position:fixed;inset:0}.dialog{border:1px solid var(--border);background:var(--panel);border-radius:10px;max-width:340px;padding:16px 18px}.dialog p{margin:0 0 12px}.dialog-acts{justify-content:flex-end;gap:10px;display:flex}.dialog-acts button{border:1px solid var(--border);color:inherit;font:inherit;cursor:pointer;background:0 0;border-radius:7px;padding:4px 12px;font-size:12px}.dialog-acts .send{border-color:var(--accent);width:auto}.nav-top{justify-content:space-between;align-items:baseline;gap:8px;display:flex}.nav-new{border:1px solid var(--border);color:var(--accent);font:inherit;cursor:pointer;background:0 0;border-radius:7px;padding:2px 9px;font-size:11px}.board-list{margin:0;padding:0;list-style:none}.board-stage{padding:2px 0;font-size:12px}.board-stage-name{color:var(--muted);margin:0}.board-stage.is-running .board-stage-name,.board-stage.is-done .board-stage-name{color:var(--text)}.board-mark{width:14px;color:var(--muted);display:inline-block}.board-stage.is-running .board-mark{color:var(--accent)}.board-artifacts{margin:0 0 4px 14px;padding:0;list-style:none}.board-artifacts button{color:var(--accent);font:inherit;cursor:pointer;text-align:left;background:0 0;border:none;padding:1px 0;font-size:11.5px}.board-activity{color:var(--accent);margin:6px 0 0;font-size:11.5px}.board-awaiting{gap:6px;margin:10px 0 0;padding:0;list-style:none;display:grid}.form{padding-bottom:40px}.form .lede{color:var(--muted)}.choices{gap:12px;margin:0;padding:0;list-style:none;display:grid}.choices li{border:1px solid var(--border);border-radius:8px;padding:10px 12px}.choices li.is-recommended{border-color:var(--accent)}.choices label{cursor:pointer;align-items:center;gap:8px;display:flex}.choice-title{font-weight:600}.choice-flag{color:var(--accent);text-transform:uppercase;letter-spacing:.06em;margin-left:8px;font-size:10.5px;font-weight:400}.choice-about{color:var(--muted);margin:4px 0 0 22px;font-size:12.5px}.choice-why{margin:4px 0 0 22px;font-size:12.5px}.choice-mode{cursor:pointer;align-items:flex-start;gap:8px;display:flex}.field{margin-bottom:12px;display:block}.field>span{color:var(--muted);margin-bottom:4px;font-size:11.5px;display:block}.field input,.field-dump{box-sizing:border-box;background:var(--bg);border:1px solid var(--border);width:100%;color:inherit;font:inherit;resize:vertical;border-radius:7px;padding:7px 9px;font-size:13px}.field-dump{min-height:140px}.attached{gap:4px;margin:0 0 12px;padding:0;font-size:12px;list-style:none;display:grid}.attached li{justify-content:space-between;gap:8px;display:flex}.attached button{color:var(--muted);font:inherit;cursor:pointer;background:0 0;border:none;font-size:11px}.form-acts{justify-content:flex-end;gap:10px;margin-top:20px;display:flex}.form-acts button{border:1px solid var(--border);color:inherit;font:inherit;cursor:pointer;background:0 0;border-radius:7px;padding:5px 14px;font-size:12.5px}.form-acts .send{border-color:var(--accent);width:auto;color:var(--accent)}.dialog-wide{width:90%;max-width:560px}.doc-and-ask{flex-direction:column;min-width:0;display:flex;overflow-y:auto}.doc-and-ask .doc{flex:1 0 auto;overflow-y:visible}.ask{border:1px solid var(--accent);background:var(--panel);border-radius:10px;width:calc(100% - 80px);max-width:780px;margin:0 auto 40px;padding:16px 20px}.ask-head h2{margin:0;font-size:15px}.ask-about{color:var(--muted);margin:2px 0 10px;font-size:11.5px}.ask-evidence{margin:0 0 8px}.ask-tradeoff{color:var(--muted);margin:0 0 12px}.questions{gap:18px;margin:0 0 14px;padding-left:18px;display:grid}.question-ask{margin:0 0 8px;font-weight:600}.question-said{box-sizing:border-box;background:var(--bg);border:1px solid var(--border);width:100%;min-height:48px;color:inherit;font:inherit;resize:vertical;border-radius:7px;margin-top:8px;padding:6px 8px;font-size:12.5px}.board-review{padding:3px 0;font-size:12px}
|