ccdeck 3.37.1 → 3.38.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.
@@ -1,7 +1,9 @@
1
1
  // What the deck learns about a session that its hooks never say: the model,
2
- // the token spend, the session's name and recap, and the context it is
3
- // carrying — each read off the session's transcript (or, for a Codex session's
4
- // memory files, off its working tree) and sent on as a synthetic event.
2
+ // the token spend, the session's name and recap, what it is doing now, and the
3
+ // context it is carrying — each read off the session's transcript (or, for a
4
+ // Codex session's memory files, off its working tree) and sent on as a
5
+ // synthetic event. And, for a background session, the line Claude Code's own
6
+ // agent view shows, read off its job folder (see claude-jobs.mjs).
5
7
  //
6
8
  // This lived in src/server/index.mjs, between the log rotation and the Codex
7
9
  // rollout reader. pushEvent asks it to look at every live hook event, and it
@@ -18,6 +20,9 @@ import { join } from "node:path";
18
20
  import { scanAgentsMdFiles, scanClaudeMdFiles } from "./memory-files.mjs";
19
21
  // Claude Code's "※ recap:" line — see session-recap.mjs.
20
22
  import { RECAP_MARK } from "./session-recap.mjs";
23
+ // What the session is doing now, folded off the same tail — see
24
+ // session-activity.mjs.
25
+ import { foldActivityLine, newActivityState } from "./session-activity.mjs";
21
26
  // The shared transcript cursor every pass below reads through — see
22
27
  // transcript-scan.mjs.
23
28
  import { copyUsageBucket, hasSpend, mergeUsageByModel, newUsageTotals, scanTranscript } from "./transcript-scan.mjs";
@@ -495,6 +500,60 @@ function onRecapTail(sid, text, path) {
495
500
  .catch(() => {});
496
501
  }
497
502
 
503
+ // ─── Session activity ────────────────────────────────────────────────────
504
+ // What the session is doing now — the line Claude Code's agent view prints
505
+ // beside a working session, by the same rule and with no model call (see
506
+ // session-activity.mjs). Folded off the output watch's tail rather than the
507
+ // transcript cursor: the watch is what reads a file BETWEEN hook events, and
508
+ // between hook events is the whole of when this line changes. The cursor runs
509
+ // off hooks and would hold a sentence the model wrote until the next tool call.
510
+ //
511
+ // The fold is per session and survives across ticks, because one reply's blocks
512
+ // can land on either side of a tick and the sentence has to outrank the call it
513
+ // introduced whichever tick read it. The emit is gated on the text changing, so
514
+ // a reply that is still thinking says nothing.
515
+ const activityBySession = new Map(); // sid -> the fold, see newActivityState
516
+
517
+ function onActivityTail(sid, text) {
518
+ if (!sid || !text) return;
519
+ let st = activityBySession.get(sid);
520
+ if (!st) { st = newActivityState(); activityBySession.set(sid, st); }
521
+ const before = st.shown;
522
+ for (const line of text.split("\n")) foldActivityLine(st, line);
523
+ if (!st.shown || st.shown === before) return;
524
+ pushEvent({
525
+ hook_event_name: "ActivityObserved",
526
+ session_id: sid,
527
+ activity: { text: st.shown.text, source: st.shown.source, at: st.shown.at },
528
+ }, "internal");
529
+ }
530
+
531
+ /** Everything the output watch's tail feeds, in one tap: the recap and the
532
+ * activity line read the same bytes for different questions. */
533
+ function onTranscriptTail(sid, text, path) {
534
+ onRecapTail(sid, text, path);
535
+ onActivityTail(sid, text);
536
+ }
537
+
538
+ // ─── Background job ──────────────────────────────────────────────────────
539
+ // Claude Code's own line for a background session — its state, what it is
540
+ // doing or asking, and what it got done — read from the job folder its agent
541
+ // view reads (see claude-jobs.mjs). Gated on a CHANGE like the recap, and on
542
+ // the words rather than the file's clock: the classifier rewrites `updatedAt`
543
+ // every fifteen seconds of a working turn, mostly to say the same thing. A
544
+ // session with no job emits nothing at all, and a job that goes away (deleted
545
+ // in the agent view) is said once, as null.
546
+ const jobBySession = new Map(); // sid -> signature last sent, "" once gone
547
+
548
+ function noteJob(sid, job) {
549
+ const sig = job ? JSON.stringify([job.state, job.detail, job.needs, job.suggestedReply, job.result]) : "";
550
+ const prev = jobBySession.get(sid);
551
+ if (prev === sig) return;
552
+ if (prev === undefined && !job) return;
553
+ jobBySession.set(sid, sig);
554
+ pushEvent({ hook_event_name: "JobObserved", session_id: sid, job: job ?? null }, "internal");
555
+ }
556
+
498
557
  // ─── Context enrichment ──────────────────────────────────────────────────
