@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
|
@@ -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
|
+
});
|