chamba 0.5.1 → 0.6.1

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 (42) hide show
  1. package/dist/commands/dev.js +11 -11
  2. package/dist/commands/onboard.js +2 -1
  3. package/dist/commands/settings.js +1 -1
  4. package/dist/lib/agent-context.js +10 -3
  5. package/dist/lib/chamba-yaml.js +1 -1
  6. package/dist/lib/constants.js +4 -4
  7. package/dist/lib/git-env.js +21 -0
  8. package/dist/lib/ports.js +4 -4
  9. package/dist/lib/shadows.js +2 -0
  10. package/dist/lib/webterm.js +7 -7
  11. package/package.json +1 -3
  12. package/templates/claude-statusline.sh +7 -4
  13. package/templates/context/web-pane-craft.md +57 -0
  14. package/templates/context-usage.sh +30 -13
  15. package/templates/skills/chamba-statusline/SKILL.md +16 -6
  16. package/templates/skills/web-pane/SKILL.md +1 -55
  17. package/templates/webterm/README.md +16 -13
  18. package/templates/webterm/artifacts.js +25 -12
  19. package/templates/webterm/config.js +9 -9
  20. package/templates/webterm/conversation.js +3 -3
  21. package/templates/webterm/pane.js +23 -4
  22. package/templates/webterm/proc.js +1 -1
  23. package/templates/webterm/public/app/alerts.js +5 -5
  24. package/templates/webterm/public/app/composer.js +25 -4
  25. package/templates/webterm/public/app/connection.js +2 -2
  26. package/templates/webterm/public/app/dictation.js +1 -1
  27. package/templates/webterm/public/app/dom.js +4 -0
  28. package/templates/webterm/public/app/frames.js +4 -2
  29. package/templates/webterm/public/app/main.js +6 -2
  30. package/templates/webterm/public/app/new-session.js +1 -1
  31. package/templates/webterm/public/app/pane-arrival.js +36 -0
  32. package/templates/webterm/public/app/pane.js +98 -13
  33. package/templates/webterm/public/app/state.js +1 -1
  34. package/templates/webterm/public/app/status-strip.js +26 -34
  35. package/templates/webterm/public/app/strip-format.js +36 -0
  36. package/templates/webterm/public/app/tabs.js +67 -5
  37. package/templates/webterm/public/index.html +23 -3
  38. package/templates/webterm/public/styles.css +159 -14
  39. package/templates/webterm/server.js +39 -13
  40. package/templates/webterm/sessions.js +7 -7
  41. package/templates/webterm/snapshot.js +6 -4
  42. package/templates/skills/context-usage/SKILL.md +0 -53
