chamba 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +47 -44
  3. package/bin/chamba.js +212 -0
  4. package/dist/commands/advanced.js +278 -0
  5. package/dist/commands/dev.js +612 -0
  6. package/dist/commands/doctor.js +29 -0
  7. package/dist/commands/menu.js +80 -0
  8. package/dist/commands/onboard.js +229 -0
  9. package/dist/commands/settings.js +349 -0
  10. package/dist/lib/agent-context.js +177 -0
  11. package/dist/lib/browser.js +40 -0
  12. package/dist/lib/chamba-yaml.js +191 -0
  13. package/dist/lib/constants.js +125 -0
  14. package/dist/lib/dockerfile-builder.js +267 -0
  15. package/dist/lib/env.js +78 -0
  16. package/dist/lib/global-config.js +66 -0
  17. package/dist/lib/pnpm-store.js +19 -0
  18. package/dist/lib/ports.js +210 -0
  19. package/dist/lib/safe-rm.js +26 -0
  20. package/dist/lib/sessions.js +34 -0
  21. package/dist/lib/shadows.js +174 -0
  22. package/dist/lib/webterm.js +305 -0
  23. package/dist/lib/workspace-identity.js +260 -0
  24. package/package.json +61 -23
  25. package/schema/chamba.schema.json +65 -0
  26. package/templates/.dockerignore +3 -0
  27. package/templates/Dockerfile +173 -0
  28. package/templates/claude-statusline.sh +120 -0
  29. package/templates/context/baseline.md +13 -0
  30. package/templates/context/context-usage.md +1 -0
  31. package/templates/context/git-mode-local.md +1 -0
  32. package/templates/context/git-mode-strict.md +1 -0
  33. package/templates/context/git-mode-unrestricted.md +1 -0
  34. package/templates/context/git-unavailable.md +1 -0
  35. package/templates/context/shadow-paths.md +3 -0
  36. package/templates/context-usage.sh +249 -0
  37. package/templates/git-readonly-wrapper.mjs +309 -0
  38. package/templates/npmrc +2 -0
  39. package/templates/pnpm-config.yaml +9 -0
  40. package/templates/runtime-constants.mjs +18 -0
  41. package/templates/skills/chamba-statusline/SKILL.md +79 -0
  42. package/templates/skills/context-usage/SKILL.md +53 -0
  43. package/templates/skills/web-pane/SKILL.md +62 -0
  44. package/templates/startup-git-mode.mjs +145 -0
  45. package/templates/startup.mjs +333 -0
  46. package/templates/webpane.sh +126 -0
  47. package/templates/webterm/README.md +160 -0
  48. package/templates/webterm/artifacts.js +583 -0
  49. package/templates/webterm/config.js +270 -0
  50. package/templates/webterm/context/claude.md +14 -0
  51. package/templates/webterm/conversation.js +253 -0
  52. package/templates/webterm/package-lock.json +884 -0
  53. package/templates/webterm/package.json +17 -0
  54. package/templates/webterm/pane.js +156 -0
  55. package/templates/webterm/proc.js +89 -0
  56. package/templates/webterm/public/app/alerts.js +472 -0
  57. package/templates/webterm/public/app/cards.js +123 -0
  58. package/templates/webterm/public/app/clipboard.js +229 -0
  59. package/templates/webterm/public/app/composer.js +226 -0
  60. package/templates/webterm/public/app/connection.js +342 -0
  61. package/templates/webterm/public/app/dictation.js +98 -0
  62. package/templates/webterm/public/app/dom.js +37 -0
  63. package/templates/webterm/public/app/drafts.js +244 -0
  64. package/templates/webterm/public/app/frames.js +166 -0
  65. package/templates/webterm/public/app/main.js +82 -0
  66. package/templates/webterm/public/app/new-session.js +188 -0
  67. package/templates/webterm/public/app/note.js +24 -0
  68. package/templates/webterm/public/app/pane-frame.js +166 -0
  69. package/templates/webterm/public/app/pane.js +353 -0
  70. package/templates/webterm/public/app/state.js +51 -0
  71. package/templates/webterm/public/app/status-strip.js +170 -0
  72. package/templates/webterm/public/app/tabs.js +475 -0
  73. package/templates/webterm/public/app/terminal.js +102 -0
  74. package/templates/webterm/public/app/theme.js +46 -0
  75. package/templates/webterm/public/favicon.svg +21 -0
  76. package/templates/webterm/public/index.html +105 -0
  77. package/templates/webterm/public/styles.css +1193 -0
  78. package/templates/webterm/resume.js +145 -0
  79. package/templates/webterm/server.js +1181 -0
  80. package/templates/webterm/sessions.js +515 -0
  81. package/templates/webterm/snapshot.js +135 -0
  82. package/templates/webterm.sh +164 -0
  83. package/dist/cli.js +0 -1582
  84. package/dist/server.js +0 -1831
  85. package/inject/annotate.js +0 -18
  86. package/skill/README.md +0 -12
  87. package/skill/SKILL.md +0 -93
  88. package/web/assets/highlighted-body-OFNGDK62-Bn4Eu7CG.js +0 -1
  89. package/web/assets/index-B9DI4F1Z.js +0 -202
  90. package/web/assets/index-DK_n6CTo.css +0 -2
  91. package/web/assets/mermaid-GHXKKRXX-CEMduc-U.js +0 -1
  92. package/web/index.html +0 -28
