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