chamba 0.4.0 → 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.
@@ -18,7 +18,7 @@ import { buildPnpmStoreMountArgs } from "../lib/pnpm-store.js";
18
18
  import { assertHostPortsAvailable, formatPortNotice, formatWebRange, portEnvArgs, portPublishArgs, portsLabel, validatePortsConfig, } from "../lib/ports.js";
19
19
  import { containerSessionCount, loginShellExecArgs } from "../lib/sessions.js";
20
20
  import { buildShadowMountArgs, ensureShadowsInSync, expandShadowPatterns } from "../lib/shadows.js";
21
- import { buildWebpaneMountArgs, buildWebtermStateMountArgs, isWebKey, plantResumeMarker, readWebAgent, readWebKey, resolveWebPort, resumeCommandFor, setWebDefaultCwd, startWebtermAndVerify, webInterfaceAnswers, webInterfaceUrl, webPortUsable, webSessionInfo, } from "../lib/webterm.js";
21
+ import { buildWebpaneMountArgs, buildWebtermStateMountArgs, isWebKey, readWebAgent, readWebKey, resolveWebPort, setWebDefaultCwd, startWebtermAndVerify, webInterfaceAnswers, webInterfaceUrl, webPortUsable, webSessionInfo, } from "../lib/webterm.js";
22
22
  import { readActiveProfile, readGitMode, writeActiveProfile } from "../lib/workspace-identity.js";
23
23
  // --- Working directory resolution ---------------------------------------------------------------------------------------------------------
24
24
  // Always open the session where chamba was invoked. The whole workspace root is bind-mounted at
@@ -519,33 +519,26 @@ export async function run(packageDir, ctx, options) {
519
519
  process.exit(1);
520
520
  }
521
521
  }
522
- // --- Once-per-container-start hooks (resume marker + web interface launch) -----------------------------------------------------------
522
+ // --- Once-per-container-start hook (web interface launch) ----------------------------------------------------------------------------
523
523
  // Deliberately after the startup checks above: those update the AI CLIs inside the container, and the
524
524
  // interface spawns its agent the moment it comes up. Started any earlier, the browser would get a session
525
525
  // running the version the image was built with - a claude too old to know the current models - and the only
526
526
  // way out would be to wait for the update and start another session. The terminal never had this problem:
527
527
  // the login shell attaches below, after the update.
528
528
  //
529
- // Runs on the created AND resumed (docker start) paths, never on connect to an already-running container -
530
- // that is what makes the resume once-per-start. The marker is planted before the user's shell attaches,
531
- // so the first web session to come up consumes it and resumes the most recent conversation; later
532
- // sessions start fresh. Which agent it resumes is the one this workspace's web sessions start with,
533
- // which is also the agent the interface is launched with.
529
+ // Reopening the last conversation is not decided here. The container's own server does it, for the first
530
+ // session of a container start, reading the agent stores from the side of the mount that owns them
531
+ // (templates/webterm/resume.js). Which agent gets the offer is the one this workspace's web sessions start
532
+ // with, which is also the agent the interface is launched with.
534
533
  const webAgent = readWebAgent(cacheDir);
535
534
  if (startResult.status !== "connected") {
536
- // No marker when there is nothing to resume: the command would fail and take the session with it,
537
- // so the first session starts the agent fresh instead.
538
- const resumeCommand = resumeCommandFor(webAgent, cacheDir, workdir);
539
- if (resumeCommand !== null)
540
- plantResumeMarker(containerName, resumeCommand);
541
535
  if (webPort !== null)
542
536
  await launchWebInterface(containerName, webAgent, webPort, workdir);
543
537
  }
