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.
- package/README.md +55 -14
- package/hooks/context-guard.js +43 -28
- package/package.json +1 -1
- package/scripts/company-autoloop.js +30 -0
- package/scripts/dashboard.js +641 -258
- package/scripts/statusline.js +226 -14
- package/skill/SKILL.md +2 -2
package/scripts/dashboard.js
CHANGED
|
@@ -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
|
|
527
|
-
evidence: (c.evidence && String(c.evidence).trim()) ? String(c.evidence).slice(0,
|
|
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 (
|
|
803
|
+
for (let deptIdx = 0; deptIdx < departments.length; deptIdx++) {
|
|
804
|
+
const dept = departments[deptIdx];
|
|
769
805
|
const lead = dept.lead;
|
|
770
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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') :
|
|
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
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
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.
|
|
923
|
-
fs.
|
|
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
|
-
|
|
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
|
-
// ----------
|
|
977
|
-
//
|
|
978
|
-
//
|
|
979
|
-
|
|
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
|
|
1050
|
+
function readGlobalRegistry() {
|
|
982
1051
|
try {
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
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
|
-
|
|
990
|
-
|
|
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
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
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
|
-
//
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
const
|
|
1003
|
-
const
|
|
1004
|
-
|
|
1005
|
-
|
|
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
|
-
//
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
const
|
|
1012
|
-
if (!
|
|
1013
|
-
const
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
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:
|
|
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-
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
|
1466
|
-
tr.appendChild(el('td', 'mono',
|
|
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
|
-
//
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
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
|
-
|
|
2008
|
-
|
|
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
|
-
|
|
2088
|
-
titleSpan.
|
|
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:
|
|
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
|
|
2133
|
-
const
|
|
2134
|
-
const
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
const
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
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="/">← 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
|
-
|
|
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
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
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
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
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
|
-
//
|
|
2302
|
-
|
|
2303
|
-
|
|
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
|
}
|