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.
- package/README.md +4 -1
- package/dist/web/assets/{BrowserWatchModal-njN0zg93.js → BrowserWatchModal-BPObeZn1.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-DYSZZZEz.js → UsageHistoryModal-Drv13uht.js} +1 -1
- package/dist/web/assets/index-0cUs2Jed.css +1 -0
- package/dist/web/assets/index-C_h8mR-c.js +307 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +2 -1
- package/src/server/claude-jobs.mjs +152 -0
- package/src/server/ring-bounds.mjs +9 -1
- package/src/server/session-activity.mjs +176 -0
- package/src/server/session-enrichment.mjs +69 -4
- package/src/server/session-tracking.mjs +22 -3
- package/src/server/thermal-metrics.mjs +113 -4
- package/src/server/thermal-sampler.mjs +10 -4
- package/dist/web/assets/index-B0SOa-XB.css +0 -1
- package/dist/web/assets/index-DDxnWaTa.js +0 -306
package/dist/web/index.html
CHANGED
|
@@ -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-
|
|
68
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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.
|
|
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": {
|
package/release-notes.json
CHANGED
|
@@ -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([
|
|
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,
|
|
3
|
-
// carrying — each read off the session's transcript (or, for a
|
|
4
|
-
// memory files, off its working tree) and sent on as a
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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({
|