499
558
  // Approximation of `/context` since CC doesn't expose its breakdown via
500
559
  // hooks. We scan the transcript JSONL for message counts (user / assistant
@@ -626,6 +685,10 @@ function forgetEnrichment(sid) {
626
685
  nameBySession.delete(sid);
627
686
  // The recap's gate, for the same reason — see noteRecap.
628
687
  recapBySession.delete(sid);
688
+ // And the activity fold and the job's gate: a session heard from again
689
+ // after this says what it is doing and what its job says afresh.
690
+ activityBySession.delete(sid);
691
+ jobBySession.delete(sid);
629
692
  nameReads.forget(sid);
630
693
  modelReads.forget(sid);
631
694
  usageReads.forget(sid);
@@ -641,6 +704,8 @@ function forgetEnrichment(sid) {
641
704
  function clearEnrichmentGates() {
642
705
  nameBySession.clear();
643
706
  recapBySession.clear();
707
+ activityBySession.clear();
708
+ jobBySession.clear();
644
709
  modelBySession.clear();
645
710
  // The read stamps go with them. Clearing only the signatures would leave
646
711
  // the next hook event inside MODEL_READ_THROTTLE_MS, so the transcript
@@ -658,5 +723,5 @@ function clearEnrichmentGates() {
658
723
  export {
659
724
  clearEnrichmentGates, forgetEnrichment, knownModelId, maybeResolveCodexMemory,
660
725
  maybeResolveContext, maybeResolveModel, maybeResolveSessionName, maybeResolveUsage,
661
- onRecapTail,
726
+ noteJob, onRecapTail, onTranscriptTail,
662
727
  };
@@ -13,7 +13,10 @@ import { readBody, send } from "./http-io.mjs";
13
13
  // What a session is producing between its tool calls — the 16.5% of measured
14
14
  // time the hooks cannot see. See output-watch.mjs.
15
15
  import { createOutputWatch } from "./output-watch.mjs";
16
- import { forgetEnrichment, onRecapTail } from "./session-enrichment.mjs";
16
+ // Claude Code's own line for a background session, from its job folder. See
17
+ // claude-jobs.mjs.
18
+ import { createJobWatch } from "./claude-jobs.mjs";
19
+ import { forgetEnrichment, noteJob, onTranscriptTail } from "./session-enrichment.mjs";
17
20
  import { forgetCodexSession } from "./codex-enrichment.mjs";
18
21
  // event-pipeline.mjs's pushEvent, reached without importing it — see
19
22
  // event-sink.mjs.
@@ -96,6 +99,14 @@ const RECAP_WATCH_WINDOW_MS = 12 * 60 * 60_000;
96
99
  const RECAP_WATCH_EVERY = 4;
97
100
  let outputWatchTicks = 0;
98
101
 
102
+ /** The job folders, every other tick: three seconds, well inside the fifteen
103
+ * Claude Code itself waits between rewrites of a working job's line. The
104
+ * sessions asked about are the resting window's, not the live one's — a
105
+ * background job blocked on a question fires no hook for as long as nobody
106
+ * answers it, and that is the job whose line matters most. */
107
+ const jobWatch = createJobWatch();
108
+ const JOB_WATCH_EVERY = 2;
109
+
99
110
  /** One tick: stat the recent sessions' transcripts, read only what grew, and
100
111
  * say what landed. Everything expensive about this is guarded inside the watch
101
112
  * — a session that wrote nothing costs one `stat`.
@@ -112,15 +123,23 @@ async function outputWatchOnce() {
112
123
  const now = Date.now();
113
124
  const cutoff = now - OUTPUT_WATCH_WINDOW_MS;
114
125
  const restingCutoff = now - RECAP_WATCH_WINDOW_MS;
115
- const withResting = outputWatchTicks++ % RECAP_WATCH_EVERY === 0;
126
+ const tick = outputWatchTicks++;
127
+ const withResting = tick % RECAP_WATCH_EVERY === 0;
116
128
  const live = new Set();
117
129
  const polled = [];
130
+ const recent = [];
118
131
  for (const [sid, at] of sessionTouchedAt) {
119
132
  if (at >= cutoff) { live.add(sid); polled.push(sid); }
120
133
  else if (withResting && at >= restingCutoff) polled.push(sid);
134
+ if (at >= restingCutoff) recent.push(sid);
135
+ }
136
+ if (recent.length && tick % JOB_WATCH_EVERY === 0) {
137
+ const jobs = await jobWatch.poll().catch(() => null);
138
+ if (jobs) for (const sid of recent) noteJob(sid, jobs.get(sid) ?? null);
121
139
  }
122
140
  if (!polled.length) return;
123
- const found = await outputWatch.poll(polled, onRecapTail);
141
+ // The recap and the activity line, off the same tail — see onTranscriptTail.
142
+ const found = await outputWatch.poll(polled, onTranscriptTail);
124
143
  for (const f of found) {
125
144
  if (!live.has(f.sid)) continue;
126
145
  pushEvent({
@@ -5,8 +5,9 @@
5
5
  // decides how often the machine is asked and when one that has never answered
6
6
  // stops being asked (sampleThermal). This file is the asking: readThermal
7
7
  // answers once per call, per platform, and nothing here keeps state between two
8
- // calls. WARN_C and CRIT_C are exported because the history's thermal series
9
- // fall back to them.
8
+ // calls — the one reading that needs the call before it, Linux throttling, is
9
+ // handed that call's answer back by the sampler. WARN_C and CRIT_C are exported
10
+ // because the history's thermal series fall back to them.
10
11
  import { readdir, readFile } from "node:fs/promises";
11
12
  import { run } from "./metrics-run.mjs";
12
13
 
@@ -31,6 +32,17 @@ import { run } from "./metrics-run.mjs";
31
32
  // bands are the hardware's rather than ones invented here.
32
33
  // /sys/class/thermal/thermal_zone*/ is the coarser fallback.
33
34
  //
35
+ // Throttling comes from the kernel's own count of it, and for years
36
+ // it was not read at all: this branch answered `throttle: null`
37
+ // unconditionally, so a Linux machine never drew the row however
38
+ // hard it was being held back. Measured on an i5-12600H mini PC
39
+ // whose package sat at 90°C under a load of 41 — the panel showed
40
+ // the 90 and nothing else, while the kernel had logged 59 hours of
41
+ // package throttling in 17 days of uptime and was adding six seconds
42
+ // of it every ten. See readThrottleTime: Intel only, since the
43
+ // counters come from Intel's thermal-interrupt driver, and no row
44
+ // on a machine without them.
45
+ //
34
46
  // macOS No CPU degrees without root, verified: `powermetrics --samplers
35
47
  // smc` answers "powermetrics must be invoked as the superuser", and
36
48
  // `ioreg -c AppleSMC -r -d 1` publishes no temperature key at all to
@@ -260,6 +272,91 @@ export async function readThermalZones(root = "/sys/class/thermal", deps = {}) {
260
272
  return [known ?? found.reduce((a, b) => (b.celsius > a.celsius ? b : a))];
261
273
  }
262
274
 
275
+ /**
276
+ * How long each CPU package has spent thermally throttled since boot, in
277
+ * milliseconds, keyed by package — or null on a machine that does not count it.
278
+ *
279
+ * Intel's thermal-interrupt driver publishes, per CPU,
280
+ * `cpuN/thermal_throttle/package_throttle_total_time_ms`: the time the
281
+ * package's thermal monitor has held the clock down, summed over every event.
282
+ * Measured on a live machine, ten seconds apart:
283
+ *
284
+ * cpu0 package_throttle_total_time_ms 212885052 → 212890804
285
+ * cpu15 package_throttle_total_time_ms 212885101 → 212891179
286
+ *
287
+ * Every CPU of a package carries its own copy of the package's count, and the
288
+ * copies differ by the few events each one saw late — so the package's figure
289
+ * is the largest of them, which is also the one that survives a CPU going
290
+ * offline. The `core_*` files beside them are one core's own throttling and are
291
+ * not read: the CPU row is the package, and a throttle row about one core of it
292
+ * would be a different reading under the same label.
293
+ *
294
+ * Absent on AMD and on every virtual machine, and on a kernel older than 5.12,
295
+ * which counts events but not their time. A count of events is not a share of
296
+ * anything, so those machines draw no row rather than a guess.
297
+ */
298
+ export async function readThrottleTime(root = "/sys/devices/system/cpu", deps = {}) {
299
+ const dir = deps.readdir ?? readdir;
300
+ const file = deps.readFile ?? readFile;
301
+ const read = async path => { try { return String(await file(path, "utf8")).trim(); } catch { return null; } };
302
+ let cpus;
303
+ try { cpus = (await dir(root)).filter(n => /^cpu\d+$/.test(n)); } catch { return null; }
304
+ const held = {};
305
+ for (const cpu of cpus) {
306
+ const ms = Number(await read(`${root}/${cpu}/thermal_throttle/package_throttle_total_time_ms`) ?? NaN);
307
+ if (!Number.isFinite(ms) || ms < 0) continue;
308
+ const pkg = (await read(`${root}/${cpu}/topology/physical_package_id`)) ?? "0";
309
+ if (held[pkg] == null || ms > held[pkg]) held[pkg] = ms;
310
+ }
311
+ return Object.keys(held).length ? held : null;
312
+ }
313
+
314
+ /**
315
+ * The share of the time between two counts that the CPU spent throttled, as
316
+ * `{ timeHeld }`, or null when there are not two counts to compare.
317
+ *
318
+ * A SHARE OF TIME, NOT OF SPEED, and the shape says so rather than borrowing
319
+ * macOS's `speedLimit`. `pmset` reports how much of the clock the thermal
320
+ * manager allows; the kernel reports how long the clock was held down, and by
321
+ * how much it was held is not in the file. Writing 60% of the time as "held to
322
+ * 40% of full speed" would be a number this module made up.
323
+ *
324
+ * The worst package wins, for the reason the hottest sensor does: the question
325
+ * is whether this machine is being held back, and one package that is answers
326
+ * it. Any throttling at all is at least 1%, because a few milliseconds of it
327
+ * round to zero and the panel paints zero as "never held back".
328
+ *
329
+ * The kernel adds an event's time when the event ENDS, so one still running at
330
+ * a sample lands in the next. Events here run 5ms on average and 119ms at the
331
+ * longest, measured, so that is a sample's worth of lag at most — and the 100
332
+ * cap is for the one long event that lands whole in a single sample.
333
+ */
334
+ export function throttleFromTime(prev, next) {
335
+ if (!prev || !next) return null;
336
+ const elapsed = next.atMs - prev.atMs;
337
+ if (!(elapsed > 0)) return null;
338
+ let worst = null;
339
+ for (const [pkg, ms] of Object.entries(next.held)) {
340
+ const before = prev.held[pkg];
341
+ if (before == null) continue;
342
+ const share = (Math.max(0, ms - before) / elapsed) * 100;
343
+ if (worst == null || share > worst) worst = share;
344
+ }
345
+ if (worst == null) return null;
346
+ return { timeHeld: worst > 0 ? Math.min(100, Math.max(1, Math.round(worst))) : 0 };
347
+ }
348
+
349
+ /**
350
+ * The share of the CPU the thermal manager took away, whichever way this
351
+ * platform measures it: the speed `pmset` says is no longer allowed, or the
352
+ * time the kernel says the clock was held down. The one number the chart, the
353
+ * strip and the row's bar all draw — mirrored by `heldShare` in machine-live.ts.
354
+ */
355
+ export function heldShare(throttle) {
356
+ const held = "timeHeld" in throttle ? throttle.timeHeld : 100 - throttle.speedLimit;
357
+ return Math.max(0, Math.min(100, held));
358
+ }
359
+
263
360
  /**
264
361
  * GPU degrees out of `ioreg -r -k PerformanceStatistics`.
265
362
  *
@@ -458,13 +555,25 @@ export function darwinThermal({ gpuC = null, throttle = null, macmon = {} } = {}
458
555
  * collapsing them would let a throttle percentage be drawn under a °C heading
459
556
  * — the thing the label rule exists to prevent. `swapLabel` earned that rule
460
557
  * once already.
558
+ *
559
+ * `since` is the reading before this one. Only Linux uses it: its throttle is a
560
+ * difference between two counts, so the count rides along on the reading as
561
+ * `throttleCount` and the sampler hands it back on the next call. The first
562
+ * reading after a start therefore has no throttle yet, the same way `cpu` is
563
+ * null until two samples exist.
461
564
  */
462
- export async function readThermal(platform = process.platform) {
565
+ export async function readThermal(platform = process.platform, since = null) {
463
566
  if (platform === "linux") {
464
567
  const sensors = await readHwmon();
465
568
  const celsius = pickThermalRows(sensors);
466
569
  const rows = celsius.length ? celsius : await readThermalZones();
467
- return rows.length ? { celsius: rows, throttle: null } : null;
570
+ const held = await readThrottleTime();
571
+ // performance.now rather than Date.now: a clock that NTP steps backwards
572
+ // between two samples would otherwise divide by a negative ten seconds.
573
+ const throttleCount = held ? { atMs: performance.now(), held } : null;
574
+ const throttle = throttleFromTime(since?.throttleCount, throttleCount);
575
+ if (!rows.length && !throttle) return null;
576
+ return { celsius: rows, throttle, ...(throttleCount ? { throttleCount } : {}) };
468
577
  }
469
578
 
470
579
  if (platform === "darwin") {
@@ -10,7 +10,7 @@
10
10
  // thermalSnapshot on /api/system, and re-exports sampleThermal and
11
11
  // THROTTLE_LABEL, which is where the suite reaches them.
12
12
  import { pointsOf, record } from "./metrics-history.mjs";
13
- import { readThermal } from "./thermal-metrics.mjs";
13
+ import { heldShare, readThermal } from "./thermal-metrics.mjs";
14
14
 
15
15
  // ---------------------------------------------------------------------------
16
16
  // Thermal: how often this machine is asked whether it is getting hot, and when
@@ -60,7 +60,7 @@ function recordThermal(reading, nowMs = Date.now()) {
60
60
  // Stored as the share TAKEN AWAY, the same way the panel draws it, so the
61
61
  // chart and the row cannot disagree about which direction is bad.
62
62
  if (reading.throttle) {
63
- record(`thermal:${THROTTLE_LABEL}`, Math.max(0, 100 - reading.throttle.speedLimit), nowMs);
63
+ record(`thermal:${THROTTLE_LABEL}`, heldShare(reading.throttle), nowMs);
64
64
  }
65
65
  }
66
66
 
@@ -114,7 +114,9 @@ export async function sampleThermal(deps = {}) {
114
114
  const read = deps.read ?? readThermal;
115
115
  thermalInFlight = true;
116
116
  try {
117
- const next = await read();
117
+ // The last reading goes back in because Linux throttling is the difference
118
+ // between its count and this one's. See readThermal.
119
+ const next = await read(process.platform, thermal);
118
120
  if (next) { thermal = next; thermalMisses = 0; thermalEverAnswered = true; recordThermal(next); }
119
121
  else if (++thermalMisses >= THERMAL_GIVE_UP) {
120
122
  // DROP THE LAST READING. It used to be kept, and that is a number from
@@ -181,7 +183,11 @@ export function lastThermal() {
181
183
  /** The thermal row /api/system answers: the last reading, with whether the
182
184
  * machine has been held back since the deck started, or null without one. */
183
185
  export function thermalSnapshot() {
184
- return thermal ? { ...thermal, heldBack: heldBackSoFar() } : null;
186
+ if (!thermal) return null;
187
+ // The raw kernel count is the sampler's, for the next difference, and not a
188
+ // reading: the route answers what the panel draws.
189
+ const { throttleCount, ...shown } = thermal;
190
+ return { ...shown, heldBack: heldBackSoFar() };
185
191
  }
186
192
 
187
193
  /** The ten-second timer, made where startSystemMetrics in system-metrics.mjs