claudelink 1.4.3 → 1.5.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 +34 -1
- package/dist/arm-compact.d.ts +2 -0
- package/dist/arm-compact.js +182 -0
- package/dist/arm-compact.js.map +1 -0
- package/dist/cli.js +145 -0
- package/dist/cli.js.map +1 -1
- package/dist/compact-analyzer.d.ts +67 -0
- package/dist/compact-analyzer.js +309 -0
- package/dist/compact-analyzer.js.map +1 -0
- package/dist/compact-decision.d.ts +19 -0
- package/dist/compact-decision.js +47 -0
- package/dist/compact-decision.js.map +1 -0
- package/dist/compact-executor.d.ts +27 -0
- package/dist/compact-executor.js +160 -0
- package/dist/compact-executor.js.map +1 -0
- package/dist/context-watcher-settings.d.ts +21 -0
- package/dist/context-watcher-settings.js +176 -0
- package/dist/context-watcher-settings.js.map +1 -0
- package/dist/context-watcher.d.ts +70 -0
- package/dist/context-watcher.js +1005 -0
- package/dist/context-watcher.js.map +1 -0
- package/dist/db.d.ts +22 -1
- package/dist/db.js +184 -5
- package/dist/db.js.map +1 -1
- package/dist/hooks/stop-hook.js +30 -1
- package/dist/hooks/stop-hook.js.map +1 -1
- package/dist/index.js +144 -12
- package/dist/index.js.map +1 -1
- package/dist/manual-decision.d.ts +23 -0
- package/dist/manual-decision.js +67 -0
- package/dist/manual-decision.js.map +1 -0
- package/dist/prompt-clear.d.ts +8 -0
- package/dist/prompt-clear.js +33 -0
- package/dist/prompt-clear.js.map +1 -0
- package/dist/recovery-watcher.d.ts +6 -0
- package/dist/recovery-watcher.js +20 -0
- package/dist/recovery-watcher.js.map +1 -1
- package/dist/role-collision.d.ts +6 -0
- package/dist/role-collision.js +51 -0
- package/dist/role-collision.js.map +1 -0
- package/dist/ui-server.d.ts +4 -0
- package/dist/ui-server.js +899 -20
- package/dist/ui-server.js.map +1 -1
- package/dist/usage-reader.d.ts +79 -0
- package/dist/usage-reader.js +553 -0
- package/dist/usage-reader.js.map +1 -0
- package/package.json +8 -2
package/dist/ui-server.js
CHANGED
|
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.findFleetTranscript = findFleetTranscript;
|
|
6
7
|
exports.startUIServer = startUIServer;
|
|
7
8
|
const http_1 = __importDefault(require("http"));
|
|
8
9
|
const path_1 = __importDefault(require("path"));
|
|
@@ -14,8 +15,21 @@ const scheduler_js_1 = require("./scheduler.js");
|
|
|
14
15
|
const recovery_watcher_js_1 = require("./recovery-watcher.js");
|
|
15
16
|
const recovery_watcher_settings_js_1 = require("./recovery-watcher-settings.js");
|
|
16
17
|
const scheduler_settings_js_1 = require("./scheduler-settings.js");
|
|
18
|
+
const usage_reader_js_1 = require("./usage-reader.js");
|
|
19
|
+
const context_watcher_js_1 = require("./context-watcher.js");
|
|
20
|
+
const context_watcher_settings_js_1 = require("./context-watcher-settings.js");
|
|
21
|
+
const compact_analyzer_js_1 = require("./compact-analyzer.js");
|
|
22
|
+
const usage_reader_js_2 = require("./usage-reader.js");
|
|
23
|
+
const compact_executor_js_1 = require("./compact-executor.js");
|
|
24
|
+
const prompt_clear_js_1 = require("./prompt-clear.js");
|
|
17
25
|
const NEXUS_DIR = path_1.default.join(os_1.default.homedir(), ".claudelink");
|
|
18
|
-
const
|
|
26
|
+
const DEFAULT_DB_PATH = path_1.default.join(NEXUS_DIR, "nexus.db");
|
|
27
|
+
// Honors CLAUDELINK_DB_PATH for test isolation. Evaluated at call time
|
|
28
|
+
// (not module load) so tests can set the env var AFTER static-import
|
|
29
|
+
// hoisting brings this module in.
|
|
30
|
+
function DB_PATH() {
|
|
31
|
+
return process.env.CLAUDELINK_DB_PATH || DEFAULT_DB_PATH;
|
|
32
|
+
}
|
|
19
33
|
const LOCK_PATH = path_1.default.join(NEXUS_DIR, "ui.lock");
|
|
20
34
|
function isProcessAlive(pid) {
|
|
21
35
|
try {
|
|
@@ -67,9 +81,117 @@ function isClaudelinkServerPid(pid) {
|
|
|
67
81
|
return false;
|
|
68
82
|
}
|
|
69
83
|
}
|
|
70
|
-
|
|
84
|
+
// Resolve the live transcript for an alive agent — DISPLAY/OBSERVATION path
|
|
85
|
+
// (read-only). Trust the agent's own captured transcript_path whenever the file
|
|
86
|
+
// EXISTS, even if its mtime is stale: a stale own-transcript shows the terminal's
|
|
87
|
+
// real last context size (unchanged precisely because it's idle), which is
|
|
88
|
+
// CORRECT. We deliberately do NOT apply the watcher's mtime-staleness fallback
|
|
89
|
+
// here — in a shared project dir (several agents in one repo) "most-recent .jsonl"
|
|
90
|
+
// is usually ANOTHER agent's session, so the fallback misattributes that agent's
|
|
91
|
+
// context (e.g. several idle terminals in a shared repo all showing one peer's
|
|
92
|
+
// large 572K session instead of their own). The
|
|
93
|
+
// staleness guard belongs only to the INJECTION path (resolveSession), where
|
|
94
|
+
// targeting a wrong live session is a write hazard; observation has no such risk.
|
|
95
|
+
// Fall back to the dir-heuristic ONLY when there is no own path (pre-v3 agent) or
|
|
96
|
+
// the file is gone. (Edge: a session that rotates without re-registering shows a
|
|
97
|
+
// stale own number until it re-registers — rarer and far less harmful than a
|
|
98
|
+
// cross-agent number, and /compact does not rotate the transcript file.)
|
|
99
|
+
function findFleetTranscript(opts) {
|
|
100
|
+
if (opts.transcript_path) {
|
|
101
|
+
try {
|
|
102
|
+
fs_1.default.statSync(opts.transcript_path); // exists + readable → it's this agent's session
|
|
103
|
+
return opts.transcript_path;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
/* missing/unreadable → fall through to heuristic */
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const cwd = (0, usage_reader_js_2.cwdForPid)(opts.pid);
|
|
110
|
+
if (!cwd)
|
|
111
|
+
return null;
|
|
112
|
+
const dir = path_1.default.join(usage_reader_js_2.PROJECTS_DIR, (0, usage_reader_js_2.projectIdFromCwd)(cwd));
|
|
113
|
+
let files;
|
|
114
|
+
try {
|
|
115
|
+
files = fs_1.default
|
|
116
|
+
.readdirSync(dir)
|
|
117
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
118
|
+
.map((f) => {
|
|
119
|
+
const full = path_1.default.join(dir, f);
|
|
120
|
+
return { file: full, mtime: fs_1.default.statSync(full).mtimeMs };
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
if (files.length === 0)
|
|
127
|
+
return null;
|
|
128
|
+
files.sort((a, b) => b.mtime - a.mtime);
|
|
129
|
+
return files[0].file;
|
|
130
|
+
}
|
|
131
|
+
// Populate AgentRow.fleet for each alive agent. Best-effort: a per-agent
|
|
132
|
+
// failure logs and skips that agent rather than failing the whole response.
|
|
133
|
+
// All file I/O is read-only; the watcher's enabled/mode setting is unaffected.
|
|
134
|
+
async function enrichAgentsFleet(agents, checkpointMap) {
|
|
135
|
+
const now = Date.now();
|
|
136
|
+
await Promise.all(agents.map(async (a) => {
|
|
137
|
+
if (!a.alive)
|
|
138
|
+
return;
|
|
139
|
+
const ck = checkpointMap.get(a.id);
|
|
140
|
+
if (!ck)
|
|
141
|
+
return;
|
|
142
|
+
const transcript = findFleetTranscript({
|
|
143
|
+
pid: a.pid,
|
|
144
|
+
transcript_path: ck.transcript_path,
|
|
145
|
+
});
|
|
146
|
+
if (!transcript)
|
|
147
|
+
return;
|
|
148
|
+
let mtime = null;
|
|
149
|
+
try {
|
|
150
|
+
mtime = fs_1.default.statSync(transcript).mtimeMs;
|
|
151
|
+
}
|
|
152
|
+
catch { }
|
|
153
|
+
let econ;
|
|
154
|
+
try {
|
|
155
|
+
econ = await (0, context_watcher_js_1.latestTurnEconomics)(transcript);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
econ = null;
|
|
159
|
+
}
|
|
160
|
+
if (!econ)
|
|
161
|
+
return;
|
|
162
|
+
const window = (0, usage_reader_js_2.effectiveContextWindow)(econ.model, econ.contextTokens);
|
|
163
|
+
const perTurnUsd = (econ.contextTokens * (0, usage_reader_js_2.cacheReadPricePerMtok)(econ.model)) / 1000000;
|
|
164
|
+
let handoffOk = false;
|
|
165
|
+
let handoffMtime = null;
|
|
166
|
+
if (ck.checkpoint_handoff_path) {
|
|
167
|
+
try {
|
|
168
|
+
handoffOk = (0, compact_executor_js_1.verifyHandoff)(ck.checkpoint_handoff_path).ok;
|
|
169
|
+
handoffMtime = fs_1.default.statSync(ck.checkpoint_handoff_path).mtimeMs;
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
handoffOk = false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const signalAgeMin = ck.checkpoint_ts != null ? (now - ck.checkpoint_ts) / 60000 : null;
|
|
176
|
+
a.fleet = {
|
|
177
|
+
context_tokens: econ.contextTokens,
|
|
178
|
+
occupancy_pct: (econ.contextTokens / window) * 100,
|
|
179
|
+
model: econ.model,
|
|
180
|
+
window_tokens: window,
|
|
181
|
+
per_turn_usd: perTurnUsd,
|
|
182
|
+
transcript_path: transcript,
|
|
183
|
+
transcript_mtime_ms: mtime,
|
|
184
|
+
handoff_path: ck.checkpoint_handoff_path,
|
|
185
|
+
handoff_mtime_ms: handoffMtime,
|
|
186
|
+
handoff_ok: handoffOk,
|
|
187
|
+
signal_age_min: signalAgeMin,
|
|
188
|
+
safe_to_clear: ck.checkpoint_safe_to_clear === 1 ? 1 : 0,
|
|
189
|
+
};
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
192
|
+
async function getState() {
|
|
71
193
|
const servers = listClaudelinkServers();
|
|
72
|
-
const db = new better_sqlite3_1.default(DB_PATH, { readonly: false });
|
|
194
|
+
const db = new better_sqlite3_1.default(DB_PATH(), { readonly: false });
|
|
73
195
|
const agents = db
|
|
74
196
|
.prepare(`SELECT a.id, a.role, a.description, a.registered_at, a.last_seen, a.pid,
|
|
75
197
|
a.autonomous_reply,
|
|
@@ -78,6 +200,22 @@ function getState() {
|
|
|
78
200
|
FROM agents a
|
|
79
201
|
ORDER BY a.registered_at DESC`)
|
|
80
202
|
.all();
|
|
203
|
+
// Pull the checkpoint columns separately — older DBs may not have them.
|
|
204
|
+
// Wrapped in try so a pre-v3 schema doesn't break the fleet view, just
|
|
205
|
+
// leaves the fleet field undefined for those agents.
|
|
206
|
+
const checkpointMap = new Map();
|
|
207
|
+
try {
|
|
208
|
+
const ckRows = db
|
|
209
|
+
.prepare(`SELECT id, checkpoint_ts, checkpoint_safe_to_clear,
|
|
210
|
+
checkpoint_handoff_path, transcript_path
|
|
211
|
+
FROM agents`)
|
|
212
|
+
.all();
|
|
213
|
+
for (const r of ckRows)
|
|
214
|
+
checkpointMap.set(r.id, r);
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
/* pre-v3 schema — fleet enrichment will be a no-op */
|
|
218
|
+
}
|
|
81
219
|
const enriched = agents.map((a) => ({
|
|
82
220
|
...a,
|
|
83
221
|
alive: isProcessAlive(a.pid),
|
|
@@ -109,6 +247,16 @@ function getState() {
|
|
|
109
247
|
LIMIT 10`)
|
|
110
248
|
.all();
|
|
111
249
|
db.close();
|
|
250
|
+
// Stage 1 — enrich each ALIVE agent with live fleet metrics (context %, $,
|
|
251
|
+
// handoff mtime, etc.). Best-effort: per-agent failures fall through silently
|
|
252
|
+
// so a single bad transcript never breaks the dashboard. Performed AFTER the
|
|
253
|
+
// DB is closed so the enrichment scan doesn't keep the DB handle hot.
|
|
254
|
+
try {
|
|
255
|
+
await enrichAgentsFleet(enriched, checkpointMap);
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
/* whole-batch failure → dashboard still renders without fleet column */
|
|
259
|
+
}
|
|
112
260
|
return {
|
|
113
261
|
servers,
|
|
114
262
|
agents: enriched,
|
|
@@ -123,8 +271,104 @@ function getState() {
|
|
|
123
271
|
recent_messages: recent,
|
|
124
272
|
};
|
|
125
273
|
}
|
|
274
|
+
// Fleet token meter. Reads LIVE registered agents (role + pid) and hands them to
|
|
275
|
+
// the usage reader, which maps each to its Claude Code project dir and tallies
|
|
276
|
+
// real token usage from the local transcripts. Privacy guardrail: only LIVE
|
|
277
|
+
// fleet agents are passed, so we never scan the user's unrelated ~/.claude
|
|
278
|
+
// history. Read-only — no DB writes, no terminal contact.
|
|
279
|
+
// Scanning the fleet's transcripts is expensive (large sessions → multi-second
|
|
280
|
+
// parses). Two safeguards so the dashboard never piles up requests:
|
|
281
|
+
// 1. A short TTL result cache keyed by windowDays — repeat views / the
|
|
282
|
+
// periodic refresh return instantly within the window.
|
|
283
|
+
// 2. In-flight dedupe — concurrent requests for the same window share ONE
|
|
284
|
+
// computation instead of each kicking off its own full scan.
|
|
285
|
+
const USAGE_TTL_MS = 5 * 60 * 1000;
|
|
286
|
+
const usageCache = new Map();
|
|
287
|
+
const usageInflight = new Map();
|
|
288
|
+
async function computeUsage(windowDays) {
|
|
289
|
+
const db = new better_sqlite3_1.default(DB_PATH(), { readonly: true });
|
|
290
|
+
let rows;
|
|
291
|
+
try {
|
|
292
|
+
try {
|
|
293
|
+
rows = db.prepare(`SELECT role, pid, transcript_path FROM agents`).all();
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
// Pre-migration DB (live MCP servers still on the old schema, no v3
|
|
297
|
+
// transcript_path column yet). Fall back — per-session breakdown then
|
|
298
|
+
// labels by session-id, and lights up by agent role once deployed.
|
|
299
|
+
rows = db.prepare(`SELECT role, pid FROM agents`).all().map((r) => ({ ...r, transcript_path: null }));
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
finally {
|
|
303
|
+
db.close();
|
|
304
|
+
}
|
|
305
|
+
const live = rows
|
|
306
|
+
.filter((r) => isProcessAlive(r.pid))
|
|
307
|
+
.map((r) => ({ role: r.role, pid: r.pid, transcriptPath: r.transcript_path }));
|
|
308
|
+
// Single transcript pass computes BOTH the meter and the compact-opportunity
|
|
309
|
+
// (the projection's threshold/baseline track the watcher's settings) — no
|
|
310
|
+
// second full scan. transcriptPath (when populated) labels per-session
|
|
311
|
+
// breakdown by agent role.
|
|
312
|
+
const cw = (0, context_watcher_settings_js_1.readContextWatcherSettings)();
|
|
313
|
+
return (0, usage_reader_js_1.readFleetUsage)(live, windowDays, cw.thresholdTokens, cw.compactBaselineTokens);
|
|
314
|
+
}
|
|
315
|
+
async function getUsage(windowDays) {
|
|
316
|
+
const cached = usageCache.get(windowDays);
|
|
317
|
+
if (cached && Date.now() - cached.at < USAGE_TTL_MS)
|
|
318
|
+
return cached.data;
|
|
319
|
+
const existing = usageInflight.get(windowDays);
|
|
320
|
+
if (existing)
|
|
321
|
+
return existing;
|
|
322
|
+
const p = computeUsage(windowDays)
|
|
323
|
+
.then((data) => {
|
|
324
|
+
usageCache.set(windowDays, { at: Date.now(), data });
|
|
325
|
+
return data;
|
|
326
|
+
})
|
|
327
|
+
.finally(() => usageInflight.delete(windowDays));
|
|
328
|
+
usageInflight.set(windowDays, p);
|
|
329
|
+
return p;
|
|
330
|
+
}
|
|
331
|
+
// Per-agent timeline (the 3 dashboard surfaces). All-time scan, so cached on a
|
|
332
|
+
// longer TTL — cumulative changes slowly — with in-flight dedupe.
|
|
333
|
+
const TIMELINE_TTL_MS = 5 * 60 * 1000;
|
|
334
|
+
let timelineCache = null;
|
|
335
|
+
let timelineInflight = null;
|
|
336
|
+
async function getAgentTimelines() {
|
|
337
|
+
if (timelineCache && Date.now() - timelineCache.at < TIMELINE_TTL_MS)
|
|
338
|
+
return timelineCache.data;
|
|
339
|
+
if (timelineInflight)
|
|
340
|
+
return timelineInflight;
|
|
341
|
+
const p = (async () => {
|
|
342
|
+
const db = new better_sqlite3_1.default(DB_PATH(), { readonly: true });
|
|
343
|
+
let rows;
|
|
344
|
+
try {
|
|
345
|
+
try {
|
|
346
|
+
rows = db.prepare(`SELECT role, pid, transcript_path FROM agents`).all();
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
rows = db.prepare(`SELECT role, pid FROM agents`).all().map((r) => ({ ...r, transcript_path: null }));
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
finally {
|
|
353
|
+
db.close();
|
|
354
|
+
}
|
|
355
|
+
const live = rows
|
|
356
|
+
.filter((r) => isProcessAlive(r.pid))
|
|
357
|
+
.map((r) => ({ role: r.role, pid: r.pid, transcriptPath: r.transcript_path }));
|
|
358
|
+
return (0, usage_reader_js_1.readAgentTimelines)(live);
|
|
359
|
+
})()
|
|
360
|
+
.then((data) => {
|
|
361
|
+
timelineCache = { at: Date.now(), data };
|
|
362
|
+
return data;
|
|
363
|
+
})
|
|
364
|
+
.finally(() => {
|
|
365
|
+
timelineInflight = null;
|
|
366
|
+
});
|
|
367
|
+
timelineInflight = p;
|
|
368
|
+
return p;
|
|
369
|
+
}
|
|
126
370
|
function healOrphans() {
|
|
127
|
-
const db = new better_sqlite3_1.default(DB_PATH);
|
|
371
|
+
const db = new better_sqlite3_1.default(DB_PATH());
|
|
128
372
|
const deadAgents = db.prepare(`SELECT id, pid FROM agents`).all().filter((a) => !isProcessAlive(a.pid));
|
|
129
373
|
if (deadAgents.length === 0) {
|
|
130
374
|
db.close();
|
|
@@ -147,7 +391,7 @@ function healOrphans() {
|
|
|
147
391
|
return result;
|
|
148
392
|
}
|
|
149
393
|
function setAutonomousReply(agentId, enabled) {
|
|
150
|
-
const db = new better_sqlite3_1.default(DB_PATH);
|
|
394
|
+
const db = new better_sqlite3_1.default(DB_PATH());
|
|
151
395
|
try {
|
|
152
396
|
const r = db
|
|
153
397
|
.prepare(`UPDATE agents SET autonomous_reply = ? WHERE id = ?`)
|
|
@@ -159,7 +403,7 @@ function setAutonomousReply(agentId, enabled) {
|
|
|
159
403
|
}
|
|
160
404
|
}
|
|
161
405
|
function removeStaleAgent(agentId) {
|
|
162
|
-
const db = new better_sqlite3_1.default(DB_PATH);
|
|
406
|
+
const db = new better_sqlite3_1.default(DB_PATH());
|
|
163
407
|
const tx = db.transaction(() => {
|
|
164
408
|
db.prepare(`DELETE FROM messages WHERE from_agent = ?`).run(agentId);
|
|
165
409
|
db.prepare(`DELETE FROM bulletin WHERE from_agent = ?`).run(agentId);
|
|
@@ -229,8 +473,15 @@ const HTML = String.raw `<!doctype html>
|
|
|
229
473
|
header button:hover { background: var(--border); }
|
|
230
474
|
header button.danger { color: var(--red); border-color: rgba(255,107,107,0.3); }
|
|
231
475
|
header button.danger:hover { background: rgba(255,107,107,0.1); }
|
|
232
|
-
main { padding: 20px;
|
|
233
|
-
|
|
476
|
+
main { padding: 20px; max-width: 1400px; margin: 0 auto; }
|
|
477
|
+
.grid { display: grid; gap: 16px; grid-template-columns: 1fr 1fr; }
|
|
478
|
+
@media (max-width: 900px) { .grid { grid-template-columns: 1fr; } }
|
|
479
|
+
.tabs { display: flex; gap: 2px; padding: 0 20px; border-bottom: 1px solid var(--border); background: var(--panel); }
|
|
480
|
+
.tab { background: none; border: none; border-bottom: 2px solid transparent; color: var(--muted); padding: 12px 18px; font-size: 14px; font-weight: 500; cursor: pointer; }
|
|
481
|
+
.tab:hover { color: var(--text); }
|
|
482
|
+
.tab.active { color: var(--text); border-bottom-color: var(--accent); }
|
|
483
|
+
.tab-content { display: none; }
|
|
484
|
+
.tab-content.active { display: block; }
|
|
234
485
|
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
|
|
235
486
|
.panel h2 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.8px; color: var(--muted); margin: 0; padding: 12px 16px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; }
|
|
236
487
|
.panel h2 .count { background: var(--panel-2); color: var(--text); padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
|
|
@@ -280,6 +531,52 @@ const HTML = String.raw `<!doctype html>
|
|
|
280
531
|
.nudge-status.show { opacity: 1; }
|
|
281
532
|
.nudge-hint { color: var(--muted); font-size: 12px; padding: 0 16px 14px; line-height: 1.5; margin: 0; }
|
|
282
533
|
.nudge-hint .mono { color: var(--text); }
|
|
534
|
+
/* Fleet — live context (Stage 1) */
|
|
535
|
+
.fleet-tbl td { vertical-align: middle; }
|
|
536
|
+
.occ-bar { position: relative; width: 140px; height: 10px; background: var(--panel-2); border-radius: 5px; overflow: hidden; border: 1px solid var(--border); }
|
|
537
|
+
.occ-bar .fill { position: absolute; left: 0; top: 0; bottom: 0; background: var(--accent); transition: width 0.2s; }
|
|
538
|
+
.occ-bar .fill.warn { background: var(--amber); }
|
|
539
|
+
.occ-bar .fill.bad { background: var(--red); }
|
|
540
|
+
.occ-pct { font-family: var(--mono, monospace); font-size: 11px; color: var(--muted); margin-left: 8px; min-width: 38px; display: inline-block; text-align: right; }
|
|
541
|
+
.pill.ok { background: rgba(61,220,132,0.15); color: var(--green); }
|
|
542
|
+
.pill.miss { background: rgba(120,120,140,0.15); color: var(--muted); }
|
|
543
|
+
/* Fleet Token Meter */
|
|
544
|
+
.usage-summary { display: flex; gap: 14px; flex-wrap: wrap; padding: 4px 16px 12px; }
|
|
545
|
+
.usage-stat { background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px; padding: 10px 14px; min-width: 120px; }
|
|
546
|
+
.usage-stat .label { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; }
|
|
547
|
+
.usage-stat .value { color: var(--text); font-size: 20px; font-weight: 600; margin-top: 3px; }
|
|
548
|
+
.usage-stat .sub { color: var(--muted); font-size: 11px; margin-top: 2px; }
|
|
549
|
+
.usage-trend { display: flex; align-items: flex-end; gap: 6px; height: 84px; padding: 0 16px 10px; }
|
|
550
|
+
.usage-bar { flex: 1; background: var(--accent); border-radius: 4px 4px 0 0; min-height: 2px; position: relative; opacity: 0.85; transition: height 0.2s; }
|
|
551
|
+
.usage-bar:hover { opacity: 1; }
|
|
552
|
+
.usage-bar .cap { position: absolute; top: -16px; left: 0; right: 0; text-align: center; font-size: 9px; color: var(--muted); }
|
|
553
|
+
.usage-bar .day { position: absolute; bottom: -16px; left: 0; right: 0; text-align: center; font-size: 9px; color: var(--muted); }
|
|
554
|
+
/* single-day distribution donut (replaces the degenerate one-bar trend) */
|
|
555
|
+
.usage-trend.donut-mode { height: auto; align-items: center; gap: 28px; padding: 14px 16px; flex-wrap: wrap; }
|
|
556
|
+
.usage-donut { width: 150px; height: 150px; border-radius: 50%; position: relative; flex: none; }
|
|
557
|
+
.usage-donut::after { content: ''; position: absolute; inset: 34px; border-radius: 50%; background: var(--panel); }
|
|
558
|
+
.donut-center { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 15px; font-weight: 600; color: var(--text); z-index: 1; }
|
|
559
|
+
.usage-donut-legend { display: flex; flex-direction: column; gap: 6px; font-size: 12px; min-width: 240px; flex: 1; }
|
|
560
|
+
.donut-leg-item { display: flex; align-items: center; gap: 8px; }
|
|
561
|
+
.donut-swatch { width: 10px; height: 10px; border-radius: 2px; flex: none; }
|
|
562
|
+
.donut-name { color: var(--text); }
|
|
563
|
+
.donut-pct { color: var(--muted); margin-left: auto; }
|
|
564
|
+
.donut-tok { color: var(--muted); font-family: ui-monospace, Menlo, monospace; min-width: 64px; text-align: right; }
|
|
565
|
+
.usage-proj { width: 100%; border-collapse: collapse; margin: 6px 0 4px; }
|
|
566
|
+
.usage-proj th { text-align: left; color: var(--muted); font-size: 11px; font-weight: 500; padding: 6px 16px; border-bottom: 1px solid var(--border); }
|
|
567
|
+
.usage-proj td { padding: 8px 16px; border-bottom: 1px solid var(--border); font-size: 13px; color: var(--text); vertical-align: top; }
|
|
568
|
+
.usage-proj td.num { text-align: right; font-family: ui-monospace, Menlo, monospace; }
|
|
569
|
+
.usage-proj tr:last-child td { border-bottom: none; }
|
|
570
|
+
.usage-model-chip { display: inline-block; background: rgba(184,156,255,0.14); color: var(--accent); border-radius: 8px; padding: 1px 7px; font-size: 10px; margin: 1px 3px 1px 0; font-family: ui-monospace, Menlo, monospace; }
|
|
571
|
+
.usage-roles { color: var(--muted); font-size: 11px; margin-top: 2px; }
|
|
572
|
+
/* per-project + expand → per-session/agent sub-rows */
|
|
573
|
+
.proj-expand { display: inline-flex; align-items: center; justify-content: center; background: var(--panel-2); border: 1px solid var(--border); color: var(--accent); width: 18px; height: 18px; border-radius: 4px; font-size: 13px; line-height: 1; cursor: pointer; margin-right: 8px; padding: 0; vertical-align: middle; }
|
|
574
|
+
.proj-expand:hover { border-color: var(--accent); }
|
|
575
|
+
.proj-expand-spacer { display: inline-block; width: 18px; margin-right: 8px; }
|
|
576
|
+
.usage-subrow td { background: rgba(184,156,255,0.05); font-size: 12px; padding-top: 6px; padding-bottom: 6px; }
|
|
577
|
+
.usage-subrow td:first-child { padding-left: 42px; }
|
|
578
|
+
.usage-subrow .sub-name { color: var(--text); }
|
|
579
|
+
.usage-subrow .sub-unmapped { color: var(--muted); font-style: italic; }
|
|
283
580
|
.auto-reply-cell { display: inline-flex; gap: 6px; align-items: center; cursor: pointer; user-select: none; }
|
|
284
581
|
.auto-reply-cell input[type="checkbox"] { width: 14px; height: 14px; cursor: pointer; accent-color: var(--accent); margin: 0; }
|
|
285
582
|
.auto-reply-cell .lbl { font-size: 11px; color: var(--muted); min-width: 22px; }
|
|
@@ -307,7 +604,25 @@ const HTML = String.raw `<!doctype html>
|
|
|
307
604
|
<button id="btn-retry-now">Retry now</button>
|
|
308
605
|
</div>
|
|
309
606
|
|
|
607
|
+
<nav class="tabs">
|
|
608
|
+
<button class="tab active" data-tab="overview">Overview</button>
|
|
609
|
+
<button class="tab" data-tab="meter">Fleet Token Meter</button>
|
|
610
|
+
</nav>
|
|
611
|
+
|
|
310
612
|
<main>
|
|
613
|
+
<div class="tab-content active" id="tab-overview">
|
|
614
|
+
<div class="grid">
|
|
615
|
+
<section class="panel full">
|
|
616
|
+
<h2>Health</h2>
|
|
617
|
+
<div class="body">
|
|
618
|
+
<div class="health-grid" id="health-grid"></div>
|
|
619
|
+
<div class="actions-bar">
|
|
620
|
+
<button id="btn-heal" class="primary">Heal orphans</button>
|
|
621
|
+
<span id="heal-hint" style="color: var(--muted); font-size: 12px; align-self: center;"></span>
|
|
622
|
+
</div>
|
|
623
|
+
</div>
|
|
624
|
+
</section>
|
|
625
|
+
|
|
311
626
|
<section class="panel">
|
|
312
627
|
<h2>Running servers <span class="count" id="srv-count">0</span></h2>
|
|
313
628
|
<div class="body">
|
|
@@ -329,13 +644,24 @@ const HTML = String.raw `<!doctype html>
|
|
|
329
644
|
</section>
|
|
330
645
|
|
|
331
646
|
<section class="panel full">
|
|
332
|
-
<h2>
|
|
647
|
+
<h2>Fleet — live context <span class="count" id="fleet-count">0</span></h2>
|
|
333
648
|
<div class="body">
|
|
334
|
-
<
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
649
|
+
<table id="fleet-table" class="fleet-tbl">
|
|
650
|
+
<thead><tr>
|
|
651
|
+
<th>Role</th>
|
|
652
|
+
<th>Model</th>
|
|
653
|
+
<th style="text-align:right">Context</th>
|
|
654
|
+
<th>Occupancy</th>
|
|
655
|
+
<th style="text-align:right">$/turn</th>
|
|
656
|
+
<th>Handoff</th>
|
|
657
|
+
<th>Last signal</th>
|
|
658
|
+
<th></th>
|
|
659
|
+
</tr></thead>
|
|
660
|
+
<tbody></tbody>
|
|
661
|
+
</table>
|
|
662
|
+
<p class="nudge-hint">
|
|
663
|
+
Per-agent live context occupancy, sorted by % desc — most-urgent first. <strong>Copy prompt</strong> copies the operator's standard "your context is high, ready to clear/compact?" prompt with that agent's measured numbers interpolated — it never types into a terminal; you paste it manually (same source as <span class="mono">claudelink prompt-clear</span>). <strong>Compact</strong> and <strong>Clear</strong> are your manual override: they start the same consent handshake the watcher uses, but on demand instead of at the occupancy threshold. The agent is asked to flush its handover and consent; the action fires a tick or two later, once it acknowledges — nothing fires mid-work or without a yes. <strong>Clear additionally won't fire without a verified handover</strong> (it's a full wipe). Skips the allowlist/economics gates (your click authorizes it); dispatch works for tmux & iTerm2 only.
|
|
664
|
+
</p>
|
|
339
665
|
</div>
|
|
340
666
|
</section>
|
|
341
667
|
|
|
@@ -398,6 +724,51 @@ const HTML = String.raw `<!doctype html>
|
|
|
398
724
|
<h2>Recent messages <span class="count" id="msg-count">0</span></h2>
|
|
399
725
|
<div class="body" id="msg-body"></div>
|
|
400
726
|
</section>
|
|
727
|
+
</div>
|
|
728
|
+
</div>
|
|
729
|
+
|
|
730
|
+
<div class="tab-content" id="tab-meter">
|
|
731
|
+
<div class="grid">
|
|
732
|
+
<section class="panel full">
|
|
733
|
+
<h2>Fleet Token Meter <span class="count" id="usage-window">7d</span></h2>
|
|
734
|
+
<div class="body">
|
|
735
|
+
<div class="nudge-controls">
|
|
736
|
+
<label class="nudge-interval">
|
|
737
|
+
Window:
|
|
738
|
+
<select id="usage-days">
|
|
739
|
+
<option value="1">Today</option>
|
|
740
|
+
<option value="7" selected>7 days</option>
|
|
741
|
+
<option value="14">14 days</option>
|
|
742
|
+
<option value="30">30 days</option>
|
|
743
|
+
</select>
|
|
744
|
+
</label>
|
|
745
|
+
<span id="usage-status" class="nudge-status"></span>
|
|
746
|
+
</div>
|
|
747
|
+
<div class="usage-summary" id="usage-summary"></div>
|
|
748
|
+
<div class="usage-trend" id="usage-trend"></div>
|
|
749
|
+
<div id="usage-projects"></div>
|
|
750
|
+
<p class="nudge-hint">
|
|
751
|
+
Reads each live agent's local Claude Code transcript and totals real token usage per project and per model. Counts are exact (input / output / cache), deduped across forked sessions. The dollar figure is <em>API-equivalent value at list price</em> — on a Max plan you pay a flat fee, so it shows how much consumption the subscription is buying, not a bill. Shows consumption, not your plan's weekly quota ceiling (that's server-side and not exposed locally). <strong>Compact opportunity</strong> = an upper-bound estimate of the cache-read tokens that compacting over-threshold sessions could avoid re-reading — the fixable slice the context-hygiene watcher targets (the real soak measures the actual delta). Read-only — never writes transcripts or touches terminals.
|
|
752
|
+
</p>
|
|
753
|
+
</div>
|
|
754
|
+
</section>
|
|
755
|
+
|
|
756
|
+
<section class="panel full">
|
|
757
|
+
<h2>Per-Agent — Lifetime · Live Context · Compactions</h2>
|
|
758
|
+
<div class="body">
|
|
759
|
+
<div id="timeline-body"><p class="nudge-hint">loading…</p></div>
|
|
760
|
+
<p class="nudge-hint">
|
|
761
|
+
<strong>How to read each column:</strong><br>
|
|
762
|
+
• <strong>Cumulative (lifetime)</strong> — every token this agent has ever consumed (input + output + cache), summed across <em>all</em> its sessions/transcripts. Monotonic — it never drops: a compaction resets the live context but doesn't un-spend past tokens. The $ is API-equivalent value at list price (Max plan = flat fee).<br>
|
|
763
|
+
• <strong>Current context (live)</strong> — the size of the window it re-reads <em>this turn</em>, from its latest activity. A compaction RESETS this (e.g. 611K → 13K), then it re-climbs as the agent works — the sawtooth. The $/turn is the cache-read cost at that size.<br>
|
|
764
|
+
• <strong>Compacts</strong> — total number of times this agent's context has been reset over its lifetime, counting BOTH Claude Code's automatic auto-compacts (fire near the context limit) AND manual <span class="mono">/compact</span> or <span class="mono">/clear</span>. The split (N auto · M manual) is shown beneath the total. Each one is a <span class="mono">compact_boundary</span> marker in the transcript.<br>
|
|
765
|
+
• <strong>Last compaction</strong> — the most recent reset: its trigger (auto/manual), the before→after token size, and the time.<br>
|
|
766
|
+
The before/after is the current-context drop at a compaction, against the continuous cumulative line. Read-only — never writes transcripts or touches terminals.
|
|
767
|
+
</p>
|
|
768
|
+
</div>
|
|
769
|
+
</section>
|
|
770
|
+
</div>
|
|
771
|
+
</div>
|
|
401
772
|
</main>
|
|
402
773
|
|
|
403
774
|
<footer>ClaudeLink Command Center · auto-refresh every 2s · <span class="mono">127.0.0.1</span></footer>
|
|
@@ -518,6 +889,11 @@ function render(state) {
|
|
|
518
889
|
? "Nothing to heal — DB is clean."
|
|
519
890
|
: "Will remove dead-agent rows and any messages that would block their cleanup.";
|
|
520
891
|
|
|
892
|
+
// Stage 1 — fleet table (live context per alive agent, sorted by occupancy
|
|
893
|
+
// desc). Only renders agents that have fleet metrics; offline agents and
|
|
894
|
+
// alive-but-no-transcript agents are excluded — they'd just be noise.
|
|
895
|
+
renderFleet(state);
|
|
896
|
+
|
|
521
897
|
// messages
|
|
522
898
|
$("msg-count").textContent = state.recent_messages.length;
|
|
523
899
|
const mb = $("msg-body");
|
|
@@ -546,6 +922,107 @@ function escapeHtml(s) {
|
|
|
546
922
|
return String(s).replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">","\"":""","'":"'"}[c]));
|
|
547
923
|
}
|
|
548
924
|
|
|
925
|
+
function fmtAge(ms) {
|
|
926
|
+
if (ms === null || ms === undefined) return "—";
|
|
927
|
+
const s = Math.round((Date.now() - ms) / 1000);
|
|
928
|
+
if (s < 60) return s + "s ago";
|
|
929
|
+
const m = Math.round(s / 60);
|
|
930
|
+
if (m < 60) return m + "m ago";
|
|
931
|
+
const h = Math.round(m / 60);
|
|
932
|
+
if (h < 48) return h + "h ago";
|
|
933
|
+
return Math.round(h / 24) + "d ago";
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function renderFleet(state) {
|
|
937
|
+
const fb = $("fleet-table").querySelector("tbody");
|
|
938
|
+
const rows = state.agents
|
|
939
|
+
.filter((a) => a.alive && a.fleet)
|
|
940
|
+
.slice()
|
|
941
|
+
.sort((a, b) => b.fleet.occupancy_pct - a.fleet.occupancy_pct);
|
|
942
|
+
$("fleet-count").textContent = rows.length;
|
|
943
|
+
fb.innerHTML = "";
|
|
944
|
+
if (rows.length === 0) {
|
|
945
|
+
fb.innerHTML = '<tr><td colspan="8" class="empty">No live fleet data — either no agents online, or their transcripts have no usage records yet.</td></tr>';
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
for (const a of rows) {
|
|
949
|
+
const f = a.fleet;
|
|
950
|
+
const pct = f.occupancy_pct;
|
|
951
|
+
const fillCls = pct >= 100 ? "bad" : pct >= 75 ? "warn" : "";
|
|
952
|
+
const barWidth = Math.min(100, Math.max(0, pct));
|
|
953
|
+
const ctxK = Math.round(f.context_tokens / 1000);
|
|
954
|
+
const winK = Math.round(f.window_tokens / 1000);
|
|
955
|
+
const handoffCell = f.handoff_ok
|
|
956
|
+
? '<span class="pill ok">● fresh</span> <span class="mono">' + fmtAge(f.handoff_mtime_ms) + '</span>'
|
|
957
|
+
: f.handoff_path
|
|
958
|
+
? '<span class="pill warn">● stale</span> <span class="mono">' + fmtAge(f.handoff_mtime_ms) + '</span>'
|
|
959
|
+
: '<span class="pill miss">— none</span>';
|
|
960
|
+
const sigCell = f.signal_age_min === null
|
|
961
|
+
? '<span class="pill miss">— none</span>'
|
|
962
|
+
: '<span class="mono">' + (f.signal_age_min < 60 ? f.signal_age_min.toFixed(1) + 'm' : (f.signal_age_min/60).toFixed(1) + 'h') + ' ago</span>' +
|
|
963
|
+
(f.safe_to_clear ? ' <span class="pill ok">safe</span>' : '');
|
|
964
|
+
const tr = document.createElement("tr");
|
|
965
|
+
tr.innerHTML =
|
|
966
|
+
'<td>' + escapeHtml(a.role) + '</td>' +
|
|
967
|
+
'<td class="mono">' + escapeHtml(f.model.replace(/^claude-/, "")) + '</td>' +
|
|
968
|
+
'<td class="mono" style="text-align:right">' + ctxK + 'K / ' + winK + 'K</td>' +
|
|
969
|
+
'<td><div style="display:flex;align-items:center"><div class="occ-bar"><div class="fill ' + fillCls + '" style="width:' + barWidth.toFixed(1) + '%"></div></div><span class="occ-pct">' + pct.toFixed(0) + '%</span></div></td>' +
|
|
970
|
+
'<td class="mono" style="text-align:right">$' + f.per_turn_usd.toFixed(2) + '</td>' +
|
|
971
|
+
'<td>' + handoffCell + '</td>' +
|
|
972
|
+
'<td>' + sigCell + '</td>' +
|
|
973
|
+
'<td><div style="display:flex;gap:6px;justify-content:flex-end">' +
|
|
974
|
+
'<button class="row-action" data-copy-prompt="' + escapeHtml(a.role) + '">Copy prompt</button>' +
|
|
975
|
+
'<button class="row-action" data-trigger-id="' + escapeHtml(a.id) + '" data-trigger-cmd="/compact" data-trigger-role="' + escapeHtml(a.role) + '" data-trigger-pct="' + pct.toFixed(0) + '">Compact</button>' +
|
|
976
|
+
'<button class="row-action danger" data-trigger-id="' + escapeHtml(a.id) + '" data-trigger-cmd="/clear" data-trigger-role="' + escapeHtml(a.role) + '" data-trigger-pct="' + pct.toFixed(0) + '">Clear</button>' +
|
|
977
|
+
'</div></td>';
|
|
978
|
+
fb.appendChild(tr);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
async function copyPromptForRole(role) {
|
|
983
|
+
try {
|
|
984
|
+
const r = await api("/api/prompt-clear?role=" + encodeURIComponent(role), "GET");
|
|
985
|
+
if (!r || !r.text) {
|
|
986
|
+
toast("No prompt text returned for " + role, "error");
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
await navigator.clipboard.writeText(r.text);
|
|
990
|
+
toast("Prompt for " + role + " copied — paste into that terminal.");
|
|
991
|
+
} catch (e) {
|
|
992
|
+
toast("Copy failed: " + (e && e.message ? e.message : String(e)), "error");
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// Manual override — QUEUES a consent-gated /compact or /clear. The watcher asks
|
|
997
|
+
// the agent to flush its handoff and consent, then fires the action a tick or
|
|
998
|
+
// two later. Bypasses the occupancy/economics/allowlist gates (the click is the
|
|
999
|
+
// authorization) but keeps the safe handshake — nothing fires until the agent
|
|
1000
|
+
// acknowledges.
|
|
1001
|
+
async function triggerForAgent(id, command, role, pct) {
|
|
1002
|
+
const isClear = command === "/clear";
|
|
1003
|
+
const prompt = isClear
|
|
1004
|
+
? "⚠️ Request /clear for \"" + role + "\" (" + pct + "%)?\n\n" +
|
|
1005
|
+
"This does NOT clear immediately. " + role + " is first asked to write a COMPLETE " +
|
|
1006
|
+
"handover; the wipe fires only after it consents AND that handover is verified. " +
|
|
1007
|
+
"Nothing is lost in the meantime."
|
|
1008
|
+
: "Request /compact for \"" + role + "\" (" + pct + "%)?\n\n" +
|
|
1009
|
+
role + " is asked to flush its handover and consent; the compact fires a moment " +
|
|
1010
|
+
"later, once it acknowledges. Skips the occupancy/economics gates but keeps the " +
|
|
1011
|
+
"safe handshake.";
|
|
1012
|
+
if (!confirm(prompt)) return;
|
|
1013
|
+
try {
|
|
1014
|
+
const r = await api("/api/agents/" + encodeURIComponent(id) + "/trigger", "POST", { command });
|
|
1015
|
+
if (r && r.ok) {
|
|
1016
|
+
toast(command + " requested for " + role + " — it'll be asked to consent, then " +
|
|
1017
|
+
(isClear ? "cleared" : "compacted") + " (usually within a couple of minutes).");
|
|
1018
|
+
} else {
|
|
1019
|
+
toast(command + " could not be queued for " + role, "error");
|
|
1020
|
+
}
|
|
1021
|
+
} catch (e) {
|
|
1022
|
+
toast(command + ": " + ((e && e.message) ? e.message : String(e)), "error");
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
549
1026
|
function setPollInterval(ms) {
|
|
550
1027
|
if (pollMs === ms) return;
|
|
551
1028
|
pollMs = ms;
|
|
@@ -579,6 +1056,17 @@ document.addEventListener("click", async (e) => {
|
|
|
579
1056
|
if (!(t instanceof HTMLElement)) return;
|
|
580
1057
|
const killPid = t.getAttribute("data-kill-pid");
|
|
581
1058
|
const removeId = t.getAttribute("data-remove-id");
|
|
1059
|
+
const copyPromptRole = t.getAttribute("data-copy-prompt");
|
|
1060
|
+
const triggerId = t.getAttribute("data-trigger-id");
|
|
1061
|
+
if (triggerId) {
|
|
1062
|
+
triggerForAgent(
|
|
1063
|
+
triggerId,
|
|
1064
|
+
t.getAttribute("data-trigger-cmd"),
|
|
1065
|
+
t.getAttribute("data-trigger-role") || triggerId,
|
|
1066
|
+
t.getAttribute("data-trigger-pct")
|
|
1067
|
+
);
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
582
1070
|
if (killPid) {
|
|
583
1071
|
if (!confirm("Kill PID " + killPid + " (claudelink-server)?")) return;
|
|
584
1072
|
try {
|
|
@@ -593,6 +1081,10 @@ document.addEventListener("click", async (e) => {
|
|
|
593
1081
|
toast("Stale agent removed");
|
|
594
1082
|
refresh();
|
|
595
1083
|
} catch (err) { toast(err.message, "error"); }
|
|
1084
|
+
} else if (copyPromptRole) {
|
|
1085
|
+
// Stage 1 fleet-view "Copy prompt" — never types into a terminal; just
|
|
1086
|
+
// copies the operator's measured-numbers prompt to clipboard.
|
|
1087
|
+
copyPromptForRole(copyPromptRole);
|
|
596
1088
|
}
|
|
597
1089
|
});
|
|
598
1090
|
|
|
@@ -715,13 +1207,220 @@ $("rw-interval").addEventListener("change", saveRecoveryWatcher);
|
|
|
715
1207
|
$("rw-cooldown").addEventListener("change", saveRecoveryWatcher);
|
|
716
1208
|
$("rw-escalate").addEventListener("change", saveRecoveryWatcher);
|
|
717
1209
|
loadRecoveryWatcher();
|
|
1210
|
+
|
|
1211
|
+
// --- Fleet Token Meter ---
|
|
1212
|
+
function fmtTokens(n) {
|
|
1213
|
+
if (n >= 1e9) return (n / 1e9).toFixed(2) + "B";
|
|
1214
|
+
if (n >= 1e6) return (n / 1e6).toFixed(2) + "M";
|
|
1215
|
+
if (n >= 1e3) return (n / 1e3).toFixed(1) + "K";
|
|
1216
|
+
return String(n);
|
|
1217
|
+
}
|
|
1218
|
+
function fmtUsd(n) {
|
|
1219
|
+
return "$" + (n >= 100 ? Math.round(n).toLocaleString() : n.toFixed(2));
|
|
1220
|
+
}
|
|
1221
|
+
function renderUsage(u) {
|
|
1222
|
+
const t = u.fleet.totals;
|
|
1223
|
+
const models = Object.keys(u.fleet.perModel).sort();
|
|
1224
|
+
$("usage-window").textContent = u.windowDays + "d";
|
|
1225
|
+
$("usage-summary").innerHTML =
|
|
1226
|
+
'<div class="usage-stat"><div class="label">Total tokens</div><div class="value">' + fmtTokens(t.totalTokens) + '</div><div class="sub">' + u.scannedProjects + ' projects</div></div>' +
|
|
1227
|
+
'<div class="usage-stat"><div class="label">API-equiv. value</div><div class="value">' + fmtUsd(t.estCostUsd) + '</div><div class="sub">at list price · Max = flat fee</div></div>' +
|
|
1228
|
+
'<div class="usage-stat"><div class="label">Output tokens</div><div class="value">' + fmtTokens(t.output) + '</div><div class="sub">' + fmtTokens(t.input) + ' input</div></div>' +
|
|
1229
|
+
'<div class="usage-stat"><div class="label">Cache reads</div><div class="value">' + fmtTokens(t.cacheRead) + '</div><div class="sub">' + fmtTokens(t.cacheCreation) + ' writes</div></div>' +
|
|
1230
|
+
'<div class="usage-stat"><div class="label">Models</div><div class="value" style="font-size:13px;font-weight:500;line-height:1.5">' + (models.map(m => '<span class="usage-model-chip">' + escapeHtml(m.replace("claude-","")) + '</span>').join("") || "—") + '</div></div>';
|
|
1231
|
+
|
|
1232
|
+
// Context-hygiene opportunity: the fixable slice of the burn.
|
|
1233
|
+
const co = u.compactOpportunity;
|
|
1234
|
+
if (co) {
|
|
1235
|
+
const thK = Math.round(co.thresholdTokens / 1000);
|
|
1236
|
+
$("usage-summary").innerHTML +=
|
|
1237
|
+
'<div class="usage-stat" style="border-color:var(--accent)"><div class="label">Compact opportunity</div><div class="value">' + fmtUsd(co.estUsdUpperBound) + '</div><div class="sub">upper-bound · ' + co.flaggedTurns.toLocaleString() + ' turns >' + thK + 'K ctx</div></div>';
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
// Trend visualization adapts to the window. A single day makes a per-day bar
|
|
1241
|
+
// a meaningless full-width strip, so for "Today" show a per-project DONUT
|
|
1242
|
+
// (where today's burn went) instead. Multi-day keeps the per-day trend bars.
|
|
1243
|
+
const trendEl = $("usage-trend");
|
|
1244
|
+
if (u.windowDays <= 1) {
|
|
1245
|
+
trendEl.className = "usage-trend donut-mode";
|
|
1246
|
+
const projs = u.projects.filter(p => p.totals.totalTokens > 0);
|
|
1247
|
+
const tot = projs.reduce((s, p) => s + p.totals.totalTokens, 0) || 1;
|
|
1248
|
+
const palette = ['#b89cff','#6bb1ff','#3ddc84','#ffb454','#ff6b6b','#9b8cff','#5ac8c8','#ff9cce'];
|
|
1249
|
+
let acc = 0;
|
|
1250
|
+
const stops = projs.map((p, i) => {
|
|
1251
|
+
const start = (acc / tot) * 360; acc += p.totals.totalTokens; const end = (acc / tot) * 360;
|
|
1252
|
+
return palette[i % palette.length] + ' ' + start.toFixed(2) + 'deg ' + end.toFixed(2) + 'deg';
|
|
1253
|
+
}).join(', ');
|
|
1254
|
+
const legend = projs.map((p, i) => {
|
|
1255
|
+
const pct = (p.totals.totalTokens / tot) * 100;
|
|
1256
|
+
const name = escapeHtml(p.label.split(' (')[0]);
|
|
1257
|
+
return '<div class="donut-leg-item"><span class="donut-swatch" style="background:' + palette[i % palette.length] + '"></span><span class="donut-name">' + name + '</span><span class="donut-pct">' + pct.toFixed(0) + '%</span><span class="donut-tok">' + fmtTokens(p.totals.totalTokens) + '</span></div>';
|
|
1258
|
+
}).join('');
|
|
1259
|
+
trendEl.innerHTML = projs.length
|
|
1260
|
+
? '<div class="usage-donut" style="background:conic-gradient(' + stops + ')"><span class="donut-center">' + fmtTokens(tot) + '</span></div><div class="usage-donut-legend">' + legend + '</div>'
|
|
1261
|
+
: '<div style="color:var(--muted)">No usage in this window.</div>';
|
|
1262
|
+
} else {
|
|
1263
|
+
trendEl.className = "usage-trend";
|
|
1264
|
+
const max = Math.max(1, ...u.fleet.perDay.map(d => d.totalTokens));
|
|
1265
|
+
trendEl.innerHTML = u.fleet.perDay.map(d => {
|
|
1266
|
+
const h = Math.round((d.totalTokens / max) * 100);
|
|
1267
|
+
const dd = d.date.slice(5); // MM-DD
|
|
1268
|
+
return '<div class="usage-bar" style="height:' + h + '%" title="' + d.date + ': ' + fmtTokens(d.totalTokens) + ' tok · ' + fmtUsd(d.estCostUsd) + '"><span class="cap">' + (d.totalTokens ? fmtTokens(d.totalTokens) : '') + '</span><span class="day">' + dd + '</span></div>';
|
|
1269
|
+
}).join("");
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
// Per-project table (heaviest first), with a + expand for per-session/agent.
|
|
1273
|
+
lastUsage = u;
|
|
1274
|
+
renderProjectTable(u);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
let lastUsage = null;
|
|
1278
|
+
const expandedProjects = new Set();
|
|
1279
|
+
function renderProjectTable(u) {
|
|
1280
|
+
let rows = u.projects.map(pr => {
|
|
1281
|
+
const chips = Object.keys(pr.perModel).sort().map(m =>
|
|
1282
|
+
'<span class="usage-model-chip">' + escapeHtml(m.replace("claude-","")) + ' ' + fmtTokens(pr.perModel[m].totalTokens) + '</span>').join("");
|
|
1283
|
+
// Expandable only when there's more than one session to break out.
|
|
1284
|
+
const expandable = pr.breakdown && pr.breakdown.length > 1;
|
|
1285
|
+
const isExp = expandedProjects.has(pr.projectId);
|
|
1286
|
+
const toggle = expandable
|
|
1287
|
+
? '<button class="proj-expand" data-proj="' + escapeHtml(pr.projectId) + '" title="Show per-agent / per-session">' + (isExp ? '−' : '+') + '</button>'
|
|
1288
|
+
: '<span class="proj-expand-spacer"></span>';
|
|
1289
|
+
const rolesLine = pr.agentRoles.length > 1
|
|
1290
|
+
? '<div class="usage-roles">' + escapeHtml(pr.agentRoles.join(", ")) + '</div>' : "";
|
|
1291
|
+
let html = '<tr>' +
|
|
1292
|
+
'<td>' + toggle + escapeHtml(pr.label) + rolesLine + '</td>' +
|
|
1293
|
+
'<td class="num">' + fmtTokens(pr.totals.totalTokens) + '</td>' +
|
|
1294
|
+
'<td class="num">' + fmtUsd(pr.totals.estCostUsd) + '</td>' +
|
|
1295
|
+
'<td>' + (chips || "—") + '</td>' +
|
|
1296
|
+
'</tr>';
|
|
1297
|
+
if (expandable && isExp) {
|
|
1298
|
+
html += pr.breakdown.map(s => {
|
|
1299
|
+
const nm = s.mapped
|
|
1300
|
+
? '<span class="sub-name">' + escapeHtml(s.label) + '</span>'
|
|
1301
|
+
: '<span class="sub-unmapped">' + escapeHtml(s.label) + '</span>';
|
|
1302
|
+
return '<tr class="usage-subrow">' +
|
|
1303
|
+
'<td>' + nm + '</td>' +
|
|
1304
|
+
'<td class="num">' + fmtTokens(s.totalTokens) + '</td>' +
|
|
1305
|
+
'<td class="num">' + fmtUsd(s.estCostUsd) + '</td>' +
|
|
1306
|
+
'<td></td>' +
|
|
1307
|
+
'</tr>';
|
|
1308
|
+
}).join("");
|
|
1309
|
+
// If any session is unattributed, explain why (no register call found —
|
|
1310
|
+
// e.g. a resumed/forked session); the rest are labeled by registered name.
|
|
1311
|
+
if (pr.breakdown.some(s => !s.mapped)) {
|
|
1312
|
+
html += '<tr class="usage-subrow"><td colspan="4" style="font-style:italic;color:var(--muted)">A "session …" row had no register call in its transcript (e.g. a resumed or forked session) — the rest are by registered agent name.</td></tr>';
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
return html;
|
|
1316
|
+
}).join("");
|
|
1317
|
+
if (!rows) rows = '<tr><td colspan="4" style="color:var(--muted)">No in-window usage found for live fleet agents.</td></tr>';
|
|
1318
|
+
let note = "";
|
|
1319
|
+
if (u.skippedNoCwd && u.skippedNoCwd.length) {
|
|
1320
|
+
note = '<p class="nudge-hint" style="padding-top:8px">Unmapped (no project dir resolved): ' + escapeHtml(u.skippedNoCwd.join(", ")) + '</p>';
|
|
1321
|
+
}
|
|
1322
|
+
$("usage-projects").innerHTML =
|
|
1323
|
+
'<table class="usage-proj"><thead><tr><th>Project / agent</th><th style="text-align:right">Tokens</th><th style="text-align:right">API-equiv. $</th><th>By model</th></tr></thead><tbody>' +
|
|
1324
|
+
rows + '</tbody></table>' + note;
|
|
1325
|
+
}
|
|
1326
|
+
let usageLoading = false;
|
|
1327
|
+
let usageHasData = false;
|
|
1328
|
+
async function loadUsage() {
|
|
1329
|
+
// Re-entrancy guard: a fleet-transcript scan can take many seconds. Never
|
|
1330
|
+
// start a second fetch while one is in flight — overlapping fetches pile up,
|
|
1331
|
+
// slam the server, and leave the status stuck on "reading…".
|
|
1332
|
+
if (usageLoading) return;
|
|
1333
|
+
usageLoading = true;
|
|
1334
|
+
const days = parseInt($("usage-days").value, 10) || 7;
|
|
1335
|
+
const status = $("usage-status");
|
|
1336
|
+
// Only show the prominent "reading…" on the FIRST load (no data yet). On
|
|
1337
|
+
// background refreshes the existing numbers stay visible — a subtle hint only.
|
|
1338
|
+
status.textContent = usageHasData ? "refreshing…" : "first scan of fleet transcripts — can take a moment…";
|
|
1339
|
+
status.classList.add("show");
|
|
1340
|
+
try {
|
|
1341
|
+
const u = await api("/api/usage?days=" + days, "GET");
|
|
1342
|
+
renderUsage(u);
|
|
1343
|
+
usageHasData = true;
|
|
1344
|
+
status.textContent = "updated";
|
|
1345
|
+
setTimeout(() => status.classList.remove("show"), 1200);
|
|
1346
|
+
} catch (e) {
|
|
1347
|
+
status.textContent = "";
|
|
1348
|
+
status.classList.remove("show");
|
|
1349
|
+
if (!e.disconnected) toast("Failed to load usage: " + e.message, "error");
|
|
1350
|
+
} finally {
|
|
1351
|
+
usageLoading = false;
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
$("usage-days").addEventListener("change", loadUsage);
|
|
1355
|
+
loadUsage();
|
|
1356
|
+
// Transcript parsing is heavy + server-cached (5 min TTL); refresh on a slow
|
|
1357
|
+
// cadence so we mostly serve the cache and never overlap a scan.
|
|
1358
|
+
setInterval(loadUsage, 120000);
|
|
1359
|
+
|
|
1360
|
+
// --- Per-agent timeline: cumulative-lifetime · current-context · compactions ---
|
|
1361
|
+
function renderTimeline(ts) {
|
|
1362
|
+
if (!ts || !ts.length) { $("timeline-body").innerHTML = '<p class="nudge-hint">No agent timelines yet.</p>'; return; }
|
|
1363
|
+
const rows = ts.map(t => {
|
|
1364
|
+
const ev = t.compactEvents || [];
|
|
1365
|
+
const autoN = ev.filter(e => e.trigger === 'auto').length;
|
|
1366
|
+
const manN = ev.filter(e => e.trigger === 'manual').length;
|
|
1367
|
+
const last = ev.length ? ev[ev.length - 1] : null;
|
|
1368
|
+
const lastStr = last
|
|
1369
|
+
? escapeHtml(last.trigger) + ' ' + fmtTokens(last.preTokens) + '→' + fmtTokens(last.postTokens) + ' <span class="donut-pct">@' + (last.ts || '').slice(11, 16) + '</span>'
|
|
1370
|
+
: '—';
|
|
1371
|
+
const compactsCell = ev.length
|
|
1372
|
+
? ev.length + '<div class="usage-roles">' + autoN + ' auto · ' + manN + ' manual</div>'
|
|
1373
|
+
: '0';
|
|
1374
|
+
return '<tr>' +
|
|
1375
|
+
'<td>' + escapeHtml(t.role) + '</td>' +
|
|
1376
|
+
'<td class="num">' + fmtTokens(t.cumulativeTokens) + '<div class="usage-roles">' + fmtUsd(t.cumulativeEstUsd) + ' lifetime</div></td>' +
|
|
1377
|
+
'<td class="num">' + fmtTokens(t.currentContextTokens) + '<div class="usage-roles">$' + t.currentContextPerTurnUsd.toFixed(2) + '/turn</div></td>' +
|
|
1378
|
+
'<td class="num">' + compactsCell + '</td>' +
|
|
1379
|
+
'<td>' + lastStr + '</td>' +
|
|
1380
|
+
'</tr>';
|
|
1381
|
+
}).join('');
|
|
1382
|
+
$("timeline-body").innerHTML =
|
|
1383
|
+
'<table class="usage-proj"><thead><tr><th>Agent</th><th style="text-align:right">Cumulative (lifetime)</th><th style="text-align:right">Current context (live)</th><th style="text-align:right">Compacts</th><th>Last compaction</th></tr></thead><tbody>' +
|
|
1384
|
+
rows + '</tbody></table>';
|
|
1385
|
+
}
|
|
1386
|
+
async function loadTimeline() {
|
|
1387
|
+
try {
|
|
1388
|
+
renderTimeline(await api("/api/agent-timeline", "GET"));
|
|
1389
|
+
} catch (e) {
|
|
1390
|
+
if (!e.disconnected) $("timeline-body").innerHTML = '<p class="nudge-hint">timeline unavailable: ' + (e.message || '') + '</p>';
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
loadTimeline();
|
|
1394
|
+
setInterval(loadTimeline, 120000);
|
|
1395
|
+
|
|
1396
|
+
// --- Project expand (+) → per-session / per-agent breakdown ---
|
|
1397
|
+
$("usage-projects").addEventListener("click", (e) => {
|
|
1398
|
+
const btn = e.target.closest(".proj-expand");
|
|
1399
|
+
if (!btn) return;
|
|
1400
|
+
const id = btn.getAttribute("data-proj");
|
|
1401
|
+
if (expandedProjects.has(id)) expandedProjects.delete(id);
|
|
1402
|
+
else expandedProjects.add(id);
|
|
1403
|
+
if (lastUsage) renderProjectTable(lastUsage); // re-render from cached data, no refetch
|
|
1404
|
+
});
|
|
1405
|
+
|
|
1406
|
+
// --- Tabs ---
|
|
1407
|
+
document.querySelectorAll(".tab").forEach((btn) => {
|
|
1408
|
+
btn.addEventListener("click", () => {
|
|
1409
|
+
document.querySelectorAll(".tab").forEach((b) => b.classList.remove("active"));
|
|
1410
|
+
document.querySelectorAll(".tab-content").forEach((c) => c.classList.remove("active"));
|
|
1411
|
+
btn.classList.add("active");
|
|
1412
|
+
const target = document.getElementById("tab-" + btn.getAttribute("data-tab"));
|
|
1413
|
+
if (target) target.classList.add("active");
|
|
1414
|
+
});
|
|
1415
|
+
});
|
|
718
1416
|
</script>
|
|
719
1417
|
</body>
|
|
720
1418
|
</html>`;
|
|
721
1419
|
function startUIServer(port = 7878) {
|
|
722
1420
|
let schedulerHandle = null;
|
|
723
1421
|
let recoveryWatcherHandle = null;
|
|
724
|
-
|
|
1422
|
+
let contextWatcherHandle = null;
|
|
1423
|
+
const server = http_1.default.createServer(async (req, res) => {
|
|
725
1424
|
const send = (status, body, contentType = "application/json") => {
|
|
726
1425
|
res.statusCode = status;
|
|
727
1426
|
res.setHeader("Content-Type", contentType);
|
|
@@ -741,7 +1440,7 @@ function startUIServer(port = 7878) {
|
|
|
741
1440
|
return send(200, { ok: true, pid: process.pid });
|
|
742
1441
|
}
|
|
743
1442
|
if (req.method === "GET" && p === "/api/state") {
|
|
744
|
-
return send(200, getState());
|
|
1443
|
+
return send(200, await getState());
|
|
745
1444
|
}
|
|
746
1445
|
if (req.method === "POST" && p === "/api/heal") {
|
|
747
1446
|
return send(200, healOrphans());
|
|
@@ -780,6 +1479,72 @@ function startUIServer(port = 7878) {
|
|
|
780
1479
|
req.on("error", (e) => send(400, { error: e.message }));
|
|
781
1480
|
return;
|
|
782
1481
|
}
|
|
1482
|
+
// Manual founder override — QUEUES a consent-gated /compact or /clear for
|
|
1483
|
+
// the named agent. It does NOT type the command directly: the watcher
|
|
1484
|
+
// picks up the queued action on its next tick, ASKS the agent to flush its
|
|
1485
|
+
// handoff and consent, and fires the action only once that genuine consent
|
|
1486
|
+
// lands (and, for /clear, a verified handoff). This is the same safe
|
|
1487
|
+
// handshake as the autonomous path, just started by a button instead of
|
|
1488
|
+
// the occupancy threshold — bypassing only the allowlist/economics gates
|
|
1489
|
+
// (the click is the authorization). Keyed by agent `id`, NOT role: roles
|
|
1490
|
+
// can collide and a destructive action must target exactly one terminal.
|
|
1491
|
+
// command is an exact-match whitelist — never caller-supplied free text.
|
|
1492
|
+
if (req.method === "POST" && p.startsWith("/api/agents/") && p.endsWith("/trigger")) {
|
|
1493
|
+
const inner = p.slice("/api/agents/".length, p.length - "/trigger".length);
|
|
1494
|
+
const agentId = decodeURIComponent(inner);
|
|
1495
|
+
let body = "";
|
|
1496
|
+
req.on("data", (chunk) => { body += chunk; });
|
|
1497
|
+
req.on("end", () => {
|
|
1498
|
+
try {
|
|
1499
|
+
const parsed = body ? JSON.parse(body) : {};
|
|
1500
|
+
const command = parsed.command;
|
|
1501
|
+
if (command !== "/compact" && command !== "/clear") {
|
|
1502
|
+
return send(400, { error: 'command must be exactly "/compact" or "/clear"' });
|
|
1503
|
+
}
|
|
1504
|
+
const action = command === "/clear" ? "clear" : "compact";
|
|
1505
|
+
// The watcher must be running to process the queued trigger.
|
|
1506
|
+
if (!(0, context_watcher_settings_js_1.readContextWatcherSettings)().enabled) {
|
|
1507
|
+
return send(409, { error: "context watcher is disabled — enable it (Watcher panel) so the trigger can run" });
|
|
1508
|
+
}
|
|
1509
|
+
// Validate the target, then queue the action with a request stamp.
|
|
1510
|
+
const db2 = new better_sqlite3_1.default(DB_PATH());
|
|
1511
|
+
// A founder click is a third writer alongside the MCP servers
|
|
1512
|
+
// (register / setCheckpoint) and the watcher's consume — tolerate
|
|
1513
|
+
// brief contention instead of throwing SQLITE_BUSY.
|
|
1514
|
+
db2.pragma("busy_timeout = 5000");
|
|
1515
|
+
try {
|
|
1516
|
+
const row = db2
|
|
1517
|
+
.prepare(`SELECT id, role, tty, terminal_app, pid FROM agents WHERE id = ? LIMIT 1`)
|
|
1518
|
+
.get(agentId);
|
|
1519
|
+
if (!row) {
|
|
1520
|
+
return send(404, { error: `no registered agent with id "${agentId}"` });
|
|
1521
|
+
}
|
|
1522
|
+
if (!row.tty) {
|
|
1523
|
+
return send(400, { error: `agent "${row.role}" registered before v2 (no tty) — restart its session to enable triggers` });
|
|
1524
|
+
}
|
|
1525
|
+
if (!isProcessAlive(row.pid)) {
|
|
1526
|
+
return send(400, { error: `agent "${row.role}" process (pid ${row.pid}) is not alive` });
|
|
1527
|
+
}
|
|
1528
|
+
db2
|
|
1529
|
+
.prepare(`UPDATE agents SET manual_action = ?, manual_action_ts = ? WHERE id = ?`)
|
|
1530
|
+
.run(action, Date.now(), row.id);
|
|
1531
|
+
try {
|
|
1532
|
+
fs_1.default.appendFileSync(path_1.default.join(os_1.default.homedir(), ".claudelink", "manual-trigger.log"), `${new Date().toISOString()} QUEUED role="${row.role}" id=${row.id} action=${action} tty=${row.tty} app="${row.terminal_app ?? "null"}"\n`);
|
|
1533
|
+
}
|
|
1534
|
+
catch { }
|
|
1535
|
+
send(200, { ok: true, queued: true, role: row.role, action });
|
|
1536
|
+
}
|
|
1537
|
+
finally {
|
|
1538
|
+
db2.close();
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
catch (e) {
|
|
1542
|
+
send(400, { error: e.message });
|
|
1543
|
+
}
|
|
1544
|
+
});
|
|
1545
|
+
req.on("error", (e) => send(400, { error: e.message }));
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
783
1548
|
if (req.method === "POST" && p === "/api/quit-ui") {
|
|
784
1549
|
send(200, { ok: true });
|
|
785
1550
|
setTimeout(() => {
|
|
@@ -812,6 +1577,106 @@ function startUIServer(port = 7878) {
|
|
|
812
1577
|
req.on("error", (e) => send(400, { error: e.message }));
|
|
813
1578
|
return;
|
|
814
1579
|
}
|
|
1580
|
+
if (req.method === "GET" && p === "/api/context-watcher") {
|
|
1581
|
+
return send(200, (0, context_watcher_settings_js_1.readContextWatcherSettings)());
|
|
1582
|
+
}
|
|
1583
|
+
// Stage 1 — returns the operator's standard "your context is high"
|
|
1584
|
+
// prompt with the named agent's measured numbers interpolated. Used by
|
|
1585
|
+
// the fleet-view's per-row "copy prompt" button. Read-only: no terminal
|
|
1586
|
+
// write, no /clear or /compact fired. The operator pastes manually.
|
|
1587
|
+
if (req.method === "GET" && p === "/api/prompt-clear") {
|
|
1588
|
+
const role = url.searchParams.get("role");
|
|
1589
|
+
if (!role) {
|
|
1590
|
+
return send(400, { error: "missing required query param: role" });
|
|
1591
|
+
}
|
|
1592
|
+
const db2 = new better_sqlite3_1.default(DB_PATH(), { readonly: true });
|
|
1593
|
+
let row;
|
|
1594
|
+
try {
|
|
1595
|
+
row = db2
|
|
1596
|
+
.prepare(`SELECT pid, transcript_path FROM agents WHERE role = ? LIMIT 1`)
|
|
1597
|
+
.get(role);
|
|
1598
|
+
}
|
|
1599
|
+
finally {
|
|
1600
|
+
db2.close();
|
|
1601
|
+
}
|
|
1602
|
+
if (!row) {
|
|
1603
|
+
return send(404, { error: `no registered agent with role "${role}"` });
|
|
1604
|
+
}
|
|
1605
|
+
const transcript = findFleetTranscript({
|
|
1606
|
+
pid: row.pid,
|
|
1607
|
+
transcript_path: row.transcript_path,
|
|
1608
|
+
});
|
|
1609
|
+
if (!transcript) {
|
|
1610
|
+
return send(404, {
|
|
1611
|
+
error: `no live session transcript for "${role}"`,
|
|
1612
|
+
});
|
|
1613
|
+
}
|
|
1614
|
+
(0, context_watcher_js_1.latestTurnEconomics)(transcript).then((econ) => {
|
|
1615
|
+
if (!econ) {
|
|
1616
|
+
return send(404, {
|
|
1617
|
+
error: `no usage records in transcript for "${role}" yet`,
|
|
1618
|
+
});
|
|
1619
|
+
}
|
|
1620
|
+
const text = (0, prompt_clear_js_1.buildPromptClearText)({
|
|
1621
|
+
role,
|
|
1622
|
+
model: econ.model,
|
|
1623
|
+
contextTokens: econ.contextTokens,
|
|
1624
|
+
windowTokens: (0, usage_reader_js_2.effectiveContextWindow)(econ.model, econ.contextTokens),
|
|
1625
|
+
perTurnUsd: (econ.contextTokens * (0, usage_reader_js_2.cacheReadPricePerMtok)(econ.model)) /
|
|
1626
|
+
1000000,
|
|
1627
|
+
});
|
|
1628
|
+
return send(200, { text });
|
|
1629
|
+
}, (e) => send(500, { error: e?.message ?? String(e) }));
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
if (req.method === "GET" && p === "/api/compact-analysis") {
|
|
1633
|
+
const raw = parseInt(url.searchParams.get("days") || "30", 10);
|
|
1634
|
+
const days = Number.isFinite(raw) ? Math.max(1, Math.min(90, raw)) : 30;
|
|
1635
|
+
const db = new better_sqlite3_1.default(DB_PATH(), { readonly: true });
|
|
1636
|
+
let rows;
|
|
1637
|
+
try {
|
|
1638
|
+
rows = db.prepare(`SELECT role, pid FROM agents`).all();
|
|
1639
|
+
}
|
|
1640
|
+
finally {
|
|
1641
|
+
db.close();
|
|
1642
|
+
}
|
|
1643
|
+
const live = rows.filter((r) => isProcessAlive(r.pid));
|
|
1644
|
+
// .then(ok, err) split — not .then(ok).catch(err). If `send(200,...)`
|
|
1645
|
+
// itself throws (e.g. circular JSON, headers already written), the
|
|
1646
|
+
// sibling .catch would also call send → "Cannot set headers after they
|
|
1647
|
+
// are sent." The two-arg .then keeps the success and failure paths
|
|
1648
|
+
// mutually exclusive.
|
|
1649
|
+
(0, compact_analyzer_js_1.analyzeCompactEvents)(live, days).then((a) => send(200, a), (e) => send(500, { error: e?.message ?? String(e) }));
|
|
1650
|
+
return;
|
|
1651
|
+
}
|
|
1652
|
+
if (req.method === "POST" && p === "/api/context-watcher") {
|
|
1653
|
+
let body = "";
|
|
1654
|
+
req.on("data", (chunk) => { body += chunk; });
|
|
1655
|
+
req.on("end", () => {
|
|
1656
|
+
try {
|
|
1657
|
+
const parsed = body ? JSON.parse(body) : {};
|
|
1658
|
+
const next = (0, context_watcher_settings_js_1.writeContextWatcherSettings)(parsed);
|
|
1659
|
+
if (contextWatcherHandle)
|
|
1660
|
+
contextWatcherHandle.reschedule();
|
|
1661
|
+
send(200, next);
|
|
1662
|
+
}
|
|
1663
|
+
catch (e) {
|
|
1664
|
+
send(400, { error: e.message });
|
|
1665
|
+
}
|
|
1666
|
+
});
|
|
1667
|
+
req.on("error", (e) => send(400, { error: e.message }));
|
|
1668
|
+
return;
|
|
1669
|
+
}
|
|
1670
|
+
if (req.method === "GET" && p === "/api/usage") {
|
|
1671
|
+
const raw = parseInt(url.searchParams.get("days") || "7", 10);
|
|
1672
|
+
const days = Number.isFinite(raw) ? Math.max(1, Math.min(30, raw)) : 7;
|
|
1673
|
+
getUsage(days).then((u) => send(200, u), (e) => send(500, { error: e?.message ?? String(e) }));
|
|
1674
|
+
return;
|
|
1675
|
+
}
|
|
1676
|
+
if (req.method === "GET" && p === "/api/agent-timeline") {
|
|
1677
|
+
getAgentTimelines().then((t) => send(200, t), (e) => send(500, { error: e?.message ?? String(e) }));
|
|
1678
|
+
return;
|
|
1679
|
+
}
|
|
815
1680
|
if (req.method === "GET" && p === "/api/recovery-watcher") {
|
|
816
1681
|
return send(200, (0, recovery_watcher_settings_js_1.readRecoveryWatcherSettings)());
|
|
817
1682
|
}
|
|
@@ -840,10 +1705,22 @@ function startUIServer(port = 7878) {
|
|
|
840
1705
|
}
|
|
841
1706
|
});
|
|
842
1707
|
server.listen(port, "127.0.0.1");
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
1708
|
+
// Render-only mode: serve the UI + read endpoints WITHOUT starting the
|
|
1709
|
+
// scheduler / recovery-watcher / notifier, and without touching the
|
|
1710
|
+
// singleton lock. Lets a second instance be spun up safely (e.g. for a
|
|
1711
|
+
// local screenshot or a smoke test) while the real Command Center keeps
|
|
1712
|
+
// owning the lock and the keystroke dispatchers — no double-firing.
|
|
1713
|
+
const noServices = process.env.CLAUDELINK_UI_NO_SERVICES === "1";
|
|
1714
|
+
let stopNotifier = () => { };
|
|
1715
|
+
if (!noServices) {
|
|
1716
|
+
stopNotifier = startMessageNotifier();
|
|
1717
|
+
schedulerHandle = (0, scheduler_js_1.startScheduler)();
|
|
1718
|
+
recoveryWatcherHandle = (0, recovery_watcher_js_1.startRecoveryWatcher)();
|
|
1719
|
+
contextWatcherHandle = (0, context_watcher_js_1.startContextWatcher)();
|
|
1720
|
+
}
|
|
846
1721
|
const writeLock = () => {
|
|
1722
|
+
if (noServices)
|
|
1723
|
+
return;
|
|
847
1724
|
try {
|
|
848
1725
|
fs_1.default.writeFileSync(LOCK_PATH, JSON.stringify({ pid: process.pid, port, startedAt: new Date().toISOString() }), { mode: 0o600 });
|
|
849
1726
|
}
|
|
@@ -864,6 +1741,8 @@ function startUIServer(port = 7878) {
|
|
|
864
1741
|
schedulerHandle.stop();
|
|
865
1742
|
if (recoveryWatcherHandle)
|
|
866
1743
|
recoveryWatcherHandle.stop();
|
|
1744
|
+
if (contextWatcherHandle)
|
|
1745
|
+
contextWatcherHandle.stop();
|
|
867
1746
|
cleanup();
|
|
868
1747
|
};
|
|
869
1748
|
process.on("SIGTERM", () => { stopAll(); process.exit(0); });
|
|
@@ -911,7 +1790,7 @@ function startMessageNotifier() {
|
|
|
911
1790
|
}
|
|
912
1791
|
let db = null;
|
|
913
1792
|
try {
|
|
914
|
-
db = new better_sqlite3_1.default(DB_PATH, { readonly: true });
|
|
1793
|
+
db = new better_sqlite3_1.default(DB_PATH(), { readonly: true });
|
|
915
1794
|
}
|
|
916
1795
|
catch {
|
|
917
1796
|
return () => { };
|