chamba 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chamba",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Run AI coding agents in a container, from your browser",
5
5
  "type": "module",
6
6
  "bin": {
@@ -35,11 +35,15 @@ const MAX_JSON_BYTES = 1024 * 1024;
35
35
  const MAX_STORE_DEPTH = 5;
36
36
 
37
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.
38
+ * Every matching file under `dir`, in no particular order, each handed to `onFile`. Returning true from
39
+ * `onFile` stops the walk, which is what lets a caller that only needs to know whether there is one at all
40
+ * stop at the first instead of listing a whole store.
41
+ *
42
+ * Depth-limited, and unreadable dirs contribute nothing. Symlinks are skipped rather than followed: these
43
+ * stores are the agents' own, and a link in one of them would only ever lead somewhere this has no business
44
+ * reading - readdir reports one as neither a file nor a directory, which is what leaves it out.
40
45
  */
41
- export function filesNewestFirst(dir, pattern, depth = MAX_STORE_DEPTH) {
42
- const found = [];
46
+ function walkStore(dir, pattern, depth, onFile) {
43
47
  const queue = [{ dir, depth }];
44
48
  while (queue.length > 0) {
45
49
  const current = queue.shift();
@@ -51,25 +55,52 @@ export function filesNewestFirst(dir, pattern, depth = MAX_STORE_DEPTH) {
51
55
  }
52
56
  for (const entry of entries) {
53
57
  const full = join(current.dir, entry.name);
54
- // Symlinks are skipped rather than followed: these stores are the agents' own, and a link in one
55
- // of them would only ever lead somewhere this lookup has no business reading.
56
58
  if (entry.isDirectory() && current.depth > 0) {
57
59
  queue.push({ dir: full, depth: current.depth - 1 });
58
- } else if (entry.isFile() && pattern.test(entry.name)) {
59
- try {
60
- found.push({ path: full, mtime: statSync(full).mtimeMs });
61
- } catch {
62
- // Vanished between the listing and the stat.
63
- }
60
+ } else if (entry.isFile() && pattern.test(entry.name) && onFile(full)) {
61
+ return;
64
62
  }
65
63
  }
66
64
  }
65
+ }
66
+
67
+ /**
68
+ * Files under `dir` matching `pattern`, newest first, capped.
69
+ * Exported for resume.js, which reads the same stores to answer a different question.
70
+ */
71
+ export function filesNewestFirst(dir, pattern, depth = MAX_STORE_DEPTH) {
72
+ const found = [];
73
+ walkStore(dir, pattern, depth, (path) => {
74
+ try {
75
+ found.push({ path, mtime: statSync(path).mtimeMs });
76
+ } catch {
77
+ // Vanished between the listing and the stat.
78
+ }
79
+ return false;
80
+ });
67
81
  return found
68
82
  .sort((a, b) => b.mtime - a.mtime)
69
83
  .slice(0, MAX_CANDIDATES)
70
84
  .map((entry) => entry.path);
71
85
  }
72
86
 
87
+ /**
88
+ * Whether the store holds anything matching at all, stopping at the first one it finds.
89
+ *
90
+ * The cheap half of the question above, and the only half resume.js asks of codex and opencode: they are
91
+ * reopened by their own flag rather than by id, so all that is wanted is whether there is a conversation
92
+ * there. Answering that by listing and stat-ing everything first is what would make a heavy opencode store -
93
+ * one file per message part - a pause on the way to a new session.
94
+ */
95
+ export function hasAnyFile(dir, pattern, depth = MAX_STORE_DEPTH) {
96
+ let found = false;
97
+ walkStore(dir, pattern, depth, () => {
98
+ found = true;
99
+ return true;
100
+ });
101
+ return found;
102
+ }
103
+
73
104
  /**
74
105
  * The head of a file as text, or "" when it cannot be read.
75
106
  *
@@ -196,6 +227,12 @@ export function claudeConversationId(session, stores) {
196
227
  // rollout-<timestamp>-<uuid>.jsonl - the uuid is the conversation id codex resumes by.
197
228
  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$/;
198
229
 
230
+ // The same rollout once codex has compressed it. Rollouts are compressed when they go cold, so a store whose
231
+ // history has all gone cold holds nothing matching the pattern above - which is why resume.js asks with this
232
+ // one instead. It is deliberately not what a live session is matched against below: compressing a rollout
233
+ // rewrites it, and the fresh mtime that leaves behind looks exactly like a session writing to it.
234
+ export const CODEX_ANY_ROLLOUT = /^rollout-.*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl(\.zst)?$/;
235
+
199
236
  /** Codex's conversation id for a session: the newest rollout file that could be this session's. */
200
237
  export function codexConversationId(session, stores) {
201
238
  for (const path of filesNewestFirst(stores.codexSessions, CODEX_ROLLOUT)) {
@@ -208,13 +245,20 @@ export function codexConversationId(session, stores) {
208
245
 
209
246
  // --- OpenCode ----------------------------------------------------------------------------------------------------------------------------
210
247
 
248
+ // How far under opencode's store a session record sits. It nests everything under
249
+ // storage/session/{info,message,part}/, and only info/ holds one file per session - message/ and part/ hold
250
+ // one per message and one per message part, deeper down and far newer. So the bound is not a cost saving
251
+ // here but the whole of the answer: without it the newest .json in the store is a message part, and the pane
252
+ // would file a conversation's pages under an id that changes with every message.
253
+ const OPENCODE_SESSION_DEPTH = 1;
254
+
211
255
  /**
212
256
  * OpenCode's conversation id for a session: the newest session record in its local store that could be this
213
257
  * session's. The id comes from the record when it carries one and from the filename otherwise, which is the
214
258
  * same value - opencode names the file after the session.
215
259
  */
216
260
  export function opencodeConversationId(session, stores) {
217
- for (const path of filesNewestFirst(stores.opencodeSessions, /\.json$/)) {
261
+ for (const path of filesNewestFirst(stores.opencodeSessions, /\.json$/, OPENCODE_SESSION_DEPTH)) {
218
262
  if (mtimeOf(path) < session.since) continue;
219
263
  const record = readJson(path);
220
264
  const recorded = firstPathField(record);
@@ -17,7 +17,15 @@
17
17
 
18
18
  import { readFileSync } from "node:fs";
19
19
  import { basename, dirname } from "node:path";
20
- import { CLAUDE_TRANSCRIPT, CODEX_ROLLOUT, filesNewestFirst } from "./conversation.js";
20
+ import { CLAUDE_TRANSCRIPT, CODEX_ANY_ROLLOUT, filesNewestFirst, hasAnyFile } from "./conversation.js";
21
+
22
+ // How far below a store's root a conversation can sit, per store, because the shapes are not alike and a walk
23
+ // deep enough for the deepest is wasted on the others. codex files rollouts under a date tree
24
+ // (sessions/2026/08/24/); opencode nests everything under storage/session/{info,message,part}/, where part/
25
+ // holds one file per message part and is the reason a bound matters at all; claude's transcripts sit exactly
26
+ // one level down, in a dir named after the directory the session ran in, and anything deeper there is a
27
+ // session's own scratch rather than this workspace's history.
28
+ const STORE_DEPTH = { claudeProjects: 1, codexSessions: 3, opencodeSessions: 2 };
21
29
 
22
30
  // --- Claude ------------------------------------------------------------------------------------------------------------------------------
23
31
 
@@ -42,7 +50,7 @@ export function claudeProjectKey(containerPath) {
42
50
  * the fallback, and for those the dir name is the only evidence there is.
43
51
  */
44
52
  function claudeResumeId(cwd, stores) {
45
- const transcripts = filesNewestFirst(stores.claudeProjects, CLAUDE_TRANSCRIPT);
53
+ const transcripts = filesNewestFirst(stores.claudeProjects, CLAUDE_TRANSCRIPT, STORE_DEPTH.claudeProjects);
46
54
  const byRecordedDir = firstResumable(transcripts, cwd);
47
55
  if (byRecordedDir !== null) return byRecordedDir;
48
56
  const key = claudeProjectKey(cwd);
@@ -113,10 +121,11 @@ function transcriptFacts(transcriptPath) {
113
121
  //
114
122
  // These two are asked a shallower question than claude - does the store hold a conversation file at all -
115
123
  // 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.
124
+ // conversation is really resumable would tie chamba to two more internal layouts for no gain. codex is asked
125
+ // with the pattern that also matches a compressed rollout, because a cold history is still a history.
117
126
  export const CONTINUE = {
118
127
  opencode: { argv: ["opencode", "--continue"], store: "opencodeSessions", pattern: /\.json$/ },
119
- codex: { argv: ["codex", "resume", "--last"], store: "codexSessions", pattern: CODEX_ROLLOUT },
128
+ codex: { argv: ["codex", "resume", "--last"], store: "codexSessions", pattern: CODEX_ANY_ROLLOUT },
120
129
  };
121
130
 
122
131
  // --- The one entry point -----------------------------------------------------------------------------------------------------------------
@@ -136,7 +145,7 @@ export function resumeArgvFor(agent, cwd, stores) {
136
145
  }
137
146
  const known = CONTINUE[agent];
138
147
  if (!known) return null;
139
- return filesNewestFirst(stores[known.store], known.pattern).length > 0 ? [...known.argv] : null;
148
+ return hasAnyFile(stores[known.store], known.pattern, STORE_DEPTH[known.store]) ? [...known.argv] : null;
140
149
  } catch {
141
150
  // A store that changed shape under us must never take the session down with it - starting fresh is
142
151
  // a worse session than the user asked for, but it is a session.