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,515 @@
1
+ // sessions.js - The session registry: what a session is, who drives it, and what ends it.
2
+ //
3
+ // A session is one live agent process (one PTY) that outlives every browser connection. Nothing here
4
+ // closes a session because a socket went away: a closed browser, a dropped wifi and a slept laptop are
5
+ // indistinguishable from the server, and killing a conversation on any of them is what made the
6
+ // interface lossy. A session ends only when the user ends it or the agent exits on its own; the rest
7
+ // die with the container.
8
+ //
9
+ // The PTY is injected (`spawn`) so this file needs no node-pty and can be unit-tested, and all socket
10
+ // I/O belongs to the caller: the registry only reports what happened through `onEvent`.
11
+
12
+ import { randomBytes } from "node:crypto";
13
+
14
+ // How many distinct colours the browser cycles through when labelling sessions. The palette itself is
15
+ // presentation and lives in the client; the registry only hands out the index, so every window that
16
+ // renders the same session agrees on its colour.
17
+ export const PALETTE_SIZE = 5;
18
+
19
+ // --- Is the agent working? ---------------------------------------------------------------------------------------------------------------
20
+ //
21
+ // Nothing tells us directly: a PTY carries bytes, not "thinking" and "waiting". What it does carry is a
22
+ // rhythm. An agent at work redraws constantly (a spinner, a tool line, streamed text); an agent waiting for
23
+ // you writes nothing at all. So a stretch of output means working, and going quiet for WORK_QUIET_MS means it
24
+ // stopped. This stays agent-agnostic on purpose - no output is parsed, so nothing here breaks when a CLI
25
+ // changes its spinner, and a plain shell behaves sensibly too.
26
+ //
27
+ // A stretch, though, not a byte. An idle TUI is not perfectly silent: a session left alone still emits the
28
+ // odd small redraw, and reading one of those as "working" is what makes a tab flicker at a user who is
29
+ // waiting for their turn to type. So a stretch has to keep streaming for WORK_WARMUP_MS before it counts,
30
+ // which no lone redraw ever does.
31
+ //
32
+ // The other thing that is not the agent is the user. What you type comes straight back as echo, and a TUI
33
+ // answers each keystroke by redrawing its whole input box - plenty of output, none of it work. Output within
34
+ // ECHO_MS of a keystroke on that session is therefore left out of the rhythm entirely.
35
+ //
36
+ // One threshold decides both halves of this. A stretch that outlasted the warm-up and then stopped is what
37
+ // raises the "it finished" alert, with nothing extra asked of it. An earlier version wanted a longer stretch
38
+ // before a tab could light up, on the theory that short work is not worth interrupting anyone for - but that
39
+ // can only ever withhold the ending of a signal already on screen, since anything long enough to alert has
40
+ // been showing as working since the warm-up. Work between the two lengths turned the tab on and then off
41
+ // again with nothing to close it. Whatever is worth showing as work is worth reporting the end of.
42
+ //
43
+ // The length of the stretch, that is. How long the quiet has to last is a separate question, and the two
44
+ // states answer it differently. The tab light is a live reading and can be wrong for a moment - it goes out
45
+ // the instant the output does, and comes back when it comes back. "It finished and is waiting for you" is a
46
+ // claim about the turn being over, so it needs more than a gap: an agent pauses mid-turn (a slow first token,
47
+ // a tool that prints nothing while it runs) and carries straight on. So going quiet only starts a countdown,
48
+ // and the alert is raised at the end of it, if the session is still quiet and did not go back to work. Work
49
+ // that resumes takes the countdown with it and is never reported as finished at all. The cost is that a real
50
+ // ending is announced a few seconds late, which is far too small a delay for anyone to notice.
51
+
52
+ /** No output for this long means the agent stopped working. */
53
+ export const WORK_QUIET_MS = 1_500;
54
+
55
+ /** How long output has to keep coming before the session is shown as working. */
56
+ export const WORK_WARMUP_MS = 1_200;
57
+
58
+ /** How long a session has to stay stopped before the stop counts as the end of the turn. */
59
+ export const ALERT_SETTLE_MS = 4_000;
60
+
61
+ /** Output this soon after the user typed is the echo of their own keystroke, not the agent. */
62
+ export const ECHO_MS = 500;
63
+
64
+ /** How often tick() should be called. Fine enough that the thresholds above land where they say. */
65
+ export const WORK_TICK_MS = 300;
66
+
67
+ // --- Are you there? ----------------------------------------------------------------------------------------------------------------------
68
+ //
69
+ // Every ending lights the tab, the browser title and the icon, whoever is watching: a light that is sometimes
70
+ // redundant costs nothing, and one rule for every session is easier to trust than one rule for the tab you have
71
+ // open and another for the rest. A sound is the opposite - it cannot be taken back and it reaches you in another
72
+ // room - so the sound, and only the sound, asks whether you are there.
73
+ //
74
+ // It asks it by interaction with that session: a keystroke, a click, a scroll. Not by browser focus, which was
75
+ // the first answer and the wrong one - it varies by browser and platform, it has to be re-reported on every
76
+ // reconnect, and each hole in it fails towards silence, which is the one direction a notification must not fail
77
+ // in.
78
+ //
79
+ // The question is asked once, after the ending, and only about the seconds that follow it: the page holds the
80
+ // alert for a short while, and a touch inside that window puts it out before it is ever heard. Nothing before
81
+ // the ending counts, including the prompt that started the turn - sitting and watching a reply arrive is not
82
+ // using the session, and the whole complaint this rule answers is a finish you did not hear. The cost is a
83
+ // chime you did not need when you were reading all along, which is a few seconds of your attention; the cost
84
+ // the other way is a turn that finished an hour ago and you never knew.
85
+
86
+ // Longest a user-chosen session name may be. The bar is one row, so a name that ran on would push the
87
+ // tabs around; past this it is cut. Sanitising lives here so every window agrees on the stored name.
88
+ export const MAX_NAME_LENGTH = 40;
89
+
90
+ // Turn a raw name from the client into what gets stored: control characters (newlines, tabs, the lot)
91
+ // stripped so a name is always one clean line, trimmed, and cut to the cap. An empty result means
92
+ // "no name" - the session falls back to its default label. Filtering by code point rather than a regex
93
+ // keeps the source free of literal control characters.
94
+ export function cleanName(raw) {
95
+ if (typeof raw !== "string") return null;
96
+ let kept = "";
97
+ for (const ch of raw) {
98
+ const code = ch.codePointAt(0);
99
+ // Drop C0 control characters (below space) and DEL; keep everything printable, emoji included.
100
+ if (code >= 0x20 && code !== 0x7f) kept += ch;
101
+ }
102
+ const cleaned = kept.trim().slice(0, MAX_NAME_LENGTH).trim();
103
+ return cleaned || null;
104
+ }
105
+
106
+ // WebSocket.OPEN. The registry has to tell a live socket from a dead one to know whether taking a
107
+ // session over needs to ask first; 1 is the protocol-level constant, not a ws-library detail.
108
+ const SOCKET_OPEN = 1;
109
+
110
+ function isOpen(socket) {
111
+ return Boolean(socket) && socket.readyState === SOCKET_OPEN;
112
+ }
113
+
114
+ /**
115
+ * Create the session registry.
116
+ *
117
+ * - `spawn({ cwd })` returns a PTY-like object: { onData, onExit, write, resize, kill }.
118
+ * - `maxSessions` caps how many agents may be alive at once. This is about memory, not correctness.
119
+ * - `maxBuffer` caps the replay buffer kept per session (bytes).
120
+ * - `onEvent(event)` is called with { t: "replay" | "out" | "taken" | "exit" | "changed", ... }.
121
+ * Events that target one window carry that window's socket as `client`; "changed" means the session
122
+ * list moved and every window needs to hear about it.
123
+ */
124
+ export function createRegistry({ spawn, maxSessions, maxBuffer, onEvent }) {
125
+ // Insertion-ordered, so iteration is always oldest session first.
126
+ const sessions = new Map();
127
+ // Monotonic: numbers are never reused, so "claude 7" means the same session in every window for as
128
+ // long as it lives, and its colour (derived from the same counter) is predictable.
129
+ let seq = 0;
130
+
131
+ function emit(event) {
132
+ onEvent?.(event);
133
+ }
134
+
135
+ // --- Reading the registry ------------------------------------------------------------------------------------------------------------
136
+
137
+ function wireEntry(session) {
138
+ return {
139
+ id: session.id,
140
+ label: session.label,
141
+ name: session.name,
142
+ // Which CLI this session is running. Several agents can be live at once, so the bar says which.
143
+ agent: session.agent,
144
+ colorIndex: session.colorIndex,
145
+ createdAt: session.createdAt,
146
+ // Relative to the workspace root, so "" reads as "the usual place" and the bar shows nothing.
147
+ cwd: session.cwdLabel,
148
+ attached: isOpen(session.client),
149
+ unread: session.unread,
150
+ working: session.working,
151
+ // "It finished something" - the one state the bar shouts about. Raised on every ending, and it is
152
+ // also what the sound waits on: an alert still standing a few seconds later is one nobody caught.
153
+ attention: session.attention,
154
+ };
155
+ }
156
+
157
+ // What the browser needs to draw the session bar. The replay buffer is deliberately not in here.
158
+ function list() {
159
+ return [...sessions.values()].map(wireEntry);
160
+ }
161
+
162
+ function count() {
163
+ return sessions.size;
164
+ }
165
+
166
+ /**
167
+ * The user touched this session - typed into it, clicked in it, scrolled it. That puts out an alert that is
168
+ * already standing, since you cannot be missing an ending you are sitting in, and putting it out is also what
169
+ * calls off the sound the page was holding.
170
+ * Broadcasts only when something actually changed - this is called from every keystroke.
171
+ */
172
+ function seen(sid) {
173
+ const session = sessions.get(sid);
174
+ if (!session?.attention) return;
175
+ session.attention = false;
176
+ emit({ t: "changed" });
177
+ }
178
+
179
+ // Sessions a window is actually watching right now. This is what the host asks about before it
180
+ // offers to stop the container.
181
+ function attachedCount() {
182
+ let total = 0;
183
+ for (const session of sessions.values()) {
184
+ if (isOpen(session.client)) total += 1;
185
+ }
186
+ return total;
187
+ }
188
+
189
+ function get(sid) {
190
+ return sessions.get(sid);
191
+ }
192
+
193
+ // The session a given window is driving, if any. One window drives at most one session.
194
+ function sessionFor(client) {
195
+ for (const session of sessions.values()) {
196
+ if (session.client === client) return session;
197
+ }
198
+ return undefined;
199
+ }
200
+
201
+ function attachedSid(client) {
202
+ return sessionFor(client)?.id ?? null;
203
+ }
204
+
205
+ // --- Who may drive a session ---------------------------------------------------------------------------------------------------------
206
+
207
+ /**
208
+ * True when this window can take the session without asking. The live socket holding it is the only
209
+ * thing that decides: a dead holder is the sleep case, and a closed one is the reload case, neither of
210
+ * which is someone else's conversation. A window identifier deliberately does not get a say - browsers
211
+ * copy sessionStorage into a duplicated tab, so any id the page can hold is shared by the duplicate,
212
+ * and trusting it would let that duplicate silently take a live session and then fight over the one
213
+ * PTY's size. Erring towards one extra prompt is the safe direction.
214
+ */
215
+ function available(session, client) {
216
+ const holder = session.client;
217
+ return holder === client || !isOpen(holder);
218
+ }
219
+
220
+ /**
221
+ * Where a freshly connected window should land: the session it was last looking at when it can have
222
+ * it back, else the oldest one nobody is watching, else nothing - every session is being driven
223
+ * elsewhere, so the user picks from the bar (and takes over deliberately).
224
+ */
225
+ function pickForClient(client, wantSid) {
226
+ const wanted = wantSid ? sessions.get(wantSid) : undefined;
227
+ if (wanted && available(wanted, client)) return wanted.id;
228
+ for (const session of sessions.values()) {
229
+ if (available(session, client)) return session.id;
230
+ }
231
+ return null;
232
+ }
233
+
234
+ // --- Attaching and detaching ---------------------------------------------------------------------------------------------------------
235
+
236
+ function bind(session, client) {
237
+ // A window drives one session at a time, so joining this one leaves whatever it held before.
238
+ const previous = sessionFor(client);
239
+ if (previous && previous !== session) previous.client = null;
240
+ session.client = client;
241
+ session.unread = false;
242
+ // Visiting the session is what the alert was asking for, so it is spent the moment you arrive.
243
+ session.attention = false;
244
+ // Replay is emitted before anything else can reach this socket, so the window resets its
245
+ // terminal and repaints the session exactly once - never on top of what was already there.
246
+ emit({ t: "replay", sid: session.id, client, data: session.buffer });
247
+ emit({ t: "changed" });
248
+ }
249
+
250
+ /** "attached" | "busy" | "gone". "busy" is the only case the browser has to ask the user about. */
251
+ function attach(sid, client) {
252
+ const session = sessions.get(sid);
253
+ if (!session) return "gone";
254
+ if (!available(session, client)) return "busy";
255
+ bind(session, client);
256
+ return "attached";
257
+ }
258
+
259
+ /** Take a session over from the window that holds it. The displaced window is told, and can take it back. */
260
+ function takeover(sid, client) {
261
+ const session = sessions.get(sid);
262
+ if (!session) return "gone";
263
+ const displaced = available(session, client) ? null : session.client;
264
+ bind(session, client);
265
+ if (displaced) emit({ t: "taken", sid: session.id, client: displaced });
266
+ return "attached";
267
+ }
268
+
269
+ /**
270
+ * A window went away. This clears the attachment and does nothing else - no timer, no kill.
271
+ * The agent keeps running and keeps buffering, which is what makes a session survive sleep.
272
+ */
273
+ function detach(client) {
274
+ const session = sessionFor(client);
275
+ if (!session) return;
276
+ session.client = null;
277
+ emit({ t: "changed" });
278
+ }
279
+
280
+ // --- Lifecycle -----------------------------------------------------------------------------------------------------------------------
281
+
282
+ function onData(session, data) {
283
+ session.buffer += data;
284
+ if (session.buffer.length > maxBuffer) {
285
+ session.buffer = session.buffer.slice(session.buffer.length - maxBuffer);
286
+ }
287
+ const now = Date.now();
288
+ // Record the rhythm, but only for output the agent produced of its own accord - the echo of a
289
+ // keystroke is the user's, and counting it would put a tab to work while its owner types into it.
290
+ if (now - session.lastInputAt >= ECHO_MS) {
291
+ // A gap this long ended the previous stretch, so this byte starts a new one.
292
+ if (now - session.lastOutputAt >= WORK_QUIET_MS) session.streamSince = now;
293
+ session.lastOutputAt = now;
294
+ }
295
+ if (isOpen(session.client)) {
296
+ emit({ t: "out", sid: session.id, client: session.client, data });
297
+ } else if (!session.unread) {
298
+ // Nobody is watching: remember that this session has something new to show, once, on the flip
299
+ // and not on every chunk - a chatty background agent would otherwise be a broadcast storm.
300
+ session.unread = true;
301
+ emit({ t: "changed" });
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Move every session's working state on by one step, and raise the alert on the ones that just stopped.
307
+ * Called on a timer by the server rather than driven by a timer per session: the state is a function of
308
+ * "how long output has been running", so one sweep answers it for every session, and a registry with no
309
+ * timers of its own stays testable by calling this by hand. Every flip is announced here, so the whole
310
+ * sweep costs at most one broadcast however many sessions moved.
311
+ *
312
+ * Every ending raises the alert, whoever is watching and whatever they were doing a moment ago. Whether it
313
+ * is also worth a sound is settled afterwards, by whether anyone touches the session - see "Are you there?".
314
+ */
315
+ function tick() {
316
+ const now = Date.now();
317
+ let changed = false;
318
+ for (const session of sessions.values()) {
319
+ // Length of the current stretch, measured to its last byte rather than to now: the quiet period
320
+ // is how we noticed it ended, not part of the work.
321
+ const streamed = session.lastOutputAt - session.streamSince;
322
+ const working = now - session.lastOutputAt < WORK_QUIET_MS && streamed >= WORK_WARMUP_MS;
323
+ if (working !== session.working) {
324
+ session.working = working;
325
+ // Going quiet is a candidate ending, so it starts the countdown rather than raising the alert.
326
+ // No length test on the work itself: reaching this line at all means the session had been
327
+ // showing as working, so the warm-up has already vouched for the stretch. Starting up again
328
+ // takes the countdown with it, and makes any earlier "it finished" stale.
329
+ session.stoppedAt = working ? 0 : now;
330
+ if (working) session.attention = false;
331
+ changed = true;
332
+ }
333
+ // The stop held: the turn really is over, and this is the moment worth interrupting the user for.
334
+ // Still-quiet is checked again here because a session can be off the light and yet be producing
335
+ // output - a stretch that has not reached the warm-up - and saying "it finished" over the top of
336
+ // output arriving is the mistake this whole countdown is here to avoid.
337
+ if (session.stoppedAt && now - session.stoppedAt >= ALERT_SETTLE_MS && now - session.lastOutputAt >= WORK_QUIET_MS) {
338
+ session.stoppedAt = 0;
339
+ session.attention = true;
340
+ changed = true;
341
+ }
342
+ }
343
+ if (changed) emit({ t: "changed" });
344
+ }
345
+
346
+ /**
347
+ * The user typed into a session. The write to the PTY belongs to the caller; this is the timestamp that
348
+ * keeps the echo coming back out of the working rhythm, and typing is also the plainest way of being here,
349
+ * so it counts as having seen the session.
350
+ */
351
+ function typed(sid) {
352
+ const session = sessions.get(sid);
353
+ if (!session) return;
354
+ session.lastInputAt = Date.now();
355
+ seen(sid);
356
+ }
357
+
358
+ // The agent exited by itself (/exit, a crash). There is nothing left to reattach to, so the session
359
+ // goes away - which is the one close the user did not ask for, and the only one that needs no confirm.
360
+ function onExit(session) {
361
+ // close() removes the session before killing the PTY, so this is reached only by a real exit.
362
+ if (sessions.get(session.id) !== session) return;
363
+ sessions.delete(session.id);
364
+ // The display name (custom, else the default) is what the server names it by in the log.
365
+ emit({ t: "exit", sid: session.id, label: session.name || session.label, agent: session.agent });
366
+ emit({ t: "changed" });
367
+ }
368
+
369
+ /**
370
+ * Start a session running `agent` in `cwd` (absolute), shown as `cwdLabel` (relative to the workspace).
371
+ * All three are handed in rather than worked out here: which agents may be run, which directories exist
372
+ * and how one is written for the browser belong to the server, and the registry only carries the values -
373
+ * the command and path to the PTY, the labels to the bar.
374
+ *
375
+ * { ok: true, session } or { ok: false, error: "cap" } when the limit is reached.
376
+ */
377
+ function create({ cwd, cwdLabel, agent } = {}) {
378
+ if (sessions.size >= maxSessions) return { ok: false, error: "cap" };
379
+ const now = Date.now();
380
+ seq += 1;
381
+ const session = {
382
+ id: randomBytes(6).toString("hex"),
383
+ seq,
384
+ // Named after what it runs, since the bar can hold several different agents at once.
385
+ label: `${agent} ${seq}`,
386
+ // A user-chosen label, or null to fall back to `label`. The number in `label` is always kept,
387
+ // so clearing the name shows "claude 7" again and the tooltip can still surface it.
388
+ name: null,
389
+ agent,
390
+ colorIndex: (seq - 1) % PALETTE_SIZE,
391
+ createdAt: now,
392
+ // Where the agent runs, and how that reads in the bar ("" for the workspace root).
393
+ cwd,
394
+ cwdLabel: cwdLabel ?? "",
395
+ term: null,
396
+ buffer: "",
397
+ client: null,
398
+ unread: false,
399
+ // The working rhythm: when the last byte arrived, when this stretch of output started, when the
400
+ // user last typed (so the echo can be skipped), whether it is still going, when it stopped (0
401
+ // once that stop has been answered, either by the alert or by work starting again), and whether
402
+ // the end of it is still waiting to be seen.
403
+ lastOutputAt: 0,
404
+ streamSince: 0,
405
+ lastInputAt: 0,
406
+ working: false,
407
+ stoppedAt: 0,
408
+ attention: false,
409
+ };
410
+ session.term = spawn({ cwd, agent });
411
+ session.term.onData((data) => onData(session, data));
412
+ session.term.onExit(() => onExit(session));
413
+ sessions.set(session.id, session);
414
+ emit({ t: "changed" });
415
+ return { ok: true, session };
416
+ }
417
+
418
+ /**
419
+ * Where a window goes when the session it was driving is closed: the next session along the bar, else
420
+ * the one before it, skipping any that another window is watching. `index` is the position the closed
421
+ * session held, so from the caller's point of view this is "the closest tab that is free".
422
+ */
423
+ function neighbourFor(index, client) {
424
+ const remaining = [...sessions.values()];
425
+ // The session at `index` is now the one that was to its right.
426
+ for (let i = index; i < remaining.length; i++) {
427
+ if (available(remaining[i], client)) return remaining[i];
428
+ }
429
+ for (let i = index - 1; i >= 0; i--) {
430
+ if (available(remaining[i], client)) return remaining[i];
431
+ }
432
+ return null;
433
+ }
434
+
435
+ /** End one session on purpose. Returns false when it was already gone. */
436
+ function close(sid) {
437
+ const session = sessions.get(sid);
438
+ if (!session) return false;
439
+ const index = [...sessions.keys()].indexOf(sid);
440
+ // Whoever was driving it is about to be left looking at a dead screen, so remember them.
441
+ const orphan = isOpen(session.client) ? session.client : null;
442
+ // Removed first so the PTY's own exit callback knows this was deliberate and stays quiet.
443
+ sessions.delete(sid);
444
+ try {
445
+ session.term?.kill();
446
+ } catch {
447
+ // Already gone.
448
+ }
449
+ // Move that window to the closest session it can have, the way closing a browser tab lands you on
450
+ // its neighbour. This covers the window that asked for the close and any other window that
451
+ // happened to be watching the same session.
452
+ const next = orphan ? neighbourFor(index, orphan) : null;
453
+ if (next) bind(next, orphan);
454
+ else emit({ t: "changed" });
455
+ return true;
456
+ }
457
+
458
+ /**
459
+ * Relabel a session. `rawName` is sanitised here (the one place that decides what a name is); an empty
460
+ * result clears the name, so the session shows its default `label` again. Renaming touches nothing but
461
+ * the label - not who drives it, not the buffer - and every window hears the new bar. False if gone.
462
+ */
463
+ function rename(sid, rawName) {
464
+ const session = sessions.get(sid);
465
+ if (!session) return false;
466
+ session.name = cleanName(rawName);
467
+ emit({ t: "changed" });
468
+ return true;
469
+ }
470
+
471
+ /**
472
+ * Move a session to a new place in the bar. Order is registry state, not a per-window preference: every
473
+ * window draws the same bar, so a drag has to move it here or the next broadcast would put it back.
474
+ * `index` is the position the session should end up at in the resulting list, and is clamped rather than
475
+ * rejected - a window whose bar moved under it mid-drag should still land somewhere sensible. Only the
476
+ * order changes: not the label, not the colour, not who drives it. False when nothing moved.
477
+ */
478
+ function reorder(sid, index) {
479
+ if (!sessions.has(sid)) return false;
480
+ const order = [...sessions.keys()];
481
+ const from = order.indexOf(sid);
482
+ const wanted = Number.isInteger(index) ? index : from;
483
+ const to = Math.max(0, Math.min(wanted, order.length - 1));
484
+ if (from === to) return false;
485
+ order.splice(from, 1);
486
+ order.splice(to, 0, sid);
487
+ // A Map keeps insertion order and cannot reorder in place, so it is rebuilt in the new order. The
488
+ // session objects are the same ones, so PTYs, buffers and attachments come along untouched.
489
+ const moved = order.map((id) => [id, sessions.get(id)]);
490
+ sessions.clear();
491
+ for (const [id, session] of moved) sessions.set(id, session);
492
+ emit({ t: "changed" });
493
+ return true;
494
+ }
495
+
496
+ return {
497
+ attach,
498
+ attachedCount,
499
+ attachedSid,
500
+ close,
501
+ count,
502
+ create,
503
+ detach,
504
+ get,
505
+ list,
506
+ pickForClient,
507
+ rename,
508
+ reorder,
509
+ seen,
510
+ sessionFor,
511
+ takeover,
512
+ tick,
513
+ typed,
514
+ };
515
+ }
@@ -0,0 +1,135 @@
1
+ // snapshot.js - What claude wrote down about a session, and which session it belongs to.
2
+ //
3
+ // The status line script runs on every prompt render and writes one small file per conversation: the model,
4
+ // how much context it is holding, what is left of the five-hour quota, and the pid of the claude process that
5
+ // wrote it. That file is the only place any of this exists - nothing here asks claude anything, and there is
6
+ // nothing to ask - so it is what the strip above the composer is drawn from, and what tells the pane which
7
+ // conversation a session is having.
8
+ //
9
+ // The pid is what turns a directory of files into a per-session fact. A snapshot belongs to a session when the
10
+ // process that wrote it is that session's PTY leader or something it started. The start time recorded beside
11
+ // the pid is the other half: the directory is a chamba mount, so it holds files written by containers that are
12
+ // gone, and their pids can be live again as something else entirely. A snapshot whose process is not the one
13
+ // that wrote it is nobody's.
14
+ //
15
+ // What the pid does not do is authenticate the writer. Everything in the container runs as the same user, so
16
+ // a process that wanted to could write a snapshot naming a pid in another session's tree: that session's
17
+ // strip would draw its numbers, and - since this is also where the pane gets its conversation id - the
18
+ // session's pane would adopt the directory the snapshot named. Neither reaches past what a process running
19
+ // as this user can already do to those directories directly. The check is against honest mistakes: one
20
+ // session's real snapshot showing up under another, or a recycled pid resurrecting a dead conversation.
21
+ //
22
+ // Every read here fails towards null. A file half-written, a field that changed shape in a Claude Code
23
+ // release, a directory that does not exist yet - all of them mean "no snapshot for this session", which is an
24
+ // ordinary answer: a session gets one a second after its agent starts, and never at all when it runs an agent
25
+ // that writes none.
26
+
27
+ import { readdirSync, readFileSync, statSync } from "node:fs";
28
+ import { join } from "node:path";
29
+ import { isSameProcess, isSelfOrDescendant } from "./proc.js";
30
+
31
+ // The largest a snapshot can be and still be read. The script writes a few hundred bytes; the cap only keeps
32
+ // an unrelated file that happens to sit in the directory from being pulled into memory.
33
+ const MAX_SNAPSHOT_BYTES = 64 * 1024;
34
+
35
+ // How many files one lookup will consider, newest first. A home directory that has been through many
36
+ // conversations accumulates them, and the one being looked for was written seconds ago.
37
+ const MAX_CANDIDATES = 40;
38
+
39
+ /**
40
+ * Whether an agent writes snapshots at all. Only claude does - the status line is a Claude Code feature, and
41
+ * the file is a side effect of it. A session running anything else has no status to show, which is why the
42
+ * server never sends it a frame rather than sending an empty one.
43
+ */
44
+ export function writesSnapshots(agent) {
45
+ return agent === "claude";
46
+ }
47
+
48
+ /** The snapshot files in `dir`, newest first and capped. A directory that is not there contributes nothing. */
49
+ function snapshotFiles(dir) {
50
+ let entries;
51
+ try {
52
+ entries = readdirSync(dir, { withFileTypes: true });
53
+ } catch {
54
+ return [];
55
+ }
56
+ const found = [];
57
+ for (const entry of entries) {
58
+ if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
59
+ const path = join(dir, entry.name);
60
+ try {
61
+ found.push({ path, mtime: statSync(path).mtimeMs });
62
+ } catch {
63
+ // Vanished between the listing and the stat.
64
+ }
65
+ }
66
+ return found
67
+ .sort((a, b) => b.mtime - a.mtime)
68
+ .slice(0, MAX_CANDIDATES)
69
+ .map((entry) => entry.path);
70
+ }
71
+
72
+ /** One snapshot as an object, or null. Size-checked first: a file this big is not one of these. */
73
+ function readSnapshot(path) {
74
+ try {
75
+ if (statSync(path).size > MAX_SNAPSHOT_BYTES) return null;
76
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
77
+ return parsed && typeof parsed === "object" ? parsed : null;
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ /** A finite number, or null for anything else - including a field that is missing or has gone null. */
84
+ function num(value) {
85
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
86
+ }
87
+
88
+ /** A non-empty string, or null. */
89
+ function text(value) {
90
+ return typeof value === "string" && value !== "" ? value : null;
91
+ }
92
+
93
+ /**
94
+ * The newest snapshot that belongs to this session, or null when it has not written one yet.
95
+ *
96
+ * `session` is `{ pid }` - the PTY leader, the root of the process tree the agent runs in - and `dir` is the
97
+ * directory the status line writes to. The two checks are the whole of the match: the writer is inside this
98
+ * session's tree, and it is still the process that wrote it.
99
+ */
100
+ export function snapshotFor(session, dir) {
101
+ const leader = Number(session?.pid);
102
+ if (!Number.isInteger(leader)) return null;
103
+ for (const path of snapshotFiles(dir)) {
104
+ const snapshot = readSnapshot(path);
105
+ const claudePid = Number(snapshot?.claude_pid);
106
+ if (!Number.isInteger(claudePid)) continue;
107
+ if (!isSelfOrDescendant(claudePid, leader)) continue;
108
+ if (!isSameProcess(claudePid, snapshot.claude_pid_start ?? null)) continue;
109
+ return snapshot;
110
+ }
111
+ return null;
112
+ }
113
+
114
+ /**
115
+ * What the status strip draws for this session, or null when there is no snapshot for it.
116
+ *
117
+ * Every field is normalised here rather than in the browser: a field the strip cannot use is null, and a null
118
+ * is what the strip leaves out. That way a Claude Code release that renames something takes a segment off the
119
+ * strip instead of putting a "NaN%" on it.
120
+ */
121
+ export function statusFor(session, dir) {
122
+ const snapshot = snapshotFor(session, dir);
123
+ if (snapshot === null) return null;
124
+ return {
125
+ model: text(snapshot.model),
126
+ effort: text(snapshot.effort),
127
+ tokens: num(snapshot.context_tokens),
128
+ windowSize: num(snapshot.context_window_size),
129
+ contextPct: num(snapshot.context_used_pct),
130
+ quotaPct: num(snapshot.quota_left_pct),
131
+ quotaResetsAt: num(snapshot.quota_resets_at),
132
+ version: text(snapshot.version),
133
+ updatedAt: num(snapshot.updated_at),
134
+ };
135
+ }