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.
Files changed (42) hide show
  1. package/dist/commands/dev.js +11 -11
  2. package/dist/commands/onboard.js +2 -1
  3. package/dist/commands/settings.js +1 -1
  4. package/dist/lib/agent-context.js +10 -3
  5. package/dist/lib/chamba-yaml.js +1 -1
  6. package/dist/lib/constants.js +4 -4
  7. package/dist/lib/git-env.js +21 -0
  8. package/dist/lib/ports.js +4 -4
  9. package/dist/lib/shadows.js +2 -0
  10. package/dist/lib/webterm.js +7 -7
  11. package/package.json +1 -3
  12. package/templates/claude-statusline.sh +7 -4
  13. package/templates/context/web-pane-craft.md +57 -0
  14. package/templates/context-usage.sh +30 -13
  15. package/templates/skills/chamba-statusline/SKILL.md +16 -6
  16. package/templates/skills/web-pane/SKILL.md +1 -55
  17. package/templates/webterm/README.md +16 -13
  18. package/templates/webterm/artifacts.js +25 -12
  19. package/templates/webterm/config.js +9 -9
  20. package/templates/webterm/conversation.js +3 -3
  21. package/templates/webterm/pane.js +23 -4
  22. package/templates/webterm/proc.js +1 -1
  23. package/templates/webterm/public/app/alerts.js +5 -5
  24. package/templates/webterm/public/app/composer.js +25 -4
  25. package/templates/webterm/public/app/connection.js +2 -2
  26. package/templates/webterm/public/app/dictation.js +1 -1
  27. package/templates/webterm/public/app/dom.js +4 -0
  28. package/templates/webterm/public/app/frames.js +4 -2
  29. package/templates/webterm/public/app/main.js +6 -2
  30. package/templates/webterm/public/app/new-session.js +1 -1
  31. package/templates/webterm/public/app/pane-arrival.js +36 -0
  32. package/templates/webterm/public/app/pane.js +98 -13
  33. package/templates/webterm/public/app/state.js +1 -1
  34. package/templates/webterm/public/app/status-strip.js +26 -34
  35. package/templates/webterm/public/app/strip-format.js +36 -0
  36. package/templates/webterm/public/app/tabs.js +67 -5
  37. package/templates/webterm/public/index.html +23 -3
  38. package/templates/webterm/public/styles.css +159 -14
  39. package/templates/webterm/server.js +39 -13
  40. package/templates/webterm/sessions.js +7 -7
  41. package/templates/webterm/snapshot.js +6 -4
  42. 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 show the three stacked buttons (3 x 28px + 2 x 6px gap, plus 8px
1092
- top and bottom insets = 112px), which also gives the box a comfortable typing height. */
1093
- min-height: 112px;
1094
- max-height: 200px;
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
- sliding behind the buttons (button column is 28px wide at right: 8px, so ~44px clears it). */
1102
- padding: 9px 44px 9px 12px;
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 vertical column pinned to the right edge. The
1137
- textarea's right padding keeps typed text out from behind it. */
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: flex;
1143
- flex-direction: column;
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 three grey squares. */
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=), minted fresh at every start.
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 cap that bites drops the deepest entries rather than a whole
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 probes /status. Called once the port is bound and
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; cap the size so a bad request cannot fill the disk.
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
- const list = registry.list();
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({ sid: session.id, agent: session.agent, cwd: session.cwd, pid, since: session.createdAt });
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) => sendPages(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
- return pty.spawn(spawnCmd, spawnArgs, {
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 probe from a window that just woke up: an answer proves the socket really works,
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 sweep, on the same principle: one pass over the live sessions asks the two questions that
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 sweep, for the same reason: a snapshot file is written by a shell script into a bind mount, so
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 sweep. A window that stops answering is terminated, which releases the session it was
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 probe proves something is listening, not
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 cap. An empty result means
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` 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).
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 sweep answers it for every session, and a registry with no
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
- * sweep costs at most one broadcast however many sessions moved.
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 { ok: false, error: "cap" } when the limit is reached.
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 surface it.
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 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
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 cap only keeps
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 capped. A directory that is not there contributes nothing. */
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.