@@ -0,0 +1,26 @@
1
+ // =========================================================================================================================================
2
+ // src/lib/safe-rm.ts - Safe wrapper around rmSync
3
+ // All deletions in chamba go through safeRmSync. A biome lint rule (noRestrictedImports) bans
4
+ // importing rmSync from node:fs everywhere else, enforcing this as the single deletion point.
5
+ // =========================================================================================================================================
6
+ // biome-ignore lint/style/noRestrictedImports: safeRmSync is the only approved user of rmSync
7
+ import { rmSync } from "node:fs";
8
+ import { homedir, tmpdir } from "node:os";
9
+ import { basename, join, resolve, sep } from "node:path";
10
+ import { CHAMBA_DIR, CHAMBA_YAML, CONTAINER_NAME_PREFIX } from "./constants.js";
11
+ const CHAMBA_HOME = resolve(join(homedir(), CHAMBA_DIR));
12
+ const TEST_TMP_PREFIX = join(tmpdir(), `${CONTAINER_NAME_PREFIX}test-`);
13
+ /**
14
+ * Safe wrapper around rmSync. Throws if the path is outside a chamba-owned location:
15
+ * - ~/.chamba/ (workspace caches, agents, shadows, global config)
16
+ * - A file named chamba.yaml (workspace config file in any user workspace root)
17
+ * - <tmpdir>/chamba-test-* (test temp directories)
18
+ */
19
+ export function safeRmSync(path, options) {
20
+ const r = resolve(path);
21
+ const ok = r === CHAMBA_HOME || r.startsWith(CHAMBA_HOME + sep) || basename(r) === CHAMBA_YAML || r.startsWith(TEST_TMP_PREFIX);
22
+ if (!ok) {
23
+ throw new Error(`safeRmSync: refusing to delete '${r}' - must be under ~/.chamba/, named chamba.yaml, or a test temp dir`);
24
+ }
25
+ rmSync(r, options);
26
+ }
@@ -0,0 +1,34 @@
1
+ // =========================================================================================================================================
2
+ // src/lib/sessions.ts - Host-side detection of live interactive container sessions.
3
+ // dev.ts connects each session with `docker exec -it ... <container> bash --login`. The host-side exec
4
+ // client process is the proxy for one live session: it dies on clean exit, closed terminal, or kill, but
5
+ // survives a sleep-freeze. Orphaned in-container shells (host side gone, PTY left open by dockerd) have no
6
+ // host client and are correctly ignored - which is why counting on the host, not inside the container, is
7
+ // the reliable signal for "last session closed".
8
+ // =========================================================================================================================================
9
+ import { spawnSync } from "node:child_process";
10
+ // The interactive session shell, as argv. Single source of truth: both the connect command and the
11
+ // detector's match needle derive from this, so they cannot drift apart.
12
+ export const CONTAINER_LOGIN_SHELL = ["bash", "--login"];
13
+ // --- Connect -----------------------------------------------------------------------------------------------------------------------------
14
+ // Build the `docker exec` argv that opens an interactive session in `containerName` at `workdir`.
15
+ // dev.ts spawns exactly this; sessionMatchNeedle matches against it, so detection stays in lockstep.
16
+ export function loginShellExecArgs(workdir, containerName) {
17
+ return ["exec", "-it", "-w", workdir, containerName, ...CONTAINER_LOGIN_SHELL];
18
+ }
19
+ // --- Detection ---------------------------------------------------------------------------------------------------------------------------
20
+ // The substring identifying one container's session client in a host `pgrep -f` scan: the container name
21
+ // followed by the login shell args (e.g. "chamba-orot-io bash --login"). Specific enough to exclude other
22
+ // `docker exec <c> ...` shapes. Container ids are kebab-case (schema-constrained), so there are no regex
23
+ // metacharacters to escape.
24
+ export function sessionMatchNeedle(containerName) {
25
+ return `${containerName} ${CONTAINER_LOGIN_SHELL.join(" ")}`;
26
+ }
27
+ // Count live interactive sessions to a single container by scanning HOST processes for the exec client.
28
+ // `pgrep -f` (portable across macOS/BSD and Linux; `-c` is Linux-only) excludes its own PID. A missing or
29
+ // errored pgrep yields no stdout -> 0, preserving the old "errored -> 0" safety. At a clean exit the just-
30
+ // left client is already reaped before this runs, so the last exit reaches 0.
31
+ export function containerSessionCount(containerName) {
32
+ const r = spawnSync("pgrep", ["-f", sessionMatchNeedle(containerName)], { encoding: "utf8", stdio: "pipe" });
33
+ return (r.stdout ?? "").split("\n").filter(Boolean).length;
34
+ }
@@ -0,0 +1,174 @@
1
+ // =========================================================================================================================================
2
+ // src/lib/shadows.ts - Gitignore-style shadow path expansion and sync
3
+ // Expands patterns like "node_modules", ".env*" into concrete paths, then syncs shadow directories.
4
+ // =========================================================================================================================================
5
+ import { spawnSync } from "node:child_process";
6
+ import { existsSync, lstatSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
7
+ import { dirname, join, relative } from "node:path";
8
+ import fg from "fast-glob";
9
+ import { CONTAINER_WORKSPACE, SHADOWS_DIR } from "./constants.js";
10
+ import { safeRmSync } from "./safe-rm.js";
11
+ /**
12
+ * Expand gitignore-style patterns into concrete relative paths.
13
+ *
14
+ * Patterns without a directory separator are treated as recursive (prepended with **&#47;)
15
+ * following gitignore convention. Patterns with a / are matched relative to the workspace root.
16
+ * Matched directories are not recursed into (e.g. node_modules matches once, not its children).
17
+ *
18
+ * Paths that git tracks (or whose descendants git tracks) are dropped from the result and
19
+ * reported in skippedTracked. Shadowing tracked content is a no-op (agents can `git show` it)
20
+ * and breaks `git stash`/`pop` workflows.
21
+ */
22
+ export function expandShadowPatterns(patterns, workspaceRoot) {
23
+ if (patterns.length === 0)
24
+ return { paths: [], skippedTracked: [] };
25
+ // Convert gitignore-style patterns to fast-glob patterns
26
+ const globPatterns = patterns.map((p) => (p.includes("/") ? p : `**/${p}`));
27
+ // Build ignore list: skip .git and contents of any matched directory
28
+ const ignorePatterns = ["**/.git", ...globPatterns.map((p) => `${p}/**/*`)];
29
+ const results = fg.sync(globPatterns, {
30
+ cwd: workspaceRoot,
31
+ onlyFiles: false,
32
+ dot: true,
33
+ ignore: ignorePatterns,
34
+ });
35
+ const expanded = removeNestedPaths(results.sort());
36
+ const { kept, dropped } = filterGitTrackedPaths(expanded, workspaceRoot);
37
+ return { paths: kept, skippedTracked: dropped };
38
+ }
39
+ // --- Hit counting (for menu UX) ----------------------------------------------------------------------------------------------------------
40
+ /** Count how many paths a pattern would match in the workspace (post git-tracked filter). */
41
+ export function countPatternHits(pattern, workspaceRoot) {
42
+ return expandShadowPatterns([pattern], workspaceRoot).paths.length;
43
+ }
44
+ // --- Git-tracked filtering ---------------------------------------------------------------------------------------------------------------
45
+ /**
46
+ * Partition expanded shadow paths into those safe to shadow and those tracked by git.
47
+ * A path is dropped if it equals a tracked file, or (for directories) any tracked file
48
+ * lives anywhere beneath it. Returns the input unchanged when no .git is present.
49
+ */
50
+ function filterGitTrackedPaths(paths, workspaceRoot) {
51
+ if (paths.length === 0)
52
+ return { kept: [], dropped: [] };
53
+ // .git may be a file (worktrees) or a directory; existsSync handles both
54
+ if (!existsSync(join(workspaceRoot, ".git")))
55
+ return { kept: paths, dropped: [] };
56
+ // Pass shadow paths as pathspecs so git enumerates only tracked files within them.
57
+ // Output then scales with shadow scope (typically tiny), not the size of the repo index.
58
+ const result = spawnSync("git", ["-C", workspaceRoot, "ls-files", "-z", "--", ...paths], {
59
+ encoding: "utf8",
60
+ stdio: ["ignore", "pipe", "pipe"],
61
+ });
62
+ if (result.status !== 0)
63
+ return { kept: paths, dropped: [] };
64
+ const matched = result.stdout.split("\0").filter((s) => s.length > 0);
65
+ if (matched.length === 0)
66
+ return { kept: paths, dropped: [] };
67
+ const matchedSet = new Set(matched);
68
+ const kept = [];
69
+ const dropped = [];
70
+ for (const p of paths) {
71
+ const prefix = `${p}/`;
72
+ if (matchedSet.has(p) || matched.some((f) => f.startsWith(prefix))) {
73
+ dropped.push(p);
74
+ }
75
+ else {
76
+ kept.push(p);
77
+ }
78
+ }
79
+ return { kept, dropped };
80
+ }
81
+ // --- Shadow sync -------------------------------------------------------------------------------------------------------------------------
82
+ /**
83
+ * Ensures the shadows/ directory matches the given expanded paths.
84
+ * - Creates missing shadow entries (empty dir or empty file, matching source path type)
85
+ * - If `freshPaths` is provided, those entries are deleted and recreated (clean slate)
86
+ * - Removes shadow entries not in the expanded set
87
+ * - Cleans up empty parent directories
88
+ */
89
+ export function ensureShadowsInSync(workspaceDir, expandedPaths, workspaceRoot, freshPaths) {
90
+ const expected = new Set(expandedPaths);
91
+ const shadowsDir = join(workspaceDir, SHADOWS_DIR);
92
+ // Create shadows/ root if needed
93
+ mkdirSync(shadowsDir, { recursive: true });
94
+ // Remove stale entries
95
+ removeStaleEntries(shadowsDir, shadowsDir, expected);
96
+ // Create or refresh expected entries
97
+ for (const relPath of expected) {
98
+ const sourcePath = join(workspaceRoot, relPath);
99
+ const shadowPath = join(shadowsDir, relPath);
100
+ if (freshPaths?.has(relPath) && existsSync(shadowPath)) {
101
+ safeRmSync(shadowPath, { recursive: true, force: true });
102
+ }
103
+ if (!existsSync(shadowPath)) {
104
+ createShadowEntry(sourcePath, shadowPath);
105
+ }
106
+ }
107
+ }
108
+ // --- Mount args --------------------------------------------------------------------------------------------------------------------------
109
+ /** Build -v args for shadow mounts from expanded paths. */
110
+ export function buildShadowMountArgs(workspaceDir, expandedPaths) {
111
+ const args = [];
112
+ for (const relPath of expandedPaths) {
113
+ args.push("-v", `${join(workspaceDir, SHADOWS_DIR, relPath)}:${CONTAINER_WORKSPACE}/${relPath}`);
114
+ }
115
+ return args;
116
+ }
117
+ // --- Helpers -----------------------------------------------------------------------------------------------------------------------------
118
+ function createShadowEntry(sourcePath, shadowPath) {
119
+ if (existsSync(sourcePath) && !lstatSync(sourcePath).isDirectory()) {
120
+ mkdirSync(dirname(shadowPath), { recursive: true });
121
+ writeFileSync(shadowPath, "");
122
+ }
123
+ else {
124
+ mkdirSync(shadowPath, { recursive: true });
125
+ }
126
+ }
127
+ /**
128
+ * Walk the shadows/ tree and remove any entry whose relative path is not in the expected set.
129
+ * After removing leaf entries, prunes empty parent directories.
130
+ */
131
+ function removeStaleEntries(baseDir, currentDir, expected) {
132
+ if (!existsSync(currentDir))
133
+ return;
134
+ for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
135
+ const fullPath = join(currentDir, entry.name);
136
+ const rel = relative(baseDir, fullPath);
137
+ if (expected.has(rel)) {
138
+ // This path is expected - keep it
139
+ continue;
140
+ }
141
+ if (entry.isDirectory()) {
142
+ // Check if any expected path is nested under this directory
143
+ const hasExpectedChild = [...expected].some((p) => isDescendantOf(p, rel));
144
+ if (hasExpectedChild) {
145
+ // Recurse into the directory to clean stale children
146
+ removeStaleEntries(baseDir, fullPath, expected);
147
+ }
148
+ else {
149
+ // No expected paths under here - remove entirely
150
+ safeRmSync(fullPath, { recursive: true, force: true });
151
+ }
152
+ }
153
+ else {
154
+ // Stale file - remove
155
+ safeRmSync(fullPath, { force: true });
156
+ }
157
+ }
158
+ // Prune if this directory is now empty (and is not the shadows root)
159
+ if (currentDir !== baseDir && readdirSync(currentDir).length === 0) {
160
+ safeRmSync(currentDir, { recursive: true, force: true });
161
+ }
162
+ }
163
+ /**
164
+ * Drop paths that descend from another path in the input (keeps only the outermost path per subtree),
165
+ * so nested matches (e.g. node_modules inside a shadowed .next dir) don't become redundant bind mounts.
166
+ */
167
+ function removeNestedPaths(paths) {
168
+ const unique = [...new Set(paths)];
169
+ return unique.filter((p) => !unique.some((a) => a !== p && isDescendantOf(p, a)));
170
+ }
171
+ // Expects forward-slash paths (fast-glob output and shadows/ relative paths on POSIX).
172
+ function isDescendantOf(child, ancestor) {
173
+ return child.startsWith(`${ancestor}/`);
174
+ }
@@ -0,0 +1,305 @@
1
+ // =========================================================================================================================================
2
+ // src/lib/webterm.ts - Host-side logic for the web agent interface (webterm)
3
+ // Sticky per-workspace host-port assignment from the global web_range, plus the container hooks that start
4
+ // the baked webterm server once per container start and ask it what it is running.
5
+ // =========================================================================================================================================
6
+ import { spawnSync } from "node:child_process";
7
+ import { mkdirSync, readFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { AGENTS_DIR, CONTAINER_USER, CONTAINER_WEBPANE_DIR, CONTAINER_WEBTERM_STATE_DIR, DEFAULT_WEB_AGENT, WEB_AGENTS, WEB_KEY_FILE_PATH, WEBPANE_DIR, WEBTERM_AGENT_FILE, WEBTERM_STATE_DIR, } from "./constants.js";
10
+ import { canBind, containerPublishedPorts, dockerPublishedPorts, PORT_LOOPBACK_HOST } from "./ports.js";
11
+ import { listWorkspaceIds, readWebPort, writeWebPort } from "./workspace-identity.js";
12
+ // --- Port assignment (pure over the .lock files) -----------------------------------------------------------------------------------------
13
+ /** Every web port currently assigned to a workspace, optionally excluding one workspace's own assignment. */
14
+ export function collectAssignedWebPorts(excludeWorkspaceId) {
15
+ const taken = new Set();
16
+ for (const id of listWorkspaceIds()) {
17
+ if (id === excludeWorkspaceId)
18
+ continue;
19
+ const port = readWebPort(id);
20
+ if (port !== null)
21
+ taken.add(port);
22
+ }
23
+ return taken;
24
+ }
25
+ /** Lowest port in the range not already taken, or null when the range is exhausted. */
26
+ export function nextFreeWebPort(range, taken) {
27
+ for (let port = range.start; port <= range.end; port++) {
28
+ if (!taken.has(port))
29
+ return port;
30
+ }
31
+ return null;
32
+ }
33
+ /**
34
+ * Return a workspace's sticky web port, assigning the lowest free port in the range when it has none.
35
+ * An already-assigned port is returned as-is even when it falls outside the range - stickiness wins;
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
38
+ * that it can be published. resolveWebPort adds that.
39
+ */
40
+ export function ensureWebPort(workspaceId, range) {
41
+ const existing = readWebPort(workspaceId);
42
+ if (existing !== null)
43
+ return { ok: true, port: existing };
44
+ const port = nextFreeWebPort(range, collectAssignedWebPorts(workspaceId));
45
+ if (port === null)
46
+ return { ok: false, reason: "exhausted" };
47
+ // A port that could not be recorded must not be used: it would be invisible to the next
48
+ // workspace's scan and handed out twice.
49
+ if (!writeWebPort(workspaceId, port))
50
+ return { ok: false, reason: "unrecorded" };
51
+ return { ok: true, port };
52
+ }
53
+ /**
54
+ * The host port this workspace's web interface should publish this session.
55
+ *
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
58
+ * assignment fails that test the port MOVES and the new one is persisted: sticky means it stays where it
59
+ * was last put, not that it stays somewhere unusable and warns about the same conflict at every start.
60
+ * When nothing in the range qualifies the old assignment is left alone rather than cleared, so the
61
+ * workspace keeps its identity and picks the port back up once the conflict clears.
62
+ */
63
+ export async function resolveWebPort(workspaceId, range, usable) {
64
+ const assigned = ensureWebPort(workspaceId, range);
65
+ if (!assigned.ok)
66
+ return { ok: false, reason: assigned.reason, keptPort: readWebPort(workspaceId) };
67
+ if (await usable(assigned.port))
68
+ return { ok: true, port: assigned.port };
69
+ const taken = collectAssignedWebPorts(workspaceId);
70
+ for (let port = range.start; port <= range.end; port++) {
71
+ if (port === assigned.port || taken.has(port))
72
+ continue;
73
+ if (!(await usable(port)))
74
+ continue;
75
+ // Same rule as the first assignment: a port that could not be recorded must not be handed out.
76
+ if (!writeWebPort(workspaceId, port))
77
+ return { ok: false, reason: "unrecorded", keptPort: assigned.port };
78
+ return { ok: true, port, movedFrom: assigned.port };
79
+ }
80
+ return { ok: false, reason: "exhausted", keptPort: assigned.port };
81
+ }
82
+ /**
83
+ * Range-change walk: move only the sticky ports that fall outside the new range to the lowest free
84
+ * in-range port. In-range assignments are never touched. Returns the moves that were made.
85
+ */
86
+ export function reassignOutOfRangeWebPorts(range) {
87
+ const moves = [];
88
+ for (const workspaceId of listWorkspaceIds()) {
89
+ const current = readWebPort(workspaceId);
90
+ if (current === null || (current >= range.start && current <= range.end))
91
+ continue;
92
+ const port = nextFreeWebPort(range, collectAssignedWebPorts(workspaceId));
93
+ if (port === null)
94
+ continue; // Range exhausted; leave the old assignment rather than dropping it.
95
+ if (!writeWebPort(workspaceId, port))
96
+ continue; // Not recorded - keep the old port rather than report a move that did not happen.
97
+ moves.push({ workspaceId, from: current, to: port });
98
+ }
99
+ return moves;
100
+ }
101
+ // --- Default agent (per workspace, kept on the host side of a mount) ---------------------------------------------------------------------
102
+ /** This workspace's webterm state dir on the host - the directory the container sees as its state mount. */
103
+ export function webtermStateDir(cacheDir) {
104
+ return join(cacheDir, WEBTERM_STATE_DIR);
105
+ }
106
+ /**
107
+ * The `docker run -v` args for the webterm state dir, creating it on the host first (docker would otherwise
108
+ * create it as root). Small on purpose: the only thing in it is the agent file the server writes.
109
+ */
110
+ export function buildWebtermStateMountArgs(cacheDir) {
111
+ const host = webtermStateDir(cacheDir);
112
+ mkdirSync(host, { recursive: true });
113
+ return ["-v", `${host}:${CONTAINER_WEBTERM_STATE_DIR}`];
114
+ }
115
+ /**
116
+ * This workspace's web pane artifacts dir on the host: one directory per conversation, each holding that
117
+ * conversation's published pages and the feedback submitted on them. Under `agents/` on purpose - the pages
118
+ * are the agent's own output, and clearing agent memory is what clears them.
119
+ */
120
+ export function webpaneDir(cacheDir) {
121
+ return join(cacheDir, AGENTS_DIR, WEBPANE_DIR);
122
+ }
123
+ /**
124
+ * The `docker run -v` args for the web pane artifacts dir, creating it on the host first (docker would
125
+ * otherwise create it as root). Mounted rather than kept in the container so a published page outlives the
126
+ * container it was written in, which is what lets a resumed conversation open its own pages again.
127
+ */
128
+ export function buildWebpaneMountArgs(cacheDir) {
129
+ const host = webpaneDir(cacheDir);
130
+ mkdirSync(host, { recursive: true });
131
+ return ["-v", `${host}:${CONTAINER_WEBPANE_DIR}`];
132
+ }
133
+ /**
134
+ * Which agent this workspace's web sessions start with. The server writes the file whenever `webterm <agent>`
135
+ * moves the default, and it sits in the mounted state dir rather than the container's /tmp, so the choice
136
+ * outlives a container recreate. Falls back to the default agent when nothing has been chosen yet, and when
137
+ * the file holds something this chamba does not run - a value from a newer version, or a stray edit.
138
+ * It is also the agent the resume marker is written for, so a wrong answer here would resume nothing.
139
+ */
140
+ export function readWebAgent(cacheDir) {
141
+ let value;
142
+ try {
143
+ value = readFileSync(join(webtermStateDir(cacheDir), WEBTERM_AGENT_FILE), "utf8").trim();
144
+ }
145
+ catch {
146
+ return DEFAULT_WEB_AGENT; // Never chosen, or the dir is not there yet.
147
+ }
148
+ return WEB_AGENTS.includes(value) ? value : DEFAULT_WEB_AGENT;
149
+ }
150
+ // --- Host-port and live-session probing ---------------------------------------------------------------------------------------------------
151
+ /**
152
+ * True when this workspace can publish its sticky web port right now: either the port is free on the
153
+ * host, or this workspace's own running container is the one already publishing it (the normal case on
154
+ * the connect path). Anything else holding it - another container, an unrelated host process - means
155
+ * the mapping has to be dropped for this session, because chamba's own port mapping must never fail a
156
+ * session the way a chamba.yaml port does.
157
+ */
158
+ export async function webPortUsable(webPort, containerName) {
159
+ if (containerPublishedPorts(containerName).has(webPort))
160
+ return true;
161
+ return !dockerPublishedPorts().has(webPort) && (await canBind(webPort));
162
+ }
163
+ /**
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,
166
+ * so it is always read fresh, right before it is used.
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
169
+ * (which ignores the query parameter) expects.
170
+ */
171
+ export function readWebKey(containerName) {
172
+ const result = spawnSync("docker", ["exec", "-u", CONTAINER_USER, containerName, "cat", WEB_KEY_FILE_PATH], { stdio: "pipe" });
173
+ if (result.status !== 0)
174
+ return null;
175
+ const key = result.stdout?.toString().trim();
176
+ return key ? key : null;
177
+ }
178
+ // The shape the server mints: 16 random bytes, hex. The key file is writable by every process in the
179
+ // container, so anything read back from it is checked against this before the host builds a URL out of it
180
+ // and hands that URL to a program. Argv-style spawning already keeps a strange value from being a command;
181
+ // this keeps it from being a URL at all.
182
+ const WEB_KEY_PATTERN = /^[0-9a-f]{32}$/;
183
+ /** Whether a key read back from the container has the shape the server mints. */
184
+ export function isWebKey(key) {
185
+ return key !== null && WEB_KEY_PATTERN.test(key);
186
+ }
187
+ /**
188
+ * The URL that opens this workspace's interface. The key is what the server demands of every caller, so a
189
+ * URL without one lands on a 403 - callers pass the key whenever they have a valid one, and the keyless
190
+ * form is only for showing a person where the interface lives.
191
+ */
192
+ export function webInterfaceUrl(webPort, key) {
193
+ const base = `http://${PORT_LOOPBACK_HOST}:${webPort}`;
194
+ return isWebKey(key) ? `${base}/?k=${key}` : base;
195
+ }
196
+ /**
197
+ * Ask this workspace's web interface what it is running, over the published loopback port. `sessions` is
198
+ * how many agents are alive in the container - they outlive every browser connection and only end with
199
+ * the container - and `attached` how many of them a browser window is watching right now.
200
+ * `key` is what the interface demands of every caller (readWebKey); a wrong or missing one is answered
201
+ * with 403, which reads here like any other non-answer.
202
+ * Returns null when the interface did not answer at all, so callers can tell "running, nothing open"
203
+ * from "not running": the connect path relaunches the server on null, and the stop prompt warns only
204
+ * when sessions would be lost.
205
+ */
206
+ export async function webSessionInfo(webPort, key) {
207
+ const query = key === null ? "" : `?k=${encodeURIComponent(key)}`;
208
+ try {
209
+ const response = await fetch(`http://${PORT_LOOPBACK_HOST}:${webPort}/status${query}`, {
210
+ signal: AbortSignal.timeout(WEB_STATUS_TIMEOUT_MS),
211
+ });
212
+ if (!response.ok)
213
+ return null;
214
+ const body = (await response.json());
215
+ if (typeof body.sessions !== "number" || typeof body.attached !== "number")
216
+ return null;
217
+ return { sessions: body.sessions, attached: body.attached };
218
+ }
219
+ catch {
220
+ return null; // Not listening, wrong thing listening, or too slow to matter.
221
+ }
222
+ }
223
+ /**
224
+ * Point an already-running interface at the directory this session was started from, so its new sessions open
225
+ * where a terminal session would. The interface takes that directory from the launcher, which only runs once
226
+ * per container start - so on a container that has been up since yesterday the default is yesterday's
227
+ * directory, and this is what moves it.
228
+ * `key` is what the interface demands of every caller (readWebKey); without one it answers 403, which reads
229
+ * here like any other refusal. Best-effort: a false only means the browser's "+ New session" still opens
230
+ * where it did, and the picker can be used instead.
231
+ */
232
+ export async function setWebDefaultCwd(webPort, key, workdir) {
233
+ const query = key === null ? "" : `?k=${encodeURIComponent(key)}`;
234
+ try {
235
+ const response = await fetch(`http://${PORT_LOOPBACK_HOST}:${webPort}/cwd${query}`, {
236
+ method: "POST",
237
+ headers: { "Content-Type": "application/json" },
238
+ body: JSON.stringify({ cwd: workdir }),
239
+ signal: AbortSignal.timeout(WEB_STATUS_TIMEOUT_MS),
240
+ });
241
+ return response.ok;
242
+ }
243
+ catch {
244
+ return false; // Not listening, an interface too old to know the route, or too slow to matter.
245
+ }
246
+ }
247
+ // The interface is on loopback in the same machine, so a slow answer means something is wrong, not far.
248
+ const WEB_STATUS_TIMEOUT_MS = 1500;
249
+ /**
250
+ * Whether the interface is serving at all. Any HTTP answer counts, including the 403 a keyless probe gets:
251
+ * refusing a caller without a key is still proof that the server is up. Used where only liveness matters
252
+ * (did the launch work, is the interface still there), so those paths need no key and no docker exec.
253
+ */
254
+ export async function webInterfaceAnswers(webPort) {
255
+ try {
256
+ await fetch(`http://${PORT_LOOPBACK_HOST}:${webPort}/status`, { signal: AbortSignal.timeout(WEB_STATUS_TIMEOUT_MS) });
257
+ return true;
258
+ }
259
+ catch {
260
+ return false; // Not listening, or too slow to matter.
261
+ }
262
+ }
263
+ // --- Container hooks (docker I/O, best-effort) --------------------------------------------------------------------------------------------
264
+ /**
265
+ * The `docker exec` argv that starts the baked webterm server, with the given agent as the one its new
266
+ * sessions start with, opening them in `workdir`. The server runs every agent it knows, one per session, so
267
+ * this is a default rather than the only agent it will run. It is always passed explicitly - `webterm` on its
268
+ * own only prints usage.
269
+ *
270
+ * The directory travels as an env var rather than as `docker exec -w`: `-w` on a path docker cannot use
271
+ * fails the whole exec, which would cost the user the interface over something the server itself can just
272
+ * refuse and fall back to the workspace root.
273
+ */
274
+ export function webtermExecArgs(containerName, agent, workdir) {
275
+ return ["exec", "-d", "-u", CONTAINER_USER, "-e", `WEBTERM_CWD=${workdir}`, containerName, "webterm", agent];
276
+ }
277
+ /**
278
+ * Start the baked webterm server detached, once per container start, defaulting to the given agent.
279
+ * The launcher is idempotent (exits quietly when the port is already bound).
280
+ * Best-effort - the user can always run `webterm <agent>` by hand.
281
+ */
282
+ function startWebtermDetached(containerName, agent, workdir) {
283
+ spawnSync("docker", webtermExecArgs(containerName, agent, workdir), { stdio: "pipe" });
284
+ }
285
+ // How long to wait for a just-launched interface to answer, and how often to ask. `docker exec -d` returns
286
+ // as soon as the process is detached, so these cover node starting up and binding the port.
287
+ const WEB_START_TIMEOUT_MS = 8000;
288
+ const WEB_START_POLL_MS = 250;
289
+ /**
290
+ * Start the interface and wait until it actually answers on its port. `docker exec -d` reports success the
291
+ * moment the process detaches, which says nothing about whether the server came up, so without this a
292
+ * crashed launch would leave the greeting advertising a URL that never responds. Returns false when the
293
+ * interface never answered - the caller reports that and starts nothing in its place, leaving the user to
294
+ * retry `webterm <agent>` or run the agent in the terminal.
295
+ */
296
+ export async function startWebtermAndVerify(containerName, agent, webPort, workdir) {
297
+ startWebtermDetached(containerName, agent, workdir);
298
+ const deadline = Date.now() + WEB_START_TIMEOUT_MS;
299
+ while (Date.now() < deadline) {
300
+ if (await webInterfaceAnswers(webPort))
301
+ return true;
302
+ await new Promise((r) => setTimeout(r, WEB_START_POLL_MS));
303
+ }
304
+ return false;
305
+ }