@henryz2004/agency 1.0.0

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.
Files changed (41) hide show
  1. package/README.md +106 -0
  2. package/lib/codex.js +211 -0
  3. package/lib/control.js +168 -0
  4. package/lib/live.js +493 -0
  5. package/lib/opencode.js +447 -0
  6. package/lib/paths.js +12 -0
  7. package/lib/roster.js +204 -0
  8. package/lib/transcript.js +361 -0
  9. package/lib/usage.js +346 -0
  10. package/package.json +27 -0
  11. package/public/app.js +1021 -0
  12. package/public/audio-controls.js +165 -0
  13. package/public/avatar.js +467 -0
  14. package/public/characters/dev-auburn.json +32 -0
  15. package/public/characters/dev-auburn.png +0 -0
  16. package/public/characters/dev-beanie.json +32 -0
  17. package/public/characters/dev-beanie.png +0 -0
  18. package/public/characters/dev-glasses.json +32 -0
  19. package/public/characters/dev-glasses.png +0 -0
  20. package/public/chat-panel.css +514 -0
  21. package/public/chat-panel.js +815 -0
  22. package/public/index.html +190 -0
  23. package/public/lab.html +129 -0
  24. package/public/leaderboard.js +222 -0
  25. package/public/metric.js +34 -0
  26. package/public/mock-agents.js +70 -0
  27. package/public/mock.js +277 -0
  28. package/public/music/Console_Morning.mp3 +0 -0
  29. package/public/music/Midnight_Desk.mp3 +0 -0
  30. package/public/music/The_Plant_Beside_the_Door.mp3 +0 -0
  31. package/public/music/Three_AM_Window.mp3 +0 -0
  32. package/public/office.js +1484 -0
  33. package/public/sound.js +382 -0
  34. package/public/sprites.js +983 -0
  35. package/public/style.css +506 -0
  36. package/public/ui.js +50 -0
  37. package/scripts/_pixpng.mjs +104 -0
  38. package/scripts/animsheet.mjs +60 -0
  39. package/scripts/charsheet.mjs +61 -0
  40. package/scripts/install-hook.mjs +120 -0
  41. package/server.js +370 -0
