ccdeck 3.22.3 → 3.22.5

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.
@@ -40,8 +40,8 @@
40
40
  document.documentElement.setAttribute("data-theme", stored === "light" ? "light" : "dark");
41
41
  })();
42
42
  </script>
43
- <script type="module" crossorigin src="/assets/index-BDUK5zhD.js"></script>
44
- <link rel="stylesheet" crossorigin href="/assets/index-CJYsv0lr.css">
43
+ <script type="module" crossorigin src="/assets/index-Dr2467D6.js"></script>
44
+ <link rel="stylesheet" crossorigin href="/assets/index-D0ZefIva.css">
45
45
  </head>
46
46
  <body>
47
47
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccdeck",
3
- "version": "3.22.3",
3
+ "version": "3.22.5",
4
4
  "description": "Live deck of Claude Code and Codex agents — watch tool calls, token spend and every Claude Code subagent on one calm canvas. Run it with npx ccdeck.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -394,5 +394,11 @@
394
394
  "title": "🖥️ A first run now opens This machine too",
395
395
  "body": "Accounts and usage already opened themselves the first time you ran the deck; the machine panel did not, and now does. It is the panel that answers \"is this box coping\" — cores, memory, load, temperature — and somebody meeting the deck for the first time cannot ask for a panel they do not know is there.\n\nIt only does this when you have never said otherwise. Close it once and it stays closed, on that browser, for good — the same rule the other two panels have always followed. Nothing reopens it behind you.\n\nWhile it is open it polls this machine once every few seconds, and it stops the moment you close it or switch tabs."
396
396
  }
397
+ ],
398
+ "3.22.4": [
399
+ {
400
+ "title": "📈 A card stops looking idle while it is thinking",
401
+ "body": "The activity chart on each card counted tool calls and nothing else, so the time an agent spends reading, reasoning and writing drew as a flat line — measured on this machine, 16.5% of all working time, more than the time spent inside the tools themselves. A session that was busy looked exactly like one that had died, and the only way to tell them apart was to wait and see whether anything ever appeared.\n\nIt marks that work now. A chart with bars in it means the agent is doing something, whether or not it has called a tool in the last minute; a flat chart means it genuinely is not. Hover it for the split — how many tool calls and how many blocks of thinking and writing landed in the last 60 seconds.\n\nNothing was added to the card and nothing new is polled. The deck already reads each session's transcript for its token counts; this reads the same file for what the model has finished writing, and a session that is quiet costs one file check per second and a half."
402
+ }
397
403
  ]
398
404
  }
@@ -27,6 +27,9 @@ import { CODEX_HOME, CODEX_SESSIONS_DIR, STOP, walkRolloutDays } from "./codex-d
27
27
  import { PRODUCT } from "./brand.mjs";
28
28
  import { createBlockNotifier } from "./block-notify.mjs";
29
29
  import { createActivity } from "./activity.mjs";
30
+ // What a session is producing between its tool calls — the 16.5% of measured
31
+ // time the hooks cannot see. See output-watch.mjs.
32
+ import { createOutputWatch } from "./output-watch.mjs";
30
33
  import { AWAY_BOOT_GRACE_MS, AWAY_RECHECK_MS, AWAY_TICK_MS, awayGate, awayUpdateStep } from "./auto-update.mjs";
31
34
  import { createPresence } from "./presence.mjs";
32
35
  import { DEFAULTS as PREF_DEFAULTS, cleanAlias, isAliasKey, notificationsOn, notificationsVetoed, publicPrefs, readPrefs, writePrefs } from "./deck-prefs.mjs";
