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.
Files changed (55) hide show
  1. package/README.md +13 -6
  2. package/dist/commands/dev.js +11 -11
  3. package/dist/commands/settings.js +1 -1
  4. package/dist/lib/agent-context.js +33 -7
  5. package/dist/lib/chamba-yaml.js +1 -1
  6. package/dist/lib/constants.js +4 -4
  7. package/dist/lib/dockerfile-builder.js +2 -1
  8. package/dist/lib/ports.js +4 -4
  9. package/dist/lib/safe-rm.js +13 -3
  10. package/dist/lib/webterm.js +7 -7
  11. package/package.json +3 -5
  12. package/templates/Dockerfile +20 -1
  13. package/templates/context/web-pane-craft.md +1 -1
  14. package/templates/pane-apps/client/assets/specs-B1970L17.css +1 -0
  15. package/templates/pane-apps/client/assets/specs-cEee_SPn.js +23 -0
  16. package/templates/pane-apps/client/specs/index.html +13 -0
  17. package/templates/pane-apps/server/specs.mjs +1588 -0
  18. package/templates/skills/chamba-statusline/SKILL.md +1 -1
  19. package/templates/skills/dx-spec/SKILL.md +365 -0
  20. package/templates/skills/dx-spec/references/imagination-guide.md +140 -0
  21. package/templates/skills/dx-spec/references/review-guide.md +173 -0
  22. package/templates/skills/dx-spec/references/spec-guide.md +125 -0
  23. package/templates/skills/dx-spec/references/stages.md +399 -0
  24. package/templates/skills/dx-spec-config/SKILL.md +313 -0
  25. package/templates/skills/dx-spec-config/references/principles-template.md +12 -0
  26. package/templates/skills/dx-spec-execute/SKILL.md +324 -0
  27. package/templates/specs.sh +106 -0
  28. package/templates/webterm/README.md +50 -14
  29. package/templates/webterm/artifacts.js +11 -11
  30. package/templates/webterm/config.js +52 -9
  31. package/templates/webterm/conversation.js +3 -3
  32. package/templates/webterm/pane.js +14 -3
  33. package/templates/webterm/proc.js +1 -1
  34. package/templates/webterm/public/app/alerts.js +5 -5
  35. package/templates/webterm/public/app/composer.js +5 -2
  36. package/templates/webterm/public/app/connection.js +2 -2
  37. package/templates/webterm/public/app/dictation.js +1 -1
  38. package/templates/webterm/public/app/dom.js +13 -5
  39. package/templates/webterm/public/app/frames.js +8 -1
  40. package/templates/webterm/public/app/main.js +8 -2
  41. package/templates/webterm/public/app/new-session.js +1 -1
  42. package/templates/webterm/public/app/pane-shell.js +315 -0
  43. package/templates/webterm/public/app/pane.js +58 -183
  44. package/templates/webterm/public/app/specs-host.js +222 -0
  45. package/templates/webterm/public/app/state.js +1 -1
  46. package/templates/webterm/public/app/tabs.js +1 -1
  47. package/templates/webterm/public/app/terminal.js +8 -0
  48. package/templates/webterm/public/index.html +51 -27
  49. package/templates/webterm/public/styles.css +144 -30
  50. package/templates/webterm/server.js +300 -11
  51. package/templates/webterm/sessions.js +7 -7
  52. package/templates/webterm/snapshot.js +2 -2
  53. package/templates/webterm/specs.js +358 -0
  54. package/templates/webterm/tool-document.js +67 -0
  55. 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 of pages, there from the first second of a session: until an agent has published anything it is a spine at the edge of the window, and opening it says what the pane is for and what to ask for.
47
- An agent publishes a standalone HTML file into it - `webpane report.html --title "Port rework"` - and it shows up as a chip above the pane: a plan, a comparison table, a long answer, anything worth reading at your own pace instead of scrolling back for.
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, build
72
+ pnpm check # build, typecheck, lint, repo checks, tests
66
73
  pnpm release # check, bump, commit and publish to npm
67
74
  ```
68
75
 
@@ -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
- // probed just-in-time on the create path (assertHostPortsAvailable), never on resume/connect.
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() probing the web port and this create path (profile selection, a
247
- // recreate confirm, the image build), so re-probe it here. A port taken in the meantime drops the
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 probe host availability up front. The old container was already removed on
259
- // every path that reaches here, so we never probe our own live port. A taken host port fails clearly and
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-probe above: this gates the greeting's URL line and the
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 probe
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 probe,
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-probes the web port and drops it when something took it while the image built, so
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-probe.
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 minted at every server start and nothing on the host keeps one.
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 minted, so the live one is shown
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
- // Renders every templates/skills/<name>/SKILL.md into agents/claude/skills/<name>/SKILL.md. Drop a
149
- // new directory under templates/skills/ to ship a new skill - no other code changes needed.
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
- const sourcePath = join(templatesSkillsDir, entry.name, "SKILL.md");
162
- if (!existsSync(sourcePath))
165
+ if (!existsSync(join(templatesSkillsDir, entry.name, "SKILL.md")))
163
166
  continue;
164
- const rendered = renderTemplate(readFileSync(sourcePath, "utf8"), vars);
165
- writeFileEnsuringDir(join(targetSkillsDir, entry.name, "SKILL.md"), rendered);
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 ----------------------------------------------------------------------------------------------------------------
@@ -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 canonical formatting. Returns a result describing what happened.
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);
@@ -1,5 +1,5 @@
1
1
  // =========================================================================================================================================
2
- // src/lib/constants.ts - Canonical constants used across the chamba codebase
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 minted every time
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 probes /status. Container-side path - the host only ever reads it through `docker exec`.
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 sweeps it by age.
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 probe (net bind) cannot bind ports below 1024.
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 probe on loopback only - never 0.0.0.0. Nothing on the LAN may reach these sessions.
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 probe still applies).
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 probe our own live port. Throws a clear error naming the taken
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.
@@ -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 || r.startsWith(CHAMBA_HOME + sep) || basename(r) === CHAMBA_YAML || r.startsWith(TEST_TMP_PREFIX);
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, or a test temp dir`);
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
  }
@@ -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 probes the host, so it can say a port is assigned but not
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 probe and any host port the workspace already declares in chamba.yaml. When the sticky
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 minted at every server start and nothing on the host stores one,
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 probe without a key, which is exactly what such an older server
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 mints: 16 random bytes, hex. The key file is writable by every process in the
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 mints. */
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 probe gets:
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.6.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": "^2.4.10",
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 validate && pnpm re:build",
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",
@@ -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 web pane publish helper
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 prose, and short paragraphs. Keep it small - a couple of hundred kilobytes is plenty. The per-page cap is 2 MB, and a page past it is refused rather than trimmed.
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}