ccdeck 3.37.1 → 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-DDxnWaTa.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.1",
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,8 @@
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"
73
74
  ],
74
75
  "3.37.1": [
75
76
  {
@@ -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
+ }
@@ -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({