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.
Files changed (91) 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 +619 -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 +135 -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 +490 -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 +157 -0
  48. package/templates/webterm/artifacts.js +583 -0
  49. package/templates/webterm/config.js +269 -0
  50. package/templates/webterm/context/claude.md +14 -0
  51. package/templates/webterm/conversation.js +248 -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/server.js +1142 -0
  79. package/templates/webterm/sessions.js +515 -0
  80. package/templates/webterm/snapshot.js +135 -0
  81. package/templates/webterm.sh +167 -0
  82. package/dist/cli.js +0 -1582
  83. package/dist/server.js +0 -1831
  84. package/inject/annotate.js +0 -18
  85. package/skill/README.md +0 -12
  86. package/skill/SKILL.md +0 -93
  87. package/web/assets/highlighted-body-OFNGDK62-Bn4Eu7CG.js +0 -1
  88. package/web/assets/index-B9DI4F1Z.js +0 -202
  89. package/web/assets/index-DK_n6CTo.css +0 -2
  90. package/web/assets/mermaid-GHXKKRXX-CEMduc-U.js +0 -1
  91. package/web/index.html +0 -28
@@ -0,0 +1,490 @@
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
4
+ // plant the resume marker and start the baked webterm server once per container start.
5
+ // =========================================================================================================================================
6
+ import { spawnSync } from "node:child_process";
7
+ import { mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
8
+ import { basename, join } from "node:path";
9
+ import { AGENT_RESUME_COMMAND, AGENTS_DIR, CONTAINER_USER, CONTAINER_WEBPANE_DIR, CONTAINER_WEBTERM_STATE_DIR, CONTAINER_WORKSPACE, DEFAULT_WEB_AGENT, RESUME_MARKER_PATH, 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
+ // --- Resume command selection (host-side, over the mounted agent dirs) -------------------------------------------------------------------
264
+ const SESSION_FILE_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jsonl$/;
265
+ /**
266
+ * Claude Code files its transcripts per directory, in a dir named after the directory it ran in with every
267
+ * character that is not a letter or a digit turned into "-": /workspace -> -workspace, /workspace/apps/api ->
268
+ * -workspace-apps-api. So a session in a sub-directory reads and writes a different dir from one at the root,
269
+ * and a resume command built from the wrong one names an id that claude cannot find.
270
+ * Only the fallback below relies on this naming; the main path reads what a transcript says about itself.
271
+ */
272
+ export function claudeProjectKey(containerPath) {
273
+ return containerPath.replace(/[^a-zA-Z0-9]/g, "-");
274
+ }
275
+ /**
276
+ * The resume command planted in the marker for a given agent, for a session that will run in `workdir`, or
277
+ * null when this workspace has no conversation for that agent to reopen.
278
+ *
279
+ * Null is the important half. A resume command with nothing behind it is not harmless: every one of these
280
+ * CLIs treats "resume, but there is nothing there" as an error and exits, so the browser would open on a
281
+ * session that died the moment it started. With null the marker is never planted and the first session
282
+ * starts the agent fresh, which is what a workspace with no history should give.
283
+ *
284
+ * For claude the session is picked explicitly: `claude --continue` takes the newest transcript by mtime and
285
+ * silently starts FRESH when that transcript has no real messages (e.g. a session that only ran a slash
286
+ * command), so relying on it makes auto-resume fail quietly whenever such a session happens to be the newest.
287
+ * Resuming by id sidesteps the picker entirely, and having no id is also the honest answer to "is there
288
+ * anything to resume". The other two are asked the same question of their own session stores.
289
+ */
290
+ export function resumeCommandFor(agent, workspaceCacheDir, workdir = CONTAINER_WORKSPACE) {
291
+ if (agent === "claude") {
292
+ const sessionId = latestClaudeSessionId(workspaceCacheDir, workdir);
293
+ return sessionId === null ? null : `claude --resume ${sessionId}`;
294
+ }
295
+ return hasStoredConversation(agent, workspaceCacheDir) ? AGENT_RESUME_COMMAND[agent] : null;
296
+ }
297
+ // Where opencode and codex keep their conversations, under this workspace's mounted agent dirs. These are
298
+ // the host side of the directories the container sees at ~/.local/share/opencode/storage/session and
299
+ // ~/.codex/sessions (templates/webterm/config.js holds the container-side pair).
300
+ const AGENT_SESSION_STORE = {
301
+ opencode: ["opencode", "data", "storage", "session"],
302
+ codex: ["codex", "sessions"],
303
+ };
304
+ /**
305
+ * Whether this agent has anything to resume: any file at all in the store it writes conversations to.
306
+ * Deliberately shallow as a judgement - a file in there means the agent has run and kept something, which is
307
+ * all the resume flag needs to not fail. Reading each store's own format to find out whether the newest
308
+ * conversation is really resumable would tie chamba to two more internal layouts for no gain.
309
+ */
310
+ function hasStoredConversation(agent, workspaceCacheDir) {
311
+ return containsFile(join(workspaceCacheDir, AGENTS_DIR, ...AGENT_SESSION_STORE[agent]));
312
+ }
313
+ /**
314
+ * True when `dir` holds a file anywhere below it. Recursive because both stores nest (by project, by date),
315
+ * depth-bounded so an unexpected directory can never turn this into a walk of the disk, and it stops at the
316
+ * first file it finds.
317
+ */
318
+ function containsFile(dir, depth = 4) {
319
+ let entries;
320
+ try {
321
+ entries = readdirSync(dir, { withFileTypes: true });
322
+ }
323
+ catch {
324
+ return false; // Never run, so never created.
325
+ }
326
+ for (const entry of entries) {
327
+ if (entry.isFile())
328
+ return true;
329
+ if (entry.isDirectory() && depth > 0 && containsFile(join(dir, entry.name), depth - 1))
330
+ return true;
331
+ }
332
+ return false;
333
+ }
334
+ /**
335
+ * The most recent Claude Code conversation that can be resumed in `workdir`, read from the host-side mounted
336
+ * agent dir: the newest transcript with a real (non-sidechain) user message that ran in that directory.
337
+ *
338
+ * The directory is matched on what the transcript records, not on which dir it sits in, so however claude
339
+ * names those dirs we never hand back a conversation from somewhere else - resuming it would either fail or
340
+ * drop the user into another directory's history. Transcripts old enough to record no directory at all are
341
+ * the fallback, and for those the dir name is the only evidence there is.
342
+ * Returns null when nothing qualifies.
343
+ */
344
+ export function latestClaudeSessionId(workspaceCacheDir, workdir = CONTAINER_WORKSPACE) {
345
+ const projectsDir = join(workspaceCacheDir, AGENTS_DIR, "claude", "projects");
346
+ const byRecordedDir = newestResumableSessionId(transcriptsNewestFirst(projectsDir, projectDirNames(projectsDir)), workdir);
347
+ if (byRecordedDir !== null)
348
+ return byRecordedDir;
349
+ return newestResumableSessionId(transcriptsNewestFirst(projectsDir, [claudeProjectKey(workdir)]), null);
350
+ }
351
+ // Every project dir claude has written under this workspace's cache. Read rather than derived: the point of
352
+ // the walk is to find the transcript that says it ran where we are about to run.
353
+ function projectDirNames(projectsDir) {
354
+ try {
355
+ return readdirSync(projectsDir, { withFileTypes: true })
356
+ .filter((entry) => entry.isDirectory())
357
+ .map((entry) => entry.name);
358
+ }
359
+ catch {
360
+ return [];
361
+ }
362
+ }
363
+ // Transcript paths from the given project dirs, newest first. Session files only - anything else in there
364
+ // (subagent dirs, stray notes) is not a conversation. A missing or unreadable dir contributes nothing.
365
+ function transcriptsNewestFirst(projectsDir, dirNames) {
366
+ const found = [];
367
+ for (const dirName of dirNames) {
368
+ const dir = join(projectsDir, dirName);
369
+ let names;
370
+ try {
371
+ names = readdirSync(dir);
372
+ }
373
+ catch {
374
+ continue;
375
+ }
376
+ for (const name of names) {
377
+ if (!SESSION_FILE_PATTERN.test(name))
378
+ continue;
379
+ const path = join(dir, name);
380
+ try {
381
+ found.push({ path, mtime: statSync(path).mtimeMs });
382
+ }
383
+ catch {
384
+ // Vanished between the listing and the stat.
385
+ }
386
+ }
387
+ }
388
+ return found.sort((a, b) => b.mtime - a.mtime).map((entry) => entry.path);
389
+ }
390
+ // The first of these transcripts that can be resumed, in the order given. `requiredCwd` of null asks only
391
+ // that the conversation has messages; a directory asks that it recorded that one too. Transcripts are read
392
+ // in order and the walk stops at the first hit, so the usual case reads a single file.
393
+ function newestResumableSessionId(transcriptPaths, requiredCwd) {
394
+ for (const path of transcriptPaths) {
395
+ const facts = transcriptFacts(path);
396
+ if (!facts.resumable)
397
+ continue;
398
+ if (requiredCwd !== null && facts.cwd !== requiredCwd)
399
+ continue;
400
+ return basename(path).slice(0, -".jsonl".length);
401
+ }
402
+ return null;
403
+ }
404
+ // What a transcript says about itself, in one pass: whether it holds a real user message, and the directory
405
+ // that message was typed in. Sessions that only recorded UI state (mode changes, slash commands) have
406
+ // nothing to resume; sidechain records belong to subagents. isSidechain is a field we do not own, so only an
407
+ // explicit true excludes a record: a record that stopped carrying the field still counts as resumable, which
408
+ // fails on the safe side. `cwd` is null for transcripts written before claude recorded it.
409
+ function transcriptFacts(transcriptPath) {
410
+ let content;
411
+ try {
412
+ content = readFileSync(transcriptPath, "utf8");
413
+ }
414
+ catch {
415
+ return { resumable: false, cwd: null };
416
+ }
417
+ // Walked by index rather than split("\n") so a multi-MB transcript is not copied into an array of lines.
418
+ let start = 0;
419
+ while (start <= content.length) {
420
+ const end = content.indexOf("\n", start);
421
+ const line = content.slice(start, end === -1 ? content.length : end);
422
+ start = (end === -1 ? content.length : end) + 1;
423
+ // Cheap pre-filter so multi-MB transcripts are not JSON.parsed line by line.
424
+ if (!line.includes('"type":"user"'))
425
+ continue;
426
+ try {
427
+ const record = JSON.parse(line);
428
+ if (record.type === "user" && record.isSidechain !== true) {
429
+ return { resumable: true, cwd: typeof record.cwd === "string" ? record.cwd : null };
430
+ }
431
+ }
432
+ catch {
433
+ // Skip malformed lines.
434
+ }
435
+ }
436
+ return { resumable: false, cwd: null };
437
+ }
438
+ // --- Container hooks (docker I/O, best-effort) --------------------------------------------------------------------------------------------
439
+ /**
440
+ * Plant the resume marker in the container: a devuser-owned file whose content is the full command that
441
+ * resumes the most recent conversation. Called on every container create/start; the first web session
442
+ * to come up consumes it atomically and runs the command, and every later one starts fresh.
443
+ * Best-effort - a failure only means the session starts fresh. The command is always one of the
444
+ * AGENT_RESUME_COMMAND constants, so interpolating it into the shell string is safe.
445
+ */
446
+ export function plantResumeMarker(containerName, resumeCommand) {
447
+ spawnSync("docker", ["exec", "-u", CONTAINER_USER, containerName, "bash", "-c", `printf %s "${resumeCommand}" > "${RESUME_MARKER_PATH}"`], { stdio: "pipe" });
448
+ }
449
+ /**
450
+ * The `docker exec` argv that starts the baked webterm server, with the given agent as the one its new
451
+ * sessions start with, opening them in `workdir`. The server runs every agent it knows, one per session, so
452
+ * this is a default rather than the only agent it will run. It is always passed explicitly - `webterm` on its
453
+ * own only prints usage.
454
+ *
455
+ * The directory travels as an env var rather than as `docker exec -w`: `-w` on a path docker cannot use
456
+ * fails the whole exec, which would cost the user the interface over something the server itself can just
457
+ * refuse and fall back to the workspace root.
458
+ */
459
+ export function webtermExecArgs(containerName, agent, workdir) {
460
+ return ["exec", "-d", "-u", CONTAINER_USER, "-e", `WEBTERM_CWD=${workdir}`, containerName, "webterm", agent];
461
+ }
462
+ /**
463
+ * Start the baked webterm server detached, once per container start, defaulting to the given agent.
464
+ * The launcher is idempotent (exits quietly when the port is already bound).
465
+ * Best-effort - the user can always run `webterm <agent>` by hand.
466
+ */
467
+ function startWebtermDetached(containerName, agent, workdir) {
468
+ spawnSync("docker", webtermExecArgs(containerName, agent, workdir), { stdio: "pipe" });
469
+ }
470
+ // How long to wait for a just-launched interface to answer, and how often to ask. `docker exec -d` returns
471
+ // as soon as the process is detached, so these cover node starting up and binding the port.
472
+ const WEB_START_TIMEOUT_MS = 8000;
473
+ const WEB_START_POLL_MS = 250;
474
+ /**
475
+ * Start the interface and wait until it actually answers on its port. `docker exec -d` reports success the
476
+ * moment the process detaches, which says nothing about whether the server came up, so without this a
477
+ * crashed launch would leave the greeting advertising a URL that never responds. Returns false when the
478
+ * interface never answered - the caller reports that and starts nothing in its place, leaving the user to
479
+ * retry `webterm <agent>` or run the agent in the terminal.
480
+ */
481
+ export async function startWebtermAndVerify(containerName, agent, webPort, workdir) {
482
+ startWebtermDetached(containerName, agent, workdir);
483
+ const deadline = Date.now() + WEB_START_TIMEOUT_MS;
484
+ while (Date.now() < deadline) {
485
+ if (await webInterfaceAnswers(webPort))
486
+ return true;
487
+ await new Promise((r) => setTimeout(r, WEB_START_POLL_MS));
488
+ }
489
+ return false;
490
+ }