@walwal-harness/cli 7.1.41 → 7.1.43

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.
@@ -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.41",
3
+ "version": "7.1.43",
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" = "claude" ] && command -v claude >/dev/null 2>&1; then
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) |