@@ -2781,7 +2784,49 @@ export function eventsSince(seq) {
2781
2784
  const sessionTouchedAt = new Map(); // sid -> ms of the last event seen
2782
2785
  const MAX_TRACKED_SESSIONS = 256;
2783
2786
 
2787
+ /** The transcript watch, and how far back a session stays worth polling.
2788
+ *
2789
+ * A session is polled while its last HOOK event is recent — and the window has
2790
+ * to be generous for exactly the reason this watch exists: a session that is
2791
+ * thinking has, by definition, not fired a hook. Measured on this machine's
2792
+ * log, the gaps with no event at all run to p99 53s and 155s at the longest,
2793
+ * so five minutes clears the whole measured distribution and still keeps the
2794
+ * polled set to the sessions somebody is actually running. */
2795
+ const outputWatch = createOutputWatch();
2796
+ const OUTPUT_WATCH_WINDOW_MS = 5 * 60_000;
2797
+ const OUTPUT_WATCH_MS = 1_500;
2798
+ let outputWatchTimer = null;
2799
+
2800
+ /** One tick: stat the recent sessions' transcripts, read only what grew, and
2801
+ * say what landed. Everything expensive about this is guarded inside the watch
2802
+ * — a session that wrote nothing costs one `stat`. */
2803
+ async function outputWatchOnce() {
2804
+ const cutoff = Date.now() - OUTPUT_WATCH_WINDOW_MS;
2805
+ const live = [];
2806
+ for (const [sid, at] of sessionTouchedAt) if (at >= cutoff) live.push(sid);
2807
+ if (!live.length) return;
2808
+ const found = await outputWatch.poll(live);
2809
+ for (const f of found) {
2810
+ pushEvent({
2811
+ hook_event_name: "OutputObserved",
2812
+ session_id: f.sid,
2813
+ kind: f.kind,
2814
+ at: f.at,
2815
+ }, "internal");
2816
+ }
2817
+ }
2818
+
2819
+ function startOutputWatch() {
2820
+ if (outputWatchTimer) return outputWatchTimer;
2821
+ outputWatchTimer = setInterval(() => { outputWatchOnce().catch(() => {}); }, OUTPUT_WATCH_MS);
2822
+ // Unref'd like the Codex watcher beside it: a poll must never be the reason
2823
+ // the process stays up.
2824
+ if (outputWatchTimer.unref) outputWatchTimer.unref();
2825
+ return outputWatchTimer;
2826
+ }
2827
+
2784
2828
  function forgetSession(sid) {
2829
+ outputWatch.forget(sid);
2785
2830
  modelBySession.delete(sid);
2786
2831
  // The two the session-naming work added (#520/#522) and did not list here.
2787
2832
  // Both are keyed by session id and nothing else ever removed an entry, which
@@ -3792,6 +3837,10 @@ function pushEvent(raw, source, opts = {}) {
3792
3837
  maybeResolveUsage(raw);
3793
3838
  maybeResolveContext(raw);
3794
3839
  maybeResolveSessionName(raw);
3840
+ // Where the transcript IS, learned from the one place it is free. The
3841
+ // four scanners above read it on this event; the watch reads it between
3842
+ // events, which is the whole of what it adds.
3843
+ if (raw?.transcript_path) outputWatch.note(raw.session_id, raw.transcript_path);
3795
3844
  } else {
3796
3845
  noteRefusedTranscript(raw.transcript_path);
3797
3846
  }
@@ -5175,6 +5224,7 @@ async function handleClear(res) {
5175
5224
  // The rule, for the next cache that gates an emit: anything answering
5176
5225
  // "has this changed" has to appear in BOTH places that mean the client no
5177
5226
  // longer has it — here, and in forgetSession.
5227
+ outputWatch.clear();
5178
5228
  nameBySession.clear();
5179
5229
  modelBySession.clear();
5180
5230
  // The read stamps go with them. Clearing only the signatures would leave
@@ -5995,6 +6045,10 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
5995
6045
  await tryListen(server, candidate, host);
5996
6046
  // Codex has no working hooks on Windows — tail its rollout files instead.
5997
6047
  if (codex) startCodexWatcher(workspace);
6048
+ // What a session is producing between its tool calls. Claude only — it
6049
+ // reads `transcript_path`, which Codex hooks never send — and unref'd
6050
+ // like its neighbours.
6051
+ startOutputWatch();
5998
6052
  // Both timers are unref'd, so this never holds the process open.
5999
6053
  startSystemMetrics();
6000
6054
  // LAN sync, from the prefs the import read — and only from here, so a
@@ -186,7 +186,15 @@ export function resetMacmonFetch() { _fetched = false; }
186
186
  */
187
187
  export function macmonAsset(release) {
188
188
  const tag = release?.tag_name;
189
- const a = (release?.assets ?? []).find(x => typeof x?.name === "string" && x.name.endsWith(".tar.gz"));
189
+ // Named, not merely suffixed. Every macmon release back to v0.5.1 has
190
+ // published exactly ONE asset, so `.tar.gz` alone has always picked the right
191
+ // one — but it picks the FIRST match, and the day a release adds a second
192
+ // tarball beside the binary (a `source.tar.gz`, or per-arch builds) the wrong
193
+ // one is chosen silently: it downloads, it hashes against a digest that
194
+ // matches, and what lands is not an executable. The published name has been
195
+ // `macmon-v<X.Y.Z>.tar.gz` throughout, so requiring that shape costs nothing
196
+ // today and refuses to guess on the day it would otherwise guess wrong.
197
+ const a = (release?.assets ?? []).find(x => typeof x?.name === "string" && /^macmon-v[\d.]+\.tar\.gz$/.test(x.name));
190
198
  if (!a?.browser_download_url) return null;
191
199
  const m = /^sha256:([0-9a-f]{64})$/.exec(String(a.digest ?? ""));
192
200
  if (!m) return null;
@@ -0,0 +1,222 @@
1
+ // What a session is producing between its tool calls.
2
+ //
3
+ // THE HOLE THIS FILLS. The hooks fire at boundaries — a prompt goes in, a tool
4
+ // starts, a tool ends, a turn ends — and nothing at all fires while the model
5
+ // is working. Measured on this machine's transcripts over six hours: 86 minutes
6
+ // in 648 thinking blocks, 16.5% of all measured time and MORE than the time
7
+ // spent inside tool calls, with nothing drawn for any of it. A card that is
8
+ // working looks exactly like a card that has died, and the only way to tell
9
+ // them apart was to wait and see whether anything ever appeared.
10
+ //
11
+ // WHAT THE TRANSCRIPT KNOWS THAT THE HOOKS DO NOT. Claude Code appends one JSON
12
+ // line per content block as that block completes, each with its own timestamp:
13
+ //
14
+ // assistant 19:04:40.038 thinking
15
+ // assistant 19:04:41.446 text
16
+ // assistant 19:04:44.174 tool_use
17
+ //
18
+ // So the file advances DURING a turn, at a measured median of 4.1s between
19
+ // blocks, and the deck already knows where that file is — every Claude hook
20
+ // payload carries `transcript_path` and four scanners already read it. What
21
+ // none of them do is look BETWEEN events, which is the only place this signal
22
+ // lives.
23
+ //
24
+ // WHY THIS IS A STAT AND NOT A READ. The transcripts here run to megabytes and
25
+ // the question being asked each tick is one bit: did it grow. So the poll stats
26
+ // and nothing else, and only a file that moved is opened — and then only from
27
+ // the byte where the last read stopped, never from the beginning. A session
28
+ // that is quiet costs one `stat` per tick and no bytes at all.
29
+ //
30
+ // WHAT IT DELIBERATELY DOES NOT DO. It does not claim to know what the model is
31
+ // doing NOW. A block is written when it FINISHES, so the honest statement is
32
+ // "this session completed a thinking block 2 seconds ago" — and 2.1s is the
33
+ // measured median for what follows one, with 99% landing inside 25.5s. The
34
+ // freshness judgement belongs to the reader, which is why this emits a
35
+ // timestamp and a kind and no verdict.
36
+ import { open, stat } from "node:fs/promises";
37
+ import { StringDecoder } from "node:string_decoder";
38
+
39
+ /** Read no more than this from one file in one tick.
40
+ *
41
+ * A tail is normally a few kilobytes — one block. This is the ceiling for the
42
+ * case that is not normal: a session that was quiet while the deck was not
43
+ * watching, or a file replaced under the same name. Past it the watch skips
44
+ * to the end rather than reading megabytes to answer a one-bit question; the
45
+ * next block to land is then seen normally. */
46
+ const MAX_TAIL_BYTES = 256 * 1024;
47
+
48
+ /** The block kinds worth telling apart, in the order a tie is broken.
49
+ *
50
+ * One assistant record can carry several blocks — a thinking block and the
51
+ * tool call it produced arrive together often enough to matter — and the
52
+ * interesting one is the one that says the model was REASONING rather than
53
+ * the mechanical result of it. */
54
+ const KIND_ORDER = ["thinking", "tool_use", "text"];
55
+
56
+ /**
57
+ * What one transcript line says was produced, or null for a line that says
58
+ * nothing about production.
59
+ *
60
+ * A `user` record carrying a tool_result is the tool answering, not the model
61
+ * producing, and counting it would make every tool call look like generation.
62
+ * Anything unparseable is null rather than a throw: this reads a file that is
63
+ * being appended to, and a torn final line is the ordinary case, not an error.
64
+ */
65
+ export function blockKindOf(line) {
66
+ if (!line) return null;
67
+ let rec;
68
+ try { rec = JSON.parse(line); } catch { return null; }
69
+ if (!rec || rec.type !== "assistant") return null;
70
+ const content = rec.message?.content;
71
+ if (!Array.isArray(content)) return null;
72
+ const types = new Set(content.map(b => b?.type));
73
+ for (const kind of KIND_ORDER) if (types.has(kind)) return kind;
74
+ return null;
75
+ }
76
+
77
+ /** When that line says it happened, or null. Its own stamp rather than the
78
+ * clock here: the deck may be reading a block that landed while it was busy,
79
+ * and dating it `now` would report work as having just happened when it did
80
+ * not. */
81
+ export function blockTimeOf(line) {
82
+ if (!line) return null;
83
+ let rec;
84
+ try { rec = JSON.parse(line); } catch { return null; }
85
+ const t = rec?.timestamp ? Date.parse(rec.timestamp) : NaN;
86
+ return Number.isFinite(t) ? t : null;
87
+ }
88
+
89
+ /**
90
+ * EVERY block in a chunk of whole-ish lines, oldest first.
91
+ *
92
+ * Not the newest one. This took only the last block and lost the interesting
93
+ * half of nearly every turn: measured on this machine, consecutive blocks land
94
+ * 1.4s and 2.7s apart, so a `thinking` and the `tool_use` it produced arrive
95
+ * inside one 1500ms tick more often than not — and reporting only the newest
96
+ * meant the canvas saw `tool_use` forever and `thinking` never, which is the
97
+ * one thing this whole watch exists to show. Confirmed on the live deck: two
98
+ * ticks, two events, both `tool_use`, no thinking at all.
99
+ *
100
+ * It also undercounted. Each block is a unit of work the model finished and
101
+ * the activity chart marks one per block, so folding three into one drew a
102
+ * third of the movement that happened.
103
+ */
104
+ export function blocksIn(chunk) {
105
+ const out = [];
106
+ for (const line of chunk.split("\n")) {
107
+ const kind = blockKindOf(line);
108
+ if (kind) out.push({ kind, at: blockTimeOf(line) });
109
+ }
110
+ return out;
111
+ }
112
+
113
+ /**
114
+ * The watch.
115
+ *
116
+ * `io` is injectable so the whole of this can be tested without a filesystem,
117
+ * and so the suite can drive a file that grows, shrinks, vanishes and comes
118
+ * back without arranging any of that for real.
119
+ */
120
+ export function createOutputWatch(io = {}) {
121
+ const statFile = io.stat ?? (p => stat(p));
122
+ const openFile = io.open ?? (p => open(p, "r"));
123
+ /** sid -> { path, offset, size } — where this session's file is and how far
124
+ * into it the watch has already looked. */
125
+ const seen = new Map();
126
+
127
+ /** Remember where a session's transcript is. Called for every hook payload
128
+ * that carries one, which is every Claude hook: the path is free there and
129
+ * there is nowhere else to learn it. A path that changes for a session
130
+ * replaces the entry and restarts at that file's end — see `note`. */
131
+ function note(sid, path) {
132
+ if (!sid || !path) return;
133
+ const prev = seen.get(sid);
134
+ if (prev && prev.path === path) return;
135
+ // Start at the END of a file the watch has not seen before. Everything
136
+ // already in it happened before the deck looked, and replaying it would
137
+ // report a morning's thinking as having just occurred.
138
+ seen.set(sid, { path, offset: null, size: null });
139
+ }
140
+
141
+ function forget(sid) { seen.delete(sid); }
142
+
143
+ /** For the suite, and for the one place the server clears everything. */
144
+ function clear() { seen.clear(); }
145
+ function size() { return seen.size; }
146
+
147
+ /**
148
+ * One tick. Stats each named session's file, reads only what is new, and
149
+ * answers with EVERY block that landed in it, oldest first — see blocksIn
150
+ * for why the newest alone was the wrong answer.
151
+ *
152
+ * Sessions the caller believes are live, and no others: a finished session's
153
+ * file can still be appended to by a later turn, and the deck has its own
154
+ * rules about what counts as live. This asks no questions about that.
155
+ */
156
+ async function poll(liveSids) {
157
+ const out = [];
158
+ for (const sid of liveSids) {
159
+ const entry = seen.get(sid);
160
+ if (!entry) continue;
161
+ let st;
162
+ try { st = await statFile(entry.path); } catch { continue; }
163
+ const size = Number(st?.size ?? 0);
164
+ // First sight: record where the end is and report nothing. The file's
165
+ // whole history is older than this deck's interest in it.
166
+ if (entry.offset == null) { entry.offset = size; entry.size = size; continue; }
167
+ // TRUNCATED OR REPLACED. A file that is now shorter than the byte this
168
+ // watch was going to read from is not the file it was reading; seeking to
169
+ // the old offset would return whatever now lives there. Start again at
170
+ // the new end rather than guessing which bytes are which.
171
+ if (size < entry.offset) { entry.offset = size; entry.size = size; continue; }
172
+ if (size === entry.offset) continue;
173
+ // Past the ceiling, skip to the end. See MAX_TAIL_BYTES.
174
+ if (size - entry.offset > MAX_TAIL_BYTES) { entry.offset = size; entry.size = size; continue; }
175
+
176
+ const from = entry.offset;
177
+ const want = size - from;
178
+ let fd;
179
+ let text = "";
180
+ try {
181
+ fd = await openFile(entry.path);
182
+ const buf = Buffer.allocUnsafe(want);
183
+ const { bytesRead } = await fd.read(buf, 0, want, from);
184
+ // A decoder rather than toString, because a tail can begin or end in
185
+ // the middle of a UTF-8 sequence and a replacement character in the
186
+ // middle of a JSON string breaks the parse for a whole block.
187
+ const dec = new StringDecoder("utf8");
188
+ text = dec.write(buf.subarray(0, Math.max(0, bytesRead))) + dec.end();
189
+ entry.offset = from + Math.max(0, bytesRead);
190
+ entry.size = size;
191
+ } catch {
192
+ continue;
193
+ } finally {
194
+ // Awaited, for the reason codex-usage.mjs gives: a close scheduled and
195
+ // abandoned leaves a handle open, and on Windows a file with a handle
196
+ // on it cannot be deleted.
197
+ await fd?.close().catch(() => {});
198
+ }
199
+
200
+ // A tail that ends mid-line is the ordinary case — the file is being
201
+ // appended to while this reads it. Rewind the offset to the start of the
202
+ // torn line so the next tick sees it whole rather than dropping it.
203
+ const lastNl = text.lastIndexOf("\n");
204
+ if (lastNl < 0) {
205
+ // No complete line at all yet. Give the bytes back and wait.
206
+ entry.offset = from;
207
+ continue;
208
+ }
209
+ if (lastNl < text.length - 1) {
210
+ entry.offset -= Buffer.byteLength(text.slice(lastNl + 1), "utf8");
211
+ text = text.slice(0, lastNl);
212
+ }
213
+
214
+ // Oldest first, so a reader that refuses to move backwards accepts the
215
+ // whole run rather than only its last member.
216
+ for (const b of blocksIn(text)) out.push({ sid, kind: b.kind, at: b.at });
217
+ }
218
+ return out;
219
+ }
220
+
221
+ return { note, forget, clear, size, poll };
222
+ }
@@ -36,7 +36,12 @@ const DOWNLOAD_BASE = "https://github.com/astral-sh/uv/releases/download";
36
36
  // Used when the releases API cannot be reached — rate limiting is per-IP and
37
37
  // unauthenticated, so this is a normal outcome rather than an error. Any
38
38
  // reasonably recent uv can install claude-swap; it self-updates later.
39
- const FALLBACK_VERSION = "0.12.3";
39
+ //
40
+ // Dated, because this constant rots silently: nothing fails when it falls
41
+ // behind, it simply installs an older uv on the machines that reach it, so no
42
+ // test and no user will ever report it. Checked against the releases API on the
43
+ // date below; worth re-reading whenever this file is opened for another reason.
44
+ const FALLBACK_VERSION = "0.12.13"; // latest as of 2026-09-13
40
45
 
41
46
  const DOWNLOAD_TIMEOUT_MS = 120_000;
42
47