@@ -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`;
@@ -9,6 +9,7 @@ import { basename, join } from "node:path";
9
9
  import { cancel, confirm, intro, isCancel, log, outro, select, text } from "@clack/prompts";
10
10
  import { buildDefaultChambaYaml, readChambaYaml, slugifyForWorkspaceId, validateWorkspaceId, writeChambaYaml, } from "../lib/chamba-yaml.js";
11
11
  import { CHAMBA_YAML } from "../lib/constants.js";
12
+ import { gitEnv } from "../lib/git-env.js";
12
13
  import { readWebRange } from "../lib/global-config.js";
13
14
  import { safeRmSync } from "../lib/safe-rm.js";
14
15
  import { ensureWebPort } from "../lib/webterm.js";
@@ -26,7 +27,7 @@ function deriveUniqueWorkspaceId(baseId, workspaceRoot) {
26
27
  }
27
28
  function tryGetGitRoot(cwd) {
28
29
  try {
29
- return execSync("git rev-parse --show-toplevel", { encoding: "utf8", cwd, stdio: "pipe" }).trim();
30
+ return execSync("git rev-parse --show-toplevel", { encoding: "utf8", cwd, stdio: "pipe", env: gitEnv() }).trim();
30
31
  }
31
32
  catch {
32
33
  return null;
@@ -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);
@@ -108,12 +108,17 @@ export function buildAgentContextDocs(hasGit, shadowPatterns, gitMode = GIT_MODE
108
108
  ];
109
109
  return `${sections.join("\n")}\n`;
110
110
  }
111
+ // The page-writing craft is claude's web-pane skill body, and this is the same file: a skill is a Claude
112
+ // Code mechanism, so an AGENTS.md section is the only channel codex and opencode have for it. Claude does
113
+ // not get it here - it reads it as the skill, and a second copy in its CLAUDE.md would say it twice.
114
+ // The leading newline: the sections above are one bullet list, and this one opens with a heading.
115
+ const craftSection = `\n${loadTemplate("web-pane-craft")}`;
111
116
  // The context-usage section is Claude-only: the snapshot it points at is written by the status line
112
117
  // script Claude Code runs, so other agents' sessions never refresh it.
113
118
  return {
114
119
  claude: build("~/.claude/CLAUDE.md", [loadTemplate("context-usage")]),
115
- opencode: build("~/.config/opencode/AGENTS.md"),
116
- codex: build("~/.codex/AGENTS.md"),
120
+ opencode: build("~/.config/opencode/AGENTS.md", [craftSection]),
121
+ codex: build("~/.codex/AGENTS.md", [craftSection]),
117
122
  };
118
123
  }
119
124
  // --- Claude settings.json bootstrap ------------------------------------------------------------------------------------------------------
@@ -147,7 +152,9 @@ export function injectClaudeSkills(workspaceDir) {
147
152
  if (!existsSync(templatesSkillsDir))
148
153
  return;
149
154
  const targetSkillsDir = join(workspaceDir, AGENTS_DIR, "claude", "skills");
150
- const vars = { statusline_path: CLAUDE_STATUSLINE_PATH };
155
+ // web_pane_craft is the page-writing advice codex and opencode also get, as a section of their AGENTS.md.
156
+ // One file, rendered into both channels, so the two cannot come to say different things.
157
+ const vars = { statusline_path: CLAUDE_STATUSLINE_PATH, web_pane_craft: loadTemplate("web-pane-craft") };
151
158
  for (const entry of readdirSync(templatesSkillsDir, { withFileTypes: true })) {
152
159
  if (!entry.isDirectory())
153
160
  continue;
@@ -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
@@ -0,0 +1,21 @@
1
+ // =========================================================================================================================================
2
+ // src/lib/git-env.ts - The environment every git call in chamba runs in
3
+ // git takes its repo from GIT_DIR, GIT_WORK_TREE and GIT_INDEX_FILE before it takes it from -C or the working
4
+ // directory, so a call made from inside a git hook would answer about that hook's repo instead of this one.
5
+ // =========================================================================================================================================
6
+ /**
7
+ * The environment to run git in when the repo is chosen by path.
8
+ *
9
+ * git takes its repo from the environment as well as from the command line, and GIT_DIR, GIT_WORK_TREE and
10
+ * GIT_INDEX_FILE win over `-C` and over the working directory. Anything chamba starts inherits whatever the
11
+ * caller had set - a git hook sets all three - so a question asked about the workspace could be answered about
12
+ * another repo entirely. Every git call here names its repo by path, so none of those variables is ever wanted.
13
+ */
14
+ export function gitEnv() {
15
+ const env = { ...process.env };
16
+ for (const key of Object.keys(env)) {
17
+ if (key.startsWith("GIT_"))
18
+ delete env[key];
19
+ }
20
+ return env;
21
+ }
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,6 +7,7 @@ import { existsSync, lstatSync, mkdirSync, readdirSync, writeFileSync } from "no
7
7
  import { dirname, join, relative } from "node:path";
8
8
  import fg from "fast-glob";
9
9
  import { CONTAINER_WORKSPACE, SHADOWS_DIR } from "./constants.js";
10
+ import { gitEnv } from "./git-env.js";
10
11
  import { safeRmSync } from "./safe-rm.js";
11
12
  /**
12
13
  * Expand gitignore-style patterns into concrete relative paths.
@@ -58,6 +59,7 @@ function filterGitTrackedPaths(paths, workspaceRoot) {
58
59
  const result = spawnSync("git", ["-C", workspaceRoot, "ls-files", "-z", "--", ...paths], {
59
60
  encoding: "utf8",
60
61
  stdio: ["ignore", "pipe", "pipe"],
62
+ env: gitEnv(),
61
63
  });
62
64
  if (result.status !== 0)
63
65
  return { kept: paths, dropped: [] };
@@ -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.5.1",
3
+ "version": "0.6.1",
4
4
  "description": "Run AI coding agents in a container, from your browser",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 .",
@@ -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",
@@ -73,13 +73,15 @@ cc_version=""
73
73
 
74
74
  # One jq pass over the session state, straight into the snapshot object.
75
75
  # Tokens are floor-rounded and percentages rounded to integers, so a reader uses them as they are.
76
- # The rate limit is inverted here: the API reports the percentage USED, and everything downstream
76
+ # A rate limit is inverted here: the API reports the percentage USED, and everything downstream
77
77
  # shows the percentage REMAINING (an energy-left metaphor), clamped to 0..100 so an over-limit
78
- # report never becomes a negative number.
78
+ # report never becomes a negative number. Both windows go through the same "remaining" definition,
79
+ # because two meters drawn side by side that meant opposite things would look identical.
79
80
  snapshot_json=$(jq -rc \
80
81
  --arg claude_pid "$claude_pid" \
81
82
  --arg claude_pid_start "$claude_pid_start" \
82
83
  --arg version "$cc_version" '
84
+ def remaining: if . == null then null else ((100 - round) | if . < 0 then 0 elif . > 100 then 100 else . end) end;
83
85
  (.session_id // "") as $sid |
84
86
  {
85
87
  session_id: $sid,
@@ -91,9 +93,10 @@ snapshot_json=$(jq -rc \
91
93
  context_window_size: (.context_window.context_window_size // 0),
92
94
  model: ((.model.display_name // "") | split(" (") | .[0]),
93
95
  effort: (.effort.level // ""),
94
- quota_left_pct: (.rate_limits.five_hour.used_percentage
95
- | if . == null then null else ((100 - round) | if . < 0 then 0 elif . > 100 then 100 else . end) end),
96
+ quota_left_pct: (.rate_limits.five_hour.used_percentage | remaining),
96
97
  quota_resets_at: (.rate_limits.five_hour.resets_at // null),
98
+ quota_week_left_pct: (.rate_limits.seven_day.used_percentage | remaining),
99
+ quota_week_resets_at: (.rate_limits.seven_day.resets_at // null),
97
100
  version: (if $version == "" then null else $version end)
98
101
  }' 2>/dev/null)
99
102
 
@@ -0,0 +1,57 @@
1
+ ## Publishing a page
2
+
3
+ The web pane is the panel beside the terminal in the chamba web interface. You publish a standalone HTML file into it and it opens at once, in front of the user, with a chip in the bar above the pane that they can come back to. A user who is looking at another session sees their tab marked instead, and reads the page when they get there.
4
+
5
+ Publish when the answer is bigger than the terminal carries well, and when the question is.
6
+
7
+ ```bash
8
+ webpane report.html --title "Publish-ports rework"
9
+ ```
10
+
11
+ The file's own `<title>` is what the pane shows. `--title` names the file it is saved as, and defaults to the filename. The helper prints what it saved, or a plain message and a non-zero exit when it could not - the usual reason being a plain terminal session, which has no pane.
12
+
13
+ ### When to reach for it
14
+
15
+ - **Long or structured output.** A plan, a comparison table, a diagram, a report, an explanation with sections. Anything a user would want to scroll back to, keep, or read twice.
16
+ - **A question with shape.** More options than a plain prompt holds, options that need a sentence each, several questions at once, or a free-text answer alongside a choice. Your own question tooling is right for a quick fork in the road; a page is right when the choice deserves a table.
17
+ - **Anything the user asked to see.** "Show me", "write it up", "as a page" - take those literally.
18
+
19
+ Do not publish a one-paragraph answer, or a wall of code that belongs in a file. A page nobody needed is a chip in the way.
20
+
21
+ After publishing, say in one line what you put there. The page is the answer; the terminal line is a pointer to it.
22
+
23
+ ### Writing the page
24
+
25
+ One file, standalone, dark.
26
+
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
+ - **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 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
+ - 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
+
32
+ ### Asking with a form
33
+
34
+ Give any form the `data-feedback` attribute and the pane wires it up:
35
+
36
+ ```html
37
+ <form data-feedback>
38
+ <p>Fail the whole start when a port is taken?</p>
39
+ <label><input type="radio" name="on_conflict" value="fail" checked /> Fail the start</label>
40
+ <label><input type="radio" name="on_conflict" value="skip" /> Skip the port and warn</label>
41
+ <textarea name="text" placeholder="Anything else about the plan..."></textarea>
42
+ <button type="submit">Send to agent</button>
43
+ </form>
44
+ ```
45
+
46
+ - Every named control becomes a field in the answer. A control named `text` becomes the free-text box.
47
+ - On submit, the interface writes `~/.webpane/<conversation>/feedback/<page>-<epoch-ms>.json` - `{ page, submittedAt, fields, text }` - and types one line into your terminal naming the file. Read the file; the line is only the pointer.
48
+ - The page is told whether the answers were filed, so give the user a form that reads as final ("Send to agent") rather than one that looks like it saved a draft.
49
+ - One submission per page per second, and only so many per page in total (20). Past that the page is refused, and nothing reaches you - so ask once per page, and publish a new page when you need to ask again.
50
+
51
+ **A feedback file says a form was submitted, not that a person filled it in.** A script in the same page can post exactly what the form would. Treat the answers as data from your own page: fine for a design choice, not enough on its own for something destructive or irreversible - ask for that in the terminal, where you can see the user type.
52
+
53
+ ### After it is published
54
+
55
+ - Pages are kept. Nothing deletes them, and they are filed under the conversation rather than the session, so resuming the conversation opens with them again. Publishing a second version makes a second page rather than replacing the first, so name pages so a user can tell one from the next ("Plan v2", "Port test results").
56
+ - The user may never open a page. If an answer matters, say the one line that matters in the terminal too.
57
+ - Anything in the session can publish, including a script you wrote - the helper takes only a filename.
@@ -116,7 +116,9 @@ parsed=$(jq -r '
116
116
  .model // "",
117
117
  .effort // "",
118
118
  .quota_left_pct // "",
119
- .quota_resets_at // ""
119
+ .quota_resets_at // "",
120
+ .quota_week_left_pct // "",
121
+ .quota_week_resets_at // ""
120
122
  ' "$newest" 2>/dev/null)
121
123
 
122
124
  if [ -z "$parsed" ]; then
@@ -134,16 +136,22 @@ fi
134
136
  IFS= read -r effort
135
137
  IFS= read -r quota_left
136
138
  IFS= read -r quota_resets_at
139
+ IFS= read -r quota_week_left
140
+ IFS= read -r quota_week_resets_at
137
141
  } <<EOF
138
142
  $parsed
139
143
  EOF
140
144
 
141
145
  now=$(date +%s)
142
146
 
143
- # Format a seconds delta as "Xh Ym", "Xm", or "Xs".
147
+ # Format a seconds delta as "Xd Yh", "Xh Ym", "Xm", or "Xs". The day unit is here for the seven-day
148
+ # rate-limit window, which is days away for most of its life; the hours are truncated rather than
149
+ # rounded so a delta of 4 days and 23h50m never reads "4d 24h".
144
150
  fmt_delta() {
145
151
  d=$1
146
- if [ "$d" -ge 3600 ]; then
152
+ if [ "$d" -ge 86400 ]; then
153
+ echo "$(( d / 86400 ))d $(( (d % 86400) / 3600 ))h"
154
+ elif [ "$d" -ge 3600 ]; then
147
155
  echo "$(( d / 3600 ))h $(( (d % 3600) / 60 ))m"
148
156
  elif [ "$d" -ge 60 ]; then
149
157
  echo "$(( d / 60 ))m"
@@ -199,22 +207,31 @@ else
199
207
  fi
200
208
  printf 'context: %s tokens (%s%%%s)\n' "$tokens_label" "${used_pct:-?}" "$window_label"
201
209
 
202
- # Quota line only when the snapshot carried rate-limit data.
203
- if [ -n "$quota_left" ]; then
204
- reset_label=""
205
- case "$quota_resets_at" in
210
+ # One rate-limit window as a line, printed only when the snapshot carried that window. Claude Code
211
+ # reports two - the five-hour one, which "quota" has always meant here, and the seven-day one - and an
212
+ # account may have both, one, or neither. The label is padded to the width the other lines use.
213
+ quota_line() {
214
+ ql_label=$1
215
+ ql_left=$2
216
+ ql_resets=$3
217
+ [ -n "$ql_left" ] || return 0
218
+ ql_reset_label=""
219
+ case "$ql_resets" in
206
220
  '' | *[!0-9]*) ;;
207
221
  *)
208
- delta=$(( quota_resets_at - now ))
209
- if [ "$delta" -gt 0 ]; then
210
- reset_label=", resets in $(fmt_delta "$delta")"
222
+ ql_delta=$(( ql_resets - now ))
223
+ if [ "$ql_delta" -gt 0 ]; then
224
+ ql_reset_label=", resets in $(fmt_delta "$ql_delta")"
211
225
  else
212
- reset_label=", resets now"
226
+ ql_reset_label=", resets now"
213
227
  fi
214
228
  ;;
215
229
  esac
216
- printf 'quota: %s%% remaining%s\n' "$quota_left" "$reset_label"
217
- fi
230
+ printf '%-8s %s%% remaining%s\n' "$ql_label" "$ql_left" "$ql_reset_label"
231
+ }
232
+
233
+ quota_line 'quota:' "$quota_left" "$quota_resets_at"
234
+ quota_line 'weekly:' "$quota_week_left" "$quota_week_resets_at"
218
235
 
219
236
  if [ -n "$model" ]; then
220
237
  effort_label=""
@@ -16,24 +16,34 @@ That is the answer to most questions this skill gets. The rest of it is the deta
16
16
 
17
17
  ## Step 1 - The strip, which is where the numbers are
18
18
 
19
- The web interface shows the attached session's status in one line between the terminal and the composer. Four segments, left to right, separated by a mid-dot:
19
+ The web interface shows the attached session's status in one line between the terminal and the composer. Its segments, left to right, separated by a mid-dot:
20
20
 
21
21
  1. **Model** - the display name Claude Code reports, with any trailing parenthetical (such as "(1M context)") trimmed, and the reasoning effort beside it.
22
22
  2. **Context** - tokens held, then the size of the window they sit in, a bar, and the percentage. The size half is dropped when Claude Code does not report a window size. The bar turns yellow as the window fills and red near the top.
23
- 3. **Quota** - what is LEFT of the five-hour rate-limit window, as a bar and a percentage, with the time until it recharges beside it. It is an energy meter: full and green when fresh, yellow at half, red near empty. Absent on free accounts and before the first API response.
23
+ 3. **Quota** - one meter per rate-limit window Claude Code reports, tagged `5h` and `7d`: what is LEFT of that window, as a bar and a percentage, with the time until it recharges beside it. It is an energy meter: full and green when fresh, yellow at half, red near empty. A window the account does not have takes its whole meter off the strip, so free accounts and sessions before their first API response show none.
24
24
  4. **Claude Code** - the installed CLI version.
25
25
 
26
26
  The strip belongs to the session it is drawn for, and only a claude session has one - the snapshot is a Claude Code feature, so a codex or opencode tab has no strip at all.
27
27
 
28
28
  ## Step 2 - The snapshot behind it, and the helper
29
29
 
30
- The script at `{{statusline_path}}` is what makes any of this exist. Claude Code runs it on every prompt render and hands it the session's state on stdin; the script parses it, resolves the pid of the claude process it was run by, and writes a small JSON file. Fields: `session_id`, `claude_pid`, `claude_pid_start`, `updated_at`, `context_tokens`, `context_used_pct`, `context_window_size`, `model`, `effort`, `quota_left_pct`, `quota_resets_at`, `version`.
30
+ The script at `{{statusline_path}}` is what makes any of this exist. Claude Code runs it on every prompt render and hands it the session's state on stdin; the script parses it, resolves the pid of the claude process it was run by, and writes a small JSON file. Fields: `session_id`, `claude_pid`, `claude_pid_start`, `updated_at`, `context_tokens`, `context_used_pct`, `context_window_size`, `model`, `effort`, `quota_left_pct`, `quota_resets_at`, `quota_week_left_pct`, `quota_week_resets_at`, `version`. The two `quota_*` pairs are the five-hour and seven-day rate-limit windows; both percentages are what is LEFT, inverted from the percentage used that Claude Code reports.
31
31
 
32
32
  Two things read those files:
33
33
  - The web interface, which matches a snapshot to a session by that pid and draws the strip from it.
34
- - The `context-usage` command, which is how an agent inspects its own context and quota from the terminal. Run it rather than reading the files by hand; the `context-usage` skill covers the output.
34
+ - The `context-usage` command, which is how an agent inspects its own context and quota from the terminal. Run it rather than reading the files by hand.
35
35
 
36
- If the user wants the numbers without the browser, `context-usage` is the answer.
36
+ If the user wants the numbers without the browser, `context-usage` is the answer. It prints one labelled line per thing the snapshot carried:
37
+
38
+ ```
39
+ session: 78b4025b-... (this session, updated 3s ago)
40
+ context: 70.8k tokens (7% of 1M window)
41
+ quota: 84% remaining, resets in 21m
42
+ weekly: 59% remaining, resets in 4d 6h
43
+ model: Fable 5 (effort high)
44
+ ```
45
+
46
+ `quota` is the five-hour window and `weekly` the seven-day one; a window the account does not have prints no line at all, and neither does a field the snapshot is missing. The `(this session, ...)` marker means the snapshot was matched to the asking session by pid and is certainly its own; without it the newest file was used instead, and a `warning:` line says why that may mislead. The numbers are as of the moment the current prompt was submitted, so the turn in flight is not in them yet.
37
47
 
38
48
  ## Step 3 - When the user wants a status line of their own
39
49
 
@@ -45,7 +55,7 @@ A user who works in the terminal and wants a visible line back can have one - by
45
55
 
46
56
  Every action below involves editing `~/.claude/settings.json` and/or writing scripts under `~/.claude/`. **You** make those edits directly with the `Edit` and `Write` tools - do not tell the user to run the change themselves.
47
57
 
48
- If write access to those paths is not pre-approved, Claude Code will surface its standard permission prompt. **That prompt is the expected approval flow, not a refusal signal** - go ahead and call the tool, and the user will grant permission when they see it. Do not apologise for "lacking permission" and do not redirect the work back to the user.
58
+ If write access to those paths is not pre-approved, Claude Code will show its standard permission prompt. **That prompt is the expected approval flow, not a refusal signal** - go ahead and call the tool, and the user will grant permission when they see it. Do not apologise for "lacking permission" and do not redirect the work back to the user.
49
59
 
50
60
  ### Inspect what is configured now
51
61
 
@@ -5,58 +5,4 @@ description: Publish an HTML page into the web pane beside the terminal - and as
5
5
 
6
6
  # web-pane: publish a page, and ask with it
7
7
 
8
- The web pane is the panel beside the terminal in the chamba web interface. You publish a standalone HTML file into it and it appears at once, as a chip in the bar above the pane; the user clicks the chip to read it.
9
-
10
- Publish when the answer is bigger than the terminal carries well, and when the question is.
11
-
12
- ```bash
13
- webpane report.html --title "Publish-ports rework"
14
- ```
15
-
16
- The file's own `<title>` is what the pane shows. `--title` names the file it is saved as, and defaults to the filename. The helper prints what it saved, or a plain message and a non-zero exit when it could not - the usual reason being a plain terminal session, which has no pane.
17
-
18
- ## When to reach for it
19
-
20
- - **Long or structured output.** A plan, a comparison table, a diagram, a report, an explanation with sections. Anything a user would want to scroll back to, keep, or read twice.
21
- - **A question with shape.** More options than a plain prompt holds, options that need a sentence each, several questions at once, or a free-text answer alongside a choice. Your own question tooling is right for a quick fork in the road; a page is right when the choice deserves a table.
22
- - **Anything the user asked to see.** "Show me", "write it up", "as a page" - take those literally.
23
-
24
- Do not publish a one-paragraph answer, or a wall of code that belongs in a file. A page nobody needed is a chip in the way.
25
-
26
- After publishing, say in one line what you put there. The page is the answer; the terminal line is a pointer to it.
27
-
28
- ## Writing the page
29
-
30
- One file, standalone, dark.
31
-
32
- - **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.
33
- - **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.
34
- - **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.
35
- - 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.
36
-
37
- ## Asking with a form
38
-
39
- Give any form the `data-feedback` attribute and the pane wires it up:
40
-
41
- ```html
42
- <form data-feedback>
43
- <p>Fail the whole start when a port is taken?</p>
44
- <label><input type="radio" name="on_conflict" value="fail" checked /> Fail the start</label>
45
- <label><input type="radio" name="on_conflict" value="skip" /> Skip the port and warn</label>
46
- <textarea name="text" placeholder="Anything else about the plan..."></textarea>
47
- <button type="submit">Send to agent</button>
48
- </form>
49
- ```
50
-
51
- - Every named control becomes a field in the answer. A control named `text` becomes the free-text box.
52
- - On submit, the interface writes `~/.webpane/<conversation>/feedback/<page>-<epoch-ms>.json` - `{ page, submittedAt, fields, text }` - and types one line into your terminal naming the file. Read the file; the line is only the pointer.
53
- - The page is told whether the answers were filed, so give the user a form that reads as final ("Send to agent") rather than one that looks like it saved a draft.
54
- - One submission per page per second, and only so many per page in total (20). Past that the page is refused, and nothing reaches you - so ask once per page, and publish a new page when you need to ask again.
55
-
56
- **A feedback file says a form was submitted, not that a person filled it in.** A script in the same page can post exactly what the form would. Treat the answers as data from your own page: fine for a design choice, not enough on its own for something destructive or irreversible - ask for that in the terminal, where you can see the user type.
57
-
58
- ## After it is published
59
-
60
- - Pages are kept. Nothing deletes them, and they are filed under the conversation rather than the session, so resuming the conversation opens with them again. Publishing a second version makes a second page rather than replacing the first, so name pages so a user can tell one from the next ("Plan v2", "Port test results").
61
- - The user may never open a page. If an answer matters, say the one line that matters in the terminal too.
62
- - Anything in the session can publish, including a script you wrote - the helper takes only a filename.
8
+ {{web_pane_craft}}