544
538
  else if (webPort !== null) {
545
539
  // Connecting to a container that is already up: the interface it started with should still be
546
540
  // serving. When it is not (crashed, or stopped by hand) the greeting would advertise a URL that
547
- // does not answer, so start it again. No resume marker here - this is not a container start, so
548
- // the relaunched interface opens a fresh conversation rather than re-resuming an old one.
541
+ // does not answer, so start it again.
549
542
  if (!(await webInterfaceAnswers(webPort))) {
550
543
  await launchWebInterface(containerName, webAgent, webPort, workdir);
551
544
  }
@@ -119,17 +119,7 @@ export const WEBTERM_AGENT_FILE_PATH = `${CONTAINER_WEBTERM_STATE_DIR}/${WEBTERM
119
119
  // and a resumed conversation finds its own history again. Nothing sweeps it by age.
120
120
  export const WEBPANE_DIR = "webpane";
121
121
  export const CONTAINER_WEBPANE_DIR = `${CONTAINER_HOME}/.webpane`;
122
- // Resume marker: a host-written container file whose content is the full command that resumes the most
123
- // recent conversation. Planted by dev.ts on every container create/start; consumed (rename-then-read,
124
- // atomic) by exactly one webterm session, so the first session after a container start resumes and every
125
- // later one starts fresh. Lives in the devuser home, not /tmp - the consumer executes the file's content,
126
- // so it must not sit in a world-writable dir.
127
- export const RESUME_MARKER_PATH = `${CONTAINER_HOME}/.chamba-resume-pending`;
128
- // Per-agent command that reopens the most recent conversation. Pinned against the real CLIs by the
129
- // drift test in tests/webterm.test.ts, which checks each flag/subcommand against the CLI's own help.
130
- // Claude is not here: it is resumed by session id instead (resumeCommandFor), because `claude --continue`
131
- // exits with "No conversation found to continue" when there is nothing to continue.
132
- export const AGENT_RESUME_COMMAND = {
133
- opencode: "opencode --continue",
134
- codex: "codex resume --last",
135
- };
122
+ // Reopening the last conversation is the container's own job: templates/webterm/resume.js reads the agent
123
+ // stores from the side of the mount that owns those paths, and the server offers the resume to the first
124
+ // session of a container start. The host used to read the same stores from out here and plant a command
125
+ // file; nothing on this side needs to know how an agent files its conversations any more.
@@ -1,12 +1,12 @@
1
1
  // =========================================================================================================================================
2
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.
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
5
  // =========================================================================================================================================
6
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";
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
10
  import { canBind, containerPublishedPorts, dockerPublishedPorts, PORT_LOOPBACK_HOST } from "./ports.js";
11
11
  import { listWorkspaceIds, readWebPort, writeWebPort } from "./workspace-identity.js";
12
12
  // --- Port assignment (pure over the .lock files) -----------------------------------------------------------------------------------------
@@ -260,192 +260,7 @@ export async function webInterfaceAnswers(webPort) {
260
260
  return false; // Not listening, or too slow to matter.
261
261
  }
262
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
263
  // --- 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
264
  /**
450
265
  * The `docker exec` argv that starts the baked webterm server, with the given agent as the one its new
451
266
  * sessions start with, opening them in `workdir`. The server runs every agent it knows, one per session, so
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chamba",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Run AI coding agents in a container, from your browser",
5
5
  "type": "module",
6
6
  "bin": {
@@ -94,8 +94,11 @@ webterm is baked into the chamba image with its dependencies preinstalled, and t
94
94
  The agent is always explicit; `webterm` on its own prints usage and the URL, and never picks one for you.
95
95
  The choice is written to `~/.chamba-webterm/agent`, a directory chamba bind-mounts from the workspace cache, and the host reads it back at the next container start - so it holds across restarts.
96
96
 
97
- The first session after a container start resumes the most recent conversation; later sessions start fresh.
98
- Only a session running that same agent may claim the resume - the marker holds a command for it, and any other agent would consume the conversation without being able to open it.
97
+ The first session after a container start reopens the most recent conversation; later sessions start fresh.
98
+ The server decides that itself, in `resume.js`, by reading the same agent stores the pane reads - the host is not involved and plants nothing.
99
+ Which conversation is the workspace's newest one in the directory the session opens in, and for claude it is named by id rather than by `--continue`, whose picker starts fresh when the newest transcript holds no real messages.
100
+ Only a session running the agent the interface was launched with may claim it; any other agent leaves the chance alone rather than spending it on a conversation it cannot open.
101
+ The chance belongs to the container start, not to the server process: it is stamped with the start time of PID 1, so a server that crashes and comes back does not reopen the conversation a second time.
99
102
 
100
103
  Running `webterm <other-agent>` while the server is up starts nothing and ends nothing: it moves what `+ New session` will start, and prints the URL.
101
104
  Sessions already open keep running the agent they were started with.
@@ -148,8 +151,8 @@ Between the terminal and the composer, a claude session shows its own numbers, l
148
151
 
149
152
  ## Config
150
153
 
151
- `config.js` holds the knobs (port, agent list and default agent, key and key file, upload dir, size cap, sweep age/interval, paste framing, resume marker, state file, session limit, keepalive interval, stop timings, claude context file, workspace root and directory-scan limits, the pane's directory, caps, scan interval, feedback bounds and agent stores, and the status strip's scan interval).
152
- Env overrides: `WEBTERM_PORT`, `WEBTERM_CWD`, `WEBTERM_AGENT`, `WEBTERM_AGENT_ARGS`, `WEBTERM_KEY`, `WEBTERM_KEY_FILE`, `WEBTERM_RESUME_MARKER`, `WEBTERM_STATE_FILE`, `WEBTERM_MAX_SESSIONS`, `WEBTERM_PING_INTERVAL_MS`, `WEBTERM_WORKSPACE`, `WEBTERM_CONTEXT_FILE`, `WEBTERM_PANE_DIR`, `WEBTERM_MAX_PAGE_BYTES`, `WEBTERM_MAX_PANE_BYTES`, `WEBTERM_MAX_PANE_FILES`, `WEBTERM_PANE_SCAN_MS`, `WEBTERM_FEEDBACK_MIN_INTERVAL_MS`, `WEBTERM_MAX_FEEDBACK_PER_PAGE`, `WEBTERM_STATUS_SCAN_MS`, `WEBTERM_SUBMIT_DELAY_MS`, and the four `WEBTERM_*_DIR` agent-store paths, plus `WEBTERM_PROC_ROOT`, which belongs to `proc.js` rather than to `config.js`.
154
+ `config.js` holds the knobs (port, agent list and default agent, key and key file, upload dir, size cap, sweep age/interval, paste framing, resume stamp, state file, session limit, keepalive interval, stop timings, claude context file, workspace root and directory-scan limits, the pane's directory, caps, scan interval, feedback bounds and agent stores, and the status strip's scan interval).
155
+ Env overrides: `WEBTERM_PORT`, `WEBTERM_CWD`, `WEBTERM_AGENT`, `WEBTERM_AGENT_ARGS`, `WEBTERM_KEY`, `WEBTERM_KEY_FILE`, `WEBTERM_RESUME_STAMP`, `WEBTERM_STATE_FILE`, `WEBTERM_MAX_SESSIONS`, `WEBTERM_PING_INTERVAL_MS`, `WEBTERM_WORKSPACE`, `WEBTERM_CONTEXT_FILE`, `WEBTERM_PANE_DIR`, `WEBTERM_MAX_PAGE_BYTES`, `WEBTERM_MAX_PANE_BYTES`, `WEBTERM_MAX_PANE_FILES`, `WEBTERM_PANE_SCAN_MS`, `WEBTERM_FEEDBACK_MIN_INTERVAL_MS`, `WEBTERM_MAX_FEEDBACK_PER_PAGE`, `WEBTERM_STATUS_SCAN_MS`, `WEBTERM_SUBMIT_DELAY_MS`, and the four `WEBTERM_*_DIR` agent-store paths, plus `WEBTERM_PROC_ROOT`, which belongs to `proc.js` rather than to `config.js`.
153
156
  `WEBTERM_KEY` pins the key instead of minting one, which is for tests and hand-run debugging - there is no way to turn the gate off.
154
157
  `WEBTERM_CWD` is where new sessions start, not where they must stay: the browser can name another directory per session, and `POST /cwd` moves the default.
155
158
  `WEBTERM_AGENT` is the same shape: the agent new sessions start with, which the browser can override per session and `POST /agent` moves. A value that is not one of the three falls back to the first, so nothing arbitrary can be spawned through it.
@@ -28,11 +28,12 @@ export const DEFAULT_AGENT = isKnownAgent(process.env.WEBTERM_AGENT) ? process.e
28
28
  // so they mean nothing to any other agent the browser might start.
29
29
  export const AGENT_ARGS = (process.env.WEBTERM_AGENT_ARGS || "").split(/\s+/).filter(Boolean);
30
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 || "";
31
+ // Where the server records that it has already offered a resume for this container start: one line holding
32
+ // the start time of PID 1, which is a new number every time the container starts. The first session of a
33
+ // container start reopens the newest conversation and stamps this file; every session after it starts fresh,
34
+ // which is what the user wants once mid-work. Sits beside STATE_FILE in the same mounted directory, so it
35
+ // outlives the container the way the stamped value is meant to be compared across one.
36
+ export const RESUME_STAMP = process.env.WEBTERM_RESUME_STAMP || "/home/devuser/.chamba-webterm/resumed-at";
36
37
 
37
38
  // One-line file naming the agent new sessions get, written once the port is bound and again whenever the
38
39
  // default moves. The `webterm` launcher reads it so a second run can say what is live instead of just
@@ -34,8 +34,11 @@ const MAX_JSON_BYTES = 1024 * 1024;
34
34
  // (sessions/2026/08/23/), opencode under a project directory; nothing goes deeper than that.
35
35
  const MAX_STORE_DEPTH = 5;
36
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) {
37
+ /**
38
+ * Files under `dir` matching `pattern`, newest first, capped. Depth-limited; unreadable dirs contribute nothing.
39
+ * Exported for resume.js, which reads the same stores to answer a different question.
40
+ */
41
+ export function filesNewestFirst(dir, pattern, depth = MAX_STORE_DEPTH) {
39
42
  const found = [];
40
43
  const queue = [{ dir, depth }];
41
44
  while (queue.length > 0) {
@@ -163,7 +166,9 @@ function couldBelong(path, cwd, since) {
163
166
 
164
167
  // --- Claude ------------------------------------------------------------------------------------------------------------------------------
165
168
 
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$/;
169
+ // A transcript is named after the conversation it holds, which is also the id claude resumes by.
170
+ // Exported with the rollout pattern below because resume.js reads the same two stores.
171
+ export 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
172
 
168
173
  /**
169
174
  * Claude's conversation id for a session, by two routes.
@@ -189,7 +194,7 @@ export function claudeConversationId(session, stores) {
189
194
  // --- Codex -------------------------------------------------------------------------------------------------------------------------------
190
195
 
191
196
  // 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$/;
197
+ export 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
198
 
194
199
  /** Codex's conversation id for a session: the newest rollout file that could be this session's. */
195
200
  export function codexConversationId(session, stores) {
@@ -0,0 +1,145 @@
1
+ // resume.js - The command that reopens the conversation this workspace was last having.
2
+ //
3
+ // A container you come back to should open where you left off, so the first session of a container start
4
+ // continues the newest conversation instead of beginning one. Working out which conversation that is means
5
+ // reading the store the agent keeps it in, and this is the only place that can honestly do it: the stores
6
+ // are the container's own paths, and the CLIs that write them are installed here and nowhere else.
7
+ //
8
+ // Deliberately next to conversation.js, and deliberately not the same question. That file asks which
9
+ // conversation a live session is having, and answers it from the process tree and the session's start time.
10
+ // This one asks whether there is anything here worth reopening at all, before any session exists, so the
11
+ // directory is the only thing it has to go on.
12
+ //
13
+ // Null is the half that matters. Every one of these CLIs treats "resume, but there is nothing there" as an
14
+ // error and exits, so a resume command with nothing behind it opens the browser onto a session that died on
15
+ // arrival. Null means the session starts the agent fresh, which is the right answer for a workspace with no
16
+ // history.
17
+
18
+ import { readFileSync } from "node:fs";
19
+ import { basename, dirname } from "node:path";
20
+ import { CLAUDE_TRANSCRIPT, CODEX_ROLLOUT, filesNewestFirst } from "./conversation.js";
21
+
22
+ // --- Claude ------------------------------------------------------------------------------------------------------------------------------
23
+
24
+ /**
25
+ * Claude Code files its transcripts per directory, in a dir named after the directory it ran in with every
26
+ * character that is not a letter or a digit turned into "-": /workspace -> -workspace, /workspace/apps/api ->
27
+ * -workspace-apps-api. So a session in a sub-directory reads and writes a different dir from one at the root,
28
+ * and a resume command built from the wrong one names an id that claude cannot find.
29
+ * Only the fallback below relies on this naming; the main path reads what a transcript says about itself.
30
+ */
31
+ export function claudeProjectKey(containerPath) {
32
+ return containerPath.replace(/[^a-zA-Z0-9]/g, "-");
33
+ }
34
+
35
+ /**
36
+ * The most recent claude conversation that can be reopened in `cwd`: the newest transcript with a real
37
+ * (non-sidechain) user message that ran in that directory. Returns null when nothing qualifies.
38
+ *
39
+ * The directory is matched on what the transcript records, not on which dir it sits in, so however claude
40
+ * names those dirs we never hand back a conversation from somewhere else - resuming it would either fail or
41
+ * drop the user into another directory's history. Transcripts old enough to record no directory at all are
42
+ * the fallback, and for those the dir name is the only evidence there is.
43
+ */
44
+ function claudeResumeId(cwd, stores) {
45
+ const transcripts = filesNewestFirst(stores.claudeProjects, CLAUDE_TRANSCRIPT);
46
+ const byRecordedDir = firstResumable(transcripts, cwd);
47
+ if (byRecordedDir !== null) return byRecordedDir;
48
+ const key = claudeProjectKey(cwd);
49
+ return firstResumable(
50
+ transcripts.filter((path) => basename(dirname(path)) === key),
51
+ null,
52
+ );
53
+ }
54
+
55
+ // The first of these transcripts that can be reopened, in the order given. `requiredCwd` of null asks only
56
+ // that the conversation has messages; a directory asks that it recorded that one too. Transcripts are read
57
+ // in order and the walk stops at the first hit, so the usual case reads a single file.
58
+ function firstResumable(transcriptPaths, requiredCwd) {
59
+ for (const path of transcriptPaths) {
60
+ const facts = transcriptFacts(path);
61
+ if (!facts.resumable) continue;
62
+ if (requiredCwd !== null && facts.cwd !== requiredCwd) continue;
63
+ return basename(path).slice(0, -".jsonl".length);
64
+ }
65
+ return null;
66
+ }
67
+
68
+ /**
69
+ * What a transcript says about itself, in one pass: whether it holds a real user message, and the directory
70
+ * that message was typed in. Sessions that only recorded UI state (mode changes, slash commands) have
71
+ * nothing to resume; sidechain records belong to subagents. isSidechain is a field we do not own, so only an
72
+ * explicit true excludes a record: a record that stopped carrying the field still counts as resumable, which
73
+ * fails on the safe side. `cwd` is null for transcripts written before claude recorded it.
74
+ *
75
+ * Read whole rather than by the head, unlike conversation.js: the first user message can sit behind any
76
+ * amount of session setup, and answering "resumable" wrongly costs a dead session rather than a retry. The
77
+ * walk stops at the first hit, and the caller stops at the first resumable file, so the usual cost is the
78
+ * first few kilobytes of one transcript.
79
+ */
80
+ function transcriptFacts(transcriptPath) {
81
+ let content;
82
+ try {
83
+ content = readFileSync(transcriptPath, "utf8");
84
+ } catch {
85
+ return { resumable: false, cwd: null };
86
+ }
87
+ // Walked by index rather than split("\n") so a multi-MB transcript is not copied into an array of lines.
88
+ let start = 0;
89
+ while (start <= content.length) {
90
+ const end = content.indexOf("\n", start);
91
+ const line = content.slice(start, end === -1 ? content.length : end);
92
+ start = (end === -1 ? content.length : end) + 1;
93
+ // Cheap pre-filter so multi-MB transcripts are not JSON.parsed line by line.
94
+ if (!line.includes('"type":"user"')) continue;
95
+ try {
96
+ const record = JSON.parse(line);
97
+ if (record.type === "user" && record.isSidechain !== true) {
98
+ return { resumable: true, cwd: typeof record.cwd === "string" ? record.cwd : null };
99
+ }
100
+ } catch {
101
+ // Skip malformed lines.
102
+ }
103
+ }
104
+ return { resumable: false, cwd: null };
105
+ }
106
+
107
+ // --- Codex and opencode ------------------------------------------------------------------------------------------------------------------
108
+
109
+ // What reopens a stored conversation, per agent, and where to look to know there is one. claude is not here:
110
+ // it is resumed by id above, because `claude --continue` takes the newest transcript by mtime and silently
111
+ // starts FRESH when that transcript holds no real messages, which makes auto-resume fail quietly whenever
112
+ // such a session happens to be the newest.
113
+ //
114
+ // These two are asked a shallower question than claude - does the store hold a conversation file at all -
115
+ // and their own flag picks which one. Reading each store's format to find out whether the newest
116
+ // conversation is really resumable would tie chamba to two more internal layouts for no gain.
117
+ export const CONTINUE = {
118
+ opencode: { argv: ["opencode", "--continue"], store: "opencodeSessions", pattern: /\.json$/ },
119
+ codex: { argv: ["codex", "resume", "--last"], store: "codexSessions", pattern: CODEX_ROLLOUT },
120
+ };
121
+
122
+ // --- The one entry point -----------------------------------------------------------------------------------------------------------------
123
+
124
+ /**
125
+ * The argv that reopens this workspace's most recent conversation for `agent` in `cwd`, or null when there
126
+ * is nothing to reopen. `stores` is config.js's AGENT_STORES, passed in so this can be exercised against a
127
+ * fixture the way conversation.js is.
128
+ *
129
+ * An agent nobody wrote a reader for is a supported answer, not an error: it starts fresh.
130
+ */
131
+ export function resumeArgvFor(agent, cwd, stores) {
132
+ try {
133
+ if (agent === "claude") {
134
+ const id = claudeResumeId(cwd, stores);
135
+ return id === null ? null : ["claude", "--resume", id];
136
+ }
137
+ const known = CONTINUE[agent];
138
+ if (!known) return null;
139
+ return filesNewestFirst(stores[known.store], known.pattern).length > 0 ? [...known.argv] : null;
140
+ } catch {
141
+ // A store that changed shape under us must never take the session down with it - starting fresh is
142
+ // a worse session than the user asked for, but it is a session.
143
+ return null;
144
+ }
145
+ }
@@ -11,7 +11,7 @@
11
11
  // container isolation it has in the terminal. Nothing here touches credentials.
12
12
 
13
13
  import { randomBytes } from "node:crypto";
14
- import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
14
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
15
15
  import { createServer } from "node:http";
16
16
  import { createRequire } from "node:module";
17
17
  import { join, resolve, sep } from "node:path";
@@ -52,7 +52,7 @@ import {
52
52
  PASTE_END,
53
53
  PASTE_START,
54
54
  PORT,
55
- RESUME_MARKER,
55
+ RESUME_STAMP,
56
56
  resolveWorkspacePath,
57
57
  STATE_FILE,
58
58
  STATUS_SCAN_MS,
@@ -68,7 +68,8 @@ import {
68
68
  workspaceLabel,
69
69
  } from "./config.js";
70
70
  import { createPane } from "./pane.js";
71
- import { isSelfOrDescendant } from "./proc.js";
71
+ import { isSelfOrDescendant, processStart } from "./proc.js";
72
+ import { resumeArgvFor } from "./resume.js";
72
73
  import { createRegistry, WORK_TICK_MS } from "./sessions.js";
73
74
  import { statusFor, writesSnapshots } from "./snapshot.js";
74
75
 
@@ -788,36 +789,74 @@ function nudge(session, title, path) {
788
789
  pasteToSession(session, `[web pane] Feedback submitted on "${quoted}" - read ${homeLabel(path)}`);
789
790
  }
790
791
 
791
- // Consume the host-planted resume marker, if any. The rename is the claim: of all the sessions that may
792
- // start at once, exactly one wins, so only the first one resumes and the rest start fresh.
793
- // Returns the resume command as [cmd, ...args], or null when there is nothing to resume.
794
- // `agent` is what the session is about to start. The marker holds a resume command for the agent the host
795
- // started this interface with (DEFAULT_AGENT, not wherever the default has moved to since), so any other
796
- // agent leaves it alone rather than claiming a conversation it cannot open.
797
- function consumeResumeMarker(agent) {
798
- if (!RESUME_MARKER || agent !== DEFAULT_AGENT) return null;
799
- const claimed = `${RESUME_MARKER}.web`;
792
+ // --- Resume, once per container start ----------------------------------------------------------------------------------------------------
793
+
794
+ // PID 1 is CONTAINER_KEEP_ALIVE, so it starts when the container does and its start time is a different
795
+ // number on every `docker start`. That makes it the name of this container start, which is the span the
796
+ // resume belongs to: not this server process (one crash and relaunch would resume a second time) and not
797
+ // this workspace (then it would only ever happen once).
798
+ function containerStartId() {
799
+ const started = processStart(1);
800
+ return started === null ? null : String(started);
801
+ }
802
+
803
+ // The fallback when /proc cannot be read at all: one resume per server process. Weaker than the stamp -
804
+ // a relaunched server would offer another - but a server that cannot see /proc is already in a container
805
+ // that is not behaving, and a spare resume is a better failure than none.
806
+ let resumeSpent = false;
807
+
808
+ /**
809
+ * Whether this session is the one that gets to reopen the last conversation, spending the chance as it
810
+ * answers. True at most once per container start.
811
+ *
812
+ * Spent whether or not anything is found to resume, which is the point: a session that started fresh
813
+ * because the workspace was empty has since written a transcript, and the next session in the same bar
814
+ * must not reopen it. Nothing here needs a lock - the server is one process and one thread, so no two
815
+ * sessions are inside this function at once.
816
+ */
817
+ function claimResumeChance() {
818
+ const startId = containerStartId();
819
+ if (startId === null) {
820
+ if (resumeSpent) return false;
821
+ resumeSpent = true;
822
+ return true;
823
+ }
824
+ let stamped = null;
800
825
  try {
801
- renameSync(RESUME_MARKER, claimed);
826
+ stamped = readFileSync(RESUME_STAMP, "utf8").trim();
802
827
  } catch {
803
- return null; // No marker, or another consumer claimed it first.
828
+ // Never stamped, or the file is gone - either way this container start has not had its resume.
804
829
  }
830
+ if (stamped === startId) return false;
805
831
  try {
806
- const command = readFileSync(claimed, "utf8").trim();
807
- unlinkSync(claimed);
808
- const parts = command.split(/\s+/).filter(Boolean);
809
- return parts.length > 0 ? parts : null;
810
- } catch {
811
- return null;
832
+ writeFileSync(RESUME_STAMP, `${startId}\n`);
833
+ } catch (err) {
834
+ // Every session would resume if this kept failing, so say so once rather than silently repeating it.
835
+ console.warn(`[webterm] could not write ${RESUME_STAMP}: ${err instanceof Error ? err.message : String(err)}`);
812
836
  }
837
+ resumeSpent = true;
838
+ return true;
839
+ }
840
+
841
+ /**
842
+ * The argv that reopens the newest conversation, for the first session of a container start, or null.
843
+ *
844
+ * `agent` is what the session is about to start. Only the agent the interface was launched with is offered
845
+ * the resume (DEFAULT_AGENT, not wherever the default has moved to since); any other agent leaves the
846
+ * chance unspent rather than claiming a conversation it cannot open.
847
+ */
848
+ function resumeArgv(agent, cwd) {
849
+ if (agent !== DEFAULT_AGENT) return null;
850
+ if (!claimResumeChance()) return null;
851
+ return resumeArgvFor(agent, cwd, AGENT_STORES);
813
852
  }
814
853
 
815
854
  // Spawn a new session's agent, in the directory the registry was given. Env is inherited so subscription
816
- // auth flows through. The first session after a container start finds the host-planted marker and continues
817
- // the most recent conversation; every later session starts fresh, which is what the user wants once mid-work.
855
+ // auth flows through. The first session after a container start continues the most recent conversation;
856
+ // every later session starts fresh, which is what the user wants once mid-work.
818
857
  // AGENT_ARGS belong to the agent the launcher named, so any other agent is spawned bare.
819
858
  function spawnAgent({ cwd, agent }) {
820
- const resume = consumeResumeMarker(agent);
859
+ const resume = resumeArgv(agent, cwd);
821
860
  const [spawnCmd, ...baseArgs] = resume ?? [agent, ...(agent === DEFAULT_AGENT ? AGENT_ARGS : [])];
822
861
  if (resume) console.log(`[webterm] resuming most recent conversation: ${resume.join(" ")}`);
823
862
  // Append the browser-awareness flag for claude (fresh or resumed); every other agent is untouched.
@@ -113,9 +113,6 @@ export WEBTERM_AGENT="$AGENT"
113
113
  export WEBTERM_STATE_FILE="$STATE_FILE"
114
114
  # Where the server publishes the key it mints for this run; every URL printed above is read back from it.
115
115
  export WEBTERM_KEY_FILE="$KEY_FILE"
116
- # Guarded like the sibling baked scripts: a detached `docker exec` need not carry HOME, and under
117
- # `set -u` a bare $HOME would abort the launcher before the server ever starts.
118
- export WEBTERM_RESUME_MARKER="${HOME:-/home/devuser}/.chamba-resume-pending"
119
116
  # The workspace name (host-injected as CHAMBA_WORKSPACE) labels the session bar and the browser tab.
120
117
  export WEBTERM_WORKSPACE="${CHAMBA_WORKSPACE:-}"
121
118
  # Where new sessions start. The host passes it (the directory `npx chamba` ran in), so a browser session