chamba 0.5.1 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/dev.js +11 -11
- package/dist/commands/onboard.js +2 -1
- package/dist/commands/settings.js +1 -1
- package/dist/lib/agent-context.js +10 -3
- package/dist/lib/chamba-yaml.js +1 -1
- package/dist/lib/constants.js +4 -4
- package/dist/lib/git-env.js +21 -0
- package/dist/lib/ports.js +4 -4
- package/dist/lib/shadows.js +2 -0
- package/dist/lib/webterm.js +7 -7
- package/package.json +1 -3
- package/templates/claude-statusline.sh +7 -4
- package/templates/context/web-pane-craft.md +57 -0
- package/templates/context-usage.sh +30 -13
- package/templates/skills/chamba-statusline/SKILL.md +16 -6
- package/templates/skills/web-pane/SKILL.md +1 -55
- package/templates/webterm/README.md +16 -13
- package/templates/webterm/artifacts.js +25 -12
- package/templates/webterm/config.js +9 -9
- package/templates/webterm/conversation.js +3 -3
- package/templates/webterm/pane.js +23 -4
- package/templates/webterm/proc.js +1 -1
- package/templates/webterm/public/app/alerts.js +5 -5
- package/templates/webterm/public/app/composer.js +25 -4
- package/templates/webterm/public/app/connection.js +2 -2
- package/templates/webterm/public/app/dictation.js +1 -1
- package/templates/webterm/public/app/dom.js +4 -0
- package/templates/webterm/public/app/frames.js +4 -2
- package/templates/webterm/public/app/main.js +6 -2
- package/templates/webterm/public/app/new-session.js +1 -1
- package/templates/webterm/public/app/pane-arrival.js +36 -0
- package/templates/webterm/public/app/pane.js +98 -13
- package/templates/webterm/public/app/state.js +1 -1
- package/templates/webterm/public/app/status-strip.js +26 -34
- package/templates/webterm/public/app/strip-format.js +36 -0
- package/templates/webterm/public/app/tabs.js +67 -5
- package/templates/webterm/public/index.html +23 -3
- package/templates/webterm/public/styles.css +159 -14
- package/templates/webterm/server.js +39 -13
- package/templates/webterm/sessions.js +7 -7
- package/templates/webterm/snapshot.js +6 -4
- package/templates/skills/context-usage/SKILL.md +0 -53
|
@@ -252,6 +252,38 @@ body {
|
|
|
252
252
|
}
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
+
/* ---- A page is waiting in this session's pane ---- */
|
|
256
|
+
/*
|
|
257
|
+
Published while this window was looking at another session. Quieter than the alert above, and about the
|
|
258
|
+
pane rather than the terminal, so it is not the .unread state either: the tab takes the session's colour on
|
|
259
|
+
its border and grows a small page beside its age, and holds that until the session is visited and the page
|
|
260
|
+
opens itself. One flash on arrival, by the same negative-delay trick, since the bar is rebuilt from scratch
|
|
261
|
+
on every frame. PAGE_ARRIVAL_MS in app/tabs.js must match the duration.
|
|
262
|
+
*/
|
|
263
|
+
.tab.pages {
|
|
264
|
+
color: var(--text);
|
|
265
|
+
border-color: color-mix(in srgb, var(--sc) 55%, var(--border));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
.tab .pagemark {
|
|
269
|
+
display: flex;
|
|
270
|
+
align-items: center;
|
|
271
|
+
color: var(--sc);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
.tab.pages.page-arriving {
|
|
275
|
+
animation: pageFlash 1.4s ease-out 1;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
@keyframes pageFlash {
|
|
279
|
+
from {
|
|
280
|
+
background: color-mix(in srgb, var(--sc) 34%, #10151c);
|
|
281
|
+
}
|
|
282
|
+
to {
|
|
283
|
+
background: #10151c;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
255
287
|
/* Someone who has asked for less movement still gets both states, told without it: the working light holds
|
|
256
288
|
still on the bottom edge, and the alert simply appears already lit. */
|
|
257
289
|
@media (prefers-reduced-motion: reduce) {
|
|
@@ -270,6 +302,12 @@ body {
|
|
|
270
302
|
.tab.attention.arriving {
|
|
271
303
|
animation: none;
|
|
272
304
|
}
|
|
305
|
+
|
|
306
|
+
/* The border and the page beside the age say it already; the flash was only ever the part that carried
|
|
307
|
+
across the room. */
|
|
308
|
+
.tab.pages.page-arriving {
|
|
309
|
+
animation: none;
|
|
310
|
+
}
|
|
273
311
|
}
|
|
274
312
|
|
|
275
313
|
.tab .age {
|
|
@@ -599,6 +637,33 @@ body.dragging {
|
|
|
599
637
|
background: var(--bg);
|
|
600
638
|
}
|
|
601
639
|
|
|
640
|
+
/* A page arrived and opened itself: one pulse of the workspace colour along the pane's own edge, and nothing
|
|
641
|
+
moves. Inset, so it is drawn over the pane rather than pushing the layout about, and over the frame's edge
|
|
642
|
+
rather than under it. ARRIVAL_MS in app/pane.js must match the duration - it is what takes the class off
|
|
643
|
+
again, since this element, unlike a tab, is not rebuilt from one frame to the next. */
|
|
644
|
+
#pane.arriving {
|
|
645
|
+
animation: paneArrival 1s ease-out 1;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
@keyframes paneArrival {
|
|
649
|
+
0%,
|
|
650
|
+
60% {
|
|
651
|
+
box-shadow: inset 0 0 0 2px var(--ws);
|
|
652
|
+
}
|
|
653
|
+
100% {
|
|
654
|
+
box-shadow: inset 0 0 0 2px transparent;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/* The same moment, held instead of pulsed. Without this the highlight would never appear at all for someone
|
|
659
|
+
who asked for less movement - the class is on the pane for exactly as long either way. */
|
|
660
|
+
@media (prefers-reduced-motion: reduce) {
|
|
661
|
+
#pane.arriving {
|
|
662
|
+
box-shadow: inset 0 0 0 2px var(--ws);
|
|
663
|
+
animation: none;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
602
667
|
/* The pagination bar: this session's pages as chips, the arrows for a bar too narrow to hold them, and where
|
|
603
668
|
in the history the open page sits. Drawn by the interface, outside the page's frame, so it always says the
|
|
604
669
|
truth about which page is open. */
|
|
@@ -696,6 +761,55 @@ body.dragging {
|
|
|
696
761
|
font-size: 11.5px;
|
|
697
762
|
}
|
|
698
763
|
|
|
764
|
+
/* How big a page is drawn in this browser. One bordered group - minus, readout, plus - so the pair reads as a
|
|
765
|
+
single control and not as two loose buttons that happen to sit together. */
|
|
766
|
+
#page-size {
|
|
767
|
+
display: flex;
|
|
768
|
+
flex: none;
|
|
769
|
+
align-items: center;
|
|
770
|
+
border: 1px solid var(--border);
|
|
771
|
+
border-radius: 6px;
|
|
772
|
+
background: #1c222b;
|
|
773
|
+
overflow: hidden;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
#page-size button {
|
|
777
|
+
display: flex;
|
|
778
|
+
width: 24px;
|
|
779
|
+
height: 24px;
|
|
780
|
+
align-items: center;
|
|
781
|
+
justify-content: center;
|
|
782
|
+
border: 0;
|
|
783
|
+
background: transparent;
|
|
784
|
+
color: var(--muted);
|
|
785
|
+
font: inherit;
|
|
786
|
+
font-size: 14px;
|
|
787
|
+
line-height: 1;
|
|
788
|
+
cursor: pointer;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
#page-size button:hover:not(:disabled) {
|
|
792
|
+
color: var(--text);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/* A spent end of the range: the button keeps its place, and says plainly that it has nowhere left to go. */
|
|
796
|
+
#page-size button:disabled {
|
|
797
|
+
opacity: 0.3;
|
|
798
|
+
cursor: default;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
#page-size-val {
|
|
802
|
+
display: flex;
|
|
803
|
+
height: 24px;
|
|
804
|
+
align-items: center;
|
|
805
|
+
padding: 0 6px;
|
|
806
|
+
border-right: 1px solid var(--border);
|
|
807
|
+
border-left: 1px solid var(--border);
|
|
808
|
+
color: var(--muted);
|
|
809
|
+
font-size: 10.5px;
|
|
810
|
+
letter-spacing: 0.02em;
|
|
811
|
+
}
|
|
812
|
+
|
|
699
813
|
/* Out of the way, at the end of the bar: the pane is put away far more often than the chips are paged. */
|
|
700
814
|
#pane-collapse {
|
|
701
815
|
display: flex;
|
|
@@ -712,13 +826,19 @@ body.dragging {
|
|
|
712
826
|
}
|
|
713
827
|
|
|
714
828
|
/* The page itself: an agent's document, in a frame with an opaque origin (see app/pane-frame.js). Nothing
|
|
715
|
-
here styles its contents - the page brings its own.
|
|
829
|
+
here styles its contents - the page brings its own.
|
|
830
|
+
|
|
831
|
+
The one exception is how big it is drawn, and it is applied from out here on purpose: the frame's document
|
|
832
|
+
has an opaque origin and the shell never reaches into it. `zoom` rather than a transform, because zoom lays
|
|
833
|
+
the document out again at the new size, so a page reflows instead of growing a horizontal scrollbar. The
|
|
834
|
+
property itself sits on #pane, which survives the frame being torn down and rebuilt on every chip click. */
|
|
716
835
|
.page-frame {
|
|
717
836
|
display: block;
|
|
718
837
|
width: 100%;
|
|
719
838
|
height: 100%;
|
|
720
839
|
border: none;
|
|
721
840
|
background: var(--bg);
|
|
841
|
+
zoom: var(--page-zoom, 1);
|
|
722
842
|
}
|
|
723
843
|
|
|
724
844
|
/* A page is a document with an event loop of its own, and a pointer that crosses into it during a drag is a
|
|
@@ -1045,6 +1165,19 @@ body.offline #tabbar {
|
|
|
1045
1165
|
font-weight: 600;
|
|
1046
1166
|
}
|
|
1047
1167
|
|
|
1168
|
+
/* Which window a meter is for - "5h", "7d". Two quota meters side by side are the same shape and the same
|
|
1169
|
+
colours, so the tag is what tells them apart before the percentage is read. */
|
|
1170
|
+
.seg .tag {
|
|
1171
|
+
color: var(--muted);
|
|
1172
|
+
font-size: 10.5px;
|
|
1173
|
+
font-weight: 600;
|
|
1174
|
+
letter-spacing: 0.04em;
|
|
1175
|
+
padding: 1px 4px;
|
|
1176
|
+
border: 1px solid var(--border);
|
|
1177
|
+
border-radius: 4px;
|
|
1178
|
+
background: #12161d;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1048
1181
|
.sep {
|
|
1049
1182
|
color: #3a4250;
|
|
1050
1183
|
}
|
|
@@ -1057,6 +1190,12 @@ body.offline #tabbar {
|
|
|
1057
1190
|
background: #262c36;
|
|
1058
1191
|
}
|
|
1059
1192
|
|
|
1193
|
+
/* The quota meters, of which there can be two. They give up the width so the strip stays one line; the context
|
|
1194
|
+
meter keeps the full 74px, because it is the one read constantly. */
|
|
1195
|
+
.bar.narrow {
|
|
1196
|
+
width: 52px;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1060
1199
|
.bar .fill {
|
|
1061
1200
|
display: block;
|
|
1062
1201
|
height: 100%;
|
|
@@ -1088,18 +1227,23 @@ body.offline #tabbar {
|
|
|
1088
1227
|
#input {
|
|
1089
1228
|
display: block;
|
|
1090
1229
|
width: 100%;
|
|
1091
|
-
/* Tall enough by default to
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1230
|
+
/* Tall enough by default to clear the 2x2 button block (2 x 28px + 6px gap, plus 8px top and bottom
|
|
1231
|
+
insets = 78px), which is about three lines of typing. It grows with what is typed up to seven lines
|
|
1232
|
+
(7 x 21px = 147px, plus the 9px insets and the border = 167px, so 168px holds them with a pixel over)
|
|
1233
|
+
and scrolls past that.
|
|
1234
|
+
These two numbers live here and nowhere else: autoGrow() in composer.js reads them off the box. */
|
|
1235
|
+
min-height: 78px;
|
|
1236
|
+
max-height: 168px;
|
|
1095
1237
|
resize: none;
|
|
1096
1238
|
background: #0d1117;
|
|
1097
1239
|
color: var(--text);
|
|
1098
1240
|
border: 1px solid var(--border);
|
|
1099
1241
|
border-radius: 10px;
|
|
1100
|
-
/* Right padding reserves the button gutter so typed text wraps to the next line instead of
|
|
1101
|
-
|
|
1102
|
-
|
|
1242
|
+
/* Right padding reserves the button gutter so typed text wraps to the next line instead of sliding
|
|
1243
|
+
behind the buttons (the block is two 28px columns and a 6px gap at right: 8px, so 78px clears it).
|
|
1244
|
+
A textarea's padding is uniform, so this width comes off every line, not only the ones beside the
|
|
1245
|
+
buttons - the cost of the second column, accepted at the mocks gate. */
|
|
1246
|
+
padding: 9px 78px 9px 12px;
|
|
1103
1247
|
font:
|
|
1104
1248
|
14px / 1.5 ui-monospace,
|
|
1105
1249
|
"SF Mono",
|
|
@@ -1133,21 +1277,22 @@ body.offline #tabbar {
|
|
|
1133
1277
|
opacity: 0.5;
|
|
1134
1278
|
}
|
|
1135
1279
|
|
|
1136
|
-
/* Action cluster floats inside the textbox as a
|
|
1137
|
-
|
|
1280
|
+
/* Action cluster floats inside the textbox as a 2x2 block pinned to the bottom-right corner. The textarea's
|
|
1281
|
+
right padding keeps typed text out from behind it. The block fills row by row, so the order in index.html
|
|
1282
|
+
is the layout: attach and dictate down the left, the pane button above Send on the right - the two buttons
|
|
1283
|
+
that send together, on the side the pane itself is on, with Send in the corner the hand already knows. */
|
|
1138
1284
|
#composer-actions {
|
|
1139
1285
|
position: absolute;
|
|
1140
1286
|
right: 8px;
|
|
1141
1287
|
bottom: 8px;
|
|
1142
|
-
display:
|
|
1143
|
-
|
|
1144
|
-
align-items: center;
|
|
1288
|
+
display: grid;
|
|
1289
|
+
grid-template-columns: 28px 28px;
|
|
1145
1290
|
gap: 6px;
|
|
1146
1291
|
}
|
|
1147
1292
|
|
|
1148
1293
|
/* Semi-transparent so they stay unobtrusive when floating over typed text; solidify on hover. The workspace's
|
|
1149
1294
|
hue is in them from the start - a faint wash and a tinted edge - so the cluster belongs to the window it
|
|
1150
|
-
sits in instead of being
|
|
1295
|
+
sits in instead of being four grey squares. */
|
|
1151
1296
|
.icon-btn {
|
|
1152
1297
|
display: inline-flex;
|
|
1153
1298
|
align-items: center;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// The window renders the live TUI (xterm.js) and forwards keystrokes; a rich composer uploads
|
|
7
7
|
// pasted images to /tmp/uploads and injects the composed message as one bracketed paste.
|
|
8
8
|
// Sessions belong to the server, not to the socket: see sessions.js for what that buys.
|
|
9
|
-
// Every route that carries the relay is gated by the key the URL holds (?k=),
|
|
9
|
+
// Every route that carries the relay is gated by the key the URL holds (?k=), created fresh at every start.
|
|
10
10
|
// Auth and sandbox are inherited: the spawned CLI sees the same agent config dirs and the same
|
|
11
11
|
// container isolation it has in the terminal. Nothing here touches credentials.
|
|
12
12
|
|
|
@@ -176,7 +176,7 @@ function publishAgent() {
|
|
|
176
176
|
* The directories the picker offers. Bounded on purpose (see DIR_SCAN_* in config.js): a deep walk of a
|
|
177
177
|
* real workspace is slow and the list would be unreadable anyway. Dot directories and the skip list are
|
|
178
178
|
* never descended into, and neither are symlinks - a link out of the workspace would list paths that the
|
|
179
|
-
* picker then refuses. Breadth-first, so a
|
|
179
|
+
* picker then refuses. Breadth-first, so a limit that bites drops the deepest entries rather than a whole
|
|
180
180
|
* branch, and it reports the cut so the caller can say the list is partial.
|
|
181
181
|
*/
|
|
182
182
|
function scanDirs() {
|
|
@@ -213,7 +213,7 @@ function scanDirs() {
|
|
|
213
213
|
// --- The key gate ------------------------------------------------------------------------------------------------------------------------
|
|
214
214
|
|
|
215
215
|
// Publish the live key where the things that print the URL can read it: the `webterm` launcher, the
|
|
216
|
-
// container greeting, and chamba on the host before it
|
|
216
|
+
// container greeting, and chamba on the host before it checks /status. Called once the port is bound and
|
|
217
217
|
// never before - a second server that loses the bind must not leave its key behind as if it had won.
|
|
218
218
|
// Owner-only, and chmod'ed after the write because the mode above applies to a file being created rather
|
|
219
219
|
// than to one that already exists.
|
|
@@ -347,7 +347,7 @@ app.use("/vendor/xterm", express.static(join(import.meta.dirname, "node_modules"
|
|
|
347
347
|
app.use("/vendor/xterm-fit", express.static(join(import.meta.dirname, "node_modules", "@xterm", "addon-fit", "lib")));
|
|
348
348
|
|
|
349
349
|
// Accept a raw image body (the client POSTs the pasted/dropped blob with its Content-Type).
|
|
350
|
-
// Reject non-image types up front;
|
|
350
|
+
// Reject non-image types up front; limit the size so a bad request cannot fill the disk.
|
|
351
351
|
app.post("/upload", requireKey, express.raw({ type: () => true, limit: MAX_UPLOAD_BYTES }), (req, res) => {
|
|
352
352
|
const contentType = String(req.headers["content-type"] || "")
|
|
353
353
|
.split(";")[0]
|
|
@@ -418,7 +418,10 @@ function send(client, frame) {
|
|
|
418
418
|
// The session bar, personalised per window: the list is the same for everyone, "which one am I
|
|
419
419
|
// driving" is not.
|
|
420
420
|
function broadcastSessions() {
|
|
421
|
-
|
|
421
|
+
// Each entry carries one thing the registry does not know: how many pages of that session's pane nobody
|
|
422
|
+
// has opened. The bar is the only frame that reaches a window about a session it is not driving, so this
|
|
423
|
+
// is the whole route by which a page published in a background session marks its tab.
|
|
424
|
+
const list = registry.list().map((entry) => ({ ...entry, unreadPages: pane.unreadCount(entry.id) }));
|
|
422
425
|
for (const client of clients) {
|
|
423
426
|
send(client, {
|
|
424
427
|
t: "sessions",
|
|
@@ -486,7 +489,14 @@ function paneSessions() {
|
|
|
486
489
|
const session = registry.get(entry.id);
|
|
487
490
|
const pid = Number(session?.term?.pid);
|
|
488
491
|
if (!session || !Number.isInteger(pid)) continue;
|
|
489
|
-
live.push({
|
|
492
|
+
live.push({
|
|
493
|
+
sid: session.id,
|
|
494
|
+
agent: session.agent,
|
|
495
|
+
cwd: session.cwd,
|
|
496
|
+
pid,
|
|
497
|
+
since: session.createdAt,
|
|
498
|
+
resumedFrom: resumedBy.get(pid) ?? null,
|
|
499
|
+
});
|
|
490
500
|
}
|
|
491
501
|
return live;
|
|
492
502
|
}
|
|
@@ -495,9 +505,16 @@ const pane = createPane({
|
|
|
495
505
|
store: paneStore,
|
|
496
506
|
stores: AGENT_STORES,
|
|
497
507
|
sessions: paneSessions,
|
|
498
|
-
onChange: (sid) =>
|
|
508
|
+
onChange: (sid) => paneChanged(sid),
|
|
499
509
|
});
|
|
500
510
|
|
|
511
|
+
// A pane moved. The window driving that session gets the list itself; every window gets the session bar again,
|
|
512
|
+
// because the count of unopened pages rides on it and that is how a tab hears about a page at all.
|
|
513
|
+
function paneChanged(sid) {
|
|
514
|
+
sendPages(sid);
|
|
515
|
+
broadcastSessions();
|
|
516
|
+
}
|
|
517
|
+
|
|
501
518
|
// The pane as one frame: the list, and the notice when this session is not showing its conversation's own
|
|
502
519
|
// pages (a second window resumed a conversation another live session already holds).
|
|
503
520
|
function pagesFrame(sid) {
|
|
@@ -855,13 +872,19 @@ function resumeArgv(agent, cwd) {
|
|
|
855
872
|
// auth flows through. The first session after a container start continues the most recent conversation;
|
|
856
873
|
// every later session starts fresh, which is what the user wants once mid-work.
|
|
857
874
|
// AGENT_ARGS belong to the agent the launcher named, so any other agent is spawned bare.
|
|
875
|
+
// Which conversation id a session's agent was told to resume, by the PTY leader's pid. Claude gives a
|
|
876
|
+
// resumed conversation a fresh id and records no link back to the old one, so the command built here is the
|
|
877
|
+
// only witness - the pane reads this to carry the old id's pages into the new one. One resume per container
|
|
878
|
+
// start, so this holds at most one entry that matters.
|
|
879
|
+
const resumedBy = new Map();
|
|
880
|
+
|
|
858
881
|
function spawnAgent({ cwd, agent }) {
|
|
859
882
|
const resume = resumeArgv(agent, cwd);
|
|
860
883
|
const [spawnCmd, ...baseArgs] = resume ?? [agent, ...(agent === DEFAULT_AGENT ? AGENT_ARGS : [])];
|
|
861
884
|
if (resume) console.log(`[webterm] resuming most recent conversation: ${resume.join(" ")}`);
|
|
862
885
|
// Append the browser-awareness flag for claude (fresh or resumed); every other agent is untouched.
|
|
863
886
|
const spawnArgs = agentSpawnArgv(spawnCmd, baseArgs);
|
|
864
|
-
|
|
887
|
+
const term = pty.spawn(spawnCmd, spawnArgs, {
|
|
865
888
|
name: "xterm-256color",
|
|
866
889
|
cols: 80,
|
|
867
890
|
rows: 24,
|
|
@@ -872,6 +895,9 @@ function spawnAgent({ cwd, agent }) {
|
|
|
872
895
|
// var is the one answer all three can read.
|
|
873
896
|
env: { ...process.env, CHAMBA_WEB_SESSION: "1" },
|
|
874
897
|
});
|
|
898
|
+
// Only a resume-by-id names a conversation to carry pages from; "--continue" style resumes name nothing.
|
|
899
|
+
if (resume?.[1] === "--resume" && typeof resume[2] === "string") resumedBy.set(term.pid, resume[2]);
|
|
900
|
+
return term;
|
|
875
901
|
}
|
|
876
902
|
|
|
877
903
|
// --- Frames from a window ----------------------------------------------------------------------------------------------------------------
|
|
@@ -1011,7 +1037,7 @@ function handleFrame(ws, msg) {
|
|
|
1011
1037
|
greet(ws, sid, msg.fresh === true);
|
|
1012
1038
|
return;
|
|
1013
1039
|
case "ping":
|
|
1014
|
-
// Liveness
|
|
1040
|
+
// Liveness check from a window that just woke up: an answer proves the socket really works,
|
|
1015
1041
|
// so it can reconnect at once instead of waiting for TCP to give up.
|
|
1016
1042
|
send(ws, { t: "pong" });
|
|
1017
1043
|
return;
|
|
@@ -1131,16 +1157,16 @@ setInterval(checkUploads, CHECK_INTERVAL_MS).unref();
|
|
|
1131
1157
|
// actually changed.
|
|
1132
1158
|
setInterval(() => registry.tick(), WORK_TICK_MS).unref();
|
|
1133
1159
|
|
|
1134
|
-
// The pane's own
|
|
1160
|
+
// The pane's own scan, on the same principle: one pass over the live sessions asks the two questions that
|
|
1135
1161
|
// are about time rather than about an event - has this agent written down which conversation it is having
|
|
1136
1162
|
// yet, and has anything been written into its pane directory that did not come through the publish route.
|
|
1137
1163
|
setInterval(() => pane.sweep(), PANE_SCAN_MS).unref();
|
|
1138
1164
|
|
|
1139
|
-
// The strip's
|
|
1165
|
+
// The strip's scan, for the same reason: a snapshot file is written by a shell script into a bind mount, so
|
|
1140
1166
|
// the only way to know it moved is to look.
|
|
1141
1167
|
setInterval(() => sweepStatus(), STATUS_SCAN_MS).unref();
|
|
1142
1168
|
|
|
1143
|
-
// Keepalive
|
|
1169
|
+
// Keepalive pass. A window that stops answering is terminated, which releases the session it was
|
|
1144
1170
|
// driving so the window that comes back can pick it up without a takeover prompt. Sessions themselves
|
|
1145
1171
|
// are never touched here - a dead socket says nothing about whether a conversation is worth keeping.
|
|
1146
1172
|
setInterval(() => {
|
|
@@ -1175,7 +1201,7 @@ server.listen(PORT, "0.0.0.0", () => {
|
|
|
1175
1201
|
if (defaultCwdRefused) {
|
|
1176
1202
|
console.warn(`[webterm] ignored WEBTERM_CWD="${WEBTERM_CWD_RAW}": not a directory inside ${WORKSPACE_ROOT}`);
|
|
1177
1203
|
}
|
|
1178
|
-
// Publish the default agent for the `webterm` launcher: a port
|
|
1204
|
+
// Publish the default agent for the `webterm` launcher: a port check proves something is listening, not
|
|
1179
1205
|
// what it runs. Written after listen so the file only exists once the port is really bound.
|
|
1180
1206
|
publishAgent();
|
|
1181
1207
|
});
|
|
@@ -88,7 +88,7 @@ export const WORK_TICK_MS = 300;
|
|
|
88
88
|
export const MAX_NAME_LENGTH = 40;
|
|
89
89
|
|
|
90
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
|
|
91
|
+
// stripped so a name is always one clean line, trimmed, and cut to the length limit. An empty result means
|
|
92
92
|
// "no name" - the session falls back to its default label. Filtering by code point rather than a regex
|
|
93
93
|
// keeps the source free of literal control characters.
|
|
94
94
|
export function cleanName(raw) {
|
|
@@ -115,8 +115,8 @@ function isOpen(socket) {
|
|
|
115
115
|
* Create the session registry.
|
|
116
116
|
*
|
|
117
117
|
* - `spawn({ cwd })` returns a PTY-like object: { onData, onExit, write, resize, kill }.
|
|
118
|
-
* - `maxSessions`
|
|
119
|
-
* - `maxBuffer`
|
|
118
|
+
* - `maxSessions` limits how many agents may be alive at once. This is about memory, not correctness.
|
|
119
|
+
* - `maxBuffer` limits the replay buffer kept per session (bytes).
|
|
120
120
|
* - `onEvent(event)` is called with { t: "replay" | "out" | "taken" | "exit" | "changed", ... }.
|
|
121
121
|
* Events that target one window carry that window's socket as `client`; "changed" means the session
|
|
122
122
|
* list moved and every window needs to hear about it.
|
|
@@ -305,9 +305,9 @@ export function createRegistry({ spawn, maxSessions, maxBuffer, onEvent }) {
|
|
|
305
305
|
/**
|
|
306
306
|
* Move every session's working state on by one step, and raise the alert on the ones that just stopped.
|
|
307
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
|
|
308
|
+
* "how long output has been running", so one pass answers it for every session, and a registry with no
|
|
309
309
|
* timers of its own stays testable by calling this by hand. Every flip is announced here, so the whole
|
|
310
|
-
*
|
|
310
|
+
* pass costs at most one broadcast however many sessions moved.
|
|
311
311
|
*
|
|
312
312
|
* Every ending raises the alert, whoever is watching and whatever they were doing a moment ago. Whether it
|
|
313
313
|
* is also worth a sound is settled afterwards, by whether anyone touches the session - see "Are you there?".
|
|
@@ -372,7 +372,7 @@ export function createRegistry({ spawn, maxSessions, maxBuffer, onEvent }) {
|
|
|
372
372
|
* and how one is written for the browser belong to the server, and the registry only carries the values -
|
|
373
373
|
* the command and path to the PTY, the labels to the bar.
|
|
374
374
|
*
|
|
375
|
-
* { ok: true, session } or
|
|
375
|
+
* { ok: true, session }, or a refusal with the session-limit error code when the limit is reached.
|
|
376
376
|
*/
|
|
377
377
|
function create({ cwd, cwdLabel, agent } = {}) {
|
|
378
378
|
if (sessions.size >= maxSessions) return { ok: false, error: "cap" };
|
|
@@ -384,7 +384,7 @@ export function createRegistry({ spawn, maxSessions, maxBuffer, onEvent }) {
|
|
|
384
384
|
// Named after what it runs, since the bar can hold several different agents at once.
|
|
385
385
|
label: `${agent} ${seq}`,
|
|
386
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
|
|
387
|
+
// so clearing the name shows "claude 7" again and the tooltip can still show it.
|
|
388
388
|
name: null,
|
|
389
389
|
agent,
|
|
390
390
|
colorIndex: (seq - 1) % PALETTE_SIZE,
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// snapshot.js - What claude wrote down about a session, and which session it belongs to.
|
|
2
2
|
//
|
|
3
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
|
|
5
|
-
// wrote it. That file is the only place any of this exists - nothing here asks claude anything, and there is
|
|
4
|
+
// how much context it is holding, what is left of each rate-limit window, and the pid of the claude process
|
|
5
|
+
// that wrote it. That file is the only place any of this exists - nothing here asks claude anything, and there is
|
|
6
6
|
// nothing to ask - so it is what the strip above the composer is drawn from, and what tells the pane which
|
|
7
7
|
// conversation a session is having.
|
|
8
8
|
//
|
|
@@ -28,7 +28,7 @@ import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
|
28
28
|
import { join } from "node:path";
|
|
29
29
|
import { isSameProcess, isSelfOrDescendant } from "./proc.js";
|
|
30
30
|
|
|
31
|
-
// The largest a snapshot can be and still be read. The script writes a few hundred bytes; the
|
|
31
|
+
// The largest a snapshot can be and still be read. The script writes a few hundred bytes; the limit only keeps
|
|
32
32
|
// an unrelated file that happens to sit in the directory from being pulled into memory.
|
|
33
33
|
const MAX_SNAPSHOT_BYTES = 64 * 1024;
|
|
34
34
|
|
|
@@ -45,7 +45,7 @@ export function writesSnapshots(agent) {
|
|
|
45
45
|
return agent === "claude";
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
/** The snapshot files in `dir`, newest first and
|
|
48
|
+
/** The snapshot files in `dir`, newest first and limited in number. A directory that is not there contributes nothing. */
|
|
49
49
|
function snapshotFiles(dir) {
|
|
50
50
|
let entries;
|
|
51
51
|
try {
|
|
@@ -129,6 +129,8 @@ export function statusFor(session, dir) {
|
|
|
129
129
|
contextPct: num(snapshot.context_used_pct),
|
|
130
130
|
quotaPct: num(snapshot.quota_left_pct),
|
|
131
131
|
quotaResetsAt: num(snapshot.quota_resets_at),
|
|
132
|
+
quotaWeekPct: num(snapshot.quota_week_left_pct),
|
|
133
|
+
quotaWeekResetsAt: num(snapshot.quota_week_resets_at),
|
|
132
134
|
version: text(snapshot.version),
|
|
133
135
|
updatedAt: num(snapshot.updated_at),
|
|
134
136
|
};
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: context-usage
|
|
3
|
-
description: Report the current session's context-window and rate-limit (quota) usage. Use whenever the user asks how much context, tokens, or quota is used or left, how full the context window is, or when the rate limit resets.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# context-usage: Report context and quota usage
|
|
7
|
-
|
|
8
|
-
In a chamba container you CAN see your own context-window and rate-limit usage.
|
|
9
|
-
The status line script chamba installs writes a per-session snapshot on every prompt render (it renders nothing in the terminal - the web interface draws a strip from the same file); a bundled helper reads it back.
|
|
10
|
-
|
|
11
|
-
## Procedure
|
|
12
|
-
|
|
13
|
-
1. Run:
|
|
14
|
-
|
|
15
|
-
```bash
|
|
16
|
-
context-usage
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
Example output:
|
|
20
|
-
|
|
21
|
-
```
|
|
22
|
-
session: 78b4025b-... (this session, updated 3s ago)
|
|
23
|
-
context: 70.8k tokens (7% of 1M window)
|
|
24
|
-
quota: 84% remaining, resets in 21m
|
|
25
|
-
model: Fable 5 (effort high)
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
2. Answer in ONE short line, in exactly this shape:
|
|
29
|
-
|
|
30
|
-
> Context: 70.8k tokens (7%) - quota: 84% left, resets in 21m.
|
|
31
|
-
|
|
32
|
-
If the user asked only about context or only about quota, answer with just that half.
|
|
33
|
-
Expand beyond one line only when the user explicitly asks for more detail.
|
|
34
|
-
Do not add commentary, interpretation, or advice.
|
|
35
|
-
|
|
36
|
-
3. Caveats are the exception, not the norm.
|
|
37
|
-
The `(this session, ...)` marker on the session line means the snapshot is guaranteed to be this session's own - say nothing about it.
|
|
38
|
-
Only when the marker is absent AND the output contains a `warning:` line, append one short sentence relaying that warning.
|
|
39
|
-
|
|
40
|
-
## Interpretation notes
|
|
41
|
-
|
|
42
|
-
- The snapshot reflects usage as of the moment the current prompt was submitted; tokens consumed during the in-flight turn are not included yet.
|
|
43
|
-
No need to mention this unless asked.
|
|
44
|
-
- The `quota` line refers to the 5-hour rate-limit window and is absent when Claude Code did not report rate-limit data.
|
|
45
|
-
|
|
46
|
-
## Fallback
|
|
47
|
-
|
|
48
|
-
If the `context-usage` command is not found, read the snapshot directly: list `~/.claude/context-usage/*.json`, pick the most recently modified file, and interpret its fields - `context_tokens`, `context_used_pct`, `context_window_size`, `model`, `effort`, `quota_left_pct`, `quota_resets_at` (epoch seconds), `updated_at` (epoch seconds), `version`, `session_id`.
|
|
49
|
-
|
|
50
|
-
If the command does not exist AND there are no snapshot files either, the container image predates this feature.
|
|
51
|
-
In that case tell the user to rebuild the image: start a new chamba session on the host and accept the rebuild prompt.
|
|
52
|
-
|
|
53
|
-
If the command exists but no snapshot files do, no Claude session in this container has rendered a prompt yet; say so rather than guessing values.
|