ccdeck 3.37.0 → 3.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -64,8 +64,8 @@
64
64
  stored === "light" || (stored !== "dark" && light) ? "light" : "dark");
65
65
  })();
66
66
  </script>
67
- <script type="module" crossorigin src="/assets/index-wCbqcRJd.js"></script>
68
- <link rel="stylesheet" crossorigin href="/assets/index-B0SOa-XB.css">
67
+ <script type="module" crossorigin src="/assets/index-C_h8mR-c.js"></script>
68
+ <link rel="stylesheet" crossorigin href="/assets/index-0cUs2Jed.css">
69
69
  </head>
70
70
  <body>
71
71
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccdeck",
3
- "version": "3.37.0",
3
+ "version": "3.38.0",
4
4
  "description": "Local dashboard for Claude Code and Codex CLI sessions: which Claude Code session needs input, every tool call and Claude Code subagent live, token cost and quota, several Claude accounts. Run npx ccdeck.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -69,7 +69,14 @@
69
69
  "3.36.7",
70
70
  "3.36.8",
71
71
  "3.36.9",
72
- "3.37.0"
72
+ "3.37.0",
73
+ "3.38.0"
74
+ ],
75
+ "3.37.1": [
76
+ {
77
+ "title": "🔁 Expired logins repair from your other machines",
78
+ "body": "When a login stops working on this machine and a machine you paired with still has it working and ticked, Local network now copies it back on the next round, whether or not the account is ticked here. Before, it stayed marked expired until the account was ticked here as well, or deleted so that it arrived again. A machine that paired on its own, through Say yes to every deck that asks, still repairs only the accounts ticked here."
79
+ }
73
80
  ],
