@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
@@ -0,0 +1,60 @@
1
+ // animsheet.mjs — a film-strip of every character & pet ANIMATION to
2
+ // docs/animations.png: one row per animation, columns = consecutive frames left
3
+ // to right (read a row like a flip-book). Static poses only — to watch them move,
4
+ // run `npm start` and open /lab.html. Some rows jump to a specific frame window so
5
+ // a given idle fidget (which fires on a seed-staggered cadence) is guaranteed to
6
+ // show; the console prints the row legend.
7
+ //
8
+ // node scripts/animsheet.mjs
9
+ import { writeFileSync } from 'node:fs';
10
+ import { Ctx, encodePNG, upscale, selfCheckPNG } from './_pixpng.mjs';
11
+ import { drawPerson, drawWalker, drawCat, drawDog } from '../public/sprites.js';
12
+
13
+ selfCheckPNG();
14
+
15
+ // idle fidgets fire when (frame + seed*37) % 116 < 22, cycling kind 0→1→2 each
16
+ // period. seed 0 → window starts at frame 0 (kind 0), 116 (kind 1), 232 (kind 2).
17
+ const clips = [
18
+ { name: 'seated · typing', kind: 'person', seed: 7, act: 'working', f: (i) => i },
19
+ { name: 'seated · sip coffee', kind: 'person', seed: 0, act: 'idle', f: (i) => i * 3 },
20
+ { name: 'seated · stretch', kind: 'person', seed: 0, act: 'idle', f: (i) => 116 + i * 3 },
21
+ { name: 'seated · lean back', kind: 'person', seed: 0, act: 'idle', f: (i) => 232 + i * 3 },
22
+ { name: 'seated · wave (done)',kind: 'person', seed: 3, act: 'idle', unread: true, f: (i) => i },
23
+ { name: 'walk cycle', kind: 'walker', seed: 5, walking: true, f: (i) => i },
24
+ { name: 'idle · check phone', kind: 'walker', seed: 0, walking: false, f: (i) => i * 3 },
25
+ { name: 'idle · stretch', kind: 'walker', seed: 0, walking: false, f: (i) => 116 + i * 3 },
26
+ { name: 'cat · walk', kind: 'cat', mode: 'walk', f: (i) => i * 2 },
27
+ { name: 'cat · groom', kind: 'cat', mode: 'sit', f: (i) => i },
28
+ { name: 'cat · sleep', kind: 'cat', mode: 'sleep', f: (i) => i * 2 },
29
+ { name: 'cat · petted', kind: 'cat', mode: 'pet', f: (i) => i },
30
+ { name: 'dog · pant/look', kind: 'dog', mode: 'idle', f: (i) => 6 + i },
31
+ { name: 'dog · yawn', kind: 'dog', mode: 'yawn', f: (i) => 99 + i },
32
+ { name: 'dog · petted', kind: 'dog', mode: 'pet', f: (i) => i },
33
+ ];
34
+
35
+ const FR = 8, CW = 30, CH = 46, M = 12, SCALE = 5, BG = '#1b1e26';
36
+ const W = M * 2 + FR * CW, H = M * 2 + clips.length * CH;
37
+ const ctx = new Ctx(W, H, BG);
38
+
39
+ function render(clip, cx, top, bottom, frame) {
40
+ if (clip.kind === 'person') drawPerson(ctx, cx, top + 9, { seed: clip.seed, activity: clip.act, frame, unread: !!clip.unread });
41
+ else if (clip.kind === 'walker') drawWalker(ctx, cx, bottom - 5, { seed: clip.seed, frame, walking: clip.walking });
42
+ else if (clip.kind === 'cat') {
43
+ const o = clip.mode === 'sleep' ? { sleeping: true } : clip.mode === 'pet' ? { petted: true } : clip.mode === 'walk' ? { walking: true } : {};
44
+ drawCat(ctx, cx - 6, bottom - 5, frame, 1, o);
45
+ } else drawDog(ctx, cx - 9, bottom - 5, { frame, petted: clip.mode === 'pet' });
46
+ }
47
+
48
+ clips.forEach((clip, r) => {
49
+ const top = M + r * CH, bottom = top + CH;
50
+ // pets live on the warm office floor and the cat is near-black — give the pet
51
+ // rows a floor band so they read (workers stay on dark so their torsos fade out).
52
+ if (clip.kind === 'cat' || clip.kind === 'dog') { ctx.fillStyle = '#a8814e'; ctx.fillRect(M, top, W - M * 2, CH); }
53
+ for (let i = 0; i < FR; i++) render(clip, M + i * CW + CW / 2, top, bottom, clip.f(i));
54
+ if (r) { ctx.fillStyle = 'rgba(255,255,255,0.07)'; ctx.fillRect(M, top, W - M * 2, 1); } // row divider
55
+ });
56
+
57
+ const out = new URL('../docs/animations.png', import.meta.url);
58
+ writeFileSync(out, encodePNG(W * SCALE, H * SCALE, upscale(ctx.buf, W, H, SCALE)));
59
+ console.log(`wrote ${out.pathname} — ${W * SCALE}×${H * SCALE}px, ${clips.length} animations × ${FR} frames`);
60
+ clips.forEach((c, r) => console.log(` row ${r + 1}: ${c.name}`));
@@ -0,0 +1,61 @@
1
+ // charsheet.mjs — render a contact sheet of every procedural character variation
2
+ // to docs/characters.png. Zero deps: the renderers only use fillStyle+fillRect,
3
+ // so scripts/_pixpng.mjs runs them headlessly and dumps a PNG (stdlib zlib).
4
+ // Re-run after tweaking palettes / hair styles / clothing in public/sprites.js.
5
+ //
6
+ // node scripts/charsheet.mjs
7
+ import { writeFileSync } from 'node:fs';
8
+ import { Ctx, encodePNG, upscale, selfCheckPNG } from './_pixpng.mjs';
9
+ import { drawWalker, personLook, SKINS, HAIRS, SHIRTS, HAIR_STYLES, TOPS } from '../public/sprites.js';
10
+
11
+ selfCheckPNG(); // throws if the shim/encoder is broken — fail before writing a bad file
12
+
13
+ // ---- the sheet: three stacked sections, each a centred grid of walkers -------
14
+ const CELL_W = 28, CELL_H = 50, SCALE = 4, BG = '#1b1e26';
15
+ const clean = (l) => ({ ...l, glasses: false, beard: false });
16
+
17
+ // Section 1 — hair styles (cols, each its own palette) × face accessory (rows).
18
+ const hairCols = [];
19
+ for (let c = 0; c < HAIR_STYLES; c++) { const b = clean(personLook(c * 7919 + 13)); b.hairStyle = c; b.top = 0; hairCols.push(b); }
20
+ const faceRows = [(l) => l, (l) => ({ ...l, glasses: true }), (l) => ({ ...l, beard: true }), (l) => ({ ...l, glasses: true, beard: true })];
21
+ const sec1 = faceRows.map((f) => hairCols.map((b) => f(b)));
22
+
23
+ // Section 2 — clothing styles (cols) × colorways (rows): each row is one person
24
+ // wearing every `top`, rows differ in palette so colors vary too.
25
+ const sec2 = [111, 222, 333, 444].map((seed) => {
26
+ const b = clean(personLook(seed));
27
+ return Array.from({ length: TOPS }, (_, t) => ({ ...b, top: t }));
28
+ });
29
+
30
+ // Section 3 — palette sweeps: every skin tone, hair color, shirt color.
31
+ const base = clean(personLook(42)); base.top = 0;
32
+ const sec3 = [
33
+ SKINS.map((skin) => ({ ...base, skin, hairStyle: 0 })),
34
+ HAIRS.map((hair) => ({ ...base, hair, hairStyle: 0 })),
35
+ SHIRTS.map((shirt) => ({ ...base, shirt, hairStyle: 2 })),
36
+ ];
37
+
38
+ const sections = [sec1, sec2, sec3];
39
+ const M = 16, GAP = 22;
40
+ const secW = (s) => Math.max(...s.map((r) => r.length)) * CELL_W;
41
+ const W = M * 2 + Math.max(...sections.map(secW));
42
+ const H = M * 2 + sections.reduce((n, s) => n + s.length * CELL_H, 0) + GAP * (sections.length - 1);
43
+ const ctx = new Ctx(W, H, BG);
44
+
45
+ const place = (look, gridX, col, gridY, row) =>
46
+ drawWalker(ctx, gridX + col * CELL_W + CELL_W / 2, gridY + row * CELL_H + CELL_H - 6, { look, frame: 0, walking: false });
47
+
48
+ let y = M;
49
+ sections.forEach((sec, si) => {
50
+ const gx = M + Math.round((W - M * 2 - secW(sec)) / 2);
51
+ sec.forEach((looks, r) => looks.forEach((l, c) => place(l, gx, c, y, r)));
52
+ y += sec.length * CELL_H;
53
+ if (si < sections.length - 1) { ctx.fillStyle = 'rgba(255,255,255,0.10)'; ctx.fillRect(M, Math.round(y + GAP / 2), W - M * 2, 1); y += GAP; }
54
+ });
55
+
56
+ // ---- encode + write ---------------------------------------------------------
57
+ const out = new URL('../docs/characters.png', import.meta.url);
58
+ const big = upscale(ctx.buf, W, H, SCALE);
59
+ writeFileSync(out, encodePNG(W * SCALE, H * SCALE, big));
60
+ const count = sections.reduce((n, s) => n + s.reduce((m, r) => m + r.length, 0), 0);
61
+ console.log(`wrote ${out.pathname} — ${W * SCALE}×${H * SCALE}px, ${count} characters`);
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env node
2
+ // install-hook.mjs — idempotently merge Agency's global Stop hook into
3
+ // ~/.claude/settings.json so paused Claude Code agents can be answered from the
4
+ // dashboard (Control Phase-1).
5
+ //
6
+ // THE HOOK: a Stop hook of type "http" pointed at Agency's /api/hook/stop. When
7
+ // an agent stops, Claude Code POSTs the hook and BLOCKS until we respond; Agency
8
+ // holds that connection open until you reply (or a soft deadline), then resumes
9
+ // or stops the agent. The hook FAILS OPEN: if Agency isn't running (connection
10
+ // refused) or times out, the agent just stops normally — so installing this is
11
+ // safe even when the dashboard is closed.
12
+ //
13
+ // This is USER OPT-IN. The merge mirrors judge's installer (idempotent: it
14
+ // won't add a second Agency entry if one already exists, and it preserves any
15
+ // other hooks you have). It writes ONLY ~/.claude/settings.json (your hook
16
+ // config) — never the session/usage data Agency reads.
17
+ //
18
+ // Usage:
19
+ // node scripts/install-hook.mjs # install/merge
20
+ // node scripts/install-hook.mjs --uninstall # remove only Agency's entry
21
+ // node scripts/install-hook.mjs --print # show the entry, write nothing
22
+
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+ import os from 'node:os';
26
+
27
+ const PORT = process.env.PORT ? Number(process.env.PORT) : 4313;
28
+ const HOST = process.env.HOST || '127.0.0.1';
29
+ const HOOK_URL = `http://${HOST}:${PORT}/api/hook/stop`;
30
+ const HOOK_TIMEOUT = 120; // seconds — must exceed Agency's ~110s soft deadline
31
+
32
+ const SETTINGS = path.join(os.homedir(), '.claude', 'settings.json');
33
+
34
+ // The hook entry we install. We tag it by its URL path so we can find/replace
35
+ // our own entry on re-runs without disturbing anyone else's Stop hooks.
36
+ const AGENCY_SIG = '/api/hook/stop';
37
+ function agencyEntry() {
38
+ return { hooks: [{ type: 'http', url: HOOK_URL, timeout: HOOK_TIMEOUT }] };
39
+ }
40
+
41
+ // Does this Stop-hook entry belong to Agency? (matches any port/host install.)
42
+ function isAgencyEntry(entry) {
43
+ return (
44
+ entry &&
45
+ Array.isArray(entry.hooks) &&
46
+ entry.hooks.some((h) => h && h.type === 'http' && typeof h.url === 'string' && h.url.includes(AGENCY_SIG))
47
+ );
48
+ }
49
+
50
+ function loadSettings(file) {
51
+ if (!fs.existsSync(file)) return {};
52
+ let raw;
53
+ try {
54
+ raw = fs.readFileSync(file, 'utf8');
55
+ } catch (err) {
56
+ console.error(`Can't read ${file}: ${err.message} — aborting.`);
57
+ process.exit(1);
58
+ }
59
+ try {
60
+ const parsed = JSON.parse(raw);
61
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
62
+ console.error(`${file} is not a JSON object — fix it manually, aborting.`);
63
+ process.exit(1);
64
+ }
65
+ return parsed;
66
+ } catch {
67
+ console.error(`${file} is invalid JSON — fix it manually, aborting.`);
68
+ process.exit(1);
69
+ }
70
+ }
71
+
72
+ function install() {
73
+ const cfg = loadSettings(SETTINGS);
74
+ cfg.hooks = cfg.hooks && typeof cfg.hooks === 'object' && !Array.isArray(cfg.hooks) ? cfg.hooks : {};
75
+ const cur = cfg.hooks.Stop;
76
+ if (cur !== undefined && !Array.isArray(cur)) {
77
+ console.error(`${SETTINGS}: hooks.Stop is not an array — fix it manually, aborting.`);
78
+ process.exit(1);
79
+ }
80
+ cfg.hooks.Stop = Array.isArray(cur) ? cur : [];
81
+
82
+ if (cfg.hooks.Stop.some(isAgencyEntry)) {
83
+ console.log(`Agency Stop hook already wired in ${SETTINGS} (nothing to do).`);
84
+ return;
85
+ }
86
+ cfg.hooks.Stop.push(agencyEntry());
87
+ fs.mkdirSync(path.dirname(SETTINGS), { recursive: true });
88
+ fs.writeFileSync(SETTINGS, JSON.stringify(cfg, null, 2) + '\n');
89
+ console.log(`Wired Agency Stop hook into ${SETTINGS} → ${HOOK_URL}`);
90
+ console.log('Run `npm start`, then run `claude` anywhere — when an agent stops,');
91
+ console.log('answer it from the dashboard (it fails open if Agency is closed).');
92
+ }
93
+
94
+ function uninstall() {
95
+ if (!fs.existsSync(SETTINGS)) {
96
+ console.log(`No settings at ${SETTINGS} — nothing to remove.`);
97
+ return;
98
+ }
99
+ const cfg = loadSettings(SETTINGS);
100
+ if (!cfg.hooks || typeof cfg.hooks !== 'object' || !Array.isArray(cfg.hooks.Stop)) {
101
+ console.log(`No Agency Stop hook in ${SETTINGS}.`);
102
+ return;
103
+ }
104
+ const before = cfg.hooks.Stop.length;
105
+ cfg.hooks.Stop = cfg.hooks.Stop.filter((e) => !isAgencyEntry(e));
106
+ const removed = before - cfg.hooks.Stop.length;
107
+ if (cfg.hooks.Stop.length === 0) delete cfg.hooks.Stop;
108
+ if (cfg.hooks && Object.keys(cfg.hooks).length === 0) delete cfg.hooks;
109
+ fs.writeFileSync(SETTINGS, JSON.stringify(cfg, null, 2) + '\n');
110
+ console.log(removed ? `Removed Agency Stop hook from ${SETTINGS}.` : `No Agency Stop hook in ${SETTINGS}.`);
111
+ }
112
+
113
+ const arg = process.argv[2];
114
+ if (arg === '--print') {
115
+ console.log(JSON.stringify({ hooks: { Stop: [agencyEntry()] } }, null, 2));
116
+ } else if (arg === '--uninstall' || arg === '-u') {
117
+ uninstall();
118
+ } else {
119
+ install();
120
+ }
package/server.js ADDED
@@ -0,0 +1,370 @@
1
+ #!/usr/bin/env node
2
+ // server.js — zero-dependency HTTP server for Agency.
3
+ // Serves the static frontend and a single /api/state endpoint that fuses live
4
+ // running sessions with historical usage stats and stable agent identities.
5
+
6
+ import http from 'node:http';
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { execFile } from 'node:child_process';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { getUsage } from './lib/usage.js';
12
+ import { getLive } from './lib/live.js';
13
+ import { identityFor, overrideFor, setOverride } from './lib/roster.js';
14
+ import { getTranscript } from './lib/transcript.js';
15
+ import * as control from './lib/control.js';
16
+
17
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
+ const PUBLIC = path.join(__dirname, 'public');
19
+ const PORT = process.env.PORT ? Number(process.env.PORT) : 4313;
20
+ const HOST = process.env.HOST || '127.0.0.1';
21
+
22
+ const MIME = {
23
+ '.html': 'text/html; charset=utf-8',
24
+ '.css': 'text/css; charset=utf-8',
25
+ '.js': 'text/javascript; charset=utf-8',
26
+ '.json': 'application/json; charset=utf-8',
27
+ '.svg': 'image/svg+xml',
28
+ '.png': 'image/png',
29
+ '.ico': 'image/x-icon',
30
+ '.mp3': 'audio/mpeg',
31
+ };
32
+
33
+ function buildState() {
34
+ const usage = getUsage();
35
+ const live = getLive();
36
+
37
+ // Which sessions are paused on a Stop hook, waiting for a reply (Control
38
+ // Phase-1). Folded onto matching live agents as awaitingReply / pendingSince
39
+ // so the frontend HUD + render.js "needs you" indicator can read it. The
40
+ // agent shape is otherwise unchanged.
41
+ const waiting = control.list();
42
+
43
+ // Attach a stable identity to each live agent. In-process teammates keep
44
+ // their team-config label + subagent_type as name/title (the roster still
45
+ // supplies a stable avatar palette + hire date), so "cc-internals" reads as
46
+ // itself rather than a roster-minted persona.
47
+ const agents = live.agents.map((a) => {
48
+ const ident = identityFor(a.sessionId, a.model, a.startedAt);
49
+ const w = a.sessionId ? waiting[a.sessionId] : null;
50
+ const ctrl = w ? { awaitingReply: true, pendingSince: w.since } : null;
51
+ // User overrides (rename / hide), persisted per sessionId. A custom name
52
+ // wins over the minted persona AND the teammate label; `hidden` is carried
53
+ // on EVERY agent (default false) so consumers never see undefined.
54
+ const ov = overrideFor(a.sessionId);
55
+ if (a.role === 'teammate') {
56
+ return {
57
+ ...a,
58
+ ...ident,
59
+ ...ctrl,
60
+ name: ov.name || a.teammateName || ident.name,
61
+ title: a.teammateType || ident.title,
62
+ hidden: ov.hidden,
63
+ };
64
+ }
65
+ return { ...a, ...ident, ...ctrl, name: ov.name || ident.name, hidden: ov.hidden };
66
+ });
67
+
68
+ return {
69
+ generatedAt: Date.now(),
70
+ live: { agents, teams: live.teams, now: live.now },
71
+ usage,
72
+ };
73
+ }
74
+
75
+ function sendJSON(res, code, obj) {
76
+ const body = JSON.stringify(obj);
77
+ res.writeHead(code, {
78
+ 'Content-Type': 'application/json; charset=utf-8',
79
+ 'Cache-Control': 'no-store',
80
+ });
81
+ res.end(body);
82
+ }
83
+
84
+ function serveStatic(req, res) {
85
+ let urlPath = decodeURIComponent(req.url.split('?')[0]);
86
+ if (urlPath === '/') urlPath = '/index.html';
87
+ const filePath = path.normalize(path.join(PUBLIC, urlPath));
88
+ if (filePath !== PUBLIC && !filePath.startsWith(PUBLIC + path.sep)) {
89
+ res.writeHead(403);
90
+ res.end('forbidden');
91
+ return;
92
+ }
93
+ fs.readFile(filePath, (err, data) => {
94
+ if (err) {
95
+ res.writeHead(404);
96
+ res.end('not found');
97
+ return;
98
+ }
99
+ const ext = path.extname(filePath);
100
+ res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
101
+ res.end(data);
102
+ });
103
+ }
104
+
105
+ // Open a new Terminal window running `claude --resume <id>` in the agent's cwd.
106
+ // macOS-only (osascript → Terminal.app). ponytail: Terminal.app is hardcoded;
107
+ // swap the AppleScript target if you live in iTerm. sessionId is charset-checked
108
+ // by the caller and cwd is single-quoted, then both layers are escaped for the
109
+ // AppleScript string so a weird path can't break out of it.
110
+ function openResumeTerminal(sessionId, cwd, kind, cb) {
111
+ // A running BACKGROUND agent can't be `--resume`d (the CLI refuses — it's
112
+ // already live); you attach to it via the agent manager. Interactive sessions
113
+ // resume directly. ponytail: `claude agents` is a picker (no per-id attach in
114
+ // this CLI); --cwd scopes it to this project so the agent is easy to find.
115
+ const bg = kind === 'background' || kind === 'bg';
116
+ const inner = bg ? `claude agents --cwd ${shellQuote(cwd)}` : `claude --resume ${sessionId}`;
117
+ const shellCmd = `cd ${shellQuote(cwd)} && ${inner}`;
118
+ const appleScript = `tell application "Terminal"\nactivate\ndo script "${appleEscape(shellCmd)}"\nend tell`;
119
+ execFile('osascript', ['-e', appleScript], (err) => cb(err));
120
+ }
121
+ const shellQuote = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
122
+ const appleEscape = (s) => String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
123
+
124
+ const server = http.createServer((req, res) => {
125
+ try {
126
+ const q = req.url.indexOf('?');
127
+ const pathname = q === -1 ? req.url : req.url.slice(0, q);
128
+ const query = q === -1 ? '' : req.url.slice(q + 1);
129
+ if (pathname === '/api/state') {
130
+ sendJSON(res, 200, buildState());
131
+ return;
132
+ }
133
+ // Read-only peek at the tail of a session's transcript (for the chat panel).
134
+ if (pathname === '/api/transcript') {
135
+ const params = new URLSearchParams(query);
136
+ const sessionId = params.get('sessionId');
137
+ const cwd = params.get('cwd');
138
+ if (!sessionId || !cwd) {
139
+ sendJSON(res, 400, { error: 'sessionId and cwd are required' });
140
+ return;
141
+ }
142
+ sendJSON(res, 200, getTranscript(sessionId, cwd));
143
+ return;
144
+ }
145
+ // ACTION endpoint (the one place Agency acts instead of viewing): open a
146
+ // terminal running `claude --resume <id>` in the agent's cwd. User-authorized;
147
+ // it spawns osascript→Terminal but never writes the ~/.claude data sources.
148
+ if (pathname === '/api/resume' && req.method === 'POST') {
149
+ let body = '';
150
+ req.on('data', (c) => {
151
+ body += c;
152
+ if (body.length > 4096) req.destroy(); // cap — these payloads are tiny
153
+ });
154
+ req.on('end', () => {
155
+ // This async handler fires after the top-level try/catch has returned, so
156
+ // guard it too — a throw here would otherwise crash the process (the app's
157
+ // fail-soft charter: a bad request must never take down the server).
158
+ try {
159
+ let sessionId, cwd, kind;
160
+ try {
161
+ ({ sessionId, cwd, kind } = JSON.parse(body || '{}'));
162
+ } catch {
163
+ return sendJSON(res, 400, { error: 'bad json' });
164
+ }
165
+ // Validate HARD before building any command: sessionId to a safe charset,
166
+ // cwd to an existing absolute path. (cwd is still shell-quoted below.)
167
+ if (!sessionId || !/^[A-Za-z0-9._-]+$/.test(sessionId)) {
168
+ return sendJSON(res, 400, { error: 'invalid sessionId' });
169
+ }
170
+ if (!cwd || typeof cwd !== 'string' || !path.isAbsolute(cwd) || !fs.existsSync(cwd)) {
171
+ return sendJSON(res, 400, { error: 'invalid cwd' });
172
+ }
173
+ openResumeTerminal(sessionId, cwd, kind, (err) =>
174
+ err ? sendJSON(res, 500, { error: String(err.message || err) }) : sendJSON(res, 200, { ok: true })
175
+ );
176
+ } catch (err) {
177
+ sendJSON(res, 500, { error: String(err && err.message ? err.message : err) });
178
+ }
179
+ });
180
+ return;
181
+ }
182
+ // CONTROL endpoint — the Stop-hook landing pad. A Claude Code Stop hook of
183
+ // type "http" POSTs here and BLOCKS the agent until we respond; it FAILS
184
+ // OPEN (timeout / refused / non-2xx → agent just stops). We register the
185
+ // paused session and HOLD this response open: it's resolved either by
186
+ // /api/reply (→ decision:"block" JSON, resuming the agent with the user's
187
+ // instruction) or by the soft deadline (→ empty 200, agent stops normally,
188
+ // under the 120s hook timeout). Authorized control surface, like
189
+ // /api/resume — never writes the ~/.claude data sources.
190
+ if (pathname === '/api/hook/stop' && req.method === 'POST') {
191
+ let body = '';
192
+ req.on('data', (c) => {
193
+ body += c;
194
+ if (body.length > 64 * 1024) req.destroy(); // cap — hook payloads are small
195
+ });
196
+ req.on('end', () => {
197
+ // Fires after the top-level try/catch returns, so guard it too — a
198
+ // throw here would crash the process (fail-soft charter: a bad hook
199
+ // payload must never take down the server / /api/state).
200
+ try {
201
+ let info = {};
202
+ try {
203
+ info = JSON.parse(body || '{}') || {};
204
+ } catch {
205
+ // Malformed payload: fail open — let the agent stop normally.
206
+ return sendJSON(res, 200, {});
207
+ }
208
+ const sessionId = info.session_id;
209
+ // No usable session id: nothing to register against, fail open.
210
+ if (!sessionId || typeof sessionId !== 'string' || !/^[A-Za-z0-9._-]+$/.test(sessionId)) {
211
+ return sendJSON(res, 200, {});
212
+ }
213
+ let settled = false;
214
+ // settle(text): text → resume with the user's instruction; null →
215
+ // empty 200 so the agent stops. Idempotent (timeout vs reply vs close
216
+ // race) and returns true only if it actually wrote to a live socket,
217
+ // so control.resolve can tell a delivered reply from a dropped one.
218
+ const settle = (text) => {
219
+ if (settled) return false;
220
+ settled = true;
221
+ try {
222
+ if (typeof text === 'string') {
223
+ sendJSON(res, 200, {
224
+ decision: 'block',
225
+ reason: text,
226
+ hookSpecificOutput: { hookEventName: 'Stop', additionalContext: text },
227
+ });
228
+ } else {
229
+ sendJSON(res, 200, {});
230
+ }
231
+ return true;
232
+ } catch {
233
+ return false; // connection already gone
234
+ }
235
+ };
236
+ // If the client hangs up while we hold (agent killed, hook timed out
237
+ // on its side), mark this hold settled AND drop the registry entry
238
+ // (entry-scoped, so a superseding hold survives) — otherwise the agent
239
+ // would keep showing awaitingReply and a late /api/reply would falsely
240
+ // report success on a dead socket.
241
+ res.on('close', () => {
242
+ settled = true;
243
+ control.cancel(sessionId, settle);
244
+ });
245
+ control.register(sessionId, {
246
+ cwd: info.cwd,
247
+ transcriptPath: info.transcript_path,
248
+ settle,
249
+ });
250
+ } catch (err) {
251
+ // Last-ditch: fail open so the agent stops rather than hanging.
252
+ try {
253
+ sendJSON(res, 200, {});
254
+ } catch {
255
+ /* ignore */
256
+ }
257
+ }
258
+ });
259
+ return;
260
+ }
261
+ // CONTROL endpoint — deliver the user's typed reply to a paused agent.
262
+ // { sessionId, text }: resolves the matching held /api/hook/stop request
263
+ // (→ resumes the agent with `text`). 404 if no agent is pending for that id.
264
+ if (pathname === '/api/reply' && req.method === 'POST') {
265
+ let body = '';
266
+ req.on('data', (c) => {
267
+ body += c;
268
+ if (body.length > 16 * 1024) req.destroy(); // cap — text is capped to ~8KB below
269
+ });
270
+ req.on('end', () => {
271
+ try {
272
+ let sessionId, text;
273
+ try {
274
+ ({ sessionId, text } = JSON.parse(body || '{}'));
275
+ } catch {
276
+ return sendJSON(res, 400, { error: 'bad json' });
277
+ }
278
+ if (!sessionId || typeof sessionId !== 'string' || !/^[A-Za-z0-9._-]+$/.test(sessionId)) {
279
+ return sendJSON(res, 400, { error: 'invalid sessionId' });
280
+ }
281
+ if (typeof text !== 'string' || !text.trim()) {
282
+ return sendJSON(res, 400, { error: 'text is required' });
283
+ }
284
+ const reply = text.slice(0, 8 * 1024); // cap the instruction length
285
+ const delivered = control.resolve(sessionId, reply);
286
+ if (delivered) sendJSON(res, 200, { ok: true });
287
+ else sendJSON(res, 404, { error: 'no agent waiting for this session' });
288
+ } catch (err) {
289
+ sendJSON(res, 500, { error: String(err && err.message ? err.message : err) });
290
+ }
291
+ });
292
+ return;
293
+ }
294
+ // OVERRIDE endpoint — persist a per-session rename / hide. Authorized like
295
+ // the other POSTs; it writes only data/roster.json (a regenerable cache),
296
+ // never the ~/.claude data sources. Body { sessionId, name?, hidden? }:
297
+ // name '' or null clears the rename; hidden is a boolean toggle. Only the
298
+ // keys present in the body are applied, so a hide-toggle won't wipe a rename.
299
+ if (pathname === '/api/agent-override' && req.method === 'POST') {
300
+ let body = '';
301
+ req.on('data', (c) => {
302
+ body += c;
303
+ if (body.length > 4096) req.destroy(); // cap — these payloads are tiny
304
+ });
305
+ req.on('end', () => {
306
+ // Fires after the top-level try/catch returns, so guard it too — a throw
307
+ // here would crash the process (fail-soft charter: a bad request must
308
+ // never take down the server / /api/state).
309
+ try {
310
+ let info;
311
+ try {
312
+ info = JSON.parse(body || '{}');
313
+ } catch {
314
+ return sendJSON(res, 400, { error: 'bad json' });
315
+ }
316
+ const { sessionId, name, hidden } = info || {};
317
+ if (!sessionId || typeof sessionId !== 'string' || !/^[A-Za-z0-9._-]+$/.test(sessionId)) {
318
+ return sendJSON(res, 400, { error: 'invalid sessionId' }); // match /api/reply's charset guard
319
+ }
320
+ // Pass through only the keys actually present so a hide-toggle doesn't
321
+ // wipe the name and vice-versa. roster.setOverride trims + length-caps
322
+ // + strips control chars from the name; it is never eval'd or exec'd.
323
+ const patch = {};
324
+ if (Object.prototype.hasOwnProperty.call(info, 'name')) patch.name = name;
325
+ if (Object.prototype.hasOwnProperty.call(info, 'hidden')) {
326
+ if (typeof hidden !== 'boolean') {
327
+ return sendJSON(res, 400, { error: 'hidden must be a boolean' });
328
+ }
329
+ patch.hidden = hidden;
330
+ }
331
+ const override = setOverride(sessionId, patch);
332
+ sendJSON(res, 200, { ok: true, override });
333
+ } catch (err) {
334
+ sendJSON(res, 500, { ok: false, error: String(err && err.message ? err.message : err) });
335
+ }
336
+ });
337
+ return;
338
+ }
339
+ serveStatic(req, res);
340
+ } catch (err) {
341
+ sendJSON(res, 500, { error: String(err && err.message ? err.message : err) });
342
+ }
343
+ });
344
+
345
+ // Open the dashboard in the default browser on launch (the `npx` UX). Best
346
+ // effort + fail-silent; skip with AGENCY_NO_OPEN=1 (dev / headless / remote).
347
+ function openBrowser(url) {
348
+ if (process.env.AGENCY_NO_OPEN) return;
349
+ const [cmd, args] =
350
+ process.platform === 'darwin' ? ['open', [url]]
351
+ : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]]
352
+ : ['xdg-open', [url]];
353
+ try {
354
+ execFile(cmd, args, () => {});
355
+ } catch {
356
+ /* no browser opener available — the printed URL still works */
357
+ }
358
+ }
359
+
360
+ server.listen(PORT, HOST, () => {
361
+ // Warm the usage cache so the first request is fast.
362
+ try {
363
+ getUsage();
364
+ } catch {
365
+ /* ignore */
366
+ }
367
+ console.log(`\n 🏢 Agency is open for business.`);
368
+ console.log(` → http://${HOST}:${PORT}\n`);
369
+ openBrowser(`http://${HOST}:${PORT}`);
370
+ });