company-skill 4.6.7 → 4.6.8

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.
@@ -200,6 +200,25 @@ function priceFor(model) {
200
200
  return { i: 10, o: 50, w: 12.5, r: 1, est: true }; // unknown: best known rates
201
201
  }
202
202
 
203
+ // Pure: sum cost + tokens across the run's session ids using a usage map.
204
+ // usageById maps a session id to its summarized usage ({ cost, total }).
205
+ // Only ids present in the map contribute; missing ids are skipped, not zero-padded.
206
+ function aggregateRunSpend(manifestSessionIds, usageById) {
207
+ // Dedup ids internally so a duplicated id can never double-count, even if a
208
+ // caller (or a corrupt manifest) passes the same session id twice.
209
+ const ids = Array.isArray(manifestSessionIds) ? Array.from(new Set(manifestSessionIds)) : [];
210
+ const map = usageById || {};
211
+ let cost = 0, tokens = 0, matched = 0;
212
+ for (const id of ids) {
213
+ const u = map[id];
214
+ if (!u) continue;
215
+ matched += 1;
216
+ cost += Number(u.cost) || 0;
217
+ tokens += Number(u.total) || 0;
218
+ }
219
+ return { cost, tokens, sessionCount: matched };
220
+ }
221
+
203
222
  function summarizeBreakdowns(breakdowns) {
204
223
  const models = [];
205
224
  let inT = 0, outT = 0, cw = 0, cr = 0, cost = 0;
@@ -282,6 +301,22 @@ function projectDir() {
282
301
  return null;
283
302
  }
284
303
 
304
+ // Reverse a ~/.claude/projects/<slug> dir to a readable path tail.
305
+ // The slug encodes the cwd with /. -> -, so reversal is lossy; we only need a
306
+ // short readable tail (last 2 path-ish segments). Tolerate failure with '-'.
307
+ function projectFromDir(dir) {
308
+ try {
309
+ if (!dir) return '-';
310
+ const slug = path.basename(dir);
311
+ const parts = slug.split('-').filter(Boolean);
312
+ if (!parts.length) return '-';
313
+ const tail = parts.slice(-2).join('/');
314
+ return tail || '-';
315
+ } catch (_) {
316
+ return '-';
317
+ }
318
+ }
319
+
285
320
  function newestSessionFile(dir) {
286
321
  let best = null;
287
322
  let entries = [];
@@ -523,8 +558,8 @@ function buildCompanyState() {
523
558
  // Full description sent; client truncates in collapsed view, wraps in expanded view
524
559
  description: String(c.description || '').slice(0, 500),
525
560
  passes: !!c.passes,
526
- // Send evidence text (truncated to 400 chars) so expanded view can show it
527
- evidence: (c.evidence && String(c.evidence).trim()) ? String(c.evidence).slice(0, 400) : null
561
+ // Send full evidence (capped at 4000 chars) so expanded view shows the complete text
562
+ evidence: (c.evidence && String(c.evidence).trim()) ? String(c.evidence).slice(0, 4000) : null
528
563
  }));
529
564
 
530
565
  const goal = cachedReadFile(path.join(COMPANY_DIR, 'GOAL.md'));
