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.
- package/dist/commands/dev.js +11 -11
- package/dist/commands/onboard.js +2 -1
- package/dist/commands/settings.js +1 -1
- package/dist/lib/agent-context.js +10 -3
- package/dist/lib/chamba-yaml.js +1 -1
- package/dist/lib/constants.js +4 -4
- package/dist/lib/git-env.js +21 -0
- package/dist/lib/ports.js +4 -4
- package/dist/lib/shadows.js +2 -0
- package/dist/lib/webterm.js +7 -7
- package/package.json +1 -3
- package/templates/claude-statusline.sh +7 -4
- package/templates/context/web-pane-craft.md +57 -0
- package/templates/context-usage.sh +30 -13
- package/templates/skills/chamba-statusline/SKILL.md +16 -6
- package/templates/skills/web-pane/SKILL.md +1 -55
- package/templates/webterm/README.md +16 -13
- package/templates/webterm/artifacts.js +25 -12
- package/templates/webterm/config.js +9 -9
- package/templates/webterm/conversation.js +3 -3
- package/templates/webterm/pane.js +23 -4
- package/templates/webterm/proc.js +1 -1
- package/templates/webterm/public/app/alerts.js +5 -5
- package/templates/webterm/public/app/composer.js +25 -4
- package/templates/webterm/public/app/connection.js +2 -2
- package/templates/webterm/public/app/dictation.js +1 -1
- package/templates/webterm/public/app/dom.js +4 -0
- package/templates/webterm/public/app/frames.js +4 -2
- package/templates/webterm/public/app/main.js +6 -2
- package/templates/webterm/public/app/new-session.js +1 -1
- package/templates/webterm/public/app/pane-arrival.js +36 -0
- package/templates/webterm/public/app/pane.js +98 -13
- package/templates/webterm/public/app/state.js +1 -1
- package/templates/webterm/public/app/status-strip.js +26 -34
- package/templates/webterm/public/app/strip-format.js +36 -0
- package/templates/webterm/public/app/tabs.js +67 -5
- package/templates/webterm/public/index.html +23 -3
- package/templates/webterm/public/styles.css +159 -14
- package/templates/webterm/server.js +39 -13
- package/templates/webterm/sessions.js +7 -7
- package/templates/webterm/snapshot.js +6 -4
- package/templates/skills/context-usage/SKILL.md +0 -53
package/dist/commands/dev.js
CHANGED
|
@@ -116,7 +116,7 @@ export async function startContainer(opts) {
|
|
|
116
116
|
const stdio = quiet ? "pipe" : ["ignore", "ignore", "inherit"];
|
|
117
117
|
// --- Published ports fingerprint (static config, no host I/O here) ----------------------------------------------------------------------
|
|
118
118
|
// Ports are declared, not resolved, so the label is a pure function of the config. Host availability is
|
|
119
|
-
//
|
|
119
|
+
// checked just-in-time on the create path (assertHostPortsAvailable), never on resume/connect.
|
|
120
120
|
const currentPortsLabel = portsLabel(portMappings);
|
|
121
121
|
// --- Env fingerprint (inline vars + resolved file contents) -----------------------------------------------------------------------------
|
|
122
122
|
// Covers inline vars and the contents of every existing env file, so editing either recreates the container.
|
|
@@ -243,8 +243,8 @@ export async function startContainer(opts) {
|
|
|
243
243
|
outro("Failed to build container image.");
|
|
244
244
|
process.exit(buildResult.status);
|
|
245
245
|
}
|
|
246
|
-
// Minutes can pass between run()
|
|
247
|
-
// recreate confirm, the image build), so re-
|
|
246
|
+
// Minutes can pass between run() checking the web port and this create path (profile selection, a
|
|
247
|
+
// recreate confirm, the image build), so re-check it here. A port taken in the meantime drops the
|
|
248
248
|
// mapping and the session continues without the interface. Only chamba.yaml ports are
|
|
249
249
|
// allowed to fail a session, which is what assertHostPortsAvailable below is for.
|
|
250
250
|
let published = portMappings;
|
|
@@ -255,8 +255,8 @@ export async function startContainer(opts) {
|
|
|
255
255
|
published = portMappings.filter((m) => m.host !== webPort);
|
|
256
256
|
publishedWebPort = undefined;
|
|
257
257
|
}
|
|
258
|
-
// Ports are static config, so
|
|
259
|
-
// every path that reaches here, so we never
|
|
258
|
+
// Ports are static config, so check host availability up front. The old container was already removed on
|
|
259
|
+
// every path that reaches here, so we never check our own live port. A taken host port fails clearly and
|
|
260
260
|
// names the entry, before docker run - so no doomed `created` container is left behind on a clash.
|
|
261
261
|
try {
|
|
262
262
|
await assertHostPortsAvailable(published, publishedWebPort);
|
|
@@ -268,7 +268,7 @@ export async function startContainer(opts) {
|
|
|
268
268
|
}
|
|
269
269
|
if (!quiet)
|
|
270
270
|
log.info("Starting dev container...");
|
|
271
|
-
// Only set when the web port survived the re-
|
|
271
|
+
// Only set when the web port survived the re-check above: this gates the greeting's URL line and the
|
|
272
272
|
// webterm launcher. Built here rather than with the other runtime env so a dropped mapping cannot
|
|
273
273
|
// leave a URL behind that nothing is listening on.
|
|
274
274
|
// Never stale either way: the web mapping is part of the ports label written just below, so a moved or
|
|
@@ -298,7 +298,7 @@ export async function startContainer(opts) {
|
|
|
298
298
|
containerName,
|
|
299
299
|
...CONTAINER_KEEP_ALIVE,
|
|
300
300
|
];
|
|
301
|
-
// Capture stderr so the real docker error is re-emitted on failure. A single run - the pre-flight
|
|
301
|
+
// Capture stderr so the real docker error is re-emitted on failure. A single run - the pre-flight check
|
|
302
302
|
// above already rejected any taken host port, so there is no port-race retry loop.
|
|
303
303
|
const runStdio = quiet ? "pipe" : ["ignore", "ignore", "pipe"];
|
|
304
304
|
const runResult = spawnSync("docker", runArgs, { stdio: runStdio });
|
|
@@ -387,7 +387,7 @@ export async function run(packageDir, ctx, options) {
|
|
|
387
387
|
}
|
|
388
388
|
// --- Web agent interface port (sticky per-workspace assignment) ----------------------------------------------------------------------
|
|
389
389
|
// Resolved on every session: the interface has no off switch, it is where the agent runs.
|
|
390
|
-
// Pushing the mapping into portMappings is the whole wiring: publish args, the availability
|
|
390
|
+
// Pushing the mapping into portMappings is the whole wiring: publish args, the availability check,
|
|
391
391
|
// and the LABEL_PORTS recreate fingerprint all pick it up with no arg-builder changes.
|
|
392
392
|
// A session still opens without it, so every problem below drops it for this session and says why - it
|
|
393
393
|
// must never fail a session the way a chamba.yaml port does. The sticky assignment is left alone in all
|
|
@@ -467,7 +467,7 @@ export async function run(packageDir, ctx, options) {
|
|
|
467
467
|
...(options?.noCache !== undefined && { noCache: options.noCache }),
|
|
468
468
|
};
|
|
469
469
|
let startResult = await startContainer(containerOpts);
|
|
470
|
-
// The create path re-
|
|
470
|
+
// The create path re-checks the web port and drops it when something took it while the image built, so
|
|
471
471
|
// from here on the published port is the only one worth talking to.
|
|
472
472
|
webPort = startResult.webPort;
|
|
473
473
|
// --- Stale image check - prompt user to rebuild if image is outdated ------------------------------------------------------------------
|
|
@@ -496,7 +496,7 @@ export async function run(packageDir, ctx, options) {
|
|
|
496
496
|
// Ports are static config, so the notice derives straight from the mappings - no .lock lookup needed.
|
|
497
497
|
// The web mapping is skipped here: its URL is announced by the container greeting, where it is actionable.
|
|
498
498
|
// Matched on the container port, which validatePortsConfig reserves - so it can only ever be chamba's own
|
|
499
|
-
// web mapping, and it is skipped whether or not the port survived the create path's re-
|
|
499
|
+
// web mapping, and it is skipped whether or not the port survived the create path's re-check.
|
|
500
500
|
for (const m of portMappings) {
|
|
501
501
|
if (m.container === WEB_CONTAINER_PORT)
|
|
502
502
|
continue;
|
|
@@ -589,7 +589,7 @@ export async function run(packageDir, ctx, options) {
|
|
|
589
589
|
// is still offered, because those sessions may be finished ones nobody has closed yet.
|
|
590
590
|
if (containerSessionCount(containerName) === 0) {
|
|
591
591
|
// The counts are behind the interface's key, so it is read from the container first - fresh, because
|
|
592
|
-
// a new one is
|
|
592
|
+
// a new one is created at every server start and nothing on the host keeps one.
|
|
593
593
|
const webSessions = (webPort === null ? null : await webSessionInfo(webPort, readWebKey(containerName)))?.sessions ?? 0;
|
|
594
594
|
if (webSessions > 0) {
|
|
595
595
|
const count = webSessions === 1 ? "1 agent session is" : `${webSessions} agent sessions are`;
|
package/dist/commands/onboard.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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;
|
package/dist/lib/chamba-yaml.js
CHANGED
|
@@ -141,7 +141,7 @@ const KNOWN_KEYS = new Set(["workspace_id", "env", "shadow_paths", "profiles", "
|
|
|
141
141
|
/**
|
|
142
142
|
* Attempt to repair an invalid chamba.yaml on disk.
|
|
143
143
|
* Strips unknown fields, fills missing required/optional fields from defaults,
|
|
144
|
-
* and rewrites with
|
|
144
|
+
* and rewrites with standard formatting. Returns a result describing what happened.
|
|
145
145
|
*/
|
|
146
146
|
export function repairChambaYaml(dir) {
|
|
147
147
|
const filePath = join(dir, CHAMBA_YAML);
|
package/dist/lib/constants.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// =========================================================================================================================================
|
|
2
|
-
// src/lib/constants.ts -
|
|
2
|
+
// src/lib/constants.ts - Shared constants used across the chamba codebase
|
|
3
3
|
// =========================================================================================================================================
|
|
4
4
|
import { dirname } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
@@ -100,10 +100,10 @@ export const DEFAULT_WEB_AGENT = "claude";
|
|
|
100
100
|
// loopback-only to a sticky per-workspace host port taken from web_range (host-global setting).
|
|
101
101
|
export const WEB_CONTAINER_PORT = 3899;
|
|
102
102
|
export const WEB_RANGE_DEFAULT = "3900-3999";
|
|
103
|
-
// Where the webterm server publishes the key its URL carries (`/?k=<key>`). A new key is
|
|
103
|
+
// Where the webterm server publishes the key its URL carries (`/?k=<key>`). A new key is created every time
|
|
104
104
|
// that server starts and written here as soon as it has the port, so whoever prints the URL reads it back
|
|
105
105
|
// from this file rather than storing one: the container greeting, the `webterm` launcher, and chamba on the
|
|
106
|
-
// host before it
|
|
106
|
+
// host before it checks /status. Container-side path - the host only ever reads it through `docker exec`.
|
|
107
107
|
export const WEB_KEY_FILE_PATH = "/tmp/webterm.key";
|
|
108
108
|
// Where the container sees this workspace's webterm state dir, bind-mounted from the cache dir above. The
|
|
109
109
|
// one thing in it is the agent file: which agent new web sessions start with, written by the server when
|
|
@@ -116,7 +116,7 @@ export const WEBTERM_AGENT_FILE_PATH = `${CONTAINER_WEBTERM_STATE_DIR}/${WEBTERM
|
|
|
116
116
|
// conversation's pane and the feedback submitted on them. On the host it sits under the cache dir's
|
|
117
117
|
// agents/ dir, which is exactly what Advanced > Clear agent memory removes, so that menu item is the one
|
|
118
118
|
// thing that clears the pane. It is bind-mounted into the container, so the pages outlive the container
|
|
119
|
-
// and a resumed conversation finds its own history again. Nothing
|
|
119
|
+
// and a resumed conversation finds its own history again. Nothing clears it by age.
|
|
120
120
|
export const WEBPANE_DIR = "webpane";
|
|
121
121
|
export const CONTAINER_WEBPANE_DIR = `${CONTAINER_HOME}/.webpane`;
|
|
122
122
|
// Reopening the last conversation is the container's own job: templates/webterm/resume.js reads the agent
|
|
@@ -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
|
|
11
|
+
// The CLI runs as a non-root user, so the availability check (net bind) cannot bind ports below 1024.
|
|
12
12
|
export const PORT_MIN = 1024;
|
|
13
13
|
export const PORT_MAX = 65535;
|
|
14
|
-
// Publish and
|
|
14
|
+
// Publish and check on loopback only - never 0.0.0.0. Nothing on the LAN may reach these sessions.
|
|
15
15
|
export const PORT_LOOPBACK_HOST = "127.0.0.1";
|
|
16
16
|
// --- Config validation and normalization -------------------------------------------------------------------------------------------------
|
|
17
17
|
/** A single port number is a usable, unprivileged host/container port. */
|
|
@@ -127,7 +127,7 @@ export function parsePublishedPorts(psOutput) {
|
|
|
127
127
|
/**
|
|
128
128
|
* Host ports currently published by any running container. On native Linux with userland-proxy=false no host
|
|
129
129
|
* socket exists (DNAT), so canBind alone would wrongly report a published port as free - this covers that case.
|
|
130
|
-
* Returns an empty set when docker is unavailable (the bind
|
|
130
|
+
* Returns an empty set when docker is unavailable (the bind check still applies).
|
|
131
131
|
*/
|
|
132
132
|
export function dockerPublishedPorts() {
|
|
133
133
|
const result = spawnSync("docker", ["ps", "--format", "{{.Ports}}"], { encoding: "utf8", stdio: "pipe" });
|
|
@@ -151,7 +151,7 @@ export function containerPublishedPorts(containerName) {
|
|
|
151
151
|
}
|
|
152
152
|
/**
|
|
153
153
|
* Check that every mapped host port is free before we create the container. Called only on the create path - the
|
|
154
|
-
* old container has already been removed, so we never
|
|
154
|
+
* old container has already been removed, so we never check our own live port. Throws a clear error naming the taken
|
|
155
155
|
* host port and its entry on the first collision. Doing this here, rather than letting `docker run` fail, buys two
|
|
156
156
|
* things: the error names the offending entry (docker's raw message does not), and no doomed `created` container is
|
|
157
157
|
* left behind on a clash.
|
package/dist/lib/shadows.js
CHANGED
|
@@ -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: [] };
|
package/dist/lib/webterm.js
CHANGED
|
@@ -34,7 +34,7 @@ export function nextFreeWebPort(range, taken) {
|
|
|
34
34
|
* Return a workspace's sticky web port, assigning the lowest free port in the range when it has none.
|
|
35
35
|
* An already-assigned port is returned as-is even when it falls outside the range - stickiness wins;
|
|
36
36
|
* out-of-range ports move only when the user changes the range (reassignOutOfRangeWebPorts).
|
|
37
|
-
* This is pure over the .lock files: it never
|
|
37
|
+
* This is pure over the .lock files: it never checks the host, so it can say a port is assigned but not
|
|
38
38
|
* that it can be published. resolveWebPort adds that.
|
|
39
39
|
*/
|
|
40
40
|
export function ensureWebPort(workspaceId, range) {
|
|
@@ -54,7 +54,7 @@ export function ensureWebPort(workspaceId, range) {
|
|
|
54
54
|
* The host port this workspace's web interface should publish this session.
|
|
55
55
|
*
|
|
56
56
|
* `usable(port)` is the caller's test for "can this port carry the interface right now" - it covers both
|
|
57
|
-
* the host-side
|
|
57
|
+
* the host-side check and any host port the workspace already declares in chamba.yaml. When the sticky
|
|
58
58
|
* assignment fails that test the port MOVES and the new one is persisted: sticky means it stays where it
|
|
59
59
|
* was last put, not that it stays somewhere unusable and warns about the same conflict at every start.
|
|
60
60
|
* When nothing in the range qualifies the old assignment is left alone rather than cleared, so the
|
|
@@ -162,10 +162,10 @@ export async function webPortUsable(webPort, containerName) {
|
|
|
162
162
|
}
|
|
163
163
|
/**
|
|
164
164
|
* The key this container's web interface is currently demanding, read from the file its server publishes
|
|
165
|
-
* before binding the port. A new key is
|
|
165
|
+
* before binding the port. A new key is created at every server start and nothing on the host stores one,
|
|
166
166
|
* so it is always read fresh, right before it is used.
|
|
167
167
|
* Returns null when there is nothing to read: no container, no interface running, or a container built
|
|
168
|
-
* before the key existed. Callers then
|
|
168
|
+
* before the key existed. Callers then check without a key, which is exactly what such an older server
|
|
169
169
|
* (which ignores the query parameter) expects.
|
|
170
170
|
*/
|
|
171
171
|
export function readWebKey(containerName) {
|
|
@@ -175,12 +175,12 @@ export function readWebKey(containerName) {
|
|
|
175
175
|
const key = result.stdout?.toString().trim();
|
|
176
176
|
return key ? key : null;
|
|
177
177
|
}
|
|
178
|
-
// The shape the server
|
|
178
|
+
// The shape the server creates: 16 random bytes, hex. The key file is writable by every process in the
|
|
179
179
|
// container, so anything read back from it is checked against this before the host builds a URL out of it
|
|
180
180
|
// and hands that URL to a program. Argv-style spawning already keeps a strange value from being a command;
|
|
181
181
|
// this keeps it from being a URL at all.
|
|
182
182
|
const WEB_KEY_PATTERN = /^[0-9a-f]{32}$/;
|
|
183
|
-
/** Whether a key read back from the container has the shape the server
|
|
183
|
+
/** Whether a key read back from the container has the shape the server creates. */
|
|
184
184
|
export function isWebKey(key) {
|
|
185
185
|
return key !== null && WEB_KEY_PATTERN.test(key);
|
|
186
186
|
}
|
|
@@ -247,7 +247,7 @@ export async function setWebDefaultCwd(webPort, key, workdir) {
|
|
|
247
247
|
// The interface is on loopback in the same machine, so a slow answer means something is wrong, not far.
|
|
248
248
|
const WEB_STATUS_TIMEOUT_MS = 1500;
|
|
249
249
|
/**
|
|
250
|
-
* Whether the interface is serving at all. Any HTTP answer counts, including the 403 a keyless
|
|
250
|
+
* Whether the interface is serving at all. Any HTTP answer counts, including the 403 a keyless check gets:
|
|
251
251
|
* refusing a caller without a key is still proof that the server is up. Used where only liveness matters
|
|
252
252
|
* (did the launch work, is the interface still there), so those paths need no key and no docker exec.
|
|
253
253
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chamba",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.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
|
-
#
|
|
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
|
|
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
|
-
#
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
209
|
-
if [ "$
|
|
210
|
-
|
|
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
|
-
|
|
226
|
+
ql_reset_label=", resets now"
|
|
213
227
|
fi
|
|
214
228
|
;;
|
|
215
229
|
esac
|
|
216
|
-
printf '
|
|
217
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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}}
|