package/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # 🏒 Agency
2
+
3
+ A live pixel-art office sim of your Claude Code, Codex, and opencode workforce.
4
+
5
+ You're a one-person startup β€” but your AI coding agents do the work of *many*.
6
+ Agency reads your **real, local** Claude Code data (from `~/.claude/`),
7
+ Codex data (from `~/.codex/`), and opencode data (from `~/.local/share/opencode/`) and visualizes it as a tiny
8
+ retro office: every running session is a pixel worker at a desk (typing
9
+ when busy, monitor glowing by model tier), and your token throughput gets
10
+ translated into **manpower** β€” effective team size, engineer-years shipped, and
11
+ the payroll you'd be paying humans to match it.
12
+
13
+ Nothing leaves your machine β€” unless you explicitly opt into the [leaderboard](#leaderboard-opt-in). No dependencies.
14
+
15
+ ![Agency](docs/screenshot.png)
16
+
17
+ ## Run it
18
+
19
+ ```bash
20
+ npx @henryz2004/agency
21
+ # β†’ opens http://localhost:4313 in your browser
22
+ ```
23
+
24
+ That's it β€” no install, no build, no dependencies. (From a checkout, `node server.js`
25
+ does the same.) Set a different port with `PORT=8080`, and `AGENCY_NO_OPEN=1` to skip
26
+ auto-opening the browser. State (your roster + usage cache) lives in `~/.agency`.
27
+
28
+ Leave it open on a second monitor and start some `claude`, `codex`, or
29
+ `opencode` sessions β€” workers appear at desks within ~3 seconds, busy ones
30
+ start typing, and uptimes tick live.
31
+
32
+ ## What it shows
33
+
34
+ **The floor** β€” one desk per *running* session (Claude Code, Codex, or opencode), discovered from
35
+ `~/.claude/sessions/<pid>.json` (validated against live PIDs), `~/.codex/process_manager/chat_processes.json` joined with `~/.codex/state_5.sqlite`, or the opencode SQLite database. Each agent gets:
36
+ - a stable name + job title (Intern β†’ Principal, keyed to its model tier),
37
+ - a glowing monitor colored by model (Opus = gold, Sonnet = cyan, Haiku = green),
38
+ - a "typing" animation while `busy`, and a live uptime counter.
39
+
40
+ Co-located agents (same project) cluster together on a shared **team rug**
41
+ labeled with the repo name, so you read the floor as teams, not a scattered grid.
42
+
43
+ **Effective team size** β€” your recent daily output tokens divided by what one
44
+ human engineer would produce. Drag the **Assumptions** sliders (tokens per
45
+ engineer-hour, hours/day, days/year, salary) to re-rate everything instantly:
46
+ - *engineer-years shipped* (lifetime output),
47
+ - *payroll equivalent* (what the humans would cost),
48
+ - *engineer-days today*.
49
+
50
+ **Comparison** β€” a row of pixel people: you (gold) vs. the team you operate like.
51
+
52
+ **Panels** β€” model mix by output, top "departments" (projects) by output, a
53
+ 30-day daily-output chart, and an all-time ledger (tokens, tool actions,
54
+ subagents hired, sessions, active days).
55
+
56
+ ## Walk the floor
57
+
58
+ Press **`g`** (or click the walk button) to drop in a controllable **player
59
+ character**. Drive it with **WASD / arrows**; the camera follows you. Walk up to
60
+ a desk and that agent's chat surfaces in the peek panel (it reuses the same
61
+ selection event a click does), so you can wander the floor and read what each
62
+ agent is doing. Press `g` again to return to free pan/zoom. A cat roams the
63
+ lounge for company.
64
+
65
+ ## How it works
66
+
67
+ Zero dependencies β€” just Node's `http` + `fs` and a vanilla-JS canvas frontend.
68
+
69
+ | File | Role |
70
+ |------|------|
71
+ | `server.js` | HTTP server; single `/api/state` endpoint fusing live + usage |
72
+ | `lib/live.js` | running sessions, uptime, status, per-session model |
73
+ | `lib/usage.js` | parses `~/.claude/projects/**/*.jsonl` plus Codex/opencode usage, cached by mtime+size |
74
+ | `lib/opencode.js` | reads opencode SQLite DB for live sessions + usage stats |
75
+ | `lib/codex.js` | reads Codex local state for live sessions + usage stats |
76
+ | `lib/roster.js` | stable name/title/palette per session (persisted) |
77
+ | `public/office.js` | the pixel office: project desk clusters, team rugs, decor, camera, animation |
78
+ | `public/sprites.js` | procedural pixel-art drawing (every glyph; no sprite sheet) |
79
+ | `public/avatar.js` | the walkable user avatar + wandering cat (`g` to toggle, WASD/arrows) |
80
+ | `public/app.js` | data polling, manpower math, panels, ticker |
81
+
82
+ Usage stats are cached in `~/.agency/usage-cache.json` and `~/.agency/opencode-usage-cache.json`
83
+ (only changed transcripts/DB state are re-parsed on refresh; the main cache now includes Claude, Codex, and opencode usage); agent identities persist in `~/.agency/roster.json`. Override the location with `AGENCY_DATA_DIR`.
84
+
85
+ Codex live sessions come from `~/.codex/process_manager/chat_processes.json`
86
+ and `~/.codex/state_5.sqlite`.
87
+
88
+ > The manpower numbers are a deliberately fun heuristic, not a benchmark β€” they
89
+ > exist to make a one-person shop *feel like more*. Tune the sliders to taste.
90
+
91
+ ## Leaderboard (opt-in)
92
+
93
+ Agency ships with an **optional** public leaderboard that ranks installs by
94
+ *standardized engineer-years* β€” the same eng-years figure as the personal card,
95
+ but with the assumption sliders **frozen to fixed constants** so everyone's
96
+ number is comparable (and not gameable by tuning your own dials).
97
+
98
+ It is **off by default and opt-in**. Nothing is uploaded until you open the πŸ†
99
+ panel and click *Join*, and even then the only data sent is **a display name and
100
+ your standardized eng-years number** β€” never your code, transcripts, repo names,
101
+ or project names. *Stop sharing* deletes your row.
102
+
103
+ The backend is a tiny Cloudflare Worker + D1 database under [`worker/`](worker/) β€”
104
+ see [`worker/README.md`](worker/README.md) to deploy your own and point the
105
+ dashboard at it (set `LEADERBOARD_API` in `public/leaderboard.js`). Until that
106
+ URL is set, the leaderboard UI stays hidden and Agency runs exactly as before.
package/lib/codex.js ADDED
@@ -0,0 +1,211 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { execFileSync } from 'node:child_process';
5
+
6
+ const HOME = os.homedir();
7
+ const PROCESS_PATH = path.join(HOME, '.codex', 'process_manager', 'chat_processes.json');
8
+ const DB_PATH = path.join(HOME, '.codex', 'state_5.sqlite');
9
+
10
+ function isAlive(pid) {
11
+ if (!pid || pid <= 0) return false;
12
+ try {
13
+ process.kill(pid, 0);
14
+ return true;
15
+ } catch (e) {
16
+ return e.code === 'EPERM';
17
+ }
18
+ }
19
+
20
+ function dbExists() {
21
+ try {
22
+ fs.accessSync(DB_PATH);
23
+ return true;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ function query(sql) {
30
+ if (!dbExists()) return [];
31
+ try {
32
+ const out = execFileSync('sqlite3', [DB_PATH, '-cmd', '.timeout 3000', '-json', sql], {
33
+ timeout: 4000,
34
+ encoding: 'utf8',
35
+ stdio: ['ignore', 'pipe', 'ignore'],
36
+ });
37
+ if (!out.trim()) return [];
38
+ return JSON.parse(out);
39
+ } catch {
40
+ return [];
41
+ }
42
+ }
43
+
44
+ function readJson(file, fallback) {
45
+ try {
46
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
47
+ } catch {
48
+ return fallback;
49
+ }
50
+ }
51
+
52
+ function parseModel(raw) {
53
+ if (!raw) return { id: null, provider: null };
54
+ if (typeof raw === 'object') {
55
+ return {
56
+ id: raw.id || raw.name || null,
57
+ provider: raw.providerID || raw.provider || null,
58
+ };
59
+ }
60
+ const trimmed = String(raw).trim();
61
+ if (!trimmed) return { id: null, provider: null };
62
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
63
+ try {
64
+ return parseModel(JSON.parse(trimmed));
65
+ } catch {
66
+ return { id: trimmed, provider: null };
67
+ }
68
+ }
69
+ return { id: trimmed, provider: null };
70
+ }
71
+
72
+ function localDay(ts) {
73
+ const d = new Date(ts);
74
+ if (Number.isNaN(d.getTime())) return null;
75
+ return d.toLocaleDateString('en-CA');
76
+ }
77
+
78
+ function emptyDay() {
79
+ return { out: 0, in: 0, cr: 0, cc: 0, tools: 0, msgs: 0, agents: 0 };
80
+ }
81
+
82
+ function openSubagentCount(id) {
83
+ const rows = query(
84
+ `SELECT count(*) AS n FROM thread_spawn_edges ` +
85
+ `WHERE parent_thread_id = '${String(id).replace(/'/g, "''")}' AND status = 'open'`
86
+ );
87
+ return rows.length ? Number(rows[0].n || 0) : 0;
88
+ }
89
+
90
+ export function getCodexLive() {
91
+ const now = Date.now();
92
+ const processes = readJson(PROCESS_PATH, []);
93
+ const procById = new Map(
94
+ Array.isArray(processes)
95
+ ? processes.map((p) => [p.conversationId || null, p]).filter(([id]) => id)
96
+ : []
97
+ );
98
+
99
+ const cutoff = now - 30 * 60 * 1000;
100
+ const rows = query(
101
+ `SELECT id, title, preview, model, cwd, tokens_used, created_at_ms, updated_at_ms, model_provider, thread_source, agent_nickname, agent_role ` +
102
+ `FROM threads WHERE archived = 0 AND updated_at_ms > ${cutoff} ORDER BY updated_at_ms DESC`
103
+ );
104
+
105
+ const agents = rows
106
+ .map((thread) => {
107
+ const p = procById.get(thread.id) || {};
108
+ const pid = p.osPid || p.pid || null;
109
+ if (pid && !isAlive(pid)) return null;
110
+
111
+ const model = parseModel(thread.model);
112
+ const startedAt = thread.created_at_ms || p.startedAtMs || null;
113
+ const updatedAt = thread.updated_at_ms || p.updatedAtMs || startedAt || null;
114
+ const staleMs = updatedAt ? now - updatedAt : Number.POSITIVE_INFINITY;
115
+ const activity = staleMs <= 2 * 60 * 1000 ? 'working' : 'idle';
116
+ const cwd = thread.cwd || p.cwd || null;
117
+ const project = cwd ? path.basename(cwd) : 'unknown';
118
+ const subagents = openSubagentCount(thread.id);
119
+
120
+ return {
121
+ pid,
122
+ sessionId: thread.id,
123
+ source: 'codex',
124
+ cwd,
125
+ project,
126
+ kind: 'interactive',
127
+ activity,
128
+ status: activity === 'working' ? 'busy' : 'idle',
129
+ state: null,
130
+ needsYou: false,
131
+ task: thread.title || p.command || null,
132
+ chatName: thread.title || p.chatTitle || null,
133
+ lastPrompt: thread.preview || p.command || null,
134
+ subagents: Array.from({ length: subagents }, () => ({ type: 'agent' })),
135
+ startedAt,
136
+ uptimeMs: startedAt ? Math.max(0, now - startedAt) : null,
137
+ model: model.id || null,
138
+ modelSlug: typeof thread.model === 'string' ? thread.model : model.id || null,
139
+ provider: model.provider || thread.model_provider || null,
140
+ title: thread.agent_role || thread.agent_nickname || null,
141
+ };
142
+ })
143
+ .filter(Boolean)
144
+ .sort((a, b) => (b.uptimeMs || 0) - (a.uptimeMs || 0));
145
+
146
+ return { agents, now };
147
+ }
148
+
149
+ export function getCodexUsage() {
150
+ if (!dbExists()) return null;
151
+
152
+ const rows = query(
153
+ `SELECT id, cwd, title, preview, model, tokens_used, created_at_ms, updated_at_ms, ` +
154
+ `(SELECT count(*) FROM thread_spawn_edges e WHERE e.parent_thread_id = threads.id AND e.status = 'open') AS open_children ` +
155
+ `FROM threads ORDER BY updated_at_ms ASC`
156
+ );
157
+
158
+ const lifetime = { out: 0, in: 0, cr: 0, cc: 0, tools: 0, msgs: 0, agents: 0, sessions: 0 };
159
+ const dayMap = {};
160
+ const byModel = {};
161
+ const byProject = {};
162
+
163
+ for (const row of rows) {
164
+ const out = Number(row.tokens_used || 0);
165
+ const startedAt = row.created_at_ms || row.updated_at_ms || null;
166
+ const day = startedAt ? localDay(startedAt) : null;
167
+ const model = parseModel(row.model).id || 'unknown';
168
+ const project = row.cwd ? path.basename(row.cwd) : 'unknown';
169
+ const agents = Number(row.open_children || 0);
170
+
171
+ lifetime.out += out;
172
+ lifetime.msgs += 1;
173
+ lifetime.agents += agents;
174
+ lifetime.sessions += 1;
175
+
176
+ if (day) {
177
+ const d = (dayMap[day] ||= emptyDay());
178
+ d.out += out;
179
+ d.msgs += 1;
180
+ d.agents += agents;
181
+ }
182
+
183
+ if (!byModel[model]) byModel[model] = { out: 0, in: 0, msgs: 0 };
184
+ byModel[model].out += out;
185
+ byModel[model].msgs += 1;
186
+
187
+ const p = (byProject[project] ||= { out: 0, msgs: 0, tools: 0, agents: 0, sessions: 0, lastTs: null });
188
+ p.out += out;
189
+ p.msgs += 1;
190
+ p.agents += agents;
191
+ p.sessions += 1;
192
+ if (row.updated_at_ms && (!p.lastTs || row.updated_at_ms > p.lastTs)) p.lastTs = row.updated_at_ms;
193
+ }
194
+
195
+ const daily = Object.entries(dayMap)
196
+ .map(([date, v]) => ({ date, ...v }))
197
+ .sort((a, b) => (a.date < b.date ? -1 : 1));
198
+
199
+ const today = new Date().toLocaleDateString('en-CA');
200
+ const todayStats = dayMap[today] ? { date: today, ...dayMap[today] } : { date: today, ...emptyDay() };
201
+
202
+ return {
203
+ lifetime,
204
+ today: todayStats,
205
+ daily,
206
+ byModel,
207
+ byProject,
208
+ firstDay: daily.length ? daily[0].date : null,
209
+ activeDays: daily.length,
210
+ };
211
+ }
package/lib/control.js ADDED
@@ -0,0 +1,168 @@
1
+ // control.js β€” in-memory registry of Claude Code agents paused on a Stop hook,
2
+ // waiting for a typed reply from the dashboard.
3
+ //
4
+ // THE MECHANISM: a Claude Code Stop hook of type "http" BLOCKS the agent until
5
+ // our server responds, and FAILS OPEN (timeout / refused / non-2xx β†’ the agent
6
+ // just stops normally). server.js holds that connection open and registers the
7
+ // pending request here, keyed by session_id. When the user types a reply in the
8
+ // chat panel (POST /api/reply), we resolve(sessionId, text) β†’ server sends the
9
+ // decision:"block" JSON that resumes the agent with the user's instruction. If
10
+ // no reply arrives before the soft deadline, we resolve with null β†’ server
11
+ // sends an empty 200 and the agent stops normally (staying under the 120s hook
12
+ // timeout). This module owns ONLY in-memory state β€” no persistence, no fs writes
13
+ // (the app's read-only charter: the hook endpoint is a control surface, like
14
+ // /api/resume, not a writer of ~/.claude data).
15
+
16
+ // sessionId -> { cwd, transcriptPath, since, timer, settle }
17
+ const pending = new Map();
18
+
19
+ // Soft deadline: resolve the held connection with null (β†’ empty 200, agent
20
+ // stops) before the hook's own timeout fires. The default Stop hook timeout we
21
+ // install is 120s; 110s leaves headroom so WE decide the outcome, not the CLI.
22
+ export const DEADLINE_MS = 110_000;
23
+
24
+ // Register a paused agent. `settle(text)` is the server's resolver for the held
25
+ // response: settle(string) resumes the agent with that instruction; settle(null)
26
+ // lets it stop. A second register() for the same session supersedes the first
27
+ // (settles the old one as a timeout) β€” the latest Stop is the live one.
28
+ export function register(sessionId, { cwd, transcriptPath, settle }, deadlineMs = DEADLINE_MS) {
29
+ if (!sessionId || typeof settle !== 'function') return;
30
+ // Supersede any existing entry for this session.
31
+ const prior = pending.get(sessionId);
32
+ if (prior) {
33
+ clearTimeout(prior.timer);
34
+ pending.delete(sessionId);
35
+ try {
36
+ prior.settle(null); // let the stale hold stop normally
37
+ } catch {
38
+ /* ignore */
39
+ }
40
+ }
41
+ const entry = {
42
+ cwd: cwd || null,
43
+ transcriptPath: transcriptPath || null,
44
+ since: Date.now(),
45
+ settle,
46
+ timer: null,
47
+ };
48
+ entry.timer = setTimeout(() => {
49
+ // Deadline hit with no reply: drop the entry and let the agent stop.
50
+ if (pending.get(sessionId) === entry) pending.delete(sessionId);
51
+ try {
52
+ settle(null);
53
+ } catch {
54
+ /* ignore */
55
+ }
56
+ }, deadlineMs);
57
+ // Let the process exit even if a hold is outstanding (don't keep Node alive).
58
+ if (entry.timer.unref) entry.timer.unref();
59
+ pending.set(sessionId, entry);
60
+ }
61
+
62
+ // Resolve a pending request with the user's reply text. `entry.settle` returns
63
+ // true only if it actually delivered (the held socket was still open); a hold
64
+ // whose client already hung up returns false, so a late reply is reported as
65
+ // "not delivered" rather than a false success. Returns true iff the reply was
66
+ // delivered to a live hold.
67
+ export function resolve(sessionId, text) {
68
+ const entry = pending.get(sessionId);
69
+ if (!entry) return false;
70
+ clearTimeout(entry.timer);
71
+ pending.delete(sessionId);
72
+ try {
73
+ return entry.settle(String(text == null ? '' : text)) === true;
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
78
+
79
+ // Drop a pending entry without resuming the agent β€” used when the held hook
80
+ // connection closes (agent killed / its hook timed out). Entry-scoped: only
81
+ // removes the entry if `settle` still matches the current one, so a stale
82
+ // connection closing after it was superseded can't delete the new hold.
83
+ export function cancel(sessionId, settle) {
84
+ const entry = pending.get(sessionId);
85
+ if (!entry || (settle && entry.settle !== settle)) return false;
86
+ clearTimeout(entry.timer);
87
+ pending.delete(sessionId);
88
+ return true;
89
+ }
90
+
91
+ // Snapshot of who is currently waiting, for buildState() to fold into the live
92
+ // agents (awaitingReply / pendingSince). Returns a Map-like plain object keyed
93
+ // by sessionId so the caller can look up by agent id.
94
+ export function list() {
95
+ const out = {};
96
+ for (const [sessionId, e] of pending) {
97
+ out[sessionId] = { cwd: e.cwd, transcriptPath: e.transcriptPath, since: e.since };
98
+ }
99
+ return out;
100
+ }
101
+
102
+ // Is a given session currently paused & waiting? (cheap point lookup)
103
+ export function isPending(sessionId) {
104
+ return pending.has(sessionId);
105
+ }
106
+
107
+ // ---- self-check (node lib/control.js) -------------------------------------
108
+ // Tiny asserts exercising register→resolve (correct decision text) and the
109
+ // deadline path. No framework β€” just throws on failure, prints OK on success.
110
+ if (import.meta.url === `file://${process.argv[1]}`) {
111
+ const assert = (cond, msg) => {
112
+ if (!cond) throw new Error('control self-check FAILED: ' + msg);
113
+ };
114
+
115
+ // A live-hold stub: records the text it was settled with and returns true
116
+ // (mimics server.js settle writing to an open socket).
117
+ const liveHold = (sink) => (t) => { sink.text = t; return true; };
118
+
119
+ // 1) register β†’ resolve returns true and delivers the user's text.
120
+ const a = {};
121
+ register('sess-A', { cwd: '/tmp', transcriptPath: '/t.jsonl', settle: liveHold(a) });
122
+ assert(isPending('sess-A'), 'sess-A should be pending after register');
123
+ assert(list()['sess-A'], 'list() should include sess-A');
124
+ const hit = resolve('sess-A', 'please run the tests');
125
+ assert(hit === true, 'resolve should return true for a delivered reply');
126
+ assert(a.text === 'please run the tests', 'settle should receive the reply text, got: ' + a.text);
127
+ assert(!isPending('sess-A'), 'sess-A should be cleared after resolve');
128
+
129
+ // 2) resolve with no pending entry returns false.
130
+ assert(resolve('nope', 'x') === false, 'resolve of unknown session should be false');
131
+
132
+ // 3) a dead hold (settle returns false) makes resolve report not-delivered.
133
+ register('sess-D', { cwd: '/tmp', settle: () => false });
134
+ assert(resolve('sess-D', 'late') === false, 'resolve of a dead hold should be false');
135
+
136
+ // 4) cancel drops the entry (entry-scoped) without resuming.
137
+ const c = {};
138
+ const cSettle = liveHold(c);
139
+ register('sess-E', { cwd: '/tmp', settle: cSettle });
140
+ assert(cancel('sess-E', cSettle) === true, 'cancel should drop the matching entry');
141
+ assert(!isPending('sess-E'), 'sess-E should be gone after cancel');
142
+ // cancel with a non-matching settle must NOT delete a re-registered entry.
143
+ const e2 = {};
144
+ register('sess-E', { cwd: '/tmp', settle: liveHold(e2) });
145
+ assert(cancel('sess-E', cSettle) === false, 'stale cancel must not drop a superseding hold');
146
+ assert(isPending('sess-E'), 'superseding hold should survive a stale cancel');
147
+ resolve('sess-E', 'ok'); // tidy up
148
+
149
+ // 5) deadline path: a short deadline settles with null and clears the entry.
150
+ const b = { text: 'unset' };
151
+ register('sess-B', { cwd: '/tmp', settle: (t) => { b.text = t; return true; } }, 20);
152
+ setTimeout(() => {
153
+ assert(b.text === null, 'deadline should settle with null, got: ' + b.text);
154
+ assert(!isPending('sess-B'), 'sess-B should be cleared after deadline');
155
+
156
+ // 6) superseding: a second register settles the first as a timeout (null)
157
+ // and the new one resolves with text.
158
+ const f = { text: 'unset' }, s = { text: 'unset' };
159
+ register('sess-C', { cwd: '/tmp', settle: (t) => { f.text = t; return true; } });
160
+ register('sess-C', { cwd: '/tmp', settle: liveHold(s) });
161
+ assert(f.text === null, 'superseded hold should settle with null, got: ' + f.text);
162
+ resolve('sess-C', 'go');
163
+ assert(s.text === 'go', 'new hold should resolve with text, got: ' + s.text);
164
+
165
+ console.log('control.js self-check OK');
166
+ process.exit(0);
167
+ }, 60);
168
+ }