company-skill 4.6.6 → 4.6.7

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 CHANGED
@@ -33,12 +33,15 @@ Optionally define your team first in `COMPANY.md` (skip it and a minimal company
33
33
 
34
34
  Every criterion starts failing. Workers run in dependency waves under delegation contracts. At the end of each cycle, the Internal Reviewer re-runs every VERIFY-WITH command and the Devil's Advocate attacks everything marked passing. The stop guard physically blocks exit until every criterion has `passes: true` with reproduced evidence. Once done, `STATUS.md` and a `playbook.md` update are written for the next session.
35
35
 
36
- ```
37
- GOAL -> THINK -> EXECUTE (parallel waves) -> VERIFY -> Done?
38
- | |
39
- COMPRESS STATUS.md
40
- |
41
- THINK (next cycle)
36
+ ```mermaid
37
+ flowchart TD
38
+ GOAL --> THINK
39
+ THINK --> EXECUTE["EXECUTE (parallel waves)"]
40
+ EXECUTE --> VERIFY
41
+ VERIFY -->|all criteria pass| DONE["Done (STATUS.md + playbook)"]
42
+ VERIFY -->|not done| COMPRESS
43
+ COMPRESS --> NEXT["THINK (next cycle)"]
44
+ NEXT --> EXECUTE
42
45
  ```
43
46
 
44
47
  **Roles:** CEO orchestrator, Internal Reviewer, Devil's Advocate, Digest Writer. The orchestrator reads `COMPANY.md`, activates only the roles the goal needs, and writes delegation contracts in dependency order. Workers append FINDING + SOURCE lines to findings files. The Digest Writer compresses each finished cycle into the next cycle's briefing so the orchestrator never carries raw worker output in its own context.
@@ -65,6 +68,29 @@ What you see, panel by panel:
65
68
  The dashboard binds 127.0.0.1 only, reads local files, and sends nothing anywhere. Override the port with `COMPANY_DASHBOARD_PORT`.
66
69
 
67
70
 
71
+ ## Unattended auto-restart loop
72
+
73
+ `scripts/company-autoloop.js` runs `/company` unattended across many sessions. It restarts into a fresh context automatically when a work session approaches the context threshold, so a long goal keeps going without a human pasting the restart prompt each time.
74
+
75
+ A script is needed because a fully unattended fresh-context restart cannot be done with Claude Code native features alone. Hooks cannot run `/clear` or start a fresh turn, a Stop-hook block keeps the same context, and auto-compaction only summarizes. The supervisor is the external driver that owns the restart decision. This is the validated finding.
76
+
77
+ Each turn runs a headless `claude -p` session while the supervisor watches the session context fill. At the threshold it drives `/company restart` to emit the `NEXT.md` continuation, then launches a fresh session seeded from it.
78
+
79
+ ```bash
80
+ node scripts/company-autoloop.js --max-turns 100 "/company GOAL: <your goal>"
81
+ ```
82
+
83
+ Key flags and env:
84
+
85
+ - `--project-dir <path>` the project the run targets (default: current dir)
86
+ - `--company-dir <path>` override the `.company` dir (default: `<project-dir>/.company`)
87
+ - `--max-turns <n>` hard cap on work turns across all sessions (default: 100)
88
+ - `--restart-timeout-secs <n>` max wait for the restart markers (default: 420)
89
+ - `COMPANY_CONTEXT_THRESHOLD` fill fraction or percent that triggers a restart (default: 0.50, set a low value for testing)
90
+
91
+ It runs with `--permission-mode bypassPermissions` for the autonomy an unattended loop needs. The threshold is configurable, so you can drop it low to exercise the restart path during testing.
92
+
93
+
68
94
  ## Cost and quality
69
95
 
70
96
  Multi-agent orchestration buys quality with tokens. /company's answer to the token cost: spend strong-model tokens only where they buy quality, and report the bill every cycle.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "company-skill",
3
- "version": "4.6.6",
3
+ "version": "4.6.7",
4
4
  "description": "Goal-driven multi-employee company for Claude Code. Give it a goal, it runs until done.",
5
5
  "bin": {
6
6
  "company-skill": "./bin/install.js"
@@ -22,6 +22,7 @@
22
22
  "scripts/check-contracts.js",
23
23
  "scripts/check-findings.js",
24
24
  "scripts/restart-debate.js",
25
+ "scripts/company-autoloop.js",
25
26
  "scripts/dashboard.js",
26
27
  "scripts/secret-scan.js",
27
28
  "scripts/reset-company-guard.js",
@@ -35,7 +36,22 @@
35
36
  "README.md",
36
37
  "LICENSE"
37
38
  ],
38
- "keywords": ["claude-code", "skill", "multi-agent", "company", "orchestration", "model-agnostic", "autonomous-agents", "stop-hook", "agent-orchestration", "ai-agents", "fable", "claude-fable", "mythos", "claude-mythos"],
39
+ "keywords": [
40
+ "claude-code",
41
+ "skill",
42
+ "multi-agent",
43
+ "company",
44
+ "orchestration",
45
+ "model-agnostic",
46
+ "autonomous-agents",
47
+ "stop-hook",
48
+ "agent-orchestration",
49
+ "ai-agents",
50
+ "fable",
51
+ "claude-fable",
52
+ "mythos",
53
+ "claude-mythos"
54
+ ],
39
55
  "author": "jagmarques",
40
56
  "license": "MIT",
41
57
  "repository": {
@@ -0,0 +1,597 @@
1
+ #!/usr/bin/env node
2
+ // company-autoloop.js - External supervisor for /company auto-restart cycles.
3
+ //
4
+ // WHY THIS IS A SEPARATE SCRIPT (not native hooks):
5
+ // A fully unattended detect->restart->fresh-context loop is IMPOSSIBLE with
6
+ // Claude Code native features alone. Stop-hook decision:block keeps the SAME
7
+ // context (does not reset). /clear is interactive-only. Auto-compaction
8
+ // summarizes in-session, never resets. Fresh context requires a NEW process
9
+ // invocation - i.e. this external script. See auto-restart-research.md.
10
+ //
11
+ // VALIDATED HEADLESS REALITY (orchestrator real 1% runs, 2026-06-13):
12
+ // A headless `claude -p` session does NOT self-trigger /company restart. The
13
+ // company Stop guards (stop-guard, context-guard) are effectively INTERACTIVE
14
+ // ONLY: a -p session completes its turn and EXITS regardless of decision:block,
15
+ // so it never writes NEXT.md / RESTART_DEBATE_CONFIRMED on its own. The earlier
16
+ // "wait for the child to self-restart" premise was therefore WRONG for headless.
17
+ // THE FIX, PROVEN LIVE: the supervisor must DRIVE the restart. Running
18
+ // `claude -p --resume <sessionId> "/company restart"` executes restart mode
19
+ // headlessly and writes NEXT.md (+ RESTART_DEBATE_CONFIRMED after the full
20
+ // 3-role debate). So the supervisor owns the threshold decision itself.
21
+ //
22
+ // HOW IT WORKS (threshold-driven, restart-via-resume):
23
+ // One logical run is MANY sessions. The supervisor tracks the work session's
24
+ // context FILL itself (the guards do not fire headless) and drives turns until
25
+ // the threshold is crossed, then drives a /company restart for fresh context.
26
+ // 1. Fresh session: spawn `claude -p --session-id <uuid> ... "<goal-or-NEXT>"`.
27
+ // Continuation of the SAME session under threshold: `--resume <uuid>`.
28
+ // Each -p call runs to completion and exits (expected headless behavior).
29
+ // 2. After a turn: CANCEL -> exit 0. Goal done (criteria all pass) -> exit 0.
30
+ // 3. Compute fill from the transcript jsonl (last assistant usage block) and
31
+ // the model window. Log it.
32
+ // 4. fill >= threshold: DRIVE `claude -p --resume <uuid> "/company restart"`
33
+ // ASYNC (do not await its exit - the company stop-guard blocks its stop so
34
+ // a headless restart invocation does NOT exit on its own), CONCURRENTLY
35
+ // poll for RESTART_DEBATE_CONFIRMED + a fresh NEXT.md, then KILL the restart
36
+ // process group and seed a NEW --session-id from NEXT.md. THIS is the
37
+ // fresh-context handoff.
38
+ // 5. fill < threshold: continue the SAME session another turn via --resume.
39
+ //
40
+ // --resume is used in exactly two legitimate places: continuing the same work
41
+ // session under threshold, and driving /company restart on that session. The
42
+ // fresh continuation AFTER a restart is ALWAYS a brand-new --session-id seeded
43
+ // from NEXT.md. --continue is NEVER used, and --resume NEVER carries work into a
44
+ // new logical cycle - that handoff is always a new session id.
45
+ //
46
+ // USAGE:
47
+ // node scripts/company-autoloop.js [options] "<goal>"
48
+ // node scripts/company-autoloop.js [options] --prompt-file /path/to/goal.txt
49
+ //
50
+ // OPTIONS:
51
+ // --project-dir <path> Project dir the /company run targets (default: cwd)
52
+ // --company-dir <path> Override .company dir (default: <project-dir>/.company)
53
+ // --max-turns <n> Hard cap on work turns across all sessions (default: 100)
54
+ // --permission-mode <m> claude --permission-mode value (default: bypassPermissions)
55
+ // --prompt-file <path> Read initial goal from file instead of CLI arg
56
+ // --restart-timeout-secs <n> Max wait for restart markers after driving it (default: 420)
57
+ // --turn-timeout-secs <n> Wall-clock backstop kill for a single work turn (default: 900)
58
+ // --projects-dir <path> Override the ~/.claude/projects transcript root (testing)
59
+ // --help Show this message
60
+ //
61
+ // ENV:
62
+ // CLAUDE_BIN Path to the claude binary (default: claude)
63
+ // COMPANY_DIR Overrides --company-dir
64
+ // COMPANY_TRANSCRIPT_DIR Overrides the transcript projects root (testing)
65
+ // COMPANY_CONTEXT_THRESHOLD Fill fraction or percent that triggers restart (default: 0.50)
66
+ // COMPANY_CONTEXT_WINDOW Force the context window size (overrides model detection)
67
+
68
+ 'use strict';
69
+
70
+ const fs = require('node:fs');
71
+ const path = require('node:path');
72
+ const os = require('node:os');
73
+ const { randomUUID } = require('node:crypto');
74
+ const { spawn } = require('node:child_process');
75
+
76
+ // ---------- arg parsing ----------
77
+ const argv = process.argv.slice(2);
78
+
79
+ function argValue(flag) {
80
+ const i = argv.indexOf(flag);
81
+ return i >= 0 && argv[i + 1] ? argv[i + 1] : null;
82
+ }
83
+
84
+ function hasFlag(flag) {
85
+ return argv.indexOf(flag) !== -1;
86
+ }
87
+
88
+ if (hasFlag('--help') || hasFlag('-h')) {
89
+ process.stdout.write(fs.readFileSync(__filename, 'utf8').split('\n')
90
+ .filter(l => l.startsWith('//'))
91
+ .map(l => l.slice(3))
92
+ .join('\n') + '\n');
93
+ process.exit(0);
94
+ }
95
+
96
+ const projectDir = path.resolve(argValue('--project-dir') || process.cwd());
97
+ const companyDirArg = argValue('--company-dir');
98
+ // COMPANY_DIR env wins, then --company-dir, then <projectDir>/.company
99
+ const companyDir = path.resolve(
100
+ process.env.COMPANY_DIR || companyDirArg || path.join(projectDir, '.company')
101
+ );
102
+
103
+ const maxTurns = parseInt(argValue('--max-turns') || '100', 10);
104
+ const permissionMode = argValue('--permission-mode') || 'bypassPermissions';
105
+ const promptFile = argValue('--prompt-file');
106
+ const restartTimeoutSecs = parseInt(argValue('--restart-timeout-secs') || '420', 10);
107
+ // Generous wall-clock backstop: kills a work turn that never exits (model looping
108
+ // on a blocked stop). Tuned NOT to kill legitimate long work - natural exit is the
109
+ // primary end of a turn, this is only a last resort.
110
+ const turnTimeoutSecs = parseInt(argValue('--turn-timeout-secs') || '900', 10);
111
+ const claudeBin = process.env.CLAUDE_BIN || 'claude';
112
+
113
+ // Transcript projects root: env wins, then --projects-dir, then ~/.claude/projects.
114
+ const projectsDir = process.env.COMPANY_TRANSCRIPT_DIR ||
115
+ argValue('--projects-dir') ||
116
+ path.join(os.homedir(), '.claude', 'projects');
117
+
118
+ // Positional goal: last non-flag arg that is not a flag value
119
+ let goalArg = null;
120
+ const flagsWithValues = new Set([
121
+ '--project-dir', '--company-dir', '--max-turns',
122
+ '--permission-mode', '--prompt-file', '--restart-timeout-secs',
123
+ '--turn-timeout-secs', '--projects-dir',
124
+ ]);
125
+ for (let i = 0; i < argv.length; i++) {
126
+ if (flagsWithValues.has(argv[i])) { i++; continue; }
127
+ if (argv[i].startsWith('--')) continue;
128
+ goalArg = argv[i];
129
+ }
130
+
131
+ function readInitialPrompt() {
132
+ if (promptFile) {
133
+ try {
134
+ return fs.readFileSync(promptFile, 'utf8').trim();
135
+ } catch (e) {
136
+ die('Cannot read --prompt-file ' + promptFile + ': ' + e.message);
137
+ }
138
+ }
139
+ if (goalArg) return goalArg.trim();
140
+ die('No goal provided. Pass a goal string or --prompt-file <path>.');
141
+ }
142
+
143
+ // ---------- helpers ----------
144
+ function log(msg) {
145
+ const ts = new Date().toISOString();
146
+ process.stderr.write('[autoloop ' + ts + '] ' + msg + '\n');
147
+ }
148
+
149
+ function die(msg) {
150
+ process.stderr.write('ERROR: ' + msg + '\n');
151
+ process.exit(1);
152
+ }
153
+
154
+ function sleep(ms) {
155
+ return new Promise(function (resolve) { setTimeout(resolve, ms); });
156
+ }
157
+
158
+ // ---------- state file paths ----------
159
+ const nextMdPath = path.join(companyDir, 'NEXT.md');
160
+ const cancelPath = path.join(companyDir, 'CANCEL');
161
+ const criteriaPath = path.join(companyDir, 'criteria.json');
162
+ const debateConfirmedPath = path.join(companyDir, 'RESTART_DEBATE_CONFIRMED');
163
+
164
+ // ---------- goal-done check ----------
165
+ // Real criteria.json is an object {goal, criteria:[{passes:bool,...}]}.
166
+ // Accept both shapes: parsed.criteria array, or a bare top-level array.
167
+ // An entry is done when passes === true (primary) or status done/pass (legacy).
168
+ // Empty/missing criteria, or a parse error: not done.
169
+ function isGoalDone() {
170
+ try {
171
+ const raw = fs.readFileSync(criteriaPath, 'utf8');
172
+ const parsed = JSON.parse(raw);
173
+ let list;
174
+ if (parsed && Array.isArray(parsed.criteria)) {
175
+ list = parsed.criteria;
176
+ } else if (Array.isArray(parsed)) {
177
+ list = parsed;
178
+ } else {
179
+ return false;
180
+ }
181
+ if (list.length === 0) return false;
182
+ return list.every(function (c) {
183
+ if (c && c.passes === true) return true;
184
+ const s = (c && c.status ? String(c.status) : '').toLowerCase();
185
+ return s === 'done' || s === 'pass';
186
+ });
187
+ } catch (e) {
188
+ return false;
189
+ }
190
+ }
191
+
192
+ // ---------- context-window detection (mirrors hooks/context-guard.js) ----------
193
+ // Known 1M-context model id substrings. Unknown/null defaults to 1M (fail-open),
194
+ // matching the guard so the supervisor never false-fires a restart on an unknown id.
195
+ const KNOWN_1M_SUBSTRINGS = [
196
+ '[1m]',
197
+ 'claude-opus-4',
198
+ 'claude-opus-4-5',
199
+ 'claude-opus-4-8',
200
+ ];
201
+ const DEFAULT_WINDOW = 1000000;
202
+ const WINDOW_200K = 200000;
203
+
204
+ function is1MModel(modelId) {
205
+ if (!modelId) return true; // unknown defaults to 1M (fail-open)
206
+ const lower = modelId.toLowerCase();
207
+ for (let i = 0; i < KNOWN_1M_SUBSTRINGS.length; i++) {
208
+ if (lower.indexOf(KNOWN_1M_SUBSTRINGS[i]) !== -1) return true;
209
+ }
210
+ return false;
211
+ }
212
+
213
+ function detectWindow(modelId) {
214
+ const envVal = process.env.COMPANY_CONTEXT_WINDOW;
215
+ if (envVal) {
216
+ const n = parseInt(envVal, 10);
217
+ if (n > 0) return n;
218
+ }
219
+ return is1MModel(modelId) ? DEFAULT_WINDOW : WINDOW_200K;
220
+ }
221
+
222
+ function parseThreshold() {
223
+ const raw = process.env.COMPANY_CONTEXT_THRESHOLD;
224
+ if (!raw) return 0.50;
225
+ const v = parseFloat(raw);
226
+ if (isNaN(v)) return 0.50;
227
+ // accept either fraction (0.5) or percent (50)
228
+ return v > 1 ? v / 100 : v;
229
+ }
230
+
231
+ // Sum every token field that counts against the context window.
232
+ // Matches the Claude Code status line: input + cache_read + cache_creation + output.
233
+ function usedTokens(usage) {
234
+ return (usage.input_tokens || 0) +
235
+ (usage.cache_read_input_tokens || 0) +
236
+ (usage.cache_creation_input_tokens || 0) +
237
+ (usage.output_tokens || 0);
238
+ }
239
+
240
+ // ---------- locate a session transcript jsonl ----------
241
+ // Sessions persist at ~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl. The
242
+ // encoded-cwd segment is not worth reconstructing by hand, so glob every project
243
+ // dir for <sessionId>.jsonl and take the newest match. Returns null if missing.
244
+ function findTranscript(sessionId) {
245
+ let entries;
246
+ try {
247
+ entries = fs.readdirSync(projectsDir, { withFileTypes: true });
248
+ } catch (e) {
249
+ return null;
250
+ }
251
+ let best = null;
252
+ let bestMtime = -1;
253
+ for (let i = 0; i < entries.length; i++) {
254
+ if (!entries[i].isDirectory()) continue;
255
+ const candidate = path.join(projectsDir, entries[i].name, sessionId + '.jsonl');
256
+ let st;
257
+ try { st = fs.statSync(candidate); } catch (e) { continue; }
258
+ if (st.mtimeMs > bestMtime) { bestMtime = st.mtimeMs; best = candidate; }
259
+ }
260
+ return best;
261
+ }
262
+
263
+ // ---------- compute the work session's context fill ----------
264
+ // Read the LAST assistant usage block from the session transcript, sum the token
265
+ // fields, divide by the detected window. A missing transcript is not a crash: log
266
+ // a warning and treat fill as 0 so the loop keeps making progress.
267
+ function computeFill(sessionId) {
268
+ const transcript = findTranscript(sessionId);
269
+ if (!transcript) {
270
+ log('WARN no transcript found for session ' + sessionId + ' under ' + projectsDir +
271
+ ' - treating fill as 0');
272
+ return { fill: 0, used: 0, window: DEFAULT_WINDOW, modelId: null, transcript: null };
273
+ }
274
+ let lastUsage = null;
275
+ let lastModelId = null;
276
+ try {
277
+ const raw = fs.readFileSync(transcript, 'utf8');
278
+ const lines = raw.split('\n');
279
+ for (let i = lines.length - 1; i >= 0; i--) {
280
+ const line = lines[i].trim();
281
+ if (!line) continue;
282
+ let msg;
283
+ try { msg = JSON.parse(line); } catch (e) { continue; }
284
+ const inner = msg.message || msg;
285
+ if (inner && inner.role === 'assistant' && inner.usage) {
286
+ lastUsage = inner.usage;
287
+ if (typeof inner.model === 'string') lastModelId = inner.model;
288
+ else if (typeof msg.model === 'string') lastModelId = msg.model;
289
+ break;
290
+ }
291
+ }
292
+ } catch (e) {
293
+ log('WARN could not read transcript ' + transcript + ': ' + e.message + ' - fill 0');
294
+ return { fill: 0, used: 0, window: DEFAULT_WINDOW, modelId: null, transcript };
295
+ }
296
+ if (!lastUsage) {
297
+ return { fill: 0, used: 0, window: DEFAULT_WINDOW, modelId: lastModelId, transcript };
298
+ }
299
+ const used = usedTokens(lastUsage);
300
+ const window = detectWindow(lastModelId);
301
+ return { fill: used / window, used, window, modelId: lastModelId, transcript };
302
+ }
303
+
304
+ // ---------- kill a child's whole process group (SIGTERM then SIGKILL) ----------
305
+ // Children are spawned detached so each has its own process group (pgid === pid).
306
+ // Signalling the negative pid hits the whole tree (e.g. restart's 3 debate
307
+ // sub-agents), not just the top claude process. Best-effort: a gone group is fine.
308
+ function killChildGroup(child, graceMs) {
309
+ if (!child || child.pid == null) return Promise.resolve();
310
+ const pgid = child.pid;
311
+ function signal(sig) {
312
+ try { process.kill(-pgid, sig); } catch (e) {
313
+ // Group already gone or never a group leader: fall back to the bare pid.
314
+ try { process.kill(pgid, sig); } catch (e2) {}
315
+ }
316
+ }
317
+ signal('SIGTERM');
318
+ return new Promise(function (resolve) {
319
+ let done = false;
320
+ function finish() { if (!done) { done = true; resolve(); } }
321
+ child.once('exit', finish);
322
+ setTimeout(function () { signal('SIGKILL'); finish(); }, graceMs || 1500);
323
+ });
324
+ }
325
+
326
+ // ---------- spawn one claude -p turn (async, caller owns the handle) ----------
327
+ // freshSession=true seeds a NEW --session-id. Otherwise --resume the same id.
328
+ // The prompt argument is the goal/NEXT on a fresh session, the continue nudge on
329
+ // a resume, or "/company restart" for restart mode. Spawned DETACHED so the child
330
+ // leads its own process group and the whole tree can be killed via killChildGroup.
331
+ // stdout/stderr are drained so a full pipe never blocks the child. Returns the
332
+ // child handle plus a `done` promise resolving with the exit result.
333
+ function spawnTurn(opts) {
334
+ const spawnArgs = ['-p'];
335
+ if (opts.freshSession) spawnArgs.push('--session-id', opts.sessionId);
336
+ else spawnArgs.push('--resume', opts.sessionId);
337
+ spawnArgs.push(
338
+ '--output-format', 'stream-json',
339
+ '--verbose',
340
+ '--include-hook-events',
341
+ '--permission-mode', permissionMode,
342
+ opts.prompt
343
+ );
344
+
345
+ const spawnEnv = Object.assign({}, process.env, { COMPANY_DIR: companyDir });
346
+ const mode = opts.freshSession ? '--session-id' : '--resume';
347
+ log('spawning: ' + claudeBin + ' -p ' + mode + ' ' + opts.sessionId +
348
+ (opts.label ? ' [' + opts.label + ']' : '') + ' ...');
349
+
350
+ const child = spawn(claudeBin, spawnArgs, {
351
+ cwd: projectDir,
352
+ env: spawnEnv,
353
+ stdio: ['ignore', 'pipe', 'pipe'],
354
+ detached: true,
355
+ });
356
+
357
+ let stderrTail = '';
358
+ let stdoutBytes = 0;
359
+ child.stdout.on('data', function (d) { stdoutBytes += d.length; });
360
+ child.stderr.on('data', function (d) { stderrTail = (stderrTail + d.toString()).slice(-2000); });
361
+ child.stdout.on('error', function () {});
362
+ child.stderr.on('error', function () {});
363
+
364
+ const done = new Promise(function (resolve) {
365
+ child.on('error', function (e) {
366
+ log('turn spawn error: ' + e.message);
367
+ resolve({ code: 127, stdoutBytes: 0, stderrTail: e.message, killed: false });
368
+ });
369
+ child.on('exit', function (code, signalName) {
370
+ resolve({ code: code, stdoutBytes: stdoutBytes, stderrTail: stderrTail,
371
+ killed: signalName != null });
372
+ });
373
+ });
374
+
375
+ return { child: child, done: done };
376
+ }
377
+
378
+ // ---------- run one work turn with a wall-clock backstop ----------
379
+ // Awaits natural exit (the expected headless behavior). If the turn never exits
380
+ // within turnTimeoutSecs (model looping on a blocked stop, or a sub-agent holding
381
+ // a pipe open), kill its process group and resolve a timed-out result. Once the
382
+ // timer fires we mark this run as timed-out FIRST, so whichever settle wins (the
383
+ // group-kill resolver OR the child exit event that the kill triggers) reports
384
+ // timedOut:true. Without that flag the kill makes the child exit, handle.done
385
+ // resolves with a plain code=143 result, that settle wins the race, and the main
386
+ // loop misclassifies a killed-on-timeout turn as an error (real9 evidence). So the
387
+ // flag is the source of truth for the timeout, not whichever exit path lands first.
388
+ function runTurn(opts) {
389
+ const handle = spawnTurn(opts);
390
+ return new Promise(function (resolve) {
391
+ let settled = false;
392
+ let timedOut = false;
393
+ function settle(res) {
394
+ if (settled) return;
395
+ settled = true;
396
+ clearTimeout(timer);
397
+ // The backstop owns the timeout classification: if the timer fired, this run
398
+ // is timed-out regardless of which exit path resolved it.
399
+ if (timedOut) res = Object.assign({}, res, { timedOut: true, killed: true });
400
+ resolve(res);
401
+ }
402
+ const timer = setTimeout(function () {
403
+ if (settled) return;
404
+ timedOut = true;
405
+ log('WARN work turn exceeded ' + turnTimeoutSecs + 's wall clock - killing it');
406
+ // Kill the detached process group (the work child AND its sub-agents), then
407
+ // resolve a timed-out result so the main loop proceeds to the fill check on
408
+ // the partial transcript. Do not wait on handle.done: resolve on our own.
409
+ killChildGroup(handle.child, 1500).then(function () {
410
+ settle({ code: null, stdoutBytes: 0, stderrTail: '', killed: true, timedOut: true });
411
+ });
412
+ }, turnTimeoutSecs * 1000);
413
+ handle.done.then(function (res) { settle(res); });
414
+ });
415
+ }
416
+
417
+ // ---------- drive a /company restart and wait for the handoff markers ----------
418
+ // MONITOR-AND-KILL: the restart invocation is spawned ASYNC and NOT awaited. A
419
+ // headless `claude -p "/company restart"` does NOT exit on its own (the company
420
+ // stop-guard blocks its stop), so awaiting it would hang and the marker poll below
421
+ // would never run (the real-world bug). Instead we poll CONCURRENTLY for
422
+ // RESTART_DEBATE_CONFIRMED present AND a NEXT.md whose mtime is newer than the
423
+ // restart start, then KILL the restart process group (its 3 debate sub-agents
424
+ // included) and return NEXT.md. If the invocation exits on its own first (e.g. a
425
+ // done goal), the markers are still evaluated. On timeout with no markers: kill
426
+ // the invocation and return null.
427
+ async function driveRestart(sessionId) {
428
+ const restartStart = Date.now();
429
+ // Clear any stale confirmed marker so we only react to THIS restart.
430
+ try { fs.unlinkSync(debateConfirmedPath); } catch (e) {}
431
+
432
+ const handle = spawnTurn({ sessionId: sessionId, freshSession: false,
433
+ prompt: '/company restart', label: 'company restart' });
434
+
435
+ // Track natural exit without awaiting it (a done goal can exit cleanly).
436
+ let exited = false;
437
+ handle.done.then(function () { exited = true; });
438
+
439
+ function readMarkers() {
440
+ let confirmed = false;
441
+ try { confirmed = fs.existsSync(debateConfirmedPath); } catch (e) {}
442
+ if (!confirmed) return null;
443
+ let freshNext = false;
444
+ try { freshNext = fs.statSync(nextMdPath).mtimeMs > restartStart; } catch (e) {}
445
+ if (!freshNext) return null;
446
+ let next = '';
447
+ try { next = fs.readFileSync(nextMdPath, 'utf8').trim(); } catch (e) {}
448
+ return next || null;
449
+ }
450
+
451
+ const deadline = restartStart + restartTimeoutSecs * 1000;
452
+ while (Date.now() < deadline) {
453
+ const next = readMarkers();
454
+ if (next) {
455
+ log('restart markers present - killing the restart invocation');
456
+ await killChildGroup(handle.child, 1500);
457
+ return next;
458
+ }
459
+ if (exited) {
460
+ // The invocation exited on its own and the markers never appeared.
461
+ return readMarkers();
462
+ }
463
+ await sleep(2000);
464
+ }
465
+ // Timeout with no markers: kill the (likely hung) invocation, signal failure.
466
+ await killChildGroup(handle.child, 1500);
467
+ return null;
468
+ }
469
+
470
+ // ---------- main loop ----------
471
+ async function main() {
472
+ const initialPrompt = readInitialPrompt();
473
+
474
+ log('supervisor start | project=' + projectDir + ' company=' + companyDir +
475
+ ' max-turns=' + maxTurns + ' permission-mode=' + permissionMode +
476
+ ' threshold=' + parseThreshold() + ' restart-timeout-secs=' + restartTimeoutSecs +
477
+ ' turn-timeout-secs=' + turnTimeoutSecs);
478
+ log('initial prompt length=' + initialPrompt.length + ' chars');
479
+
480
+ // A logical run = many sessions. sessionId is the current work session.
481
+ let sessionId = randomUUID();
482
+ let prompt = initialPrompt;
483
+ let freshSession = true;
484
+
485
+ const threshold = parseThreshold();
486
+ let errorStreak = 0;
487
+ const MAX_ERROR_STREAK = 3;
488
+
489
+ for (let turn = 1; turn <= maxTurns; turn++) {
490
+ // Exit-before-work: a CANCEL or an already-done goal short-circuits the turn.
491
+ if (fs.existsSync(cancelPath)) {
492
+ log('CANCEL file present - exiting 0');
493
+ process.exit(0);
494
+ }
495
+ if (isGoalDone()) {
496
+ log('goal DONE (all criteria pass) before turn ' + turn + ' - exiting 0');
497
+ process.exit(0);
498
+ }
499
+
500
+ log('turn ' + turn + '/' + maxTurns + ' | session-id=' + sessionId +
501
+ ' | mode=' + (freshSession ? 'fresh' : 'resume'));
502
+
503
+ const continuePrompt = freshSession ? prompt
504
+ : 'Continue the /company goal. Keep working the THINK-EXECUTE-VERIFY loop ' +
505
+ 'until every criterion passes.';
506
+ const turnResult = await runTurn({
507
+ sessionId: sessionId,
508
+ freshSession: freshSession,
509
+ prompt: continuePrompt,
510
+ });
511
+
512
+ // The turn ran. CANCEL and goal-done both win over fill.
513
+ if (fs.existsSync(cancelPath)) {
514
+ log('CANCEL file present after turn ' + turn + ' - exiting 0');
515
+ process.exit(0);
516
+ }
517
+ if (isGoalDone()) {
518
+ log('goal DONE after turn ' + turn + ' - exiting 0');
519
+ process.exit(0);
520
+ }
521
+
522
+ // A timed-out turn is NOT an error: the backstop killed a hung/over-long turn,
523
+ // so proceed to the fill check on the partial transcript (skip the error path).
524
+ if (turnResult.timedOut) {
525
+ log('turn ' + turn + ' hit the ' + turnTimeoutSecs +
526
+ 's backstop - proceeding to fill on the partial transcript');
527
+ } else if (turnResult.code !== 0) {
528
+ // A turn that errored out (bad binary, crash) counts toward the error streak.
529
+ errorStreak += 1;
530
+ log('WARN turn ' + turn + ' exited ' + turnResult.code +
531
+ ' (error streak ' + errorStreak + '/' + MAX_ERROR_STREAK + ')' +
532
+ (turnResult.stderrTail ? ' stderr: ' + turnResult.stderrTail.slice(-300) : ''));
533
+ if (errorStreak >= MAX_ERROR_STREAK) {
534
+ process.stderr.write(
535
+ 'ERROR: ' + MAX_ERROR_STREAK + ' consecutive failed turns.\n' +
536
+ 'Last exit code: ' + turnResult.code + '\n' +
537
+ 'Check: ' + companyDir + '/NEXT.md + criteria.json + CANCEL\n'
538
+ );
539
+ process.exit(2);
540
+ }
541
+ // Retry the same session, same prompt.
542
+ freshSession = false;
543
+ continue;
544
+ }
545
+ errorStreak = 0;
546
+
547
+ // The supervisor owns the threshold decision (the guards do not fire headless).
548
+ const f = computeFill(sessionId);
549
+ const pct = (f.fill * 100).toFixed(1);
550
+ log('turn ' + turn + ' fill=' + pct + '% (' + f.used + '/' + f.window +
551
+ ' tokens, model=' + (f.modelId || 'unknown') + ', threshold=' +
552
+ (threshold * 100).toFixed(0) + '%)');
553
+
554
+ if (f.fill >= threshold) {
555
+ // DRIVE the restart on this session, then seed a FRESH session from NEXT.md.
556
+ log('fill >= threshold - driving /company restart on session ' + sessionId);
557
+ const next = await driveRestart(sessionId);
558
+ if (!next) {
559
+ errorStreak += 1;
560
+ log('WARN restart markers did not appear within ' + restartTimeoutSecs +
561
+ 's (error streak ' + errorStreak + '/' + MAX_ERROR_STREAK + ')');
562
+ if (errorStreak >= MAX_ERROR_STREAK) {
563
+ process.stderr.write(
564
+ 'ERROR: ' + MAX_ERROR_STREAK + ' consecutive restart-drive failures.\n' +
565
+ 'The /company restart did not write NEXT.md + RESTART_DEBATE_CONFIRMED.\n'
566
+ );
567
+ process.exit(2);
568
+ }
569
+ // Retry the same session another turn rather than seeding a blank cycle.
570
+ freshSession = false;
571
+ continue;
572
+ }
573
+ log('restart confirmed - fresh-context handoff (NEXT.md ' + next.length + ' chars)');
574
+ sessionId = randomUUID();
575
+ prompt = next;
576
+ freshSession = true;
577
+ errorStreak = 0;
578
+ // Clean the marker so the next restart starts from a clean slate.
579
+ try { fs.unlinkSync(debateConfirmedPath); } catch (e) {}
580
+ continue;
581
+ }
582
+
583
+ // Under threshold: continue the SAME session another turn.
584
+ freshSession = false;
585
+ }
586
+
587
+ process.stderr.write(
588
+ 'ERROR: max-turns (' + maxTurns + ') reached without goal completion.\n' +
589
+ 'Increase --max-turns or verify criteria.json.\n'
590
+ );
591
+ process.exit(3);
592
+ }
593
+
594
+ main().catch(function (e) {
595
+ process.stderr.write('FATAL: ' + (e && e.stack ? e.stack : e) + '\n');
596
+ process.exit(1);
597
+ });
@@ -606,6 +606,15 @@ function parseContextThreshold() {
606
606
  return v > 1 ? v / 100 : v;
607
607
  }
608
608
 
609
+ // Sum all token fields that count against the context window.
610
+ // Matches the Claude Code status line: input + cache_read + cache_creation + output.
611
+ function usedTokens(usage) {
612
+ return (usage.input_tokens || 0) +
613
+ (usage.cache_read_input_tokens || 0) +
614
+ (usage.cache_creation_input_tokens || 0) +
615
+ (usage.output_tokens || 0);
616
+ }
617
+
609
618
  function computeContextFill(transcriptFile, overrideModel) {
610
619
  const threshold = parseContextThreshold();
611
620
  if (!transcriptFile) return { used: 0, window: DEFAULT_WINDOW, fill: 0, threshold, modelId: null };
@@ -634,9 +643,7 @@ function computeContextFill(transcriptFile, overrideModel) {
634
643
  return { used: 0, window: DEFAULT_WINDOW, fill: 0, threshold, modelId: null };
635
644
  }
636
645
  if (!lastUsage) return { used: 0, window: DEFAULT_WINDOW, fill: 0, threshold, modelId: lastModelId };
637
- const used = (lastUsage.input_tokens || 0) +
638
- (lastUsage.cache_read_input_tokens || 0) +
639
- (lastUsage.cache_creation_input_tokens || 0);
646
+ const used = usedTokens(lastUsage);
640
647
  const contextWindow = detectWindow(lastModelId);
641
648
  const fill = used / contextWindow;
642
649
  return { used, window: contextWindow, fill, threshold, modelId: lastModelId };
@@ -1290,7 +1297,7 @@ footer { margin-top: 2rem; font-size: 12.5px; color: var(--dim); border-top: 1px
1290
1297
  <button class="tree-btn" id="zoom-in">+</button>
1291
1298
  <button class="tree-btn" id="zoom-out">-</button>
1292
1299
  <button class="tree-btn" id="zoom-reset">reset</button>
1293
- <button class="tree-btn" id="tree-fullscreen">fullscreen</button>
1300
+ <button class="tree-btn" id="tree-fullscreen" aria-label="Expand" title="Expand"><svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><polyline points="1,5 1,1 5,1"/><polyline points="9,1 13,1 13,5"/><polyline points="13,9 13,13 9,13"/><polyline points="5,13 1,13 1,9"/></svg></button>
1294
1301
  </div>
1295
1302
  </div>
1296
1303
  <div class="tree-container" id="tree-container">
@@ -1356,6 +1363,28 @@ const fmtBytes = (n) => {
1356
1363
  return n + ' B';
1357
1364
  };
1358
1365
  const shortModel = (m) => m ? String(m).replace(/^claude-/, '') : '?';
1366
+ // Human-readable model label: "Opus 4.8 (1M context)" style.
1367
+ // Keep in sync with the server-side humanizeModel() in dashboard.js.
1368
+ function humanizeModel(modelId) {
1369
+ if (!modelId) return '?';
1370
+ const m = String(modelId).toLowerCase();
1371
+ let name;
1372
+ if (m.includes('fable') || m.includes('mythos')) name = 'Fable 5';
1373
+ else if (m.includes('opus-4-8')) name = 'Opus 4.8';
1374
+ else if (m.includes('opus-4-5')) name = 'Opus 4.5';
1375
+ else if (m.includes('opus-4')) name = 'Opus 4';
1376
+ else if (m.includes('sonnet-4')) name = 'Sonnet 4';
1377
+ else if (m.includes('sonnet-3-7')) name = 'Sonnet 3.7';
1378
+ else if (m.includes('sonnet-3-5')) name = 'Sonnet 3.5';
1379
+ else if (m.includes('sonnet')) name = 'Sonnet';
1380
+ else if (m.includes('haiku')) name = 'Haiku';
1381
+ else name = modelId.replace(/^claude-/, '');
1382
+ // 1M window: ids containing [1m] or known opus-4 family
1383
+ const is1M = m.includes('[1m]') || m.includes('claude-opus-4') ||
1384
+ m.includes('fable') || m.includes('mythos');
1385
+ const win = is1M ? '1M context' : '200K context';
1386
+ return name + ' (' + win + ')';
1387
+ }
1359
1388
 
1360
1389
  function renderHeader(s) {
1361
1390
  const stats = $('stats');
@@ -1375,8 +1404,8 @@ function renderHeader(s) {
1375
1404
  const su = t.session && t.session.usage;
1376
1405
  add('session cost', su ? fmtUsd(su.cost) : '?', su ? fmtTok(su.total) + ' tokens' : 'session ' + (t.session && t.session.id || '?'));
1377
1406
  if (s.savings && s.savings.tieringSaved !== null) {
1378
- add('saved by model tiering', fmtUsd(s.savings.tieringSaved) + (s.savings.estimated ? ' est.' : ''), 'vs all-' + shortModel(s.savings.topTierModel) + ' (current top tier)');
1379
- add('saved by prompt caching', fmtUsd(s.savings.cacheSaved) + (s.savings.estimated ? ' est.' : ''), 'cache reads vs full input price');
1407
+ add('saved by model tiering', fmtUsd(s.savings.tieringSaved) + (s.savings.estimated ? ' est.' : ''), 'approx - vs all-' + shortModel(s.savings.topTierModel) + ' (current top tier)');
1408
+ add('saved by prompt caching', fmtUsd(s.savings.cacheSaved) + (s.savings.estimated ? ' est.' : ''), 'approx - cache reads vs full input price');
1380
1409
  }
1381
1410
  }
1382
1411
  const bar = $('splitbar'), legend = $('legend');
@@ -1408,7 +1437,7 @@ function renderBand(s) {
1408
1437
  band.appendChild(d);
1409
1438
  };
1410
1439
  add('policy', p.policy || '?');
1411
- add('session model', shortModel(p.sessionModel));
1440
+ add('session model', humanizeModel(p.sessionModel));
1412
1441
  add('owners', String(p.ownerCount ?? '?'));
1413
1442
  if (p.halt) band.appendChild(el('span', 'halt', 'HALT REQUESTED'));
1414
1443
  if (s.warning) {
@@ -1489,7 +1518,7 @@ function renderContextFill(s) {
1489
1518
  const row = el('div', 'ctx-row');
1490
1519
  row.appendChild(el('span', 'ctx-pct ' + colorClass, pct + '%'));
1491
1520
  row.appendChild(el('span', 'muted', fmtTok(ctx.used) + ' / ' + fmtTok(ctx.window) + ' tokens'));
1492
- if (ctx.modelId) row.appendChild(el('span', 'muted', shortModel(ctx.modelId)));
1521
+ if (ctx.modelId) row.appendChild(el('span', 'muted', humanizeModel(ctx.modelId)));
1493
1522
  if (ctx.fill >= ctx.threshold && ctx.enforceRestart !== false) row.appendChild(el('span', 'pill warn', 'restart due'));
1494
1523
  root.appendChild(row);
1495
1524
  }
@@ -1585,61 +1614,86 @@ function nodeFill(status, tier) {
1585
1614
  return 'rgba(89,99,110,0.06)';
1586
1615
  }
1587
1616
 
1588
- // Compute x/y positions for each node
1617
+ // Compute x/y positions for each node with no overlapping rectangles.
1618
+ // Strategy: compute each tier-1 subtree width, pack left-to-right with a gap,
1619
+ // center each lead over its children, grow total canvas to fit.
1589
1620
  function layoutTree(org) {
1590
1621
  const nodes = org.nodes || [];
1591
1622
  if (!nodes.length) return { placed: {}, W: 400, H: 100 };
1592
- const W = 900;
1593
1623
  const nodeW = 140, nodeH = 36;
1594
- const tierY = { 0: 40, 1: 130, 2: 240 };
1624
+ const colGap = 12; // horizontal gap between node columns
1625
+ const rowGap = 14; // vertical gap between rows within a subtree
1626
+ const leadGap = 24; // horizontal gap between adjacent subtrees
1627
+ const tierY = { 0: 40, 1: 140, 2: 256 };
1595
1628
  const placed = {};
1596
1629
 
1597
1630
  const tier0 = nodes.filter(n => n.tier === 0);
1598
1631
  const tier1 = nodes.filter(n => n.tier === 1);
1599
1632
  const tier2 = nodes.filter(n => n.tier === 2);
1600
1633
 
1601
- if (tier0.length) placed[tier0[0].id] = { x: W / 2 - nodeW / 2, y: tierY[0] };
1602
-
1603
- const t1Step = tier1.length > 0 ? Math.max(nodeW + 16, W / tier1.length) : W;
1604
- tier1.forEach((n, i) => {
1605
- placed[n.id] = { x: Math.max(0, t1Step * i + (t1Step - nodeW) / 2), y: tierY[1] };
1606
- });
1607
-
1608
- // Group tier-2 under their lead
1634
+ // Build tier-2 children list per tier-1 lead (only direct edges)
1609
1635
  const byLead = {};
1610
1636
  for (const e of (org.edges || [])) {
1611
1637
  if (!byLead[e.from]) byLead[e.from] = [];
1612
1638
  byLead[e.from].push(e.to);
1613
1639
  }
1614
- let totalH = 320;
1615
- for (const [leadId, childIds] of Object.entries(byLead)) {
1616
- const leadPos = placed[leadId];
1617
- if (!leadPos) continue;
1618
- const lead = nodes.find(n => n.id === leadId);
1619
- if (!lead || lead.tier !== 1) continue;
1620
- const t2children = childIds.filter(cid => {
1640
+
1641
+ // Subtree width for a tier-1 node = max(nodeW, cols*nodeW + (cols-1)*colGap)
1642
+ // where cols = min(3, childCount)
1643
+ function subtreeWidth(leadId) {
1644
+ const children = (byLead[leadId] || []).filter(cid => {
1645
+ const cn = nodes.find(n => n.id === cid);
1646
+ return cn && cn.tier === 2;
1647
+ });
1648
+ if (!children.length) return nodeW;
1649
+ const cols = Math.min(3, children.length);
1650
+ return cols * nodeW + (cols - 1) * colGap;
1651
+ }
1652
+
1653
+ // Place tier-1 subtrees left to right, each subtree occupying its full width.
1654
+ let cursor = 0;
1655
+ let totalH = tierY[2] + nodeH + 20;
1656
+
1657
+ tier1.forEach((lead) => {
1658
+ const sw = subtreeWidth(lead.id);
1659
+ // Center the lead node over its subtree band
1660
+ placed[lead.id] = { x: cursor + (sw - nodeW) / 2, y: tierY[1] };
1661
+
1662
+ // Place tier-2 children under this lead, left-aligned within the band
1663
+ const children = (byLead[lead.id] || []).filter(cid => {
1621
1664
  const cn = nodes.find(n => n.id === cid);
1622
1665
  return cn && cn.tier === 2;
1623
1666
  });
1624
- const cols = Math.min(3, t2children.length);
1625
- const slotW = Math.max(nodeW + 8, t1Step);
1626
- const groupW = cols * slotW;
1627
- const startX = leadPos.x + nodeW / 2 - groupW / 2;
1628
- t2children.forEach((cid, i) => {
1629
- const col = i % 3;
1630
- const row = Math.floor(i / 3);
1631
- const y = tierY[2] + row * (nodeH + 14);
1632
- const x = startX + col * slotW;
1667
+ const cols = Math.min(3, children.length);
1668
+ children.forEach((cid, i) => {
1669
+ const col = i % cols;
1670
+ const row = Math.floor(i / cols);
1671
+ const x = cursor + col * (nodeW + colGap);
1672
+ const y = tierY[2] + row * (nodeH + rowGap);
1633
1673
  placed[cid] = { x, y };
1634
1674
  if (y + nodeH + 20 > totalH) totalH = y + nodeH + 20;
1635
1675
  });
1636
- }
1676
+
1677
+ cursor += sw + leadGap;
1678
+ });
1679
+
1680
+ // Orphan tier-2 nodes (no lead parent placed): append at the right edge
1637
1681
  for (const n of tier2) {
1638
1682
  if (!placed[n.id]) {
1639
- placed[n.id] = { x: 10, y: tierY[2] };
1683
+ placed[n.id] = { x: cursor, y: tierY[2] };
1684
+ cursor += nodeW + colGap;
1640
1685
  }
1641
1686
  }
1642
- return { placed, W, H: Math.max(320, totalH) };
1687
+
1688
+ // Total canvas width: max of cursor (after all subtrees) and a minimum
1689
+ const totalW = Math.max(400, cursor - leadGap);
1690
+
1691
+ // Center tier-0 (CEO) over the whole canvas
1692
+ if (tier0.length) {
1693
+ placed[tier0[0].id] = { x: totalW / 2 - nodeW / 2, y: tierY[0] };
1694
+ }
1695
+
1696
+ return { placed, W: totalW, H: Math.max(320, totalH) };
1643
1697
  }
1644
1698
 
1645
1699
  function renderTree(s) {
@@ -1892,6 +1946,17 @@ function setupTreeInteractions() {
1892
1946
  if (!document.fullscreenElement) card.requestFullscreen().catch(() => {});
1893
1947
  else document.exitFullscreen().catch(() => {});
1894
1948
  });
1949
+ // SVG icons for expand (two arrows out) and contract (two arrows in)
1950
+ const ICON_EXPAND = '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><polyline points="1,5 1,1 5,1"/><polyline points="9,1 13,1 13,5"/><polyline points="13,9 13,13 9,13"/><polyline points="5,13 1,13 1,9"/></svg>';
1951
+ const ICON_CONTRACT = '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><polyline points="5,1 5,5 1,5"/><polyline points="13,5 9,5 9,1"/><polyline points="9,13 9,9 13,9"/><polyline points="1,9 5,9 5,13"/></svg>';
1952
+ document.addEventListener('fullscreenchange', () => {
1953
+ const btn = $('tree-fullscreen');
1954
+ if (!btn) return;
1955
+ const active = !!document.fullscreenElement;
1956
+ btn.innerHTML = active ? ICON_CONTRACT : ICON_EXPAND;
1957
+ btn.setAttribute('aria-label', active ? 'Exit fullscreen' : 'Expand');
1958
+ btn.setAttribute('title', active ? 'Exit fullscreen' : 'Expand');
1959
+ });
1895
1960
  }
1896
1961
 
1897
1962
  // Per-feed-item expand: sessionStorage set of expanded item keys, keyed per session
@@ -2019,7 +2084,7 @@ function renderCriteria(s) {
2019
2084
  dot.style.flexShrink = '0';
2020
2085
  header.appendChild(dot);
2021
2086
  const titleSpan = el('span');
2022
- titleSpan.style.cssText = 'flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
2087
+ titleSpan.style.cssText = 'flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
2023
2088
  titleSpan.textContent = itemId + '. ' + item.description;
2024
2089
  header.appendChild(titleSpan);
2025
2090
  const caret = el('span', 'muted');
@@ -2071,11 +2136,10 @@ function renderCycles(s) {
2071
2136
  thead.appendChild(hr);
2072
2137
  table.appendChild(thead);
2073
2138
  const tbody = el('tbody');
2139
+ // Show counts and dates as headlines; drop bare byte sizes as primary values.
2074
2140
  const rows = [
2075
- ['cycle dirs', String(c.cycleDirs ?? '?')],
2076
- ['briefings', (c.briefingCount ?? '?') + ' (' + fmtBytes(c.briefingBytes) + ')'],
2077
- ['playbook', fmtBytes(c.playbookBytes) + (c.playbookGrowthBytes ? ' (+' + fmtBytes(c.playbookGrowthBytes) + ')' : '')],
2078
- ['active tasks', fmtBytes(c.activeTasksBytes)],
2141
+ ['cycles run', String(c.cycleDirs ?? '?')],
2142
+ ['briefings logged', String(c.briefingCount ?? '?')],
2079
2143
  ['last compaction', c.lastCompaction ? new Date(c.lastCompaction).toLocaleString() : 'none seen'],
2080
2144
  ['compactions observed', String(c.compactionsObserved ?? 0)],
2081
2145
  ];
@@ -2239,4 +2303,91 @@ server.on('error', (err) => {
2239
2303
  process.exit(1);
2240
2304
  });
2241
2305
 
2242
- startListening();
2306
+ // Guard: only bind the port when run directly, not when require()'d for tests.
2307
+ if (require.main === module) startListening();
2308
+
2309
+ // Map a raw model id to a human-readable label with context-window note.
2310
+ // Mirrors the client-side copy inside PAGE for testability.
2311
+ function humanizeModel(modelId) {
2312
+ if (!modelId) return '?';
2313
+ const m = String(modelId).toLowerCase();
2314
+ let name;
2315
+ if (m.includes('fable') || m.includes('mythos')) name = 'Fable 5';
2316
+ else if (m.includes('opus-4-8')) name = 'Opus 4.8';
2317
+ else if (m.includes('opus-4-5')) name = 'Opus 4.5';
2318
+ else if (m.includes('opus-4')) name = 'Opus 4';
2319
+ else if (m.includes('sonnet-4')) name = 'Sonnet 4';
2320
+ else if (m.includes('sonnet-3-7')) name = 'Sonnet 3.7';
2321
+ else if (m.includes('sonnet-3-5')) name = 'Sonnet 3.5';
2322
+ else if (m.includes('sonnet')) name = 'Sonnet';
2323
+ else if (m.includes('haiku')) name = 'Haiku';
2324
+ else name = modelId.replace(/^claude-/, '');
2325
+ const win = is1MModel(modelId) ? '1M context' : '200K context';
2326
+ return name + ' (' + win + ')';
2327
+ }
2328
+
2329
+ // Server-side copy of layoutTree (mirrors the client-side copy in PAGE) for test export.
2330
+ // Keep in sync with the client-side definition inside the PAGE template.
2331
+ function layoutTree(org) {
2332
+ const nodes = org.nodes || [];
2333
+ if (!nodes.length) return { placed: {}, W: 400, H: 100 };
2334
+ const nodeW = 140, nodeH = 36;
2335
+ const colGap = 12;
2336
+ const rowGap = 14;
2337
+ const leadGap = 24;
2338
+ const tierY = { 0: 40, 1: 140, 2: 256 };
2339
+ const placed = {};
2340
+ const tier0 = nodes.filter(n => n.tier === 0);
2341
+ const tier1 = nodes.filter(n => n.tier === 1);
2342
+ const tier2 = nodes.filter(n => n.tier === 2);
2343
+ const byLead = {};
2344
+ for (const e of (org.edges || [])) {
2345
+ if (!byLead[e.from]) byLead[e.from] = [];
2346
+ byLead[e.from].push(e.to);
2347
+ }
2348
+ function subtreeWidth(leadId) {
2349
+ const children = (byLead[leadId] || []).filter(cid => {
2350
+ const cn = nodes.find(n => n.id === cid);
2351
+ return cn && cn.tier === 2;
2352
+ });
2353
+ if (!children.length) return nodeW;
2354
+ const cols = Math.min(3, children.length);
2355
+ return cols * nodeW + (cols - 1) * colGap;
2356
+ }
2357
+ let cursor = 0;
2358
+ let totalH = tierY[2] + nodeH + 20;
2359
+ tier1.forEach((lead) => {
2360
+ const sw = subtreeWidth(lead.id);
2361
+ placed[lead.id] = { x: cursor + (sw - nodeW) / 2, y: tierY[1] };
2362
+ const children = (byLead[lead.id] || []).filter(cid => {
2363
+ const cn = nodes.find(n => n.id === cid);
2364
+ return cn && cn.tier === 2;
2365
+ });
2366
+ const cols = Math.min(3, children.length);
2367
+ children.forEach((cid, i) => {
2368
+ const col = i % cols;
2369
+ const row = Math.floor(i / cols);
2370
+ const x = cursor + col * (nodeW + colGap);
2371
+ const y = tierY[2] + row * (nodeH + rowGap);
2372
+ placed[cid] = { x, y };
2373
+ if (y + nodeH + 20 > totalH) totalH = y + nodeH + 20;
2374
+ });
2375
+ cursor += sw + leadGap;
2376
+ });
2377
+ for (const n of tier2) {
2378
+ if (!placed[n.id]) {
2379
+ placed[n.id] = { x: cursor, y: tierY[2] };
2380
+ cursor += nodeW + colGap;
2381
+ }
2382
+ }
2383
+ const totalW = Math.max(400, cursor - leadGap);
2384
+ if (tier0.length) {
2385
+ placed[tier0[0].id] = { x: totalW / 2 - nodeW / 2, y: tierY[0] };
2386
+ }
2387
+ return { placed, W: totalW, H: Math.max(320, totalH) };
2388
+ }
2389
+
2390
+ // Test-only exports; the server path never calls require() on itself.
2391
+ if (typeof module !== 'undefined') {
2392
+ module.exports = { usedTokens, humanizeModel, layoutTree };
2393
+ }