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.
- package/LICENSE +21 -0
- package/README.md +47 -44
- package/bin/chamba.js +212 -0
- package/dist/commands/advanced.js +278 -0
- package/dist/commands/dev.js +619 -0
- package/dist/commands/doctor.js +29 -0
- package/dist/commands/menu.js +80 -0
- package/dist/commands/onboard.js +229 -0
- package/dist/commands/settings.js +349 -0
- package/dist/lib/agent-context.js +177 -0
- package/dist/lib/browser.js +40 -0
- package/dist/lib/chamba-yaml.js +191 -0
- package/dist/lib/constants.js +135 -0
- package/dist/lib/dockerfile-builder.js +267 -0
- package/dist/lib/env.js +78 -0
- package/dist/lib/global-config.js +66 -0
- package/dist/lib/pnpm-store.js +19 -0
- package/dist/lib/ports.js +210 -0
- package/dist/lib/safe-rm.js +26 -0
- package/dist/lib/sessions.js +34 -0
- package/dist/lib/shadows.js +174 -0
- package/dist/lib/webterm.js +490 -0
- package/dist/lib/workspace-identity.js +260 -0
- package/package.json +61 -23
- package/schema/chamba.schema.json +65 -0
- package/templates/.dockerignore +3 -0
- package/templates/Dockerfile +173 -0
- package/templates/claude-statusline.sh +120 -0
- package/templates/context/baseline.md +13 -0
- package/templates/context/context-usage.md +1 -0
- package/templates/context/git-mode-local.md +1 -0
- package/templates/context/git-mode-strict.md +1 -0
- package/templates/context/git-mode-unrestricted.md +1 -0
- package/templates/context/git-unavailable.md +1 -0
- package/templates/context/shadow-paths.md +3 -0
- package/templates/context-usage.sh +249 -0
- package/templates/git-readonly-wrapper.mjs +309 -0
- package/templates/npmrc +2 -0
- package/templates/pnpm-config.yaml +9 -0
- package/templates/runtime-constants.mjs +18 -0
- package/templates/skills/chamba-statusline/SKILL.md +79 -0
- package/templates/skills/context-usage/SKILL.md +53 -0
- package/templates/skills/web-pane/SKILL.md +62 -0
- package/templates/startup-git-mode.mjs +145 -0
- package/templates/startup.mjs +333 -0
- package/templates/webpane.sh +126 -0
- package/templates/webterm/README.md +157 -0
- package/templates/webterm/artifacts.js +583 -0
- package/templates/webterm/config.js +269 -0
- package/templates/webterm/context/claude.md +14 -0
- package/templates/webterm/conversation.js +248 -0
- package/templates/webterm/package-lock.json +884 -0
- package/templates/webterm/package.json +17 -0
- package/templates/webterm/pane.js +156 -0
- package/templates/webterm/proc.js +89 -0
- package/templates/webterm/public/app/alerts.js +472 -0
- package/templates/webterm/public/app/cards.js +123 -0
- package/templates/webterm/public/app/clipboard.js +229 -0
- package/templates/webterm/public/app/composer.js +226 -0
- package/templates/webterm/public/app/connection.js +342 -0
- package/templates/webterm/public/app/dictation.js +98 -0
- package/templates/webterm/public/app/dom.js +37 -0
- package/templates/webterm/public/app/drafts.js +244 -0
- package/templates/webterm/public/app/frames.js +166 -0
- package/templates/webterm/public/app/main.js +82 -0
- package/templates/webterm/public/app/new-session.js +188 -0
- package/templates/webterm/public/app/note.js +24 -0
- package/templates/webterm/public/app/pane-frame.js +166 -0
- package/templates/webterm/public/app/pane.js +353 -0
- package/templates/webterm/public/app/state.js +51 -0
- package/templates/webterm/public/app/status-strip.js +170 -0
- package/templates/webterm/public/app/tabs.js +475 -0
- package/templates/webterm/public/app/terminal.js +102 -0
- package/templates/webterm/public/app/theme.js +46 -0
- package/templates/webterm/public/favicon.svg +21 -0
- package/templates/webterm/public/index.html +105 -0
- package/templates/webterm/public/styles.css +1193 -0
- package/templates/webterm/server.js +1142 -0
- package/templates/webterm/sessions.js +515 -0
- package/templates/webterm/snapshot.js +135 -0
- package/templates/webterm.sh +167 -0
- package/dist/cli.js +0 -1582
- package/dist/server.js +0 -1831
- package/inject/annotate.js +0 -18
- package/skill/README.md +0 -12
- package/skill/SKILL.md +0 -93
- package/web/assets/highlighted-body-OFNGDK62-Bn4Eu7CG.js +0 -1
- package/web/assets/index-B9DI4F1Z.js +0 -202
- package/web/assets/index-DK_n6CTo.css +0 -2
- package/web/assets/mermaid-GHXKKRXX-CEMduc-U.js +0 -1
- 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
|
+
}
|