@walwal-harness/cli 7.1.41 → 7.1.42
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/apps/harness-dashboard/app/api/metrics/route.ts +17 -0
- package/apps/harness-dashboard/app/brick.css +482 -0
- package/apps/harness-dashboard/app/layout.tsx +13 -0
- package/apps/harness-dashboard/app/page.tsx +2 -2
- package/apps/harness-dashboard/components/Scene.tsx +156 -35
- package/apps/harness-dashboard/components/brick/BrickDashboard.tsx +149 -0
- package/apps/harness-dashboard/components/brick/reports.tsx +291 -0
- package/apps/harness-dashboard/components/brick/tweaks.tsx +738 -0
- package/apps/harness-dashboard/components/brick/ui.tsx +463 -0
- package/apps/harness-dashboard/components/brick/views-graph.tsx +305 -0
- package/apps/harness-dashboard/components/brick/views-grid.tsx +543 -0
- package/apps/harness-dashboard/components/brick/views-timeline.tsx +238 -0
- package/apps/harness-dashboard/lib/brick/adapter.ts +423 -0
- package/apps/harness-dashboard/lib/brick/contract.ts +247 -0
- package/apps/harness-dashboard/lib/metrics/sampler.ts +545 -0
- package/package.json +1 -1
- package/scripts/harness-worker-dispatch.sh +31 -7
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
/* =============================================================
|
|
2
|
+
walwal-harness · METRICS SAMPLER (server-only, READ-ONLY)
|
|
3
|
+
|
|
4
|
+
sampleMetrics(harnessRoot) produces REAL telemetry for the
|
|
5
|
+
dashboard's previously-honest "—" placeholders:
|
|
6
|
+
- tokensPerMin real (delta of cumulative tokens / delta min)
|
|
7
|
+
- costToday ESTIMATE (tokens × per-model price; costEstimated:true)
|
|
8
|
+
- contextPct real (most-active session's last-line occupancy)
|
|
9
|
+
- cpu real (ps %cpu of discovered company PIDs)
|
|
10
|
+
- mtrend/ctrend real (persisted ring buffer of recent samples)
|
|
11
|
+
- perAgent best-effort attribution by session name
|
|
12
|
+
|
|
13
|
+
It NEVER touches the runtime loop. It only:
|
|
14
|
+
- reads ~/.claude/projects/<slug>/*.jsonl transcripts (tail, capped)
|
|
15
|
+
- reads <root>/.harness queue*.json + team*.json (recursive) for PIDs
|
|
16
|
+
- shells out to `ps` / `tmux list-panes` (both guarded; may be absent)
|
|
17
|
+
- persists a ring buffer to <root>/.harness/dashboard/metrics-ring.json
|
|
18
|
+
|
|
19
|
+
HONESTY RULE: anything that cannot be sourced stays null. Every fs/exec
|
|
20
|
+
call is guarded; the module never throws (returns nulls on failure).
|
|
21
|
+
============================================================= */
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
existsSync,
|
|
25
|
+
mkdirSync,
|
|
26
|
+
openSync,
|
|
27
|
+
readSync,
|
|
28
|
+
closeSync,
|
|
29
|
+
fstatSync,
|
|
30
|
+
readFileSync,
|
|
31
|
+
writeFileSync,
|
|
32
|
+
readdirSync,
|
|
33
|
+
statSync,
|
|
34
|
+
} from "node:fs";
|
|
35
|
+
import path from "node:path";
|
|
36
|
+
import os from "node:os";
|
|
37
|
+
import { execFileSync } from "node:child_process";
|
|
38
|
+
|
|
39
|
+
/* ---- public types ----------------------------------------------------- */
|
|
40
|
+
|
|
41
|
+
export interface PerAgentMetric {
|
|
42
|
+
cpu?: number;
|
|
43
|
+
tokens?: number;
|
|
44
|
+
contextPct?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface MetricsSample {
|
|
48
|
+
/** tokens/min between the two most recent ring samples; null until 2 exist. */
|
|
49
|
+
tokensPerMin: number | null;
|
|
50
|
+
/** USD estimate of recent-session token spend; null when no tokens seen. */
|
|
51
|
+
costToday: number | null;
|
|
52
|
+
/** costToday is always a price-table ESTIMATE, never a billed number. */
|
|
53
|
+
costEstimated: boolean;
|
|
54
|
+
/** 0..100 occupancy of the most-recently-active session; null when unknown. */
|
|
55
|
+
contextPct: number | null;
|
|
56
|
+
/** Summed %cpu of discovered company PIDs; null when none discoverable. */
|
|
57
|
+
cpu: number | null;
|
|
58
|
+
/** cumulative-tokens sparkline samples (oldest→newest); [] when no source. */
|
|
59
|
+
mtrend: number[];
|
|
60
|
+
/** cost-estimate sparkline samples (oldest→newest); [] when no source. */
|
|
61
|
+
ctrend: number[];
|
|
62
|
+
/** best-effort per-agent attribution; omitted when nothing attributable. */
|
|
63
|
+
perAgent?: Record<string, PerAgentMetric>;
|
|
64
|
+
/** epoch ms this sample was taken. */
|
|
65
|
+
sampledAt: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* ---- tables (chosen; documented for the caller) ----------------------- */
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Per-model context window (tokens). Keyed by lowercase substring of the
|
|
72
|
+
* model id; first match wins. "1m" → the 1,000,000-token window.
|
|
73
|
+
*/
|
|
74
|
+
const CONTEXT_LIMITS: Array<{ match: string; limit: number }> = [
|
|
75
|
+
{ match: "1m", limit: 1_000_000 },
|
|
76
|
+
];
|
|
77
|
+
const DEFAULT_CONTEXT_LIMIT = 200_000;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Approximate USD price per MILLION tokens, by model family substring.
|
|
81
|
+
* cache_read is billed far cheaper than fresh input (~0.1× input here);
|
|
82
|
+
* cache_creation is treated as input-priced. These are PUBLIC-LIST
|
|
83
|
+
* approximations — costToday is explicitly an estimate (costEstimated:true).
|
|
84
|
+
*/
|
|
85
|
+
interface Price {
|
|
86
|
+
in: number;
|
|
87
|
+
out: number;
|
|
88
|
+
cacheRead: number;
|
|
89
|
+
cacheWrite: number;
|
|
90
|
+
}
|
|
91
|
+
const PRICES: Array<{ match: string; price: Price }> = [
|
|
92
|
+
{ match: "opus", price: { in: 15, out: 75, cacheRead: 1.5, cacheWrite: 18.75 } },
|
|
93
|
+
{ match: "sonnet", price: { in: 3, out: 15, cacheRead: 0.3, cacheWrite: 3.75 } },
|
|
94
|
+
{ match: "haiku", price: { in: 0.8, out: 4, cacheRead: 0.08, cacheWrite: 1.0 } },
|
|
95
|
+
];
|
|
96
|
+
const DEFAULT_PRICE: Price = { in: 3, out: 15, cacheRead: 0.3, cacheWrite: 3.75 };
|
|
97
|
+
|
|
98
|
+
const RECENT_WINDOW_MS = 30 * 60_000; // sessions touched in last ~30 min = "live"
|
|
99
|
+
const RING_CAP = 60; // ring buffer entries
|
|
100
|
+
const TRANSCRIPT_TAIL_BYTES = 512 * 1024; // read at most the last 512KB of a jsonl
|
|
101
|
+
const MAX_SESSIONS = 40; // hard cap on transcripts scanned per sample
|
|
102
|
+
|
|
103
|
+
/* ---- transcript line shape (only fields we read) ---------------------- */
|
|
104
|
+
|
|
105
|
+
interface Usage {
|
|
106
|
+
input_tokens?: number;
|
|
107
|
+
output_tokens?: number;
|
|
108
|
+
cache_creation_input_tokens?: number;
|
|
109
|
+
cache_read_input_tokens?: number;
|
|
110
|
+
}
|
|
111
|
+
interface TranscriptLine {
|
|
112
|
+
type?: string;
|
|
113
|
+
cwd?: string;
|
|
114
|
+
message?: { model?: string; usage?: Usage };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface RingEntry {
|
|
118
|
+
at: number;
|
|
119
|
+
cumTokens: number;
|
|
120
|
+
costToday: number;
|
|
121
|
+
cpu: number | null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* ---- small guarded helpers -------------------------------------------- */
|
|
125
|
+
|
|
126
|
+
function priceFor(model: string): Price {
|
|
127
|
+
const m = model.toLowerCase();
|
|
128
|
+
for (const p of PRICES) if (m.includes(p.match)) return p.price;
|
|
129
|
+
return DEFAULT_PRICE;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function contextLimitFor(model: string): number {
|
|
133
|
+
const m = model.toLowerCase();
|
|
134
|
+
for (const c of CONTEXT_LIMITS) if (m.includes(c.match)) return c.limit;
|
|
135
|
+
return DEFAULT_CONTEXT_LIMIT;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Map a project root path to the Claude transcript slug dir name. */
|
|
139
|
+
function slugForRoot(harnessRoot: string): string {
|
|
140
|
+
// Rule (verified): the absolute project path with every '/' and '_' → '-'.
|
|
141
|
+
return path.resolve(harnessRoot).replace(/[/_]/g, "-");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Read at most the last `maxBytes` of a file as utf8; "" on any failure. */
|
|
145
|
+
function tailRead(filePath: string, maxBytes: number): string {
|
|
146
|
+
let fd = -1;
|
|
147
|
+
try {
|
|
148
|
+
fd = openSync(filePath, "r");
|
|
149
|
+
const size = fstatSync(fd).size;
|
|
150
|
+
const start = size > maxBytes ? size - maxBytes : 0;
|
|
151
|
+
const len = size - start;
|
|
152
|
+
if (len <= 0) return "";
|
|
153
|
+
const buf = Buffer.allocUnsafe(len);
|
|
154
|
+
const read = readSync(fd, buf, 0, len, start);
|
|
155
|
+
return buf.toString("utf8", 0, read);
|
|
156
|
+
} catch {
|
|
157
|
+
return "";
|
|
158
|
+
} finally {
|
|
159
|
+
if (fd >= 0) {
|
|
160
|
+
try {
|
|
161
|
+
closeSync(fd);
|
|
162
|
+
} catch {
|
|
163
|
+
/* ignore */
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Parse one JSONL line into a TranscriptLine, or null. */
|
|
170
|
+
function parseLine(line: string): TranscriptLine | null {
|
|
171
|
+
const s = line.trim();
|
|
172
|
+
if (!s || s[0] !== "{") return null;
|
|
173
|
+
try {
|
|
174
|
+
return JSON.parse(s) as TranscriptLine;
|
|
175
|
+
} catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function usageTokens(u: Usage | undefined): number {
|
|
181
|
+
if (!u) return 0;
|
|
182
|
+
return (
|
|
183
|
+
(u.input_tokens ?? 0) +
|
|
184
|
+
(u.output_tokens ?? 0) +
|
|
185
|
+
(u.cache_creation_input_tokens ?? 0) +
|
|
186
|
+
(u.cache_read_input_tokens ?? 0)
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function usageCost(u: Usage | undefined, price: Price): number {
|
|
191
|
+
if (!u) return 0;
|
|
192
|
+
const inTok = u.input_tokens ?? 0;
|
|
193
|
+
const outTok = u.output_tokens ?? 0;
|
|
194
|
+
const cw = u.cache_creation_input_tokens ?? 0;
|
|
195
|
+
const cr = u.cache_read_input_tokens ?? 0;
|
|
196
|
+
return (
|
|
197
|
+
(inTok * price.in +
|
|
198
|
+
outTok * price.out +
|
|
199
|
+
cw * price.cacheWrite +
|
|
200
|
+
cr * price.cacheRead) /
|
|
201
|
+
1_000_000
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Occupancy of a single usage line (context tokens currently resident). */
|
|
206
|
+
function contextTokens(u: Usage | undefined): number {
|
|
207
|
+
if (!u) return 0;
|
|
208
|
+
return (
|
|
209
|
+
(u.input_tokens ?? 0) +
|
|
210
|
+
(u.cache_read_input_tokens ?? 0) +
|
|
211
|
+
(u.cache_creation_input_tokens ?? 0)
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/* ---- transcript aggregation ------------------------------------------- */
|
|
216
|
+
|
|
217
|
+
interface SessionStat {
|
|
218
|
+
file: string;
|
|
219
|
+
mtimeMs: number;
|
|
220
|
+
tokens: number;
|
|
221
|
+
cost: number;
|
|
222
|
+
/** last assistant line's model + usage — drives contextPct. */
|
|
223
|
+
lastModel: string | null;
|
|
224
|
+
lastUsage: Usage | undefined;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function scanSession(file: string, mtimeMs: number): SessionStat {
|
|
228
|
+
const stat: SessionStat = {
|
|
229
|
+
file,
|
|
230
|
+
mtimeMs,
|
|
231
|
+
tokens: 0,
|
|
232
|
+
cost: 0,
|
|
233
|
+
lastModel: null,
|
|
234
|
+
lastUsage: undefined,
|
|
235
|
+
};
|
|
236
|
+
const text = tailRead(file, TRANSCRIPT_TAIL_BYTES);
|
|
237
|
+
if (!text) return stat;
|
|
238
|
+
// If we tailed mid-file the first fragment may be a partial line — drop it.
|
|
239
|
+
const lines = text.split("\n");
|
|
240
|
+
for (const raw of lines) {
|
|
241
|
+
const obj = parseLine(raw);
|
|
242
|
+
if (!obj || obj.type !== "assistant") continue;
|
|
243
|
+
const msg = obj.message;
|
|
244
|
+
if (!msg) continue;
|
|
245
|
+
const model = msg.model ?? "";
|
|
246
|
+
const u = msg.usage;
|
|
247
|
+
stat.tokens += usageTokens(u);
|
|
248
|
+
stat.cost += usageCost(u, priceFor(model));
|
|
249
|
+
// assistant lines are append-ordered; the final one is the live frontier.
|
|
250
|
+
stat.lastModel = model || stat.lastModel;
|
|
251
|
+
stat.lastUsage = u ?? stat.lastUsage;
|
|
252
|
+
}
|
|
253
|
+
return stat;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/* ---- PID discovery + cpu ---------------------------------------------- */
|
|
257
|
+
|
|
258
|
+
/** Recursively collect queue*.json / team*.json under <root>/.harness. */
|
|
259
|
+
function findQueueFiles(harnessDir: string): string[] {
|
|
260
|
+
const out: string[] = [];
|
|
261
|
+
const walk = (dir: string, depth: number): void => {
|
|
262
|
+
if (depth > 6) return;
|
|
263
|
+
let entries: import("node:fs").Dirent[];
|
|
264
|
+
try {
|
|
265
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
266
|
+
} catch {
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
for (const e of entries) {
|
|
270
|
+
const full = path.join(dir, e.name);
|
|
271
|
+
if (e.isDirectory()) {
|
|
272
|
+
if (e.name === "node_modules" || e.name.startsWith(".git")) continue;
|
|
273
|
+
walk(full, depth + 1);
|
|
274
|
+
} else if (e.isFile()) {
|
|
275
|
+
const n = e.name.toLowerCase();
|
|
276
|
+
if (
|
|
277
|
+
n.endsWith(".json") &&
|
|
278
|
+
(n.startsWith("queue") ||
|
|
279
|
+
n.startsWith("team") ||
|
|
280
|
+
n.includes("queue") ||
|
|
281
|
+
n.includes("team"))
|
|
282
|
+
) {
|
|
283
|
+
out.push(full);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
walk(harnessDir, 0);
|
|
289
|
+
return out;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Deep-scan a parsed JSON value for `pid` fields holding positive ints. */
|
|
293
|
+
function collectPids(value: unknown, into: Set<number>): void {
|
|
294
|
+
if (value == null) return;
|
|
295
|
+
if (Array.isArray(value)) {
|
|
296
|
+
for (const v of value) collectPids(v, into);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (typeof value === "object") {
|
|
300
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
301
|
+
if (k.toLowerCase() === "pid") {
|
|
302
|
+
const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN;
|
|
303
|
+
if (Number.isInteger(n) && n > 1) into.add(n);
|
|
304
|
+
} else {
|
|
305
|
+
collectPids(v, into);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** %cpu for a single pid via `ps -o %cpu= -p <pid>`; null if dead/guarded. */
|
|
312
|
+
function cpuForPid(pid: number): number | null {
|
|
313
|
+
try {
|
|
314
|
+
const out = execFileSync("ps", ["-o", "%cpu=", "-p", String(pid)], {
|
|
315
|
+
encoding: "utf8",
|
|
316
|
+
timeout: 4000,
|
|
317
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
318
|
+
});
|
|
319
|
+
const v = parseFloat(out.trim());
|
|
320
|
+
return Number.isFinite(v) ? v : null;
|
|
321
|
+
} catch {
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Discover tmux company panes: pane_pid keyed by session name, for sessions
|
|
328
|
+
* matching `walwal_<projbasename>_*`. tmux may be absent → returns {}.
|
|
329
|
+
*/
|
|
330
|
+
function tmuxCompanyPanes(projBase: string): Array<{ session: string; pid: number }> {
|
|
331
|
+
const panes: Array<{ session: string; pid: number }> = [];
|
|
332
|
+
const prefix = `walwal_${projBase}_`;
|
|
333
|
+
try {
|
|
334
|
+
const out = execFileSync(
|
|
335
|
+
"tmux",
|
|
336
|
+
["list-panes", "-a", "-F", "#{session_name} #{pane_pid}"],
|
|
337
|
+
// stdio: silence tmux's "no server"/connect stderr; we read stdout only.
|
|
338
|
+
{ encoding: "utf8", timeout: 4000, stdio: ["ignore", "pipe", "ignore"] }
|
|
339
|
+
);
|
|
340
|
+
for (const line of out.split("\n")) {
|
|
341
|
+
const sp = line.trim().split(/\s+/);
|
|
342
|
+
if (sp.length < 2) continue;
|
|
343
|
+
const session = sp[0];
|
|
344
|
+
const pid = Number(sp[1]);
|
|
345
|
+
if (!session.startsWith(prefix)) continue;
|
|
346
|
+
if (Number.isInteger(pid) && pid > 1) panes.push({ session, pid });
|
|
347
|
+
}
|
|
348
|
+
} catch {
|
|
349
|
+
/* tmux absent or no server — guarded, return whatever we have ([]) */
|
|
350
|
+
}
|
|
351
|
+
return panes;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/* ---- ring buffer persistence ------------------------------------------ */
|
|
355
|
+
|
|
356
|
+
function ringPath(harnessRoot: string): string {
|
|
357
|
+
return path.join(harnessRoot, ".harness", "dashboard", "metrics-ring.json");
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function readRing(harnessRoot: string): RingEntry[] {
|
|
361
|
+
const file = ringPath(harnessRoot);
|
|
362
|
+
try {
|
|
363
|
+
if (!existsSync(file)) return [];
|
|
364
|
+
const raw = readFileSync(file, "utf8");
|
|
365
|
+
const parsed = JSON.parse(raw);
|
|
366
|
+
if (!Array.isArray(parsed)) return [];
|
|
367
|
+
return parsed.filter(
|
|
368
|
+
(e): e is RingEntry =>
|
|
369
|
+
e != null &&
|
|
370
|
+
typeof e === "object" &&
|
|
371
|
+
typeof (e as RingEntry).at === "number" &&
|
|
372
|
+
typeof (e as RingEntry).cumTokens === "number"
|
|
373
|
+
);
|
|
374
|
+
} catch {
|
|
375
|
+
return [];
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function writeRing(harnessRoot: string, ring: RingEntry[]): void {
|
|
380
|
+
const file = ringPath(harnessRoot);
|
|
381
|
+
try {
|
|
382
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
383
|
+
writeFileSync(file, JSON.stringify(ring), "utf8");
|
|
384
|
+
} catch {
|
|
385
|
+
/* persistence is best-effort; sampling must still succeed */
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/* ---- the sampler ------------------------------------------------------ */
|
|
390
|
+
|
|
391
|
+
/** An empty/safe sample (used on any unrecoverable failure path). */
|
|
392
|
+
function emptySample(now: number): MetricsSample {
|
|
393
|
+
return {
|
|
394
|
+
tokensPerMin: null,
|
|
395
|
+
costToday: null,
|
|
396
|
+
costEstimated: true,
|
|
397
|
+
contextPct: null,
|
|
398
|
+
cpu: null,
|
|
399
|
+
mtrend: [],
|
|
400
|
+
ctrend: [],
|
|
401
|
+
sampledAt: now,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export async function sampleMetrics(harnessRoot: string): Promise<MetricsSample> {
|
|
406
|
+
const now = Date.now();
|
|
407
|
+
try {
|
|
408
|
+
/* ---- 1. transcripts: tokens + cost + contextPct ------------------ */
|
|
409
|
+
const slugDir = path.join(os.homedir(), ".claude", "projects", slugForRoot(harnessRoot));
|
|
410
|
+
const sessions: SessionStat[] = [];
|
|
411
|
+
let recentFiles: Array<{ file: string; mtimeMs: number }> = [];
|
|
412
|
+
try {
|
|
413
|
+
if (existsSync(slugDir)) {
|
|
414
|
+
const names = readdirSync(slugDir).filter((n) => n.endsWith(".jsonl"));
|
|
415
|
+
for (const name of names) {
|
|
416
|
+
const full = path.join(slugDir, name);
|
|
417
|
+
try {
|
|
418
|
+
const st = statSync(full);
|
|
419
|
+
if (now - st.mtimeMs <= RECENT_WINDOW_MS) {
|
|
420
|
+
recentFiles.push({ file: full, mtimeMs: st.mtimeMs });
|
|
421
|
+
}
|
|
422
|
+
} catch {
|
|
423
|
+
/* skip unstattable file */
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
} catch {
|
|
428
|
+
recentFiles = [];
|
|
429
|
+
}
|
|
430
|
+
// newest first, cap the number of transcripts we open per sample.
|
|
431
|
+
recentFiles.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
432
|
+
recentFiles = recentFiles.slice(0, MAX_SESSIONS);
|
|
433
|
+
for (const { file, mtimeMs } of recentFiles) {
|
|
434
|
+
sessions.push(scanSession(file, mtimeMs));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
let cumTokens = 0;
|
|
438
|
+
let costToday = 0;
|
|
439
|
+
let sawTokens = false;
|
|
440
|
+
for (const s of sessions) {
|
|
441
|
+
cumTokens += s.tokens;
|
|
442
|
+
costToday += s.cost;
|
|
443
|
+
if (s.tokens > 0) sawTokens = true;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// contextPct from the most-recently-active session's last assistant line.
|
|
447
|
+
let contextPct: number | null = null;
|
|
448
|
+
const mostRecent = sessions.length > 0 ? sessions[0] : null; // already newest-first
|
|
449
|
+
if (mostRecent && mostRecent.lastUsage) {
|
|
450
|
+
const limit = contextLimitFor(mostRecent.lastModel ?? "");
|
|
451
|
+
const occ = contextTokens(mostRecent.lastUsage);
|
|
452
|
+
if (limit > 0 && occ >= 0) {
|
|
453
|
+
contextPct = Math.min(100, Math.round((occ / limit) * 1000) / 10);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/* ---- 2. cpu: queue PIDs + tmux panes ----------------------------- */
|
|
458
|
+
const harnessDir = path.join(harnessRoot, ".harness");
|
|
459
|
+
const projBase = path
|
|
460
|
+
.basename(path.resolve(harnessRoot))
|
|
461
|
+
.replace(/[^A-Za-z0-9_]/g, "_");
|
|
462
|
+
const pidSet = new Set<number>();
|
|
463
|
+
if (existsSync(harnessDir)) {
|
|
464
|
+
for (const qf of findQueueFiles(harnessDir)) {
|
|
465
|
+
try {
|
|
466
|
+
const raw = readFileSync(qf, "utf8");
|
|
467
|
+
collectPids(JSON.parse(raw), pidSet);
|
|
468
|
+
} catch {
|
|
469
|
+
/* skip unreadable/corrupt queue file */
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
const panes = tmuxCompanyPanes(projBase);
|
|
474
|
+
for (const p of panes) pidSet.add(p.pid);
|
|
475
|
+
|
|
476
|
+
let cpu: number | null = null;
|
|
477
|
+
if (pidSet.size > 0) {
|
|
478
|
+
let total = 0;
|
|
479
|
+
let any = false;
|
|
480
|
+
for (const pid of pidSet) {
|
|
481
|
+
const c = cpuForPid(pid);
|
|
482
|
+
if (c != null) {
|
|
483
|
+
total += c;
|
|
484
|
+
any = true;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
cpu = any ? Math.round(total * 10) / 10 : null;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/* ---- 3. perAgent: attribute via tmux session name ---------------- */
|
|
491
|
+
let perAgent: Record<string, PerAgentMetric> | undefined;
|
|
492
|
+
if (panes.length > 0) {
|
|
493
|
+
const acc: Record<string, PerAgentMetric> = {};
|
|
494
|
+
const prefix = `walwal_${projBase}_`;
|
|
495
|
+
for (const p of panes) {
|
|
496
|
+
// session name: walwal_<projBase>_<stamp> — stamp is the agent tag.
|
|
497
|
+
const tag = p.session.slice(prefix.length) || p.session;
|
|
498
|
+
const c = cpuForPid(p.pid);
|
|
499
|
+
const cur = acc[tag] ?? {};
|
|
500
|
+
if (c != null) cur.cpu = Math.round(((cur.cpu ?? 0) + c) * 10) / 10;
|
|
501
|
+
acc[tag] = cur;
|
|
502
|
+
}
|
|
503
|
+
if (Object.keys(acc).length > 0) perAgent = acc;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/* ---- 4. ring buffer: tokensPerMin + mtrend/ctrend ---------------- */
|
|
507
|
+
const ring = readRing(harnessRoot);
|
|
508
|
+
const entry: RingEntry = {
|
|
509
|
+
at: now,
|
|
510
|
+
cumTokens,
|
|
511
|
+
costToday: Math.round(costToday * 10000) / 10000,
|
|
512
|
+
cpu,
|
|
513
|
+
};
|
|
514
|
+
ring.push(entry);
|
|
515
|
+
while (ring.length > RING_CAP) ring.shift();
|
|
516
|
+
writeRing(harnessRoot, ring);
|
|
517
|
+
|
|
518
|
+
let tokensPerMin: number | null = null;
|
|
519
|
+
if (ring.length >= 2) {
|
|
520
|
+
const prev = ring[ring.length - 2];
|
|
521
|
+
const last = ring[ring.length - 1];
|
|
522
|
+
const dMin = (last.at - prev.at) / 60_000;
|
|
523
|
+
const dTok = last.cumTokens - prev.cumTokens;
|
|
524
|
+
if (dMin > 0 && dTok >= 0) tokensPerMin = Math.round(dTok / dMin);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const mtrend = ring.map((e) => e.cumTokens);
|
|
528
|
+
const ctrend = ring.map((e) => Math.round(e.costToday * 100) / 100);
|
|
529
|
+
|
|
530
|
+
return {
|
|
531
|
+
tokensPerMin,
|
|
532
|
+
costToday: sawTokens ? Math.round(costToday * 100) / 100 : null,
|
|
533
|
+
costEstimated: true,
|
|
534
|
+
contextPct,
|
|
535
|
+
cpu,
|
|
536
|
+
mtrend,
|
|
537
|
+
ctrend,
|
|
538
|
+
perAgent,
|
|
539
|
+
sampledAt: now,
|
|
540
|
+
};
|
|
541
|
+
} catch {
|
|
542
|
+
// Absolute backstop — sampling must never throw into the route handler.
|
|
543
|
+
return emptySample(now);
|
|
544
|
+
}
|
|
545
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@walwal-harness/cli",
|
|
3
|
-
"version": "7.1.
|
|
3
|
+
"version": "7.1.42",
|
|
4
4
|
"description": "Company-style AI agent harness for Claude and Codex. Installs commands, CXX agents, skills, HR-Resource hiring pool, and project-local .harness runtime state.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"walwal-harness": "bin/init.js"
|
|
@@ -103,7 +103,7 @@ fi
|
|
|
103
103
|
|
|
104
104
|
count="$(jq 'length' <<<"$pairs")"
|
|
105
105
|
if [ "$count" -eq 0 ]; then
|
|
106
|
-
active_workers="$(jq '[.teams | to_entries[] | select(.value.status == "busy") | {team:(.key|tonumber), feature:.value.feature, pid:.value.pid, phase:(.value.phase // "gen")}]' "$QUEUE" 2>/dev/null || echo "[]")"
|
|
106
|
+
active_workers="$(jq '[.teams | to_entries[] | select(.value.status == "busy") | {team:(.key|tonumber), feature:.value.feature, pid:.value.pid, tmux_session:.value.tmux_session, phase:(.value.phase // "gen")}]' "$QUEUE" 2>/dev/null || echo "[]")"
|
|
107
107
|
jq --arg ts "$ts" --argjson workers "$active_workers" '
|
|
108
108
|
.company_state.active_workers = ($workers | length) |
|
|
109
109
|
.company_state.workers = $workers |
|
|
@@ -141,8 +141,30 @@ while [ "$i" -lt "$count" ]; do
|
|
|
141
141
|
build_prompt "$team" "$fid" "$agent" "$prompt_path" "$log_path"
|
|
142
142
|
|
|
143
143
|
pid="null"
|
|
144
|
+
tmux_session="null"
|
|
144
145
|
status="recorded"
|
|
145
|
-
if [ "$spawn_mode" = "
|
|
146
|
+
if [ "$spawn_mode" = "tmux" ] && command -v tmux >/dev/null 2>&1 && command -v claude >/dev/null 2>&1; then
|
|
147
|
+
session_safe="$(basename "$PROJECT_ROOT" | tr -c '[:alnum:]_' '_')"
|
|
148
|
+
session_name="walwal_${session_safe}_w${team}_${stamp}"
|
|
149
|
+
if tmux new-session -d -s "$session_name" "cd '$PROJECT_ROOT' && claude -p \"\$(cat '$prompt_path')\" > '$log_path' 2>&1"; then
|
|
150
|
+
pane_pid="$(tmux list-panes -t "$session_name" -F '#{pane_pid}' 2>/dev/null | head -n1)"
|
|
151
|
+
if [ -n "$pane_pid" ]; then
|
|
152
|
+
pid="$pane_pid"
|
|
153
|
+
fi
|
|
154
|
+
tmux_session="\"$session_name\""
|
|
155
|
+
status="spawned-tmux"
|
|
156
|
+
else
|
|
157
|
+
status="recorded tmux-spawn-failed"
|
|
158
|
+
fi
|
|
159
|
+
elif [ "$spawn_mode" = "tmux" ] && command -v claude >/dev/null 2>&1; then
|
|
160
|
+
# tmux requested but unavailable: fall back to background claude spawn
|
|
161
|
+
(
|
|
162
|
+
cd "$PROJECT_ROOT" || exit 1
|
|
163
|
+
claude -p "$(cat "$prompt_path")" > "$log_path" 2>&1
|
|
164
|
+
) &
|
|
165
|
+
pid="$!"
|
|
166
|
+
status="spawned tmux-fallback-claude"
|
|
167
|
+
elif [ "$spawn_mode" = "claude" ] && command -v claude >/dev/null 2>&1; then
|
|
146
168
|
(
|
|
147
169
|
cd "$PROJECT_ROOT" || exit 1
|
|
148
170
|
claude -p "$(cat "$prompt_path")" > "$log_path" 2>&1
|
|
@@ -152,8 +174,9 @@ while [ "$i" -lt "$count" ]; do
|
|
|
152
174
|
fi
|
|
153
175
|
|
|
154
176
|
tmpq="${QUEUE}.tmp.$$.$i"
|
|
155
|
-
jq --arg tid "$team" --arg agent "$agent" --arg phase "$phase" --arg prompt "$prompt_rel" --arg log "$log_rel" --arg status "$status" --argjson pid "$pid" --arg ts "$ts" '
|
|
177
|
+
jq --arg tid "$team" --arg agent "$agent" --arg phase "$phase" --arg prompt "$prompt_rel" --arg log "$log_rel" --arg status "$status" --argjson pid "$pid" --argjson tmux_session "$tmux_session" --arg ts "$ts" '
|
|
156
178
|
.teams[$tid].pid = $pid |
|
|
179
|
+
.teams[$tid].tmux_session = $tmux_session |
|
|
157
180
|
.teams[$tid].agent = $agent |
|
|
158
181
|
.teams[$tid].phase = $phase |
|
|
159
182
|
.teams[$tid].prompt = $prompt |
|
|
@@ -164,18 +187,19 @@ while [ "$i" -lt "$count" ]; do
|
|
|
164
187
|
.queue.in_progress[.teams[$tid].feature].phase = $phase |
|
|
165
188
|
.queue.in_progress[.teams[$tid].feature].prompt = $prompt |
|
|
166
189
|
.queue.in_progress[.teams[$tid].feature].log = $log |
|
|
167
|
-
.queue.in_progress[.teams[$tid].feature].pid = $pid
|
|
190
|
+
.queue.in_progress[.teams[$tid].feature].pid = $pid |
|
|
191
|
+
.queue.in_progress[.teams[$tid].feature].tmux_session = $tmux_session
|
|
168
192
|
' "$QUEUE" > "$tmpq" && mv "$tmpq" "$QUEUE"
|
|
169
193
|
|
|
170
|
-
jq -n --argjson team "$team" --arg feature "$fid" --arg agent "$agent" --arg phase "$phase" --arg prompt "$prompt_rel" --arg log "$log_rel" --arg status "$status" --argjson pid "$pid" \
|
|
171
|
-
'{team:$team, feature:$feature, agent:$agent, phase:$phase, prompt:$prompt, log:$log, status:$status, pid:$pid}' >> "$dispatches_file"
|
|
194
|
+
jq -n --argjson team "$team" --arg feature "$fid" --arg agent "$agent" --arg phase "$phase" --arg prompt "$prompt_rel" --arg log "$log_rel" --arg status "$status" --argjson pid "$pid" --argjson tmux_session "$tmux_session" \
|
|
195
|
+
'{team:$team, feature:$feature, agent:$agent, phase:$phase, prompt:$prompt, log:$log, status:$status, pid:$pid, tmux_session:$tmux_session}' >> "$dispatches_file"
|
|
172
196
|
echo "$ts | conductor | worker-dispatch | $status | T${team}/${fid} | $agent phase=$phase pid=$pid" >> "$PROJECT_ROOT/.harness/progress.log"
|
|
173
197
|
i=$((i + 1))
|
|
174
198
|
done
|
|
175
199
|
|
|
176
200
|
dispatches="$(jq -s '.' "$dispatches_file")"
|
|
177
201
|
rm -f "$dispatches_file"
|
|
178
|
-
active_workers="$(jq '[.teams | to_entries[] | select(.value.status == "busy") | {team:(.key|tonumber), feature:.value.feature, agent:.value.agent, pid:.value.pid, phase:(.value.phase // "gen"), prompt:.value.prompt, log:.value.log, spawn_status:.value.spawn_status}]' "$QUEUE")"
|
|
202
|
+
active_workers="$(jq '[.teams | to_entries[] | select(.value.status == "busy") | {team:(.key|tonumber), feature:.value.feature, agent:.value.agent, pid:.value.pid, tmux_session:.value.tmux_session, phase:(.value.phase // "gen"), prompt:.value.prompt, log:.value.log, spawn_status:.value.spawn_status}]' "$QUEUE")"
|
|
179
203
|
|
|
180
204
|
jq --arg ts "$ts" --argjson dispatches "$dispatches" --argjson workers "$active_workers" '
|
|
181
205
|
.company_state.active_workers = ($workers | length) |
|