74
81
  "3.36.3": [
75
82
  {
@@ -0,0 +1,152 @@
1
+ // Claude Code's own account of a background session, read from the file its
2
+ // agent view draws from.
3
+ //
4
+ // WHAT IT IS. A session started with `claude --bg`, or dispatched from
5
+ // `claude agents`, is a background job, and Claude Code keeps one folder per job
6
+ // at `<config dir>/jobs/<short id>/`. Its `state.json` is what the agent view
7
+ // prints: `state` (working, blocked, done, failed, stopped), a one-line `detail`,
8
+ // the question it is stuck on (`needs`), the reply Claude Code would suggest
9
+ // (`suggestedReply`) and the headline of a finished job (`output.result`).
10
+ //
11
+ // Those lines are written by Claude Code's classifier: a pattern match on the
12
+ // session's last words ("result:", "needs input:", "failed:"), and a small model
13
+ // when the pattern finds nothing — at every turn end, and inside a long turn
14
+ // once a minute or so. Read off the 2.1.289 bundle and this machine's job
15
+ // folders on 2026-10-05.
16
+ //
17
+ // WHY THE DECK READS IT. That classifier has already been paid for, by Claude
18
+ // Code, and it answers the question the deck's waiting queue cannot: not that a
19
+ // session stopped, but what it is asking and what it got done. For a background
20
+ // session the deck would otherwise show nothing at all between hooks.
21
+ //
22
+ // WHY EVERY FIELD IS CHECKED. Claude Code's documentation says in as many words
23
+ // that these files "are not a stable interface" (code.claude.com/docs/en/
24
+ // agent-view), and `claude agents --json`, the supported reader, carries the
25
+ // state but none of the words. So the shape is treated as untrusted: an unknown
26
+ // state is no job, a field of the wrong type is absent, a file that stops
27
+ // parsing is a session with no line. Nothing here may throw, and nothing here
28
+ // can make the deck worse than it was before it read the file.
29
+ import { readdir, readFile, stat } from "node:fs/promises";
30
+ import { join } from "node:path";
31
+ import { claudeConfigDir } from "./claude-dir.mjs";
32
+
33
+ /** The states the agent view groups by. `stopped` is a job somebody ended,
34
+ * or one the supervisor found dead; it says nothing worth a line. */
35
+ const JOB_STATES = new Set(["working", "blocked", "done", "failed", "stopped"]);
36
+
37
+ /** Claude Code caps its own `detail` at 800 characters. A line on a card is
38
+ * far shorter than that, but cutting is the renderer's job; this only bounds
39
+ * what a malformed file could put on the wire. */
40
+ const JOB_TEXT_MAX_CHARS = 800;
41
+ const REPLY_MAX_CHARS = 200;
42
+
43
+ /** How many job folders one pass will look at. The folders are removed when a
44
+ * job is deleted, so this is tens on a working machine; the bound is for the
45
+ * one that never deletes anything. */
46
+ const MAX_JOB_DIRS = 500;
47
+
48
+ function text(raw, max) {
49
+ if (typeof raw !== "string") return "";
50
+ const t = raw.replace(/\s+/g, " ").trim();
51
+ return t.length > max ? t.slice(0, max - 1).trimEnd() + "…" : t;
52
+ }
53
+
54
+ /**
55
+ * One `state.json`, as the deck can use it, or null.
56
+ *
57
+ * `updatedAt` is the file's own clock: the client compares it against the
58
+ * session's recap to show whichever is newer, and the deck may be reading a
59
+ * file written while it was down. A job with no session id cannot be matched to
60
+ * anything the deck draws, so it is not a job here.
61
+ */
62
+ export function jobStatusOf(raw, jobId) {
63
+ if (!raw || typeof raw !== "object") return null;
64
+ const state = raw.state;
65
+ if (!JOB_STATES.has(state)) return null;
66
+ const sessionId = typeof raw.sessionId === "string" && raw.sessionId ? raw.sessionId : null;
67
+ if (!sessionId) return null;
68
+ const updatedAt = typeof raw.updatedAt === "string" ? Date.parse(raw.updatedAt) : NaN;
69
+ const out = {
70
+ id: typeof jobId === "string" ? jobId : "",
71
+ state,
72
+ detail: text(raw.detail, JOB_TEXT_MAX_CHARS),
73
+ updatedAt: Number.isFinite(updatedAt) ? updatedAt : 0,
74
+ };
75
+ const needs = text(raw.needs, JOB_TEXT_MAX_CHARS);
76
+ if (needs && state === "blocked") out.needs = needs;
77
+ const reply = text(raw.suggestedReply, REPLY_MAX_CHARS);
78
+ if (reply && state === "blocked") out.suggestedReply = reply;
79
+ const result = text(raw.output?.result, JOB_TEXT_MAX_CHARS);
80
+ if (result) out.result = result;
81
+ return { sessionId, resumeSessionId: typeof raw.resumeSessionId === "string" ? raw.resumeSessionId : null, job: out };
82
+ }
83
+
84
+ /**
85
+ * The watch over `<config dir>/jobs`.
86
+ *
87
+ * Shaped like the output watch: `io` is injectable so the suite can drive a
88
+ * folder that grows, changes and loses files without arranging any of it for
89
+ * real. A file is re-read only when its size or mtime moved, so a quiet machine
90
+ * costs one `readdir` and one `stat` per job per pass.
91
+ */
92
+ export function createJobWatch(io = {}) {
93
+ const jobsDir = io.dir ?? (() => join(claudeConfigDir(), "jobs"));
94
+ const listDir = io.readdir ?? (p => readdir(p, { withFileTypes: true }));
95
+ const statFile = io.stat ?? (p => stat(p));
96
+ const readText = io.readFile ?? (p => readFile(p, "utf8"));
97
+ /** jobId -> { size, mtimeMs, parsed } — the last read of each file. */
98
+ const files = new Map();
99
+ let polling = false;
100
+
101
+ /**
102
+ * One pass. Answers `Map<sessionId, job>` for every job folder that holds a
103
+ * readable state, or null when a pass is already running — an overlapping
104
+ * caller has nothing new to say, and answering with the same map would only
105
+ * re-run the caller's change gate for nothing.
106
+ *
107
+ * Two folders naming one session is a job that was resumed or re-dispatched;
108
+ * the one written most recently is the one the agent view is showing.
109
+ */
110
+ async function poll() {
111
+ if (polling) return null;
112
+ polling = true;
113
+ try {
114
+ const out = new Map();
115
+ let entries;
116
+ try { entries = await listDir(jobsDir()); } catch { files.clear(); return out; }
117
+ const seen = new Set();
118
+ for (const e of entries.slice(0, MAX_JOB_DIRS)) {
119
+ if (!e?.isDirectory?.()) continue;
120
+ const id = e.name;
121
+ seen.add(id);
122
+ const path = join(jobsDir(), id, "state.json");
123
+ let st;
124
+ try { st = await statFile(path); } catch { files.delete(id); continue; }
125
+ const size = Number(st?.size ?? 0);
126
+ const mtimeMs = Number(st?.mtimeMs ?? 0);
127
+ let entry = files.get(id);
128
+ if (!entry || entry.size !== size || entry.mtimeMs !== mtimeMs) {
129
+ let parsed = null;
130
+ // A file caught mid-write fails the parse; the next pass reads it whole.
131
+ try { parsed = jobStatusOf(JSON.parse(await readText(path)), id); } catch { parsed = null; }
132
+ entry = { size, mtimeMs, parsed };
133
+ files.set(id, entry);
134
+ }
135
+ const p = entry.parsed;
136
+ if (!p) continue;
137
+ for (const sid of new Set([p.sessionId, p.resumeSessionId].filter(Boolean))) {
138
+ const prev = out.get(sid);
139
+ if (!prev || p.job.updatedAt >= prev.updatedAt) out.set(sid, p.job);
140
+ }
141
+ }
142
+ for (const id of files.keys()) if (!seen.has(id)) files.delete(id);
143
+ return out;
144
+ } finally {
145
+ polling = false;
146
+ }
147
+ }
148
+
149
+ function clear() { files.clear(); }
150
+
151
+ return { poll, clear };
152
+ }
@@ -139,10 +139,10 @@ export { anotherMachine };
139
139
  /**
140
140
  * Whether an account this round just placed is ticked for sharing here (#1188).
141
141
  *
142
- * ONLY AN ADD: a heal needed the tick to happen at all — roundWith asks for a
143
- * heal only for an account this deck already shares — so there is nothing to
144
- * add for one, and an account healed after somebody unticked it must not be
145
- * ticked again behind them.
142
+ * ONLY AN ADD: a healed slot was already here, so whether it is ticked is
143
+ * already somebody's decision — ticked, there is nothing to add; unticked,
144
+ * possibly on purpose, and an account healed after somebody unticked it must
145
+ * not be ticked again behind them.
146
146
  *
147
147
  * ONLY FROM THE LOCAL NETWORK: the reasoning for the default is that the login
148
148
  * came from the group and the group therefore has it, and a tailnet can reach
@@ -727,23 +727,32 @@ export function createEngine({
727
727
  if (!stillPaired()) throw new Error("peer no longer paired");
728
728
  if (theirs?.t !== "manifest" || !Array.isArray(theirs.accounts)) throw new Error("no manifest");
729
729
  const list = keepManifest(conn.key, theirs, conn.peerFp);
730
- // Only accounts I have also ticked. Sharing is mutual by construction:
731
- // a peer cannot push an account at me that I never agreed to hold.
732
- // A HEAL NEEDS MY TICK; AN ADD DOES NOT, and the asymmetry is deliberate.
733
- // Healing replaces a slot I already have, so it is only reasonable for an
734
- // account I said I share. Adding is the case the owner asked for by name:
735
- // an account that appears among the decks I paired with appears on all of
736
- // them, which is the whole of "I do not want to paste blobs any more".
737
- // What can reach this is what a deck somebody here pressed accept on
738
- // chose to offer.
730
+ // FROM A DECK SOMEBODY HERE CHOSE, NEITHER AN ADD NOR A HEAL NEEDS MY
731
+ // TICK. Adding is the case the owner asked for by name: an account that
732
+ // appears among the decks I paired with appears on all of them, which is
733
+ // the whole of "I do not want to paste blobs any more". What can reach
734
+ // this is what a deck somebody here pressed accept on chose to offer.
735
+ //
736
+ // A heal used to need the tick, on the reasoning that it replaces a slot
737
+ // I already have. It replaces only one claude-swap has quarantined, with
738
+ // the same account's working copy, through the same plain import an add
739
+ // uses — strictly less than an add does. And the tick is the owner's
740
+ // answer to "offer this account" (LanSetupModal), not to "repair it":
741
+ // gating the repair on it left a login dead here for as long as nobody
742
+ // ticked it, while deleting the dead slot turned the same transfer into
743
+ // an add that went straight through. So people deleted accounts to fix
744
+ // them. A copy dies on its own, too — claude-swap's refresh rotates the
745
+ // token, and every other deck holding the old one gets invalid_grant —
746
+ // so this is the ordinary case between one person's machines, not an
747
+ // edge.
739
748
  //
740
749
  // WHICH IS ONLY TRUE OF A DECK SOMEBODY CHOSE. The accept switch presses
741
750
  // accept for the owner, so "somebody here pressed accept" is not a
742
751
  // premise about a deck it paired: nobody here chose that deck, and the
743
- // logins this store holds would be whatever it decided to offer. A deck
744
- // the switch paired is treated as a heal is — what it offers comes in
745
- // only for an account ticked here — and a person's own press or invite
746
- // takes the mark off its pin (see addTrusted).
752
+ // logins this store holds would be whatever it decided to offer. What a
753
+ // deck the switch paired offers comes in — added or healed — only for an
754
+ // account ticked here, and a person's own press or invite takes the mark
755
+ // off its pin (see addTrusted).
747
756
  // OVER `list`, NOT THE RAW ARRAY. `offered`, which keepManifest runs the
748
757
  // list through above (see lan-manifest.mjs), slices to 50 and type-
749
758
  // filters `key` and `email`; this line read `theirs.accounts` and got
@@ -755,12 +764,13 @@ export function createEngine({
755
764
  // waited behind it. `step.key` also reached transferChallenge and
756
765
  // importAccount untyped, which `offered`'s filter would have caught.
757
766
  const chosen = () => trustedPeer(cfg.trusted, conn.peerFp)?.auto !== true;
758
- const takes = step => cfg.shared.includes(step.key) || (step.action === "add" && chosen());
767
+ const takes = step => chosen() || cfg.shared.includes(step.key);
759
768
  const wanted = plan(mine, list).filter(takes);
760
769
  // TWO CHECKS, BECAUSE THEY END DIFFERENT THINGS. Losing the session —
761
770
  // LAN switched off, the peer unpaired (`stillPaired`, above) — ends the
762
- // round. A heal unticked mid-round ends only that heal: the adds behind
763
- // it need no tick, and the skipped row says why rather than vanishing.
771
+ // round. From a deck the switch paired, an account unticked mid-round
772
+ // ends only that step: the next one may still be ticked, and the skipped
773
+ // row says why rather than vanishing.
764
774
  const stillWanted = takes;
765
775
  /** One login, asked for and opened: a `want` carrying its own proof that
766
776
  * names the account (see transferChallenge), and the `have` opened under
@@ -73,8 +73,16 @@ export const MAX_BUFFER = 2000; // recent HOOK events kept for late SSE subs
73
73
  * `OutputObserved` is deliberately NOT here. It says something landed at a
74
74
  * given moment, which is history — two of them are two events, not one value
75
75
  * twice.
76
+ *
77
+ * `ActivityObserved` and `JobObserved` are: each says what the session is doing
78
+ * or what its background job says NOW, and the next one replaces it outright.
79
+ * The activity line moves about once per reply the model writes, which is the
80
+ * same cadence as the tool calls the budget is there to protect.
76
81
  */
77
- const LAST_VALUE_WINS = new Set(["ModelObserved", "UsageObserved", "ContextObserved", "SessionNamed"]);
82
+ const LAST_VALUE_WINS = new Set([
83
+ "ModelObserved", "UsageObserved", "ContextObserved", "SessionNamed",
84
+ "ActivityObserved", "JobObserved",
85
+ ]);
78
86
 
79
87
  /**
80
88
  * Whether this payload is enrichment MAX_BUFFER does not count — see
@@ -0,0 +1,176 @@
1
+ // What a session is doing right now, in one line, read off its transcript.
2
+ //
3
+ // WHERE THE RULE COMES FROM. Claude Code's agent view (`claude agents`) prints
4
+ // one line beside every background session saying what it is doing, and while
5
+ // a turn is running that line is not a summary at all. It is the newest
6
+ // assistant message, read for the first thing a person can read: a text block
7
+ // of more than eight characters, and failing that the `description` the model
8
+ // wrote on its tool call — "Run the API tests" — and failing that what the call
9
+ // is touching. Read off the 2.1.289 bundle on 2026-10-05; the model-written
10
+ // summary that replaces it a minute into a long turn is the half that costs a
11
+ // request, and the deck takes that half from the job's own file when there is
12
+ // one (see claude-jobs.mjs). This half costs nothing, needs only the transcript
13
+ // the output watch is already reading, and is as true of an interactive
14
+ // session as of a background one.
15
+ //
16
+ // WHY THE MESSAGE AND NOT THE LINE. Claude Code writes one transcript line per
17
+ // content block, and the blocks of one reply share `message.id`: "Now I'll read
18
+ // the reducer" lands a moment before the Read it introduces. Folded by message,
19
+ // the sentence outranks the call it explains, as it does in the agent view;
20
+ // folded by line, the call would replace the sentence a second after it
21
+ // appeared, and the line would read like a tool log.
22
+ //
23
+ // WHEN A MESSAGE SAYS NOTHING. A reply holding only thinking, or a call this
24
+ // cannot name, leaves the line where it was. Thinking is the model working on
25
+ // what the line already says, and a blank line would claim it had stopped.
26
+ //
27
+ // What it does NOT decide is whether the line is still true. A line describes
28
+ // the turn that wrote it, and the next prompt makes it history; the client
29
+ // holds that rule (session-status.ts), because a prompt reaches the deck
30
+ // through its hook before the model has written a word.
31
+
32
+ /** The cheap test every line gets before anything is parsed. */
33
+ const ASSISTANT_MARK = '"type":"assistant"';
34
+
35
+ /** Claude Code's own floor for a text block worth showing: "ok", "Done." and
36
+ * "Let me" say nothing about what is happening, and its line skips them. */
37
+ const MIN_SAID_CHARS = 8;
38
+
39
+ /** A row is one line of a 300px sidebar, three at most. Past this the text is
40
+ * cut at a word and marked, so a model that opens with a paragraph does not
41
+ * push the row's own figures off the panel. */
42
+ const ACTIVITY_MAX_CHARS = 160;
43
+
44
+ /**
45
+ * The first line of what the model wrote, as plain words.
46
+ *
47
+ * Markdown is the model's formatting for a terminal and reads as noise in a
48
+ * one-line row: `**bold**`, backticks, a heading's hashes, a bullet's dash. Only
49
+ * the first non-empty line is taken — the rest of a long reply is the answer,
50
+ * not the activity.
51
+ */
52
+ export function cleanSaid(raw) {
53
+ if (typeof raw !== "string") return "";
54
+ const first = raw.split("\n").map(l => l.trim()).find(Boolean) ?? "";
55
+ let text = first
56
+ .replace(/^#{1,6}\s+/, "")
57
+ .replace(/^(?:[-*+]|\d+[.)])\s+/, "")
58
+ .replace(/\*\*|__|`/g, "")
59
+ .replace(/\s+/g, " ")
60
+ .trim();
61
+ if (text.length > ACTIVITY_MAX_CHARS) {
62
+ const cut = text.slice(0, ACTIVITY_MAX_CHARS - 1);
63
+ const space = cut.lastIndexOf(" ");
64
+ text = (space > ACTIVITY_MAX_CHARS * 0.6 ? cut.slice(0, space) : cut).trimEnd() + "…";
65
+ }
66
+ return text;
67
+ }
68
+
69
+ /** The last path segment, for a call whose subject is a file. */
70
+ function baseName(path) {
71
+ if (typeof path !== "string" || !path) return "";
72
+ const parts = path.split(/[\\/]/).filter(Boolean);
73
+ return parts[parts.length - 1] ?? "";
74
+ }
75
+
76
+ /** A host, for a fetch: the whole address is a query string as often as not. */
77
+ function hostOf(url) {
78
+ if (typeof url !== "string") return "";
79
+ try { return new URL(url).host; } catch { return ""; }
80
+ }
81
+
82
+ /**
83
+ * What one tool call is doing, in words, or "" when it says nothing a person
84
+ * could read.
85
+ *
86
+ * The model's own `description` first: it is a sentence written for exactly
87
+ * this reader, and Bash, Task and Agent carry one on nearly every call. The
88
+ * rest are the calls that carry none but name their subject plainly — a file,
89
+ * a pattern, a host. Anything else is left unnamed rather than printed as a
90
+ * tool id: "Using mcp__github__list_pulls" is a log line, not an activity.
91
+ */
92
+ export function toolActivity(name, input) {
93
+ const desc = typeof input?.description === "string" ? input.description : "";
94
+ if (desc.trim()) return cleanSaid(desc);
95
+ const say = (verb, subject) => (subject ? cleanSaid(`${verb} ${subject}`) : "");
96
+ switch (name) {
97
+ case "Read": return say("Reading", baseName(input?.file_path));
98
+ case "Edit":
99
+ case "MultiEdit": return say("Editing", baseName(input?.file_path));
100
+ case "Write": return say("Writing", baseName(input?.file_path));
101
+ case "NotebookEdit": return say("Editing", baseName(input?.notebook_path));
102
+ case "Grep": return say("Searching for", typeof input?.pattern === "string" ? input.pattern : "");
103
+ case "Glob": return say("Finding", typeof input?.pattern === "string" ? input.pattern : "");
104
+ case "WebFetch": return say("Fetching", hostOf(input?.url));
105
+ case "WebSearch": return say("Searching the web for", typeof input?.query === "string" ? input.query : "");
106
+ default: return "";
107
+ }
108
+ }
109
+
110
+ /**
111
+ * What one transcript line adds to the line, or null for a line that is not
112
+ * the main chain's model writing.
113
+ *
114
+ * `said` is the newest readable text block on the line, `did` the newest
115
+ * nameable call; either may be "" when the line carries neither. A subagent's
116
+ * sidechain is not the session's own reply — current Claude Code writes those
117
+ * to their own files anyway, and the parent's Task call already says what the
118
+ * subagent was sent to do.
119
+ */
120
+ export function activityOfLine(line) {
121
+ if (!line || !line.includes(ASSISTANT_MARK)) return null;
122
+ let rec;
123
+ try { rec = JSON.parse(line); } catch { return null; }
124
+ if (!rec || rec.type !== "assistant" || rec.isSidechain === true) return null;
125
+ const content = rec.message?.content;
126
+ if (!Array.isArray(content)) return null;
127
+ let said = "";
128
+ let did = "";
129
+ for (const block of content) {
130
+ if (block?.type === "text" && typeof block.text === "string") {
131
+ const text = cleanSaid(block.text);
132
+ if (text.length > MIN_SAID_CHARS) said = text;
133
+ } else if (block?.type === "tool_use") {
134
+ const text = toolActivity(block.name, block.input);
135
+ if (text) did = text;
136
+ }
137
+ }
138
+ const at = typeof rec.timestamp === "string" ? Date.parse(rec.timestamp) : NaN;
139
+ return {
140
+ messageId: typeof rec.message?.id === "string" ? rec.message.id : null,
141
+ said,
142
+ did,
143
+ at: Number.isFinite(at) ? at : null,
144
+ };
145
+ }
146
+
147
+ /** A session's fold, before it has read anything. */
148
+ export function newActivityState() {
149
+ return { messageId: null, said: "", did: "", shown: null };
150
+ }
151
+
152
+ /**
153
+ * Fold one transcript line into `st.shown` — `{ text, source, at }`, or null
154
+ * until something readable has been written.
155
+ *
156
+ * Pure apart from `st`, and shaped like foldRecapLine beside it so the output
157
+ * watch's tap can run both over the same tail. A new `message.id` starts a new
158
+ * reply; within one reply what was said outranks what was done, whichever line
159
+ * came last. A line with no message id is its own reply, which is what an
160
+ * older transcript or a hand-written fixture looks like.
161
+ */
162
+ export function foldActivityLine(st, line) {
163
+ const a = activityOfLine(line);
164
+ if (!a) return;
165
+ if (!a.messageId || a.messageId !== st.messageId) {
166
+ st.messageId = a.messageId;
167
+ st.said = "";
168
+ st.did = "";
169
+ }
170
+ if (a.said) st.said = a.said;
171
+ if (a.did) st.did = a.did;
172
+ const text = st.said || st.did;
173
+ if (!text) return;
174
+ if (st.shown && st.shown.text === text) return;
175
+ st.shown = { text, source: st.said ? "said" : "tool", at: a.at ?? Date.now() };
176
+ }
@@ -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({