@@ -765,9 +800,12 @@ function buildOrgTree(projDir, sessionId, liveAgents) {
765
800
 
766
801
  if (departments.length > 0) {
767
802
  // Render the COMPANY.md org chart with live-status overlay
768
- for (const dept of departments) {
803
+ for (let deptIdx = 0; deptIdx < departments.length; deptIdx++) {
804
+ const dept = departments[deptIdx];
769
805
  const lead = dept.lead;
770
- const leadId = 'dept-' + dept.name.replace(/\s+/g, '-').toLowerCase().slice(0, 30);
806
+ // Prefix with the department index so two depts sharing a 30-char name prefix
807
+ // never collapse to the same node id (slug-truncation collision).
808
+ const leadId = 'dept-' + deptIdx + '-' + dept.name.replace(/\s+/g, '-').toLowerCase().slice(0, 30);
771
809
 
772
810
  // Map live agents onto this department's roles by name similarity
773
811
  // DEPARTMENT-SCOPED: only use agents whose type/desc matches this dept's role names
@@ -790,13 +828,13 @@ function buildOrgTree(projDir, sessionId, liveAgents) {
790
828
  if (score > bestScore) { best = agent; bestScore = score; }
791
829
  }
792
830
  roleAgentMap.set(role.name, bestScore >= 20 ? best : null);
793
- if (best && bestScore >= 20) mappedAgentIds.add(best.id);
831
+ // C27: record the canonical org-role on the agent so the table and tree show the same label
832
+ if (best && bestScore >= 20) { mappedAgentIds.add(best.id); best.orgRole = role.name; }
794
833
  }
795
834
 
796
- // Compute lead node status from whether any dept agent is live
797
- const anyDeptLive = dept.roles.some(r => roleAgentMap.get(r.name) !== null);
835
+ // Lead is active ONLY if an agent actually maps to the lead role, never on sibling liveness
798
836
  const leadAgent = roleAgentMap.get(lead.name);
799
- const leadStatus = leadAgent ? (leadAgent.active ? 'active' : 'idle') : (anyDeptLive ? 'active' : 'idle');
837
+ const leadStatus = leadAgent ? (leadAgent.active ? 'active' : 'idle') : 'idle';
800
838
 
801
839
  // Tier-1: department lead node
802
840
  nodes.push({
@@ -913,19 +951,26 @@ function readRegistry() {
913
951
  return { sessions: {} };
914
952
  }
915
953
 
916
- function writeRegistryEntry(sessionId, entry) {
917
- // Atomic read-modify-write: read fresh, update own key, write temp, rename
918
- const reg = readRegistry();
919
- reg.sessions[sessionId] = entry;
920
- const tmp = REGISTRY_FILE + '.tmp.' + process.pid;
954
+ // Atomic write of a registry object: temp file then rename. Shared by the
955
+ // normal writers and the exit-time prunes so no path does a torn writeFileSync.
956
+ function atomicWriteRegistryFile(file, obj) {
957
+ const tmp = file + '.tmp.' + process.pid;
921
958
  try {
922
- fs.writeFileSync(tmp, JSON.stringify(reg, null, 2));
923
- fs.renameSync(tmp, REGISTRY_FILE);
959
+ fs.mkdirSync(path.dirname(file), { recursive: true });
960
+ fs.writeFileSync(tmp, JSON.stringify(obj, null, 2));
961
+ fs.renameSync(tmp, file);
924
962
  } catch (_) {
925
963
  try { fs.unlinkSync(tmp); } catch (_2) { /* ignore */ }
926
964
  }
927
965
  }
928
966
 
967
+ function writeRegistryEntry(sessionId, entry) {
968
+ // Atomic read-modify-write: read fresh, update own key, write temp, rename
969
+ const reg = readRegistry();
970
+ reg.sessions[sessionId] = entry;
971
+ atomicWriteRegistryFile(REGISTRY_FILE, reg);
972
+ }
973
+
929
974
  let ownEntry = null;
930
975
  if (SESSION_ID) {
931
976
  ownEntry = {
@@ -938,10 +983,30 @@ if (SESSION_ID) {
938
983
  };
939
984
  }
940
985
 
986
+ // Global entry mirrors ownEntry plus the cross-project fields /all groups by.
987
+ // runId comes from the run manifest when present; project is the readable cwd.
988
+ function buildGlobalEntry() {
989
+ if (!ownEntry) return null;
990
+ let runId = null;
991
+ try { const m = readRunManifest(); runId = (m && m.runId) || null; } catch (_) { /* ignore */ }
992
+ return {
993
+ sessionId: SESSION_ID,
994
+ port: ownEntry.port,
995
+ url: ownEntry.url,
996
+ pid: ownEntry.pid,
997
+ project: projectFromDir(projectDir()),
998
+ companyDir: COMPANY_DIR,
999
+ runId,
1000
+ startedAt: ownEntry.startedAt,
1001
+ lastSeen: ownEntry.lastSeen,
1002
+ };
1003
+ }
1004
+
941
1005
  function refreshRegistryLastSeen() {
942
1006
  if (!SESSION_ID || !ownEntry) return;
943
1007
  ownEntry.lastSeen = new Date().toISOString();
944
1008
  writeRegistryEntry(SESSION_ID, ownEntry);
1009
+ writeGlobalRegistryEntry(SESSION_ID, buildGlobalEntry());
945
1010
  }
946
1011
 
947
1012
  // Remove own entry only if the stored pid matches this process (prune-own-pid-only).
@@ -954,7 +1019,7 @@ function pruneOwnRegistryEntry() {
954
1019
  // Only remove if the entry still belongs to this pid
955
1020
  if (!stored || stored.pid !== process.pid) return;
956
1021
  delete reg.sessions[SESSION_ID];
957
- fs.writeFileSync(REGISTRY_FILE, JSON.stringify(reg, null, 2));
1022
+ atomicWriteRegistryFile(REGISTRY_FILE, reg);
958
1023
  } catch (_) { /* ignore */ }
959
1024
  }
960
1025
  process.on('exit', pruneOwnRegistryEntry);
@@ -969,52 +1034,159 @@ if (SESSION_ID && ownEntry) {
969
1034
  ownEntry.lastSeen = new Date().toISOString();
970
1035
  // Use the atomic write helper; re-adds the full entry if it went missing
971
1036
  writeRegistryEntry(SESSION_ID, ownEntry);
1037
+ writeGlobalRegistryEntry(SESSION_ID, buildGlobalEntry());
972
1038
  }, 15000);
973
1039
  _heartbeat.unref();
974
1040
  }
975
1041
 
976
- // ---------- per-session restart toggle ----------
977
- // Config file: .company/context-guard-config.json
978
- // Shape: { "sessions": { "<sessionId>": { "enforceRestart": true|false } } }
979
- const TOGGLE_CONFIG = path.join(COMPANY_DIR, 'context-guard-config.json');
1042
+ // ---------- global cross-project registry ----------
1043
+ // One home-level index every company dashboard writes to IN ADDITION to its
1044
+ // per-project dashboard-registry.json. It lists exactly the company dashboards
1045
+ // running or recently seen, with their project, so a /all roll-up can enumerate
1046
+ // "all projects using the skill" without scanning every Claude Code project.
1047
+ // Decided location: ~/.claude/company-dashboards.json (co-located with projects).
1048
+ const GLOBAL_REGISTRY_FILE = path.join(os.homedir(), '.claude', 'company-dashboards.json');
980
1049
 
981
- function readToggleConfig() {
1050
+ function readGlobalRegistry() {
982
1051
  try {
983
- return JSON.parse(fs.readFileSync(TOGGLE_CONFIG, 'utf8') || '{}');
984
- } catch (_) {
985
- return { sessions: {} };
986
- }
1052
+ const raw = fs.readFileSync(GLOBAL_REGISTRY_FILE, 'utf8');
1053
+ const parsed = JSON.parse(raw);
1054
+ if (parsed && typeof parsed === 'object' && parsed.sessions) {
1055
+ const now = Date.now();
1056
+ for (const [sid, entry] of Object.entries(parsed.sessions)) {
1057
+ const ts = entry.lastSeen || entry.startedAt;
1058
+ if (ts && (now - new Date(ts).getTime()) > REGISTRY_STALE_MS) {
1059
+ delete parsed.sessions[sid];
1060
+ }
1061
+ }
1062
+ return parsed;
1063
+ }
1064
+ } catch (_) { /* ignore */ }
1065
+ return { sessions: {} };
987
1066
  }
988
1067
 
989
- function writeToggleConfig(cfg) {
990
- const tmp = TOGGLE_CONFIG + '.tmp.' + process.pid;
1068
+ // Atomic read-modify-write of the global registry. Best-effort: any failure is
1069
+ // swallowed so a registry hiccup can never crash or stall the dashboard.
1070
+ function writeGlobalRegistryEntry(sessionId, entry) {
991
1071
  try {
992
- fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2));
993
- fs.renameSync(tmp, TOGGLE_CONFIG);
994
- } catch (_) {
995
- try { fs.unlinkSync(tmp); } catch (_2) { /* ignore */ }
1072
+ const reg = readGlobalRegistry();
1073
+ reg.sessions[sessionId] = entry;
1074
+ atomicWriteRegistryFile(GLOBAL_REGISTRY_FILE, reg);
1075
+ } catch (_) { /* never crash the dashboard on a registry write */ }
1076
+ }
1077
+
1078
+ function pruneOwnGlobalEntry() {
1079
+ if (!SESSION_ID) return;
1080
+ try {
1081
+ const reg = readGlobalRegistry();
1082
+ const stored = reg.sessions[SESSION_ID];
1083
+ if (!stored || stored.pid !== process.pid) return;
1084
+ delete reg.sessions[SESSION_ID];
1085
+ atomicWriteRegistryFile(GLOBAL_REGISTRY_FILE, reg);
1086
+ } catch (_) { /* ignore */ }
1087
+ }
1088
+ process.on('exit', pruneOwnGlobalEntry);
1089
+
1090
+ // Pure aggregator: given the global registry entries and a session-id -> ccusage
1091
+ // usage map, sum totals and group sessions by project. No ccusage call here so a
1092
+ // test can feed controlled inputs. A session whose usage row is missing keeps a
1093
+ // null cost/tokens/cacheRead (rendered as "?", never 0) and is flagged missing.
1094
+ function aggregateAllProjects(registryEntries, sessionUsageById) {
1095
+ const entries = Array.isArray(registryEntries) ? registryEntries : [];
1096
+ const usage = sessionUsageById || {};
1097
+ const totals = { cost: 0, tokens: 0, cacheRead: 0 };
1098
+ const byProject = new Map();
1099
+ for (const e of entries) {
1100
+ if (!e || !e.sessionId) continue;
1101
+ const project = e.project || '-';
1102
+ const u = usage[e.sessionId];
1103
+ const has = !!u;
1104
+ const cost = has ? Number(u.cost) || 0 : null;
1105
+ const tokens = has ? Number(u.total) || 0 : null;
1106
+ const cacheRead = has ? Number(u.cacheRead) || 0 : null;
1107
+ if (has) {
1108
+ totals.cost += cost;
1109
+ totals.tokens += tokens;
1110
+ totals.cacheRead += cacheRead;
1111
+ }
1112
+ if (!byProject.has(project)) {
1113
+ byProject.set(project, { project, cost: 0, tokens: 0, cacheRead: 0, sessions: [] });
1114
+ }
1115
+ const grp = byProject.get(project);
1116
+ if (has) { grp.cost += cost; grp.tokens += tokens; grp.cacheRead += cacheRead; }
1117
+ grp.sessions.push({
1118
+ sessionId: e.sessionId,
1119
+ shortId: String(e.sessionId).slice(0, 8),
1120
+ port: e.port || null,
1121
+ url: e.url || null,
1122
+ cost, tokens, cacheRead, missing: !has,
1123
+ });
996
1124
  }
1125
+ const projects = Array.from(byProject.values()).sort((a, b) => (b.cost || 0) - (a.cost || 0));
1126
+ return { totals, projects };
1127
+ }
1128
+
1129
+ // Scheme guard for the /all back-link href. The session url comes verbatim from
1130
+ // the world-writable global registry, so a planted javascript:/data:/file: url
1131
+ // must never reach an href. Returns the url only when it parses as http(s),
1132
+ // otherwise null so the caller renders plain text instead of a clickable link.
1133
+ function safeHttpUrl(url) {
1134
+ if (!url) return null;
1135
+ try {
1136
+ const p = new URL(String(url)).protocol;
1137
+ return (p === 'http:' || p === 'https:') ? String(url) : null;
1138
+ } catch (e) { return null; }
997
1139
  }
998
1140
 
999
- // Returns the enforceRestart value for a session. Defaults to true when absent.
1000
- function getEnforceRestart(sid) {
1001
- if (!sid) return true;
1002
- const cfg = readToggleConfig();
1003
- const s = cfg && cfg.sessions && cfg.sessions[sid];
1004
- if (s && typeof s === 'object' && s.enforceRestart === false) return false;
1005
- return true;
1141
+ // Read the global registry, pull per-session ccusage usage, and aggregate. The
1142
+ // server route wraps this; aggregateAllProjects stays pure for testing.
1143
+ function buildAllState() {
1144
+ const reg = readGlobalRegistry();
1145
+ const entries = Object.values(reg.sessions || {});
1146
+ const ids = entries.map((e) => e && e.sessionId).filter(Boolean);
1147
+ const session = getCcusage('session');
1148
+ const usageById = sessionUsageMap(session, ids);
1149
+ const agg = aggregateAllProjects(entries, usageById);
1150
+ return {
1151
+ totals: agg.totals,
1152
+ projects: agg.projects,
1153
+ sessionCount: ids.length,
1154
+ caveat: 'company dashboards seen in the last 5 minutes, costs from ccusage notional on subscription',
1155
+ };
1156
+ }
1157
+
1158
+ // ---------- restart enforcement (always on) ----------
1159
+ // The soft-threshold restart block is always enforced and can no longer be
1160
+ // disabled per-session. The old enforceRestart toggle and its config writer
1161
+ // were removed. /api/state still reports enforceRestart for the UI, fixed true.
1162
+
1163
+ // ---------- autoloop run manifest ----------
1164
+ // The supervisor writes <companyDir>/autoloop-run.json listing every child
1165
+ // session of one logical run. Absent file -> single-session behavior unchanged.
1166
+ function readRunManifest() {
1167
+ try {
1168
+ const raw = cachedReadFile(path.join(COMPANY_DIR, 'autoloop-run.json'), 256 * 1024);
1169
+ if (!raw) return null;
1170
+ const m = JSON.parse(raw);
1171
+ if (!m || !Array.isArray(m.sessions)) return null;
1172
+ return m;
1173
+ } catch (e) {
1174
+ return null;
1175
+ }
1006
1176
  }
1007
1177
 
1008
- // Atomically flip enforceRestart for a session and return the new value.
1009
- function flipEnforceRestart(sid) {
1010
- if (!sid) return true;
1011
- const cfg = readToggleConfig();
1012
- if (!cfg.sessions) cfg.sessions = {};
1013
- const current = (cfg.sessions[sid] && cfg.sessions[sid].enforceRestart === false) ? false : true;
1014
- const next = !current;
1015
- cfg.sessions[sid] = Object.assign({}, cfg.sessions[sid] || {}, { enforceRestart: next });
1016
- writeToggleConfig(cfg);
1017
- return next;
1178
+ // Build a session-id -> usage map from ccusage session JSON, matching the same
1179
+ // period/agent substring rule buildState already uses for the live session.
1180
+ function sessionUsageMap(sessionData, sessionIds) {
1181
+ const out = {};
1182
+ if (!sessionData || !Array.isArray(sessionData.session)) return out;
1183
+ for (const id of sessionIds || []) {
1184
+ const row = sessionData.session.find(
1185
+ (s) => String(s.period || '').includes(id) || String(s.agent || '').includes(id)
1186
+ );
1187
+ if (row) out[id] = summarizeBreakdowns(row.modelBreakdowns);
1188
+ }
1189
+ return out;
1018
1190
  }
1019
1191
 
1020
1192
  // ---------- /api/state ----------
@@ -1111,15 +1283,35 @@ function buildState() {
1111
1283
 
1112
1284
  refreshRegistryLastSeen();
1113
1285
 
1286
+ // Cumulative spend across every child session of the current autoloop run.
1287
+ // Absent manifest -> run stays null and the single-session view is unchanged.
1288
+ let run = null;
1289
+ const manifest = readRunManifest();
1290
+ if (manifest && manifest.sessions.length) {
1291
+ const usageById = sessionUsageMap(session, manifest.sessions);
1292
+ const agg = aggregateRunSpend(manifest.sessions, usageById);
1293
+ run = {
1294
+ runId: manifest.runId || null,
1295
+ current: manifest.current || resolvedSessionId,
1296
+ sessionCount: agg.sessionCount,
1297
+ totalSessions: manifest.sessions.length,
1298
+ cost: agg.cost,
1299
+ tokens: agg.tokens
1300
+ };
1301
+ }
1302
+
1114
1303
  return {
1115
1304
  sessionId: SESSION_ID,
1305
+ pid: process.pid,
1116
1306
  resolvedSessionId,
1307
+ project: projectFromDir(orchDir || projectDir()),
1117
1308
  warning,
1309
+ run,
1118
1310
  tokens: { available: !!(daily || session || blocks), today, session: sessionInfo, block },
1119
1311
  savings: computeSavings(today, orch.sessionModel),
1120
- context: { ...contextFill, enforceRestart: getEnforceRestart(SESSION_ID) },
1312
+ context: { ...contextFill, enforceRestart: true },
1121
1313
  agents: activeAgents.map((a) => ({
1122
- agentType: a.agentType, model: a.model || null, description: a.description,
1314
+ agentType: a.agentType, orgRole: a.orgRole || null, model: a.model || null, description: a.description,
1123
1315
  status: a.status, runtimeMs: a.runtimeMs || null, tokens: a.tokens || 0, toolCalls: a.toolCalls || 0
1124
1316
  })),
1125
1317
  org,
@@ -1176,6 +1368,10 @@ h1 { font-size: 22px; font-weight: 600; letter-spacing: -0.01em; }
1176
1368
  h2 { font-size: 13px; font-weight: 600; color: var(--dim); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 0.75rem; }
1177
1369
  .mono { font-family: var(--font-mono); }
1178
1370
  .muted { color: var(--dim); }
1371
+ .identity { font-family: var(--font-mono); font-size: 13px; font-weight: 600; color: var(--text); margin-top: 0.3rem; }
1372
+ .identity .all-link { color: var(--accent, #2563eb); text-decoration: none; font-weight: 600; }
1373
+ .identity .all-link:hover { text-decoration: underline; }
1374
+ .identity .warn { color: var(--amber); }
1179
1375
  .topline { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 1.5rem; }
1180
1376
  .stats { display: flex; flex-wrap: wrap; gap: 2.5rem; margin-bottom: 1rem; }
1181
1377
  .stat { min-width: 9rem; }
@@ -1205,15 +1401,12 @@ tr:last-child td { border-bottom: none; }
1205
1401
  .feed { font-size: 13px; max-height: 22rem; overflow-y: auto; }
1206
1402
  .feed .row { border-bottom: 1px solid var(--line); }
1207
1403
  .feed .row:last-child { border-bottom: none; }
1208
- .feed-header { display: flex; gap: 0.75rem; align-items: baseline; padding: 0.3rem 0; cursor: pointer; width: 100%; background: none; border: none; text-align: left; font-size: 13px; font-family: inherit; color: inherit; }
1209
- .feed-header:hover { background: var(--hover); }
1404
+ .feed-line { display: flex; gap: 0.75rem; align-items: baseline; padding: 0.3rem 0; font-size: 13px; }
1210
1405
  .feed .ts { font-family: var(--font-mono); color: var(--dim); white-space: nowrap; font-size: 12px; flex-shrink: 0; }
1211
1406
  .feed .kind { font-weight: 600; width: 3.5rem; flex-shrink: 0; }
1212
1407
  .feed .kind.spawn { color: var(--cyan); }
1213
1408
  .feed .kind.finish { color: var(--accent); }
1214
- .feed-bullet { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1215
- .feed-caret { font-size: 11px; color: var(--dim); flex-shrink: 0; padding-left: 0.5rem; }
1216
- .feed-detail { padding: 0.3rem 0 0.5rem 1.25rem; font-size: 12.5px; white-space: normal; word-break: break-word; color: var(--dim); }
1409
+ .feed-bullet { flex: 1; white-space: normal; word-break: break-word; }
1217
1410
  .ctx-card { margin-top: 2.5rem; }
1218
1411
  .ctx-gauge-wrap { position: relative; height: 10px; border-radius: var(--radius-pill); overflow: visible; background: var(--hover); margin: 1.25rem 0 2rem; }
1219
1412
  .ctx-gauge-bar { position: absolute; left: 0; top: 0; height: 100%; border-radius: var(--radius-pill); transition: width 0.3s; }
@@ -1241,13 +1434,16 @@ footer { margin-top: 2rem; font-size: 12.5px; color: var(--dim); border-top: 1px
1241
1434
  .tree-card { position: relative; }
1242
1435
  .tree-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.75rem; }
1243
1436
  .tree-controls { display: flex; gap: 0.5rem; }
1244
- .tree-btn { background: var(--surface); border: 1px solid var(--line); border-radius: 6px; cursor: pointer; padding: 0.2rem 0.5rem; font-size: 12px; color: var(--dim); }
1437
+ /* C24: pill/circle controls with consistent radius; flex centers every glyph. */
1438
+ .tree-btn { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-pill); cursor: pointer; min-width: 1.9rem; height: 1.9rem; padding: 0 0.6rem; font-size: 12px; color: var(--dim); display: inline-flex; align-items: center; justify-content: center; }
1245
1439
  .tree-btn:hover { background: var(--hover); }
1440
+ .tree-btn svg { display: block; }
1246
1441
  .tree-container { overflow: hidden; border-radius: 12px; background: #fafaf9; border: 1px solid var(--line); position: relative; }
1247
1442
  .tree-svg-wrap { overflow: hidden; cursor: grab; user-select: none; }
1248
1443
  .tree-svg-wrap:active { cursor: grabbing; }
1249
1444
  #tree { display: block; width: 100%; }
1250
1445
  .tree-note { font-size: 12px; color: var(--dim); margin-top: 0.5rem; font-style: italic; }
1446
+ .tree-recon { font-size: 12px; color: var(--dim); margin-top: 0.25rem; }
1251
1447
  .node-detail { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 1rem; margin-top: 0.75rem; font-size: 13px; }
1252
1448
  .node-detail h3 { font-size: 13px; font-weight: 600; margin-bottom: 0.5rem; }
1253
1449
  .nd-row { display: flex; gap: 0.5rem; padding: 0.15rem 0; }
@@ -1256,13 +1452,20 @@ footer { margin-top: 2rem; font-size: 12.5px; color: var(--dim); border-top: 1px
1256
1452
  @keyframes activePulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.7; } }
1257
1453
  @media (max-width: 760px) { .stats { gap: 1.5rem; } }
1258
1454
  .toggle-on { background: rgba(31,136,61,0.1) !important; border-color: var(--accent) !important; color: var(--accent) !important; font-weight: 600; }
1259
- .toggle-off { background: var(--hover) !important; color: var(--dim) !important; }
1455
+ /* C30: Apple-style pill toggle, locked ON (the 50% restart block cannot be disabled). */
1456
+ .restart-pill { display: inline-flex; align-items: center; gap: 0.5rem; font-size: 12px; color: var(--accent); font-weight: 600; cursor: default; white-space: nowrap; }
1457
+ .pill-switch { position: relative; width: 36px; height: 20px; border-radius: var(--radius-pill); background: var(--accent); flex-shrink: 0; cursor: default; }
1458
+ .pill-switch.on { background: var(--accent); }
1459
+ .pill-knob { position: absolute; top: 2px; left: 18px; width: 16px; height: 16px; border-radius: 50%; background: #fff; transition: left 0.15s ease; }
1260
1460
  </style>
1261
1461
  </head>
1262
1462
  <body>
1263
1463
  <div class="wrap">
1264
1464
  <div class="topline">
1265
- <h1>Company dashboard</h1>
1465
+ <div>
1466
+ <h1>Company dashboard</h1>
1467
+ <div class="identity" id="identity"></div>
1468
+ </div>
1266
1469
  <span class="muted mono" id="updated">connecting</span>
1267
1470
  </div>
1268
1471
 
@@ -1275,16 +1478,17 @@ footer { margin-top: 2rem; font-size: 12.5px; color: var(--dim); border-top: 1px
1275
1478
  <div class="caveat" id="caveat"></div>
1276
1479
  </section>
1277
1480
 
1481
+ <!-- C32: policy / session model / owners band sits BEFORE the context-fill gauge. -->
1482
+ <div class="band" id="policy-band"></div>
1483
+
1278
1484
  <div class="card ctx-card">
1279
1485
  <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.5rem;">
1280
1486
  <h2 style="margin-bottom:0">Context fill</h2>
1281
- <button id="restart-toggle-btn" style="display:none;background:none;border:1px solid var(--line);border-radius:6px;cursor:pointer;padding:0.2rem 0.75rem;font-size:12px;color:var(--dim);white-space:nowrap;" title="Enable or disable the auto-restart block at the context threshold"></button>
1487
+ <span id="restart-status" style="display:none;" title="The auto-restart block at the context threshold is always enforced and cannot be disabled"></span>
1282
1488
  </div>
1283
1489
  <div id="ctx-fill"></div>
1284
1490
  </div>
1285
1491
 
1286
- <div class="band" id="policy-band"></div>
1287
-
1288
1492
  <div class="card">
1289
1493
  <h2>Active agents</h2>
1290
1494
  <div id="agents"></div>
@@ -1292,7 +1496,10 @@ footer { margin-top: 2rem; font-size: 12.5px; color: var(--dim); border-top: 1px
1292
1496
 
1293
1497
  <div class="card tree-card" id="tree-card">
1294
1498
  <div class="tree-header">
1295
- <h2 style="margin-bottom:0">Company delegation tree</h2>
1499
+ <div>
1500
+ <h2 style="margin-bottom:0">Company delegation tree</h2>
1501
+ <div class="tree-recon" id="tree-recon"></div>
1502
+ </div>
1296
1503
  <div class="tree-controls">
1297
1504
  <button class="tree-btn" id="zoom-in">+</button>
1298
1505
  <button class="tree-btn" id="zoom-out">-</button>
@@ -1340,6 +1547,12 @@ const el = (tag, cls, text) => {
1340
1547
  if (text !== undefined) n.textContent = text;
1341
1548
  return n;
1342
1549
  };
1550
+ // Client-side copy of collapseTitle (kept in sync with the server-side helper).
1551
+ const collapseTitle = (id, desc) => {
1552
+ const d = desc || '';
1553
+ const short = d.length > 56 ? d.slice(0, 56).replace(/\s+\S*$/, '') + '…' : d;
1554
+ return id + '. ' + short;
1555
+ };
1343
1556
  const fmtTok = (n) => {
1344
1557
  if (n === null || n === undefined) return '?';
1345
1558
  if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
@@ -1386,6 +1599,33 @@ function humanizeModel(modelId) {
1386
1599
  return name + ' (' + win + ')';
1387
1600
  }
1388
1601
 
1602
+ // C27: canonical agent label. Prefer the org-role (tree node label) so the Active-agents
1603
+ // table and the delegation tree name the SAME running agent identically.
1604
+ function agentLabel(a) {
1605
+ if (!a) return 'agent';
1606
+ return a.orgRole || a.agentType || 'agent';
1607
+ }
1608
+
1609
+ function renderIdentity(s) {
1610
+ const root = $('identity');
1611
+ const sid = s.resolvedSessionId || s.sessionId || '';
1612
+ const shortId = sid ? String(sid).slice(0, 8) : 'unbound';
1613
+ const project = s.project || '-';
1614
+ const bound = s.sessionId ? 'bound' : 'newest';
1615
+ root.replaceChildren();
1616
+ root.appendChild(el('span', null, 'Session ' + shortId + ' - ' + project + ' - '));
1617
+ const state = el('span', s.warning ? 'warn' : null, s.warning ? 'unbound - newest session' : bound);
1618
+ root.appendChild(state);
1619
+ // C31: always-on back link to the all-sessions roll-up. This view is one session,
1620
+ // /all is every project and every session.
1621
+ root.appendChild(el('span', null, ' '));
1622
+ const allLink = el('a', 'all-link', '← All projects (all sessions)');
1623
+ allLink.href = '/all';
1624
+ allLink.title = 'Back to the main dashboard with every project and session';
1625
+ root.appendChild(allLink);
1626
+ document.title = 'Session ' + shortId + ' - company dashboard';
1627
+ }
1628
+
1389
1629
  function renderHeader(s) {
1390
1630
  const stats = $('stats');
1391
1631
  stats.replaceChildren();
@@ -1403,6 +1643,10 @@ function renderHeader(s) {
1403
1643
  add('today cost', t.today ? fmtUsd(t.today.cost) : '?', t.today ? fmtTok(t.today.total) + ' tokens' : '');
1404
1644
  const su = t.session && t.session.usage;
1405
1645
  add('session cost', su ? fmtUsd(su.cost) : '?', su ? fmtTok(su.total) + ' tokens' : 'session ' + (t.session && t.session.id || '?'));
1646
+ if (s.run) {
1647
+ add('run spend (' + s.run.sessionCount + ' sessions)', fmtUsd(s.run.cost),
1648
+ fmtTok(s.run.tokens) + ' tokens across the run');
1649
+ }
1406
1650
  if (s.savings && s.savings.tieringSaved !== null) {
1407
1651
  add('saved by model tiering', fmtUsd(s.savings.tieringSaved) + (s.savings.estimated ? ' est.' : ''), 'approx - vs all-' + shortModel(s.savings.topTierModel) + ' (current top tier)');
1408
1652
  add('saved by prompt caching', fmtUsd(s.savings.cacheSaved) + (s.savings.estimated ? ' est.' : ''), 'approx - cache reads vs full input price');
@@ -1462,8 +1706,8 @@ function renderAgents(s) {
1462
1706
  const tbody = el('tbody');
1463
1707
  for (const a of agents) {
1464
1708
  const tr = el('tr');
1465
- tr.appendChild(el('td', null, a.agentType));
1466
- tr.appendChild(el('td', 'mono', shortModel(a.model)));
1709
+ tr.appendChild(el('td', null, agentLabel(a)));
1710
+ tr.appendChild(el('td', 'mono', humanizeModel(a.model)));
1467
1711
  tr.appendChild(el('td', null, a.description));
1468
1712
  const td = el('td'); td.appendChild(el('span', 'pill run', a.status)); tr.appendChild(td);
1469
1713
  tr.appendChild(el('td', 'mono', fmtDur(a.runtimeMs)));
@@ -1481,15 +1725,24 @@ function renderContextFill(s) {
1481
1725
  root.replaceChildren();
1482
1726
  const ctx = s.context;
1483
1727
 
1484
- // Update the toggle button whenever we have a bound session
1485
- const toggleBtn = $('restart-toggle-btn');
1486
- if (toggleBtn && s.sessionId && ctx) {
1487
- const enforce = ctx.enforceRestart !== false;
1488
- toggleBtn.style.display = '';
1489
- toggleBtn.textContent = 'Auto-restart at ' + Math.round((ctx.threshold || 0.5) * 100) + '%: ' + (enforce ? 'ON' : 'OFF');
1490
- toggleBtn.className = enforce ? 'toggle-on' : 'toggle-off';
1491
- } else if (toggleBtn) {
1492
- toggleBtn.style.display = 'none';
1728
+ // C30: Apple-style pill toggle rendered ON and LOCKED. It is inert: clicking it never
1729
+ // disables the unconditional restart block. No config writer and no enforcement POST.
1730
+ const statusEl = $('restart-status');
1731
+ if (statusEl && s.sessionId && ctx) {
1732
+ statusEl.style.display = '';
1733
+ statusEl.replaceChildren();
1734
+ const pill = el('span', 'restart-pill');
1735
+ pill.title = 'Auto-restart is always enforced and cannot be disabled';
1736
+ pill.appendChild(el('span', null, 'Auto-restart at ' + Math.round((ctx.threshold || 0.5) * 100) + '%'));
1737
+ const sw = el('span', 'pill-switch on');
1738
+ sw.setAttribute('role', 'switch');
1739
+ sw.setAttribute('aria-checked', 'true');
1740
+ sw.setAttribute('aria-disabled', 'true');
1741
+ sw.appendChild(el('span', 'pill-knob'));
1742
+ pill.appendChild(sw);
1743
+ statusEl.appendChild(pill);
1744
+ } else if (statusEl) {
1745
+ statusEl.style.display = 'none';
1493
1746
  }
1494
1747
 
1495
1748
  if (!ctx || ctx.used === 0) {
@@ -1519,36 +1772,10 @@ function renderContextFill(s) {
1519
1772
  row.appendChild(el('span', 'ctx-pct ' + colorClass, pct + '%'));
1520
1773
  row.appendChild(el('span', 'muted', fmtTok(ctx.used) + ' / ' + fmtTok(ctx.window) + ' tokens'));
1521
1774
  if (ctx.modelId) row.appendChild(el('span', 'muted', humanizeModel(ctx.modelId)));
1522
- if (ctx.fill >= ctx.threshold && ctx.enforceRestart !== false) row.appendChild(el('span', 'pill warn', 'restart due'));
1775
+ if (ctx.fill >= ctx.threshold) row.appendChild(el('span', 'pill warn', 'restart due'));
1523
1776
  root.appendChild(row);
1524
1777
  }
1525
1778
 
1526
- // Set up the toggle button click handler once
1527
- let _toggleSetup = false;
1528
- function setupRestartToggle() {
1529
- if (_toggleSetup) return;
1530
- _toggleSetup = true;
1531
- const btn = $('restart-toggle-btn');
1532
- if (!btn) return;
1533
- btn.addEventListener('click', async () => {
1534
- btn.disabled = true;
1535
- try {
1536
- const res = await fetch('/api/restart-toggle', {
1537
- method: 'POST',
1538
- headers: { 'Content-Type': 'application/json' },
1539
- body: JSON.stringify({}),
1540
- });
1541
- const data = await res.json();
1542
- // Update button immediately without waiting for poll
1543
- const threshPct = btn.textContent.match(/(\d+)%/) ? btn.textContent.match(/(\d+)%/)[1] : '50';
1544
- const enforce = data.enforceRestart !== false;
1545
- btn.textContent = 'Auto-restart at ' + threshPct + '%: ' + (enforce ? 'ON' : 'OFF');
1546
- btn.className = enforce ? 'toggle-on' : 'toggle-off';
1547
- } catch (_) { /* ignore */ }
1548
- btn.disabled = false;
1549
- });
1550
- }
1551
-
1552
1779
  // MUST-FIX 1: visible unbound banner
1553
1780
  function renderUnboundBanner(s) {
1554
1781
  const banner = $('unbound-banner');
@@ -1569,14 +1796,13 @@ function treeStorageKey() {
1569
1796
  return 'companyTree:' + (_treeSessionId || 'unbound');
1570
1797
  }
1571
1798
 
1799
+ // C23: a page load (refresh) RESETS the tree to its natural fit instead of
1800
+ // restoring the persisted zoom/pan. We clear the stored view on load so the
1801
+ // saved key only outlives interactions within a single page lifetime, then the
1802
+ // natural vbW/vbH from layoutTree take over (svg.dataset.initVb path).
1572
1803
  function loadTreeView() {
1573
1804
  try {
1574
- const saved = JSON.parse(sessionStorage.getItem(treeStorageKey()) || '{}');
1575
- if (typeof saved.vbX === 'number') treeView.vbX = saved.vbX;
1576
- if (typeof saved.vbY === 'number') treeView.vbY = saved.vbY;
1577
- if (typeof saved.vbW === 'number') treeView.vbW = saved.vbW;
1578
- if (typeof saved.vbH === 'number') treeView.vbH = saved.vbH;
1579
- if (saved.selectedNode) treeView.selectedNode = saved.selectedNode;
1805
+ sessionStorage.removeItem(treeStorageKey());
1580
1806
  } catch (_) {}
1581
1807
  }
1582
1808
 
@@ -1700,6 +1926,14 @@ function renderTree(s) {
1700
1926
  const org = s.org || { nodes: [], edges: [], note: '' };
1701
1927
  _orgNodes = org.nodes || [];
1702
1928
 
1929
+ // Reconciliation line: live count == Active-agents table (s.agents.length), roles == org nodes
1930
+ const reconEl = $('tree-recon');
1931
+ if (reconEl) {
1932
+ const live = (s.agents || []).length;
1933
+ const roles = _orgNodes.filter(n => n.tier !== 0).length;
1934
+ reconEl.textContent = live + ' agents live - ' + roles + ' roles';
1935
+ }
1936
+
1703
1937
  if (s.sessionId && !_treeSessionId) {
1704
1938
  _treeSessionId = s.sessionId;
1705
1939
  loadTreeView(); // load persisted view state now that we have a session id
@@ -1835,11 +2069,17 @@ function renderNodeDetail() {
1835
2069
  ['tier', node.tier === 0 ? 'orchestrator' : (node.tier === 1 ? 'lead' : 'worker')],
1836
2070
  ['status', node.status || '?'],
1837
2071
  ['dept', node.dept || '-'],
1838
- ['model', node.model || '-'],
1839
- ['tokens', fmtTok(node.tokens)],
1840
- ['tool calls', String(node.toolCalls || 0)],
1841
- ['runtime', node.runtimeMs ? fmtDur(node.runtimeMs) : '-'],
1842
2072
  ];
2073
+ // Idle org role with no mapped agent: say so instead of fabricating model/token metrics
2074
+ const noLiveAgent = node.tier !== 0 && node.status === 'idle' && !node.model && !node.tokens;
2075
+ if (noLiveAgent) {
2076
+ rows.push(['state', 'not currently running']);
2077
+ } else {
2078
+ rows.push(['model', node.model || '-']);
2079
+ rows.push(['tokens', fmtTok(node.tokens)]);
2080
+ rows.push(['tool calls', String(node.toolCalls || 0)]);
2081
+ rows.push(['runtime', node.runtimeMs ? fmtDur(node.runtimeMs) : '-']);
2082
+ }
1843
2083
  if (node.task) rows.push(['task', node.task]);
1844
2084
  if (node.surfaces) rows.push(['surfaces', node.surfaces]);
1845
2085
  if (node.currentAction) rows.push(['action', node.currentAction.slice(0, 120)]);
@@ -1874,22 +2114,7 @@ function setupTreeInteractions() {
1874
2114
  }
1875
2115
  }
1876
2116
 
1877
- // Mouse wheel zoom (MUST-FIX 5: set isInteracting to suppress poll re-apply)
1878
- wrap.addEventListener('wheel', (e) => {
1879
- e.preventDefault();
1880
- treeView.isInteracting = true;
1881
- const factor = e.deltaY > 0 ? 1.12 : 0.89;
1882
- const cx = treeView.vbX + treeView.vbW / 2;
1883
- const cy = treeView.vbY + treeView.vbH / 2;
1884
- treeView.vbW *= factor; treeView.vbH *= factor;
1885
- treeView.vbX = cx - treeView.vbW / 2; treeView.vbY = cy - treeView.vbH / 2;
1886
- // Enforce zoom floor: cannot zoom out beyond reset/natural size
1887
- clampToFloor();
1888
- svg.setAttribute('viewBox', treeView.vbX + ' ' + treeView.vbY + ' ' + treeView.vbW + ' ' + treeView.vbH);
1889
- saveTreeView();
1890
- clearTimeout(wrap._iTimer);
1891
- wrap._iTimer = setTimeout(() => { treeView.isInteracting = false; }, 300);
1892
- }, { passive: false });
2117
+ // C26: zoom is ONLY via the +/- buttons. No wheel/pinch zoom on the tree. Pan stays.
1893
2118
 
1894
2119
  // Pan by drag
1895
2120
  let ds = null, dvs = null;
@@ -1959,74 +2184,25 @@ function setupTreeInteractions() {
1959
2184
  });
1960
2185
  }
1961
2186
 
1962
- // Per-feed-item expand: sessionStorage set of expanded item keys, keyed per session
1963
- function feedStorageKey() {
1964
- return 'feedExpanded:' + (_treeSessionId || 'unbound');
1965
- }
1966
-
1967
- function loadFeedExpanded() {
1968
- try {
1969
- return new Set(JSON.parse(sessionStorage.getItem(feedStorageKey()) || '[]'));
1970
- } catch (_) { return new Set(); }
1971
- }
1972
-
1973
- function saveFeedExpanded(set) {
1974
- try { sessionStorage.setItem(feedStorageKey(), JSON.stringify([...set])); } catch (_) {}
1975
- }
1976
-
1977
- let _lastFeed = null;
1978
-
1979
- // Build a stable per-item key from ts + kind + agentType (no id on feed events)
1980
- function feedItemKey(e, idx) {
1981
- return (e.ts || '') + ':' + (e.kind || '') + ':' + (e.agentType || '') + ':' + idx;
1982
- }
1983
-
1984
2187
  function renderFeed(s) {
1985
2188
  const root = $('feed');
1986
2189
  root.replaceChildren();
1987
2190
  const feed = s.feed || [];
1988
- _lastFeed = feed;
1989
2191
  if (!feed.length) {
1990
2192
  root.appendChild(el('div', 'empty', 'No spawn or finish events in the current transcript tail'));
1991
2193
  return;
1992
2194
  }
1993
- const expanded = loadFeedExpanded();
1994
2195
  for (let idx = 0; idx < feed.length; idx++) {
1995
2196
  const e = feed[idx];
1996
- const key = feedItemKey(e, idx);
1997
- const isOpen = expanded.has(key);
1998
-
1999
2197
  const row = el('div', 'row');
2000
-
2001
- // Collapsed header: timestamp + kind badge + truncated bullet + caret
2002
- const header = el('button', 'feed-header');
2003
- header.appendChild(el('span', 'ts', e.ts ? new Date(e.ts).toLocaleTimeString() : '?'));
2004
- header.appendChild(el('span', 'kind ' + e.kind, e.kind));
2198
+ // Static line: timestamp + kind badge + full agentType: description (wraps, no click)
2199
+ const line = el('div', 'feed-line');
2200
+ line.appendChild(el('span', 'ts', e.ts ? new Date(e.ts).toLocaleTimeString() : '?'));
2201
+ line.appendChild(el('span', 'kind ' + e.kind, e.kind));
2005
2202
  const bullet = el('span', 'feed-bullet');
2006
2203
  bullet.textContent = (e.agentType || '') + ': ' + (e.description || '');
2007
- header.appendChild(bullet);
2008
- const caret = el('span', 'feed-caret');
2009
- caret.textContent = isOpen ? 'v' : '>';
2010
- header.appendChild(caret);
2011
- row.appendChild(header);
2012
-
2013
- // Expanded detail: full text, wrapping freely
2014
- if (isOpen) {
2015
- const detail = el('div', 'feed-detail');
2016
- detail.textContent = (e.agentType || '') + ': ' + (e.description || '');
2017
- row.appendChild(detail);
2018
- }
2019
-
2020
- header.addEventListener('click', () => {
2021
- // Read fresh from storage so concurrent tabs don't clobber each other
2022
- const current = loadFeedExpanded();
2023
- if (current.has(key)) current.delete(key);
2024
- else current.add(key);
2025
- saveFeedExpanded(current);
2026
- // Re-render only the feed section using cached state
2027
- if (_lastFeed !== null) renderFeed({ feed: _lastFeed });
2028
- });
2029
-
2204
+ line.appendChild(bullet);
2205
+ row.appendChild(line);
2030
2206
  root.appendChild(row);
2031
2207
  }
2032
2208
  }
@@ -2084,8 +2260,9 @@ function renderCriteria(s) {
2084
2260
  dot.style.flexShrink = '0';
2085
2261
  header.appendChild(dot);
2086
2262
  const titleSpan = el('span');
2087
- titleSpan.style.cssText = 'flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
2088
- titleSpan.textContent = itemId + '. ' + item.description;
2263
+ // C28: collapsed criteria show the FULL description, wrapped (no ellipsis truncation).
2264
+ titleSpan.style.cssText = 'flex:1;min-width:0;white-space:normal;word-break:break-word;';
2265
+ titleSpan.textContent = item.description;
2089
2266
  header.appendChild(titleSpan);
2090
2267
  const caret = el('span', 'muted');
2091
2268
  caret.style.cssText = 'font-size:11px;flex-shrink:0;padding-left:0.5rem;';
@@ -2103,7 +2280,7 @@ function renderCriteria(s) {
2103
2280
  detail.appendChild(descP);
2104
2281
  if (item.evidence) {
2105
2282
  const evP = el('p', 'muted mono');
2106
- evP.style.cssText = 'font-size:12px;white-space:normal;word-break:break-word;';
2283
+ evP.style.cssText = 'font-size:12px;white-space:pre-wrap;word-break:break-word;';
2107
2284
  evP.textContent = 'evidence: ' + (typeof item.evidence === 'string' ? item.evidence : JSON.stringify(item.evidence));
2108
2285
  detail.appendChild(evP);
2109
2286
  }
@@ -2129,28 +2306,26 @@ function renderCycles(s) {
2129
2306
  const root = $('cycles');
2130
2307
  root.replaceChildren();
2131
2308
  const c = s.cycles || {};
2132
- const table = el('table', 'center');
2133
- const thead = el('thead');
2134
- const hr = el('tr');
2135
- ['metric', 'value'].forEach((h) => hr.appendChild(el('th', null, h)));
2136
- thead.appendChild(hr);
2137
- table.appendChild(thead);
2138
- const tbody = el('tbody');
2139
- // Show counts and dates as headlines; drop bare byte sizes as primary values.
2140
- const rows = [
2141
- ['cycles run', String(c.cycleDirs ?? '?')],
2142
- ['briefings logged', String(c.briefingCount ?? '?')],
2143
- ['last compaction', c.lastCompaction ? new Date(c.lastCompaction).toLocaleString() : 'none seen'],
2144
- ['compactions observed', String(c.compactionsObserved ?? 0)],
2145
- ];
2146
- for (const [label, value] of rows) {
2147
- const tr = el('tr');
2148
- tr.appendChild(el('td', 'muted', label));
2149
- tr.appendChild(el('td', 'mono', value));
2150
- tbody.appendChild(tr);
2151
- }
2152
- table.appendChild(tbody);
2153
- root.appendChild(table);
2309
+ const t = s.tokens || {};
2310
+ const cacheRead = (t.today && t.today.cacheRead != null) ? t.today.cacheRead : null;
2311
+ const hitRate = (t.block && t.block.cacheHitRate != null) ? t.block.cacheHitRate : null;
2312
+ // C29: non-redundant metrics only. The header already shows the ~$ saved, so the
2313
+ // cycles card never repeats a dollar-saved figure. Tokens, hit rate, cycles instead.
2314
+ const stats = el('div', 'stats');
2315
+ const tile = (label, value, sub) => {
2316
+ const d = el('div', 'stat');
2317
+ d.appendChild(el('div', 'label', label));
2318
+ d.appendChild(el('b', null, value));
2319
+ if (sub) d.appendChild(el('div', 'sub', sub));
2320
+ stats.appendChild(d);
2321
+ };
2322
+ tile('cache reads reused', cacheRead != null ? fmtTok(cacheRead) + ' tok' : '?',
2323
+ 'context tokens served from cache, not re-sent');
2324
+ tile('cache hit rate', hitRate != null ? (hitRate * 100).toFixed(1) + '%' : '?',
2325
+ 'served from cache this block');
2326
+ tile('cycles compressed', String(c.cycleDirs ?? '?'),
2327
+ (c.briefingCount ?? 0) + ' briefings logged');
2328
+ root.appendChild(stats);
2154
2329
  }
2155
2330
 
2156
2331
  function renderBurn(s) {
@@ -2169,6 +2344,7 @@ async function tick() {
2169
2344
  const res = await fetch('/api/state');
2170
2345
  const s = await res.json();
2171
2346
  renderUnboundBanner(s);
2347
+ renderIdentity(s);
2172
2348
  renderHeader(s);
2173
2349
  renderContextFill(s);
2174
2350
  renderBand(s);
@@ -2180,7 +2356,6 @@ async function tick() {
2180
2356
  renderCycles(s);
2181
2357
  renderBurn(s);
2182
2358
  setupTreeInteractions();
2183
- setupRestartToggle();
2184
2359
  $('updated').textContent = 'updated ' + new Date().toLocaleTimeString();
2185
2360
  } catch (e) {
2186
2361
  $('updated').textContent = 'disconnected';
@@ -2193,6 +2368,101 @@ setInterval(tick, 3000);
2193
2368
  </html>
2194
2369
  `;
2195
2370
 
2371
+ // Cross-project aggregate page. Reads /api/all (the global registry rolled up by
2372
+ // ccusage). Shows totals + a per-project breakdown, each row links back to that
2373
+ // session's own dashboard. No org tree, no criteria, no per-session structure.
2374
+ const ALL_PAGE = `<!DOCTYPE html>
2375
+ <html lang="en">
2376
+ <head>
2377
+ <meta charset="utf-8">
2378
+ <meta name="viewport" content="width=device-width, initial-scale=1">
2379
+ <title>All projects - company dashboard</title>
2380
+ <style>
2381
+ :root { --bg:#0f1115; --card:#1a1d24; --line:#2a2f3a; --txt:#e6e8eb; --muted:#9aa3b2; --accent:#7aa2f7; }
2382
+ * { box-sizing: border-box; }
2383
+ body { margin:0; background:var(--bg); color:var(--txt); font:14px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif; }
2384
+ .wrap { max-width: 900px; margin: 0 auto; padding: 2rem 1.5rem; }
2385
+ h1 { font-size: 1.4rem; margin: 0 0 0.25rem; }
2386
+ a { color: var(--accent); text-decoration: none; }
2387
+ a:hover { text-decoration: underline; }
2388
+ .caveat { color: var(--muted); font-size: 12px; margin: 0.5rem 0 1.5rem; }
2389
+ .stats { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
2390
+ .stat { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 0.75rem 1rem; min-width: 150px; }
2391
+ .stat .label { color: var(--muted); font-size: 12px; }
2392
+ .stat b { font-size: 1.3rem; display: block; }
2393
+ .proj { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 0.75rem 1rem; margin-bottom: 0.75rem; }
2394
+ .proj-head { display: flex; justify-content: space-between; align-items: baseline; font-weight: 600; }
2395
+ .proj-sub { color: var(--muted); font-size: 12px; }
2396
+ .sess { display: flex; justify-content: space-between; font-size: 13px; padding: 0.25rem 0; border-top: 1px solid var(--line); margin-top: 0.5rem; }
2397
+ .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
2398
+ .muted { color: var(--muted); }
2399
+ #updated { color: var(--muted); font-size: 12px; }
2400
+ </style>
2401
+ </head>
2402
+ <body>
2403
+ <div class="wrap">
2404
+ <h1>All projects</h1>
2405
+ <div><a href="/">&larr; this session's dashboard</a> <span id="updated" class="mono">connecting</span></div>
2406
+ <div class="caveat" id="caveat"></div>
2407
+ <div class="stats" id="totals"></div>
2408
+ <div id="projects"></div>
2409
+ </div>
2410
+ <script>
2411
+ function $(id) { return document.getElementById(id); }
2412
+ function el(tag, cls, txt) { var e = document.createElement(tag); if (cls) e.className = cls; if (txt != null) e.textContent = txt; return e; }
2413
+ function safeHttpUrl(url) {
2414
+ // Registry urls are world-writable, only allow http(s) into an href.
2415
+ if (!url) return null;
2416
+ try { var p = new URL(String(url)).protocol; return (p === 'http:' || p === 'https:') ? String(url) : null; }
2417
+ catch (e) { return null; }
2418
+ }
2419
+ function fmtUsd(n) { return n == null ? '?' : '$' + Number(n).toFixed(2); }
2420
+ function fmtTok(n) { if (n == null) return '?'; if (n >= 1e6) return (n/1e6).toFixed(1) + 'M'; if (n >= 1e3) return (n/1e3).toFixed(1) + 'k'; return String(n); }
2421
+ function stat(parent, label, value) {
2422
+ var d = el('div', 'stat'); d.appendChild(el('div', 'label', label)); d.appendChild(el('b', null, value)); parent.appendChild(d);
2423
+ }
2424
+ function render(s) {
2425
+ $('caveat').textContent = s.caveat || '';
2426
+ var t = $('totals'); t.replaceChildren();
2427
+ var tot = s.totals || {};
2428
+ stat(t, 'total cost', fmtUsd(tot.cost));
2429
+ stat(t, 'total tokens', fmtTok(tot.tokens));
2430
+ stat(t, 'cache read', fmtTok(tot.cacheRead));
2431
+ stat(t, 'dashboards seen', String(s.sessionCount || 0));
2432
+ var p = $('projects'); p.replaceChildren();
2433
+ (s.projects || []).forEach(function (proj) {
2434
+ var card = el('div', 'proj');
2435
+ var head = el('div', 'proj-head');
2436
+ head.appendChild(el('span', null, proj.project || '-'));
2437
+ head.appendChild(el('span', 'proj-sub', fmtUsd(proj.cost) + ' - ' + fmtTok(proj.tokens) + ' tok'));
2438
+ card.appendChild(head);
2439
+ (proj.sessions || []).forEach(function (sess) {
2440
+ var row = el('div', 'sess');
2441
+ var left = el('span', 'mono');
2442
+ var safe = safeHttpUrl(sess.url);
2443
+ if (safe) { var a = el('a', null, sess.shortId); a.href = safe; left.appendChild(a); }
2444
+ else { left.appendChild(el('span', null, sess.shortId)); }
2445
+ row.appendChild(left);
2446
+ var right = sess.missing ? '?' : (fmtUsd(sess.cost) + ' - ' + fmtTok(sess.tokens) + ' tok');
2447
+ row.appendChild(el('span', sess.missing ? 'muted' : null, right));
2448
+ card.appendChild(row);
2449
+ });
2450
+ p.appendChild(card);
2451
+ });
2452
+ $('updated').textContent = 'updated ' + new Date().toLocaleTimeString();
2453
+ }
2454
+ function tick() {
2455
+ fetch('/api/all').then(function (r) { return r.json(); }).then(render).catch(function () {
2456
+ $('updated').textContent = 'disconnected';
2457
+ });
2458
+ }
2459
+ tick();
2460
+ setInterval(tick, 3000);
2461
+ </script>
2462
+ </body>
2463
+ </html>
2464
+ `;
2465
+
2196
2466
  // ---------- server ----------
2197
2467
  const server = http.createServer((req, res) => {
2198
2468
  const url = (req.url || '/').split('?')[0];
@@ -2214,45 +2484,30 @@ const server = http.createServer((req, res) => {
2214
2484
  res.end(body);
2215
2485
  return;
2216
2486
  }
2487
+ if (req.method === 'GET' && url === '/all') {
2488
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
2489
+ res.end(ALL_PAGE);
2490
+ return;
2491
+ }
2492
+ if (req.method === 'GET' && url === '/api/all') {
2493
+ let body;
2494
+ try {
2495
+ body = JSON.stringify(buildAllState());
2496
+ } catch (e) {
2497
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2498
+ res.end(JSON.stringify({ error: String(e && e.message || e) }));
2499
+ return;
2500
+ }
2501
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
2502
+ res.end(body);
2503
+ return;
2504
+ }
2217
2505
  if (req.method === 'GET' && url === '/api/registry') {
2218
2506
  const reg = readRegistry();
2219
2507
  res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
2220
2508
  res.end(JSON.stringify({ sessions: reg.sessions }));
2221
2509
  return;
2222
2510
  }
2223
- // POST /api/restart-toggle: flip enforceRestart for the bound SESSION_ID only.
2224
- // Body is ignored for session selection; only the bound session may be toggled.
2225
- // Returns: { "sessionId": "<id>", "enforceRestart": true|false }
2226
- if (req.method === 'POST' && url === '/api/restart-toggle') {
2227
- // Reject bodies larger than 4 KB to avoid memory accumulation from large payloads.
2228
- const MAX_BODY = 4096;
2229
- let body = '';
2230
- let bodyTooLarge = false;
2231
- req.on('data', (chunk) => {
2232
- if (bodyTooLarge) return;
2233
- body += chunk;
2234
- if (body.length > MAX_BODY) { bodyTooLarge = true; body = ''; }
2235
- });
2236
- req.on('end', () => {
2237
- if (bodyTooLarge) {
2238
- res.writeHead(413, { 'Content-Type': 'application/json' });
2239
- res.end(JSON.stringify({ error: 'request body too large' }));
2240
- return;
2241
- }
2242
- // Always use the server-bound SESSION_ID; ignore any sessionId in the body.
2243
- // Accepting a caller-supplied id would let any tab flip another session's toggle.
2244
- const sid = SESSION_ID;
2245
- if (!sid) {
2246
- res.writeHead(400, { 'Content-Type': 'application/json' });
2247
- res.end(JSON.stringify({ error: 'no session id bound to this dashboard' }));
2248
- return;
2249
- }
2250
- const newValue = flipEnforceRestart(sid);
2251
- res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
2252
- res.end(JSON.stringify({ sessionId: sid, enforceRestart: newValue }));
2253
- });
2254
- return;
2255
- }
2256
2511
  res.writeHead(404, { 'Content-Type': 'text/plain' });
2257
2512
  res.end('not found');
2258
2513
  });
@@ -2261,17 +2516,99 @@ const server = http.createServer((req, res) => {
2261
2516
  const PORT_PROBE_MAX = 20;
2262
2517
  let currentPort = PORT;
2263
2518
 
2519
+ // Pure decision for an EADDRINUSE on the derived port. The port is a
2520
+ // deterministic hash of the session id, so an occupant on it that reports the
2521
+ // SAME session id is our own stale instance and we should take over. A
2522
+ // different session id is a genuine hash clash and we probe a higher port.
2523
+ //
2524
+ // SECURITY (criterion 37 follow-up): the occupant pid comes from the occupant's
2525
+ // own /api/state HTTP response and is therefore spoofable. We NEVER kill that
2526
+ // pid. The only pid we will ever SIGTERM is regPid, the pid WE recorded for this
2527
+ // session in the local registry. Take-over is gated on the occupant proving it
2528
+ // is the very instance we recorded: occupantSessionId === ownSessionId AND
2529
+ // occupantPid === regPid. A lie about pid (occupantPid !== regPid), a missing
2530
+ // regPid, a different session, or no answer all fall back to probe-higher with
2531
+ // killPid === null, so a spoofer can never make us kill an arbitrary victim.
2532
+ // Returns { action: 'take-over' | 'probe-higher', killPid: <regPid | null> };
2533
+ // killPid is never the occupant HTTP pid.
2534
+ function decideEaddrinuse(occupantSessionId, occupantPid, ownSessionId, regPid) {
2535
+ const sameSession = !!(ownSessionId && occupantSessionId && occupantSessionId === ownSessionId);
2536
+ const pidMatches = !!(regPid && occupantPid && occupantPid === regPid);
2537
+ if (sameSession && pidMatches) {
2538
+ return { action: 'take-over', killPid: regPid };
2539
+ }
2540
+ return { action: 'probe-higher', killPid: null };
2541
+ }
2542
+
2543
+ // Probe http://127.0.0.1:<port>/api/state and return the reported sessionId+pid.
2544
+ // Best-effort: any error (no answer, bad JSON, timeout) returns null so the
2545
+ // caller falls back to probe-higher rather than killing an unknown occupant.
2546
+ function probeOccupant(port, cb) {
2547
+ let done = false;
2548
+ const finish = (val) => { if (!done) { done = true; cb(val); } };
2549
+ const req = http.get(
2550
+ { host: HOST, port, path: '/api/state', timeout: 1500 },
2551
+ (res) => {
2552
+ let data = '';
2553
+ res.on('data', (c) => { data += c; });
2554
+ res.on('end', () => {
2555
+ try {
2556
+ const j = JSON.parse(data);
2557
+ finish({ sessionId: (j && j.sessionId) || null, pid: (j && j.pid) || null });
2558
+ } catch (_) { finish(null); }
2559
+ });
2560
+ }
2561
+ );
2562
+ req.on('error', () => finish(null));
2563
+ req.on('timeout', () => { req.destroy(); finish(null); });
2564
+ }
2565
+
2566
+ // SIGTERM the TRUSTED registry pid for this session, then retry binding the same
2567
+ // port as it frees. The pid is supplied by the gated decideEaddrinuse decision
2568
+ // (always regPid, never the spoofable occupant HTTP pid). Falls back to probing
2569
+ // higher if the pid never releases the port.
2570
+ function takeOverPort(killPid) {
2571
+ const pid = killPid; // trusted regPid only; occupant HTTP pid is never used here
2572
+ console.log('dashboard: port ' + currentPort + ' held by a stale instance of this session'
2573
+ + (pid ? ' (pid ' + pid + ')' : '') + ', taking it over');
2574
+ if (pid) { try { process.kill(pid, 'SIGTERM'); } catch (_) { /* already gone */ } }
2575
+ let attempts = 0;
2576
+ const maxAttempts = 30; // ~3s total at 100ms
2577
+ const retry = () => {
2578
+ attempts++;
2579
+ // Clear any pending once-listener from a prior failed retry (BUG 2 path).
2580
+ server.removeListener('listening', onListening);
2581
+ server.once('listening', onListening);
2582
+ server.once('error', onListenError);
2583
+ server.listen(currentPort, HOST);
2584
+ };
2585
+ // onListenError below distinguishes a still-busy port from a real failure and
2586
+ // schedules another retry until maxAttempts, then probes higher.
2587
+ takeOverPort._retry = retry;
2588
+ takeOverPort._attempts = () => attempts;
2589
+ takeOverPort._max = maxAttempts;
2590
+ retry();
2591
+ }
2592
+
2264
2593
  function startListening() {
2265
- server.listen(currentPort, HOST, onListening);
2594
+ // BUG 2 fix: a prior failed bind leaves its 'listening' once-listener pending
2595
+ // (the event never fired). Clear it first so a close()+re-listen registers
2596
+ // exactly one listener and onListening fires exactly once on the next bind.
2597
+ server.removeListener('listening', onListening);
2598
+ server.once('listening', onListening);
2599
+ server.once('error', onListenError);
2600
+ server.listen(currentPort, HOST);
2266
2601
  }
2267
2602
 
2268
2603
  function onListening() {
2604
+ server.removeListener('error', onListenError);
2269
2605
  const chosenPort = currentPort;
2270
2606
  if (SESSION_ID && ownEntry) {
2271
2607
  // Record actual chosen port (may differ from the initial PORT after probe)
2272
2608
  ownEntry.port = chosenPort;
2273
2609
  ownEntry.url = 'http://' + HOST + ':' + chosenPort;
2274
2610
  writeRegistryEntry(SESSION_ID, ownEntry);
2611
+ writeGlobalRegistryEntry(SESSION_ID, buildGlobalEntry());
2275
2612
  }
2276
2613
  // Warm the slow ccusage caches right away.
2277
2614
  refreshCcusage('daily');
@@ -2282,26 +2619,64 @@ function onListening() {
2282
2619
  console.log('dashboard listening on ' + url + ' (' + bound + ', company dir: ' + COMPANY_DIR + ')');
2283
2620
  }
2284
2621
 
2285
- server.on('error', (err) => {
2286
- if (err.code === 'EADDRINUSE') {
2287
- const tried = currentPort;
2288
- if (currentPort < PORT + PORT_PROBE_MAX) {
2289
- currentPort++;
2290
- // Remove old listeners so re-listen is clean
2291
- server.close(() => { startListening(); });
2622
+ let inTakeOver = false;
2623
+
2624
+ function probeHigher() {
2625
+ if (currentPort < PORT + PORT_PROBE_MAX) {
2626
+ currentPort++;
2627
+ server.close(() => { startListening(); });
2628
+ } else {
2629
+ process.stderr.write(
2630
+ 'dashboard: port ' + PORT + ' is in use and probing ' + PORT + '+1..' +
2631
+ PORT_PROBE_MAX + ' all failed. Free a port in that range and retry.\n'
2632
+ );
2633
+ process.exit(1);
2634
+ }
2635
+ }
2636
+
2637
+ // Listen-error handler: on EADDRINUSE, decide between taking over a same-session
2638
+ // stale instance and probing higher for a genuine different-session clash.
2639
+ function onListenError(err) {
2640
+ if (err.code !== 'EADDRINUSE') {
2641
+ process.stderr.write('dashboard: server error: ' + err.message + '\n');
2642
+ process.exit(1);
2643
+ return;
2644
+ }
2645
+ if (inTakeOver) {
2646
+ // Mid take-over: the SIGTERM'd pid has not freed the port yet. Retry the
2647
+ // same port a few times, then give up and probe higher.
2648
+ if (takeOverPort._attempts() < takeOverPort._max) {
2649
+ setTimeout(() => { takeOverPort._retry(); }, 100);
2292
2650
  } else {
2293
- process.stderr.write(
2294
- 'dashboard: port ' + PORT + ' is in use and probing ' + PORT + '+1..' +
2295
- PORT_PROBE_MAX + ' all failed. Free a port in that range and retry.\n'
2296
- );
2297
- process.exit(1);
2651
+ console.log('dashboard: stale instance did not release port ' + currentPort
2652
+ + ' in time, probing a higher port');
2653
+ inTakeOver = false;
2654
+ probeHigher();
2298
2655
  }
2299
2656
  return;
2300
2657
  }
2301
- // Non-EADDRINUSE errors are unexpected - surface them clearly
2302
- process.stderr.write('dashboard: server error: ' + err.message + '\n');
2303
- process.exit(1);
2304
- });
2658
+ // First EADDRINUSE on this port: ask who holds it. The trusted pid is the one
2659
+ // WE recorded for this session in the local registry; the occupant's
2660
+ // self-reported pid is spoofable and is only used to confirm identity, never to
2661
+ // pick a kill target.
2662
+ const regPid = (() => {
2663
+ try { const r = readRegistry(); const e = r.sessions[SESSION_ID]; return e && e.pid; }
2664
+ catch (_) { return null; }
2665
+ })();
2666
+ probeOccupant(currentPort, (occupant) => {
2667
+ const decision = SESSION_ID && occupant
2668
+ ? decideEaddrinuse(occupant.sessionId, occupant.pid, SESSION_ID, regPid)
2669
+ : { action: 'probe-higher', killPid: null };
2670
+ if (decision.action === 'take-over') {
2671
+ inTakeOver = true;
2672
+ server.close(() => { takeOverPort(decision.killPid); });
2673
+ } else {
2674
+ // Different session, spoofed/mismatched pid, missing regPid, or no answer:
2675
+ // never kill, keep the old probe-higher behavior.
2676
+ probeHigher();
2677
+ }
2678
+ });
2679
+ }
2305
2680
 
2306
2681
  // Guard: only bind the port when run directly, not when require()'d for tests.
2307
2682
  if (require.main === module) startListening();
@@ -2326,6 +2701,14 @@ function humanizeModel(modelId) {
2326
2701
  return name + ' (' + win + ')';
2327
2702
  }
2328
2703
 
2704
+ // Truncate a criterion title to a short word-boundary prefix for the collapsed row.
2705
+ // Mirrors the client-side copy inside PAGE so the dashboard and tests agree exactly.
2706
+ function collapseTitle(id, desc) {
2707
+ const d = desc || '';
2708
+ const short = d.length > 56 ? d.slice(0, 56).replace(/\s+\S*$/, '') + '…' : d;
2709
+ return id + '. ' + short;
2710
+ }
2711
+
2329
2712
  // Server-side copy of layoutTree (mirrors the client-side copy in PAGE) for test export.
2330
2713
  // Keep in sync with the client-side definition inside the PAGE template.
2331
2714
  function layoutTree(org) {
@@ -2389,5 +2772,5 @@ function layoutTree(org) {
2389
2772
 
2390
2773
  // Test-only exports; the server path never calls require() on itself.
2391
2774
  if (typeof module !== 'undefined') {
2392
- module.exports = { usedTokens, humanizeModel, layoutTree };
2775
+ module.exports = { usedTokens, humanizeModel, layoutTree, collapseTitle, aggregateRunSpend, buildOrgTree, aggregateAllProjects, safeHttpUrl, decideEaddrinuse, atomicWriteRegistryFile };
2393
2776
  }