chamba 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +47 -44
- package/bin/chamba.js +212 -0
- package/dist/commands/advanced.js +278 -0
- package/dist/commands/dev.js +619 -0
- package/dist/commands/doctor.js +29 -0
- package/dist/commands/menu.js +80 -0
- package/dist/commands/onboard.js +229 -0
- package/dist/commands/settings.js +349 -0
- package/dist/lib/agent-context.js +177 -0
- package/dist/lib/browser.js +40 -0
- package/dist/lib/chamba-yaml.js +191 -0
- package/dist/lib/constants.js +135 -0
- package/dist/lib/dockerfile-builder.js +267 -0
- package/dist/lib/env.js +78 -0
- package/dist/lib/global-config.js +66 -0
- package/dist/lib/pnpm-store.js +19 -0
- package/dist/lib/ports.js +210 -0
- package/dist/lib/safe-rm.js +26 -0
- package/dist/lib/sessions.js +34 -0
- package/dist/lib/shadows.js +174 -0
- package/dist/lib/webterm.js +490 -0
- package/dist/lib/workspace-identity.js +260 -0
- package/package.json +61 -23
- package/schema/chamba.schema.json +65 -0
- package/templates/.dockerignore +3 -0
- package/templates/Dockerfile +173 -0
- package/templates/claude-statusline.sh +120 -0
- package/templates/context/baseline.md +13 -0
- package/templates/context/context-usage.md +1 -0
- package/templates/context/git-mode-local.md +1 -0
- package/templates/context/git-mode-strict.md +1 -0
- package/templates/context/git-mode-unrestricted.md +1 -0
- package/templates/context/git-unavailable.md +1 -0
- package/templates/context/shadow-paths.md +3 -0
- package/templates/context-usage.sh +249 -0
- package/templates/git-readonly-wrapper.mjs +309 -0
- package/templates/npmrc +2 -0
- package/templates/pnpm-config.yaml +9 -0
- package/templates/runtime-constants.mjs +18 -0
- package/templates/skills/chamba-statusline/SKILL.md +79 -0
- package/templates/skills/context-usage/SKILL.md +53 -0
- package/templates/skills/web-pane/SKILL.md +62 -0
- package/templates/startup-git-mode.mjs +145 -0
- package/templates/startup.mjs +333 -0
- package/templates/webpane.sh +126 -0
- package/templates/webterm/README.md +157 -0
- package/templates/webterm/artifacts.js +583 -0
- package/templates/webterm/config.js +269 -0
- package/templates/webterm/context/claude.md +14 -0
- package/templates/webterm/conversation.js +248 -0
- package/templates/webterm/package-lock.json +884 -0
- package/templates/webterm/package.json +17 -0
- package/templates/webterm/pane.js +156 -0
- package/templates/webterm/proc.js +89 -0
- package/templates/webterm/public/app/alerts.js +472 -0
- package/templates/webterm/public/app/cards.js +123 -0
- package/templates/webterm/public/app/clipboard.js +229 -0
- package/templates/webterm/public/app/composer.js +226 -0
- package/templates/webterm/public/app/connection.js +342 -0
- package/templates/webterm/public/app/dictation.js +98 -0
- package/templates/webterm/public/app/dom.js +37 -0
- package/templates/webterm/public/app/drafts.js +244 -0
- package/templates/webterm/public/app/frames.js +166 -0
- package/templates/webterm/public/app/main.js +82 -0
- package/templates/webterm/public/app/new-session.js +188 -0
- package/templates/webterm/public/app/note.js +24 -0
- package/templates/webterm/public/app/pane-frame.js +166 -0
- package/templates/webterm/public/app/pane.js +353 -0
- package/templates/webterm/public/app/state.js +51 -0
- package/templates/webterm/public/app/status-strip.js +170 -0
- package/templates/webterm/public/app/tabs.js +475 -0
- package/templates/webterm/public/app/terminal.js +102 -0
- package/templates/webterm/public/app/theme.js +46 -0
- package/templates/webterm/public/favicon.svg +21 -0
- package/templates/webterm/public/index.html +105 -0
- package/templates/webterm/public/styles.css +1193 -0
- package/templates/webterm/server.js +1142 -0
- package/templates/webterm/sessions.js +515 -0
- package/templates/webterm/snapshot.js +135 -0
- package/templates/webterm.sh +167 -0
- package/dist/cli.js +0 -1582
- package/dist/server.js +0 -1831
- package/inject/annotate.js +0 -18
- package/skill/README.md +0 -12
- package/skill/SKILL.md +0 -93
- package/web/assets/highlighted-body-OFNGDK62-Bn4Eu7CG.js +0 -1
- package/web/assets/index-B9DI4F1Z.js +0 -202
- package/web/assets/index-DK_n6CTo.css +0 -2
- package/web/assets/mermaid-GHXKKRXX-CEMduc-U.js +0 -1
- package/web/index.html +0 -28
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// config.js - Single place for the knobs the server reads at startup.
|
|
2
|
+
// Values fall back to sane defaults so `node server.js` just works inside the container.
|
|
3
|
+
// Nothing here is claude-specific: the relayed agent is whichever one `webterm <agent>` was given.
|
|
4
|
+
|
|
5
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
6
|
+
import { existsSync, statSync } from "node:fs";
|
|
7
|
+
import { basename, join, relative, resolve, sep } from "node:path";
|
|
8
|
+
|
|
9
|
+
// The server always binds this container port. chamba publishes it to a host port
|
|
10
|
+
// (127.0.0.1:HOST:3899); the host side may differ per workspace, this side never does.
|
|
11
|
+
export const PORT = Number(process.env.WEBTERM_PORT) || 3899;
|
|
12
|
+
|
|
13
|
+
// The agents this server will run. One server relays all of them - the agent is per session - so this is the
|
|
14
|
+
// list a session may be started with, and the same list the `webterm` launcher accepts. Nothing else may be
|
|
15
|
+
// spawned: the browser names an agent, and a name that is not in here is refused rather than run.
|
|
16
|
+
export const AGENTS = ["claude", "opencode", "codex"];
|
|
17
|
+
|
|
18
|
+
export function isKnownAgent(name) {
|
|
19
|
+
return typeof name === "string" && AGENTS.includes(name);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The agent a session gets when the browser does not name one, which is the one-click "+ New session". The
|
|
23
|
+
// `webterm` launcher sets it from the agent the user named, so the fallback only applies to a bare
|
|
24
|
+
// `node server.js`. It moves at runtime (POST /agent), so the server holds its own copy of this.
|
|
25
|
+
export const DEFAULT_AGENT = isKnownAgent(process.env.WEBTERM_AGENT) ? process.env.WEBTERM_AGENT : AGENTS[0];
|
|
26
|
+
|
|
27
|
+
// Extra arguments for the default agent, and only for it: they come from the same launcher run that named it,
|
|
28
|
+
// so they mean nothing to any other agent the browser might start.
|
|
29
|
+
export const AGENT_ARGS = (process.env.WEBTERM_AGENT_ARGS || "").split(/\s+/).filter(Boolean);
|
|
30
|
+
|
|
31
|
+
// Resume marker: a host-written file whose content is the full command that resumes the most
|
|
32
|
+
// recent conversation. The first session to consume it (rename-then-read, atomic) spawns that
|
|
33
|
+
// command instead of the default agent; later sessions start fresh. Empty means resume is disabled,
|
|
34
|
+
// so a standalone `node server.js` behaves exactly as before.
|
|
35
|
+
export const RESUME_MARKER = process.env.WEBTERM_RESUME_MARKER || "";
|
|
36
|
+
|
|
37
|
+
// One-line file naming the agent new sessions get, written once the port is bound and again whenever the
|
|
38
|
+
// default moves. The `webterm` launcher reads it so a second run can say what is live instead of just
|
|
39
|
+
// "already running": probing the port proves something is listening, never what it runs. Empty disables it.
|
|
40
|
+
// Its directory is bind-mounted from the host workspace cache, so the choice also survives this container:
|
|
41
|
+
// chamba reads the same file when it starts the interface at the next container start.
|
|
42
|
+
export const STATE_FILE = process.env.WEBTERM_STATE_FILE || "/home/devuser/.chamba-webterm/agent";
|
|
43
|
+
|
|
44
|
+
// The mounted workspace, and the only tree a session may be started in. Every directory the interface
|
|
45
|
+
// deals with is this one or something under it.
|
|
46
|
+
export const WORKSPACE_ROOT = "/workspace";
|
|
47
|
+
|
|
48
|
+
// This user's home in the container. Every agent store the pane reads, and the pane's own directory, hang
|
|
49
|
+
// off it. Guarded because a detached `docker exec` need not carry HOME.
|
|
50
|
+
export const HOME = process.env.HOME || "/home/devuser";
|
|
51
|
+
|
|
52
|
+
// --- The web pane ------------------------------------------------------------------------------------------------------------------------
|
|
53
|
+
//
|
|
54
|
+
// Pages published by the agents, one directory per conversation. Bind-mounted from the host workspace cache,
|
|
55
|
+
// which is what makes a page outlive the container and a resumed conversation find its own history.
|
|
56
|
+
|
|
57
|
+
export const PANE_DIR = process.env.WEBTERM_PANE_DIR || `${HOME}/.webpane`;
|
|
58
|
+
|
|
59
|
+
// Caps, enforced by the server rather than by the helper: a page is refused above the first, and a publish
|
|
60
|
+
// is refused once the whole directory has reached the second. Nothing is ever deleted to make room - a page
|
|
61
|
+
// the user has not read yet is not the server's to throw away - so reaching the cap is a message, not a
|
|
62
|
+
// cleanup. A page is a document, so a couple of megabytes is already generous; the total is the backstop.
|
|
63
|
+
export const MAX_PAGE_BYTES = Number(process.env.WEBTERM_MAX_PAGE_BYTES) || 2 * 1024 * 1024;
|
|
64
|
+
export const MAX_PANE_BYTES = Number(process.env.WEBTERM_MAX_PANE_BYTES) || 128 * 1024 * 1024;
|
|
65
|
+
|
|
66
|
+
// And a cap on how many files, not only how many bytes. Answers are small, so the byte cap alone would let a
|
|
67
|
+
// page that submits to itself put hundreds of thousands of them in the directory - and every write walks the
|
|
68
|
+
// whole tree to add the bytes up, so that walk is what would slow down. A personal pane holds tens of pages.
|
|
69
|
+
export const MAX_PANE_FILES = Number(process.env.WEBTERM_MAX_PANE_FILES) || 5_000;
|
|
70
|
+
|
|
71
|
+
// How often the pane directories are looked at again. This is what notices a file written by hand rather
|
|
72
|
+
// than through the helper, and it is also when a session that has no conversation id yet asks for one.
|
|
73
|
+
// Deliberately a poll and not a watch: the directory is a bind mount, and inotify does not cross one on
|
|
74
|
+
// every host.
|
|
75
|
+
export const PANE_SCAN_MS = Number(process.env.WEBTERM_PANE_SCAN_MS) || 1_500;
|
|
76
|
+
|
|
77
|
+
// --- The status strip --------------------------------------------------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
// How often a session's snapshot file is looked at again for the strip above the composer. A poll for the
|
|
80
|
+
// same reason the pane's is: the file is written into a bind mount by a shell script, and there is nothing to
|
|
81
|
+
// subscribe to. Claude Code rewrites it on every prompt render, so this is really "how quickly the strip
|
|
82
|
+
// catches up", and a frame goes out only when something in it actually changed.
|
|
83
|
+
export const STATUS_SCAN_MS = Number(process.env.WEBTERM_STATUS_SCAN_MS) || 2_000;
|
|
84
|
+
|
|
85
|
+
// What a feedback submission may be. The body cap is what the endpoint accepts at all; the rest bound what
|
|
86
|
+
// is written, so a page with a runaway form produces a file an agent can still read.
|
|
87
|
+
export const MAX_FEEDBACK_BYTES = 64 * 1024;
|
|
88
|
+
export const MAX_FEEDBACK_FIELDS = 40;
|
|
89
|
+
export const MAX_FEEDBACK_FIELD_LENGTH = 4_000;
|
|
90
|
+
export const MAX_FEEDBACK_TEXT_LENGTH = 8_000;
|
|
91
|
+
|
|
92
|
+
// Shortest gap between two submissions on the same page. A form is submitted by a person, so anything faster
|
|
93
|
+
// is a stuck button or a script; refusing it keeps one page from filling the directory with files.
|
|
94
|
+
export const FEEDBACK_MIN_INTERVAL_MS = Number(process.env.WEBTERM_FEEDBACK_MIN_INTERVAL_MS) || 1_000;
|
|
95
|
+
|
|
96
|
+
// How many answers one page may file at all. A person submits a form once, or a few times after changing
|
|
97
|
+
// their mind. Past that it is a script in the page submitting to itself, and each submission also types a
|
|
98
|
+
// line into the agent's terminal - so this, not the byte cap, is what bounds how often a page can drive an
|
|
99
|
+
// agent. The gap above only slows that down; this stops it.
|
|
100
|
+
export const MAX_FEEDBACK_PER_PAGE = Number(process.env.WEBTERM_MAX_FEEDBACK_PER_PAGE) || 20;
|
|
101
|
+
|
|
102
|
+
// Where each agent writes down which conversation it is having. All of them are chamba mounts, so they hold
|
|
103
|
+
// what earlier containers wrote too - which is exactly what makes a resumed conversation findable, and why
|
|
104
|
+
// the reads that use them check the process and the directory rather than trusting recency alone.
|
|
105
|
+
export const AGENT_STORES = {
|
|
106
|
+
claudeSnapshots: process.env.WEBTERM_CLAUDE_SNAPSHOT_DIR || `${HOME}/.claude/context-usage`,
|
|
107
|
+
claudeProjects: process.env.WEBTERM_CLAUDE_PROJECTS_DIR || `${HOME}/.claude/projects`,
|
|
108
|
+
codexSessions: process.env.WEBTERM_CODEX_SESSIONS_DIR || `${HOME}/.codex/sessions`,
|
|
109
|
+
opencodeSessions: process.env.WEBTERM_OPENCODE_SESSIONS_DIR || `${HOME}/.local/share/opencode/storage/session`,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** A container path as it reads to an agent: its own home written as `~`, so a nudge names a path it can use. */
|
|
113
|
+
export function homeLabel(absolute) {
|
|
114
|
+
return absolute.startsWith(`${HOME}/`) ? `~${absolute.slice(HOME.length)}` : absolute;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Where sessions start unless the browser asks for somewhere else. The host sets it to the directory
|
|
118
|
+
// `npx chamba` ran in, so a browser session opens where the terminal session would; a hand-run
|
|
119
|
+
// `webterm <agent>` passes its own $PWD. Read raw here and checked by the server, which is what turns a
|
|
120
|
+
// value from outside the workspace into a log line rather than a silent oddity.
|
|
121
|
+
export const WEBTERM_CWD_RAW = process.env.WEBTERM_CWD || "";
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The absolute directory a raw value names, or null when it names nothing usable. Empty means the
|
|
125
|
+
* workspace root, a relative value is read against it, and an absolute one has to already be inside it.
|
|
126
|
+
* `root` is a parameter so this can be unit-tested against a temp dir.
|
|
127
|
+
*
|
|
128
|
+
* This is a scoping rule, not a security boundary: the container is the boundary, and an agent can cd
|
|
129
|
+
* wherever it likes once it is running. What it buys is a picker that cannot quietly start a session
|
|
130
|
+
* outside the workspace, or in a path that does not exist.
|
|
131
|
+
*/
|
|
132
|
+
export function resolveWorkspacePath(raw, root = WORKSPACE_ROOT) {
|
|
133
|
+
const wanted = typeof raw === "string" ? raw.trim() : "";
|
|
134
|
+
// resolve() takes both shapes: an absolute value is kept, a relative one is read against the root.
|
|
135
|
+
const full = wanted === "" ? root : resolve(root, wanted);
|
|
136
|
+
if (full !== root && !full.startsWith(root + sep)) return null;
|
|
137
|
+
try {
|
|
138
|
+
if (!statSync(full).isDirectory()) return null;
|
|
139
|
+
} catch {
|
|
140
|
+
return null; // Gone, or not readable.
|
|
141
|
+
}
|
|
142
|
+
return full;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* How a directory travels to the browser: relative to the workspace root, with "" for the root itself.
|
|
147
|
+
* The one place that decides it, so the wire never carries an absolute container path and the client
|
|
148
|
+
* never has to know where the workspace is mounted.
|
|
149
|
+
*/
|
|
150
|
+
export function workspaceLabel(absolute, root = WORKSPACE_ROOT) {
|
|
151
|
+
return relative(root, absolute);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// The directory list the picker offers. A workspace can hold tens of thousands of directories, so the
|
|
155
|
+
// walk is bounded on every axis: how deep it goes, how many it returns, and what it never descends into.
|
|
156
|
+
// Depth 3 covers where work actually happens (apps/*/src, packages/*/lib) without listing a whole tree.
|
|
157
|
+
export const DIR_SCAN_DEPTH = 3;
|
|
158
|
+
export const DIR_SCAN_MAX = 400;
|
|
159
|
+
export const DIR_SCAN_SKIP = new Set(["node_modules"]);
|
|
160
|
+
|
|
161
|
+
// The workspace this container belongs to, shown in the session bar. Several workspaces can have their
|
|
162
|
+
// own interface open at once, each on its own port, so the page says which one you are looking at.
|
|
163
|
+
// Empty just leaves it out.
|
|
164
|
+
export const WORKSPACE = process.env.WEBTERM_WORKSPACE || "";
|
|
165
|
+
|
|
166
|
+
// A short note appended to claude's system prompt only when it is launched through the web interface,
|
|
167
|
+
// so it knows it is reached in a browser rather than a terminal. The whole webterm/ dir is baked into
|
|
168
|
+
// the image, so this resolves next to the server. WEBTERM_CONTEXT_FILE overrides it (used by tests).
|
|
169
|
+
export const CONTEXT_FILE = process.env.WEBTERM_CONTEXT_FILE || join(import.meta.dirname, "context", "claude.md");
|
|
170
|
+
|
|
171
|
+
// Build the argv a session's agent is spawned with. claude, and only claude, is told it is reached
|
|
172
|
+
// through the web interface: its CLI takes a per-launch system-prompt file, so the browser-awareness
|
|
173
|
+
// note rides on the spawn without touching the shared managed CLAUDE.md the terminal also reads. Every
|
|
174
|
+
// other agent (opencode, codex, a bare shell) is spawned verbatim - which is decided per session, since
|
|
175
|
+
// a bar can hold several agents at once. The flag is appended last, so it survives a --resume too. Lives
|
|
176
|
+
// here beside CONTEXT_FILE so it imports with no server side effects, which is what lets it be
|
|
177
|
+
// unit-tested without starting the server.
|
|
178
|
+
export function agentSpawnArgv(cmd, args) {
|
|
179
|
+
if (basename(cmd) === "claude" && CONTEXT_FILE && existsSync(CONTEXT_FILE)) {
|
|
180
|
+
return [...args, "--append-system-prompt-file", CONTEXT_FILE];
|
|
181
|
+
}
|
|
182
|
+
return args;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Where pasted/uploaded images land. Fixed to a container tmp dir on purpose:
|
|
186
|
+
// it is never the host, and the weekly cleanup check only ever touches paths under here.
|
|
187
|
+
export const UPLOAD_DIR = "/tmp/uploads";
|
|
188
|
+
|
|
189
|
+
// Reject uploads larger than this (bytes). Images only; big files are almost always a mistake.
|
|
190
|
+
export const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
|
191
|
+
|
|
192
|
+
// Age after which a file in UPLOAD_DIR is removed, and how often the cleanup check runs.
|
|
193
|
+
// Weekly cadence, age-based - deliberately not a minute-level timer.
|
|
194
|
+
export const UPLOAD_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
195
|
+
export const CHECK_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
196
|
+
|
|
197
|
+
// The key that the URL carries (`/?k=<key>`) and the relay demands. A fresh one is minted every time
|
|
198
|
+
// this server starts, so a key never outlives the process that issued it and nothing has to store it:
|
|
199
|
+
// whoever prints the URL reads it back from KEY_FILE. WEBTERM_KEY pins it (tests, hand-run debugging).
|
|
200
|
+
// There is deliberately no unauthenticated mode - without the gate the relay would drive an agent for
|
|
201
|
+
// any process that can reach the port, which loopback publishing alone does not prevent.
|
|
202
|
+
export const KEY = process.env.WEBTERM_KEY || randomBytes(16).toString("hex");
|
|
203
|
+
|
|
204
|
+
// Where the live key is published for the things that print the URL: the `webterm` launcher and the
|
|
205
|
+
// container greeting read it, and so does chamba on the host (docker exec cat) before probing /status.
|
|
206
|
+
// Written the moment the port is bound, so a server that never got the port leaves no key behind.
|
|
207
|
+
export const KEY_FILE = process.env.WEBTERM_KEY_FILE || "/tmp/webterm.key";
|
|
208
|
+
|
|
209
|
+
// Whether a presented key is the live one. Compared in constant time so a caller cannot learn the key
|
|
210
|
+
// one character at a time from how long the answer takes. Lives here, beside KEY and with no server
|
|
211
|
+
// side effects, which is what lets it be unit-tested without starting the server.
|
|
212
|
+
export function isAuthorized(candidate) {
|
|
213
|
+
if (typeof candidate !== "string") return false;
|
|
214
|
+
// Byte lengths, not string lengths: timingSafeEqual throws on a length mismatch, and a multi-byte
|
|
215
|
+
// character makes those two differ.
|
|
216
|
+
const presented = Buffer.from(candidate);
|
|
217
|
+
const live = Buffer.from(KEY);
|
|
218
|
+
if (presented.length !== live.length) return false;
|
|
219
|
+
return timingSafeEqual(presented, live);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// WebSocket Origin gate, checked alongside the key: a page from a non-loopback origin cannot open the
|
|
223
|
+
// relay even if it somehow holds a key. We allow any localhost / 127.0.0.1 origin regardless of port,
|
|
224
|
+
// because the published host port can differ from the container port (e.g. 3900:3899) so the browser's
|
|
225
|
+
// Origin varies per workspace. The publish is loopback-only, so "any local port" is the right
|
|
226
|
+
// granularity: it still rejects remote origins like http://evil.com.
|
|
227
|
+
export function isAllowedOrigin(origin) {
|
|
228
|
+
if (!origin) return false;
|
|
229
|
+
try {
|
|
230
|
+
const { hostname } = new URL(origin);
|
|
231
|
+
return hostname === "localhost" || hostname === "127.0.0.1";
|
|
232
|
+
} catch {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Bracketed-paste framing. Wrapping the composed message in these markers makes the
|
|
238
|
+
// TUI treat a multi-line message (text + image paths) as one paste, then Enter submits it once.
|
|
239
|
+
export const PASTE_START = "\x1b[200~";
|
|
240
|
+
export const PASTE_END = "\x1b[201~";
|
|
241
|
+
export const SUBMIT = "\r";
|
|
242
|
+
|
|
243
|
+
// Delay between delivering the paste and sending the submit Enter. When a message contains an
|
|
244
|
+
// image path, claude ingests the file asynchronously; an immediate Enter can land mid-ingest and
|
|
245
|
+
// be dropped (leaving the text un-submitted until a second Enter). A short gap lets the paste settle.
|
|
246
|
+
export const SUBMIT_DELAY_MS = Number(process.env.WEBTERM_SUBMIT_DELAY_MS) || 150;
|
|
247
|
+
|
|
248
|
+
// Stopping the container from the browser. The announce delay is how long the "going down" frame gets to
|
|
249
|
+
// reach every window before PID 1 is signalled - the container can die the instant it is, and a window
|
|
250
|
+
// that never heard would blame the network. The timeout is how long to wait before deciding the signal
|
|
251
|
+
// was ignored (a container started with a keep-alive that traps nothing), so the page can say so.
|
|
252
|
+
export const STOP_ANNOUNCE_MS = 150;
|
|
253
|
+
export const STOP_TIMEOUT_MS = 3_000;
|
|
254
|
+
|
|
255
|
+
// Cap on the replayed session buffer (bytes), per session. A session's PTY outlives every socket, so
|
|
256
|
+
// on attach we replay up to this much recent output and the window lands back in the live conversation.
|
|
257
|
+
// Older output beyond the cap is dropped from the replay (the agent's own history is unaffected).
|
|
258
|
+
export const MAX_OUTPUT_BUFFER = 1_000_000;
|
|
259
|
+
|
|
260
|
+
// How many sessions (live agent processes) may exist at once. Sessions are never closed for the user,
|
|
261
|
+
// so this is the backstop on memory: each live agent holds a few hundred MB in the container. Reaching
|
|
262
|
+
// it refuses the new session and says why, rather than quietly recycling a conversation.
|
|
263
|
+
export const MAX_SESSIONS = Number(process.env.WEBTERM_MAX_SESSIONS) || 8;
|
|
264
|
+
|
|
265
|
+
// WebSocket keepalive interval. A socket that stops answering pings is terminated, which releases the
|
|
266
|
+
// session it was driving. Without this a laptop that slept would hold its session "attached" until TCP
|
|
267
|
+
// gave up: the host would see a watcher that is not there, and the window that comes back would be
|
|
268
|
+
// told its own session is busy.
|
|
269
|
+
export const CLIENT_PING_INTERVAL_MS = Number(process.env.WEBTERM_PING_INTERVAL_MS) || 20_000;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# You are running in the chamba web interface
|
|
2
|
+
|
|
3
|
+
You are being reached through the chamba web interface: a browser front-end to this container, not a plain terminal.
|
|
4
|
+
A person is talking to you from a web page - one session tab in their browser - while your process runs inside the container exactly as it would in the terminal.
|
|
5
|
+
|
|
6
|
+
Everything else is unchanged: the same workspace, the same tools, the same container isolation.
|
|
7
|
+
Two things are yours here that a terminal does not have.
|
|
8
|
+
|
|
9
|
+
**The web pane.** Beside this terminal is a pane of pages, and `webpane <file.html> --title "..."` puts one there.
|
|
10
|
+
Use it for anything longer or more structured than a terminal reply carries well, and to ask anything with more options or structure than your question tooling holds - a page can carry a form, and the answers come back to you as a file plus a line in this terminal.
|
|
11
|
+
The `web-pane` skill is how to write one well; read it before your first page.
|
|
12
|
+
|
|
13
|
+
**The status strip.** Above the composer the user can already see this session's model, the context it is holding, and what is left of the quota, so there is no need to report any of it unasked.
|
|
14
|
+
Run `context-usage` when you want the same numbers yourself.
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// conversation.js - Which conversation a session is having, according to the agent itself.
|
|
2
|
+
//
|
|
3
|
+
// The web pane files a session's pages under the agent's own conversation id, and that is the whole reason
|
|
4
|
+
// this file exists. A key the server invented would be lost the moment a conversation came back by another
|
|
5
|
+
// route: an auto-resume after a container restart, a `claude --resume <id>` typed by hand, a codex session
|
|
6
|
+
// picked from its own list. The conversation id is the one name that survives all of them, so a resumed
|
|
7
|
+
// conversation walks back into its own pages without the server having witnessed anything.
|
|
8
|
+
//
|
|
9
|
+
// Every agent already writes that id down somewhere the container can read - chamba mounts those directories
|
|
10
|
+
// so the history outlives the container. Nothing here asks an agent anything; it reads what is on disk and
|
|
11
|
+
// matches it to the session by the two facts the server holds: the process tree and the directory.
|
|
12
|
+
//
|
|
13
|
+
// A miss is normal and cheap. An agent that has not written its first record yet has no id to find, so the
|
|
14
|
+
// answer is null, the pane keeps its provisional directory, and the next sweep asks again.
|
|
15
|
+
|
|
16
|
+
import { closeSync, openSync, readdirSync, readFileSync, readSync, statSync } from "node:fs";
|
|
17
|
+
import { basename, join } from "node:path";
|
|
18
|
+
import { snapshotFor } from "./snapshot.js";
|
|
19
|
+
|
|
20
|
+
// How much of a transcript is read looking for the directory it runs in. Every agent records that within its
|
|
21
|
+
// first few lines; reading further would mean paging in megabytes of conversation for one field.
|
|
22
|
+
const HEAD_BYTES = 64 * 1024;
|
|
23
|
+
|
|
24
|
+
// How many candidate files one lookup will consider, newest first. A long-lived workspace accumulates
|
|
25
|
+
// hundreds of conversations, and the one being looked for is always among the freshest.
|
|
26
|
+
const MAX_CANDIDATES = 40;
|
|
27
|
+
|
|
28
|
+
// The largest a snapshot or a session record can be and still be read whole. The files these stores write
|
|
29
|
+
// are a few hundred bytes; the cap is only there so an unrelated file that happens to be in the directory
|
|
30
|
+
// cannot be pulled into memory.
|
|
31
|
+
const MAX_JSON_BYTES = 1024 * 1024;
|
|
32
|
+
|
|
33
|
+
// How deep the walk goes under a store's root. Codex files sessions under a date tree
|
|
34
|
+
// (sessions/2026/08/23/), opencode under a project directory; nothing goes deeper than that.
|
|
35
|
+
const MAX_STORE_DEPTH = 5;
|
|
36
|
+
|
|
37
|
+
/** Files under `dir` matching `pattern`, newest first, capped. Depth-limited; unreadable dirs contribute nothing. */
|
|
38
|
+
function filesNewestFirst(dir, pattern, depth = MAX_STORE_DEPTH) {
|
|
39
|
+
const found = [];
|
|
40
|
+
const queue = [{ dir, depth }];
|
|
41
|
+
while (queue.length > 0) {
|
|
42
|
+
const current = queue.shift();
|
|
43
|
+
let entries;
|
|
44
|
+
try {
|
|
45
|
+
entries = readdirSync(current.dir, { withFileTypes: true });
|
|
46
|
+
} catch {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
const full = join(current.dir, entry.name);
|
|
51
|
+
// Symlinks are skipped rather than followed: these stores are the agents' own, and a link in one
|
|
52
|
+
// of them would only ever lead somewhere this lookup has no business reading.
|
|
53
|
+
if (entry.isDirectory() && current.depth > 0) {
|
|
54
|
+
queue.push({ dir: full, depth: current.depth - 1 });
|
|
55
|
+
} else if (entry.isFile() && pattern.test(entry.name)) {
|
|
56
|
+
try {
|
|
57
|
+
found.push({ path: full, mtime: statSync(full).mtimeMs });
|
|
58
|
+
} catch {
|
|
59
|
+
// Vanished between the listing and the stat.
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return found
|
|
65
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
66
|
+
.slice(0, MAX_CANDIDATES)
|
|
67
|
+
.map((entry) => entry.path);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The head of a file as text, or "" when it cannot be read.
|
|
72
|
+
*
|
|
73
|
+
* Bounded at the read, not after it: a live transcript is tens of megabytes and there may be forty of them
|
|
74
|
+
* to look at, on a timer, until an id is found. Reading them whole would stall every session in the
|
|
75
|
+
* container for the sake of a field in the first few lines.
|
|
76
|
+
*/
|
|
77
|
+
function readHead(path) {
|
|
78
|
+
let fd = null;
|
|
79
|
+
try {
|
|
80
|
+
fd = openSync(path, "r");
|
|
81
|
+
const buffer = Buffer.alloc(HEAD_BYTES);
|
|
82
|
+
const read = readSync(fd, buffer, 0, HEAD_BYTES, 0);
|
|
83
|
+
return buffer.subarray(0, read).toString("utf8");
|
|
84
|
+
} catch {
|
|
85
|
+
return "";
|
|
86
|
+
} finally {
|
|
87
|
+
if (fd !== null) closeSync(fd);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A whole JSON file as an object, or null. Whole, because a record cut in half parses as nothing - so the
|
|
93
|
+
* size is checked first instead, and anything that is not the small record these stores write is not one.
|
|
94
|
+
*/
|
|
95
|
+
function readJson(path) {
|
|
96
|
+
try {
|
|
97
|
+
if (statSync(path).size > MAX_JSON_BYTES) return null;
|
|
98
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
99
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** When a file last changed, or 0 when it is gone. */
|
|
106
|
+
function mtimeOf(path) {
|
|
107
|
+
try {
|
|
108
|
+
return statSync(path).mtimeMs;
|
|
109
|
+
} catch {
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The directory a JSONL transcript says it runs in, read from its first records: the first `cwd` (or
|
|
116
|
+
* `directory`) string any of them carries. Returns null when none of them says - transcripts written before
|
|
117
|
+
* the agent recorded it, and the one case where the directory cannot be used to tell conversations apart.
|
|
118
|
+
*/
|
|
119
|
+
function recordedCwd(path) {
|
|
120
|
+
for (const line of readHead(path).split("\n")) {
|
|
121
|
+
if (!line.includes('"cwd"') && !line.includes('"directory"')) continue;
|
|
122
|
+
let record;
|
|
123
|
+
try {
|
|
124
|
+
record = JSON.parse(line);
|
|
125
|
+
} catch {
|
|
126
|
+
continue; // A line cut in half by the read window, or one that is not JSON at all.
|
|
127
|
+
}
|
|
128
|
+
const found = firstPathField(record);
|
|
129
|
+
if (found !== null) return found;
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// The first `cwd`/`directory` string in a record, looked for one level into the record's own objects too:
|
|
135
|
+
// codex wraps its fields in a payload, and which wrapper is in fashion is not something to hard-code.
|
|
136
|
+
function firstPathField(record) {
|
|
137
|
+
if (!record || typeof record !== "object") return null;
|
|
138
|
+
for (const key of ["cwd", "directory"]) {
|
|
139
|
+
if (typeof record[key] === "string" && record[key] !== "") return record[key];
|
|
140
|
+
}
|
|
141
|
+
for (const value of Object.values(record)) {
|
|
142
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) continue;
|
|
143
|
+
for (const key of ["cwd", "directory"]) {
|
|
144
|
+
if (typeof value[key] === "string" && value[key] !== "") return value[key];
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* True when a conversation file may belong to this session: it changed since the session started, and it
|
|
152
|
+
* either records this session's directory or records none at all.
|
|
153
|
+
*
|
|
154
|
+
* The time check is what keeps a session from adopting a conversation it never had - yesterday's transcript
|
|
155
|
+
* in the same directory is not this session's, however recently it was the newest. A resumed conversation
|
|
156
|
+
* passes it: resuming writes to the transcript, which is what makes it recent again.
|
|
157
|
+
*/
|
|
158
|
+
function couldBelong(path, cwd, since) {
|
|
159
|
+
if (mtimeOf(path) < since) return false;
|
|
160
|
+
const recorded = recordedCwd(path);
|
|
161
|
+
return recorded === null || recorded === cwd;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// --- Claude ------------------------------------------------------------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
const CLAUDE_TRANSCRIPT = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jsonl$/;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Claude's conversation id for a session, by two routes.
|
|
170
|
+
*
|
|
171
|
+
* The first is the status line's own snapshot: it names the conversation and the claude process that wrote
|
|
172
|
+
* it, so a snapshot that belongs to this session (snapshot.js decides that, and the status strip asks the
|
|
173
|
+
* same question the same way) names this session's conversation, whatever else is running. That is an
|
|
174
|
+
* identity rather than a guess.
|
|
175
|
+
*
|
|
176
|
+
* The second is for the minutes before the status line has rendered: the newest transcript that recorded
|
|
177
|
+
* this session's directory. Weaker (two sessions in one directory could both point at it) but bounded by the
|
|
178
|
+
* session's own start time, and replaced by the first route as soon as a snapshot appears.
|
|
179
|
+
*/
|
|
180
|
+
export function claudeConversationId(session, stores) {
|
|
181
|
+
const id = snapshotFor(session, stores.claudeSnapshots)?.session_id;
|
|
182
|
+
if (typeof id === "string" && id !== "") return id;
|
|
183
|
+
for (const path of filesNewestFirst(stores.claudeProjects, CLAUDE_TRANSCRIPT)) {
|
|
184
|
+
if (couldBelong(path, session.cwd, session.since)) return basename(path).slice(0, -".jsonl".length);
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// --- Codex -------------------------------------------------------------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
// rollout-<timestamp>-<uuid>.jsonl - the uuid is the conversation id codex resumes by.
|
|
192
|
+
const CODEX_ROLLOUT = /^rollout-.*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/;
|
|
193
|
+
|
|
194
|
+
/** Codex's conversation id for a session: the newest rollout file that could be this session's. */
|
|
195
|
+
export function codexConversationId(session, stores) {
|
|
196
|
+
for (const path of filesNewestFirst(stores.codexSessions, CODEX_ROLLOUT)) {
|
|
197
|
+
if (!couldBelong(path, session.cwd, session.since)) continue;
|
|
198
|
+
const match = CODEX_ROLLOUT.exec(basename(path));
|
|
199
|
+
if (match) return match[1];
|
|
200
|
+
}
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// --- OpenCode ----------------------------------------------------------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* OpenCode's conversation id for a session: the newest session record in its local store that could be this
|
|
208
|
+
* session's. The id comes from the record when it carries one and from the filename otherwise, which is the
|
|
209
|
+
* same value - opencode names the file after the session.
|
|
210
|
+
*/
|
|
211
|
+
export function opencodeConversationId(session, stores) {
|
|
212
|
+
for (const path of filesNewestFirst(stores.opencodeSessions, /\.json$/)) {
|
|
213
|
+
if (mtimeOf(path) < session.since) continue;
|
|
214
|
+
const record = readJson(path);
|
|
215
|
+
const recorded = firstPathField(record);
|
|
216
|
+
if (recorded !== null && recorded !== session.cwd) continue;
|
|
217
|
+
const id = typeof record?.id === "string" && record.id !== "" ? record.id : basename(path, ".json");
|
|
218
|
+
if (id !== "") return id;
|
|
219
|
+
}
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// --- The one entry point -----------------------------------------------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
const BY_AGENT = {
|
|
226
|
+
claude: claudeConversationId,
|
|
227
|
+
codex: codexConversationId,
|
|
228
|
+
opencode: opencodeConversationId,
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The conversation id for a session, or null when there is nothing to find yet.
|
|
233
|
+
*
|
|
234
|
+
* `session` is `{ agent, cwd, pid, since }`: the agent decides which store is read, the directory and the
|
|
235
|
+
* start time bound what may match, and the pid is the session's PTY leader - the root of the process tree
|
|
236
|
+
* the agent runs in. An agent nobody wrote a reader for (a plain shell, something new) has no id, which is a
|
|
237
|
+
* supported answer rather than an error: those sessions keep a provisional key for their whole life.
|
|
238
|
+
*/
|
|
239
|
+
export function conversationId(session, stores) {
|
|
240
|
+
const lookup = BY_AGENT[session.agent];
|
|
241
|
+
if (!lookup) return null;
|
|
242
|
+
try {
|
|
243
|
+
return lookup(session, stores);
|
|
244
|
+
} catch {
|
|
245
|
+
// A store that changed shape under us must never take the session down with it.
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
}
|