@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.
- package/README.md +106 -0
- package/lib/codex.js +211 -0
- package/lib/control.js +168 -0
- package/lib/live.js +493 -0
- package/lib/opencode.js +447 -0
- package/lib/paths.js +12 -0
- package/lib/roster.js +204 -0
- package/lib/transcript.js +361 -0
- package/lib/usage.js +346 -0
- package/package.json +27 -0
- package/public/app.js +1021 -0
- package/public/audio-controls.js +165 -0
- package/public/avatar.js +467 -0
- package/public/characters/dev-auburn.json +32 -0
- package/public/characters/dev-auburn.png +0 -0
- package/public/characters/dev-beanie.json +32 -0
- package/public/characters/dev-beanie.png +0 -0
- package/public/characters/dev-glasses.json +32 -0
- package/public/characters/dev-glasses.png +0 -0
- package/public/chat-panel.css +514 -0
- package/public/chat-panel.js +815 -0
- package/public/index.html +190 -0
- package/public/lab.html +129 -0
- package/public/leaderboard.js +222 -0
- package/public/metric.js +34 -0
- package/public/mock-agents.js +70 -0
- package/public/mock.js +277 -0
- package/public/music/Console_Morning.mp3 +0 -0
- package/public/music/Midnight_Desk.mp3 +0 -0
- package/public/music/The_Plant_Beside_the_Door.mp3 +0 -0
- package/public/music/Three_AM_Window.mp3 +0 -0
- package/public/office.js +1484 -0
- package/public/sound.js +382 -0
- package/public/sprites.js +983 -0
- package/public/style.css +506 -0
- package/public/ui.js +50 -0
- package/scripts/_pixpng.mjs +104 -0
- package/scripts/animsheet.mjs +60 -0
- package/scripts/charsheet.mjs +61 -0
- package/scripts/install-hook.mjs +120 -0
- 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
|
+

|
|
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
|
+
}
|