agent-office-studio 0.0.0-stage → 0.2.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/CHANGELOG.md +57 -0
- package/LICENSE +21 -0
- package/README.md +85 -2
- package/bin/agentoffice.js +38 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/_commonjsHelpers-CqkleIqs.js +1 -0
- package/dist/assets/addon-fit-BwprjgOP.js +1 -0
- package/dist/assets/index-CozPk6R9.js +4457 -0
- package/dist/assets/index-i7sU9SN-.css +32 -0
- package/dist/assets/inter-cyrillic-ext-wght-normal-B2xhLi22.woff2 +0 -0
- package/dist/assets/inter-cyrillic-wght-normal-CMZtQduZ.woff2 +0 -0
- package/dist/assets/inter-greek-ext-wght-normal-CGAr0uHJ.woff2 +0 -0
- package/dist/assets/inter-greek-wght-normal-CaVNZxsx.woff2 +0 -0
- package/dist/assets/inter-latin-ext-wght-normal-CFHvXkgd.woff2 +0 -0
- package/dist/assets/inter-latin-wght-normal-C2S99t-D.woff2 +0 -0
- package/dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- package/dist/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
- package/dist/assets/jetbrains-mono-cyrillic-400-normal-CIEtRijs.woff +0 -0
- package/dist/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
- package/dist/assets/jetbrains-mono-greek-400-normal-CNojz0t3.woff +0 -0
- package/dist/assets/jetbrains-mono-latin-400-normal-B11XCQ5g.woff +0 -0
- package/dist/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
- package/dist/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
- package/dist/assets/jetbrains-mono-latin-ext-400-normal-DHMOLxAi.woff +0 -0
- package/dist/assets/jetbrains-mono-vietnamese-400-normal-BeU3T8wf.woff +0 -0
- package/dist/assets/xterm-C7kOH91j.js +9 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/icon.svg +9 -0
- package/dist/index.html +240 -0
- package/dist/manifest.webmanifest +16 -0
- package/dist/sw.js +39 -0
- package/docs/README.md +26 -0
- package/docs/claude-access.md +31 -0
- package/docs/configuration.md +53 -0
- package/docs/development.md +76 -0
- package/docs/getting-started.md +54 -0
- package/docs/guides/troubleshooting.md +99 -0
- package/docs/guides/updating.md +36 -0
- package/docs/guides/working-with-the-team.md +56 -0
- package/docs/guides/your-first-job.md +65 -0
- package/docs/how-it-works.md +80 -0
- package/docs/images/office.jpg +0 -0
- package/docs/jobs.md +54 -0
- package/docs/leaderboard.md +65 -0
- package/docs/packaging.md +49 -0
- package/docs/phone.md +28 -0
- package/docs/security.md +48 -0
- package/docs/terminal.md +46 -0
- package/docs/using-the-office.md +109 -0
- package/docs/voice.md +44 -0
- package/package.json +64 -4
- package/server/auth.js +89 -0
- package/server/banter.js +53 -0
- package/server/changes.js +72 -0
- package/server/chief.js +130 -0
- package/server/index.js +1952 -0
- package/server/leaderboard.js +190 -0
- package/server/office-mcp.js +101 -0
- package/server/procstat.js +85 -0
- package/server/standup.js +78 -0
- package/server/supervise.js +51 -0
- package/server/terminal.js +255 -0
- package/server/tools.js +52 -0
- package/server/usage.js +40 -0
- package/server/voice.js +172 -0
- package/shared/speakable.js +50 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// The leaderboard, from this machine's side.
|
|
2
|
+
//
|
|
3
|
+
// Two things live here. The ledger always runs: per UTC day, how many jobs
|
|
4
|
+
// finished, how many tokens were used and how long the agents spent working, kept in data/activity.json so it
|
|
5
|
+
// survives clearing the job list. Sharing is separate and off until the boss
|
|
6
|
+
// joins: then the last two weeks of those counters, under a claimed handle,
|
|
7
|
+
// go to the leaderboard service. Nothing else is ever sent, no prompts, repos,
|
|
8
|
+
// file names, chat or cost. See docs/leaderboard.md.
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import crypto from 'node:crypto';
|
|
13
|
+
|
|
14
|
+
// Where the hosted service lives. OFFICE_LEADERBOARD_URL overrides it.
|
|
15
|
+
const DEFAULT_URL = '';
|
|
16
|
+
const WINDOW_DAYS = 14;
|
|
17
|
+
const DEBOUNCE_MS = 45 * 1000;
|
|
18
|
+
const RETRY_MS = 10 * 60 * 1000;
|
|
19
|
+
const HANDLE_RE = /^[a-z0-9][a-z0-9_-]{2,19}$/i;
|
|
20
|
+
|
|
21
|
+
const utcDay = (ms = Date.now()) => new Date(ms).toISOString().slice(0, 10);
|
|
22
|
+
const fail = (code, message) => Object.assign(new Error(message), { code });
|
|
23
|
+
|
|
24
|
+
function writeAtomic(file, data, mode) {
|
|
25
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
26
|
+
const tmp = file + '.tmp';
|
|
27
|
+
fs.writeFileSync(tmp, JSON.stringify(data), { mode });
|
|
28
|
+
fs.renameSync(tmp, file);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class Leaderboard {
|
|
32
|
+
constructor(dataDir, { onChange = () => {} } = {}) {
|
|
33
|
+
this.ledgerFile = path.join(dataDir, 'activity.json');
|
|
34
|
+
this.idFile = path.join(dataDir, 'leaderboard.json');
|
|
35
|
+
this.onChange = onChange;
|
|
36
|
+
this.days = {};
|
|
37
|
+
try { this.days = JSON.parse(fs.readFileSync(this.ledgerFile, 'utf8')).days || {}; } catch {}
|
|
38
|
+
this.me = {};
|
|
39
|
+
try { this.me = JSON.parse(fs.readFileSync(this.idFile, 'utf8')); } catch {}
|
|
40
|
+
this.error = null;
|
|
41
|
+
this.lastSync = this.me.lastSync || 0;
|
|
42
|
+
this.dirty = !!this.me.secret;
|
|
43
|
+
this.timer = null;
|
|
44
|
+
this.saveTimer = null;
|
|
45
|
+
this.busy = false;
|
|
46
|
+
if (this.dirty) this.later(15 * 1000);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
get url() { return (process.env.OFFICE_LEADERBOARD_URL || DEFAULT_URL).replace(/\/+$/, ''); }
|
|
50
|
+
get joined() { return !!(this.me.secret && this.me.handle); }
|
|
51
|
+
|
|
52
|
+
status() {
|
|
53
|
+
return {
|
|
54
|
+
configured: !!this.url,
|
|
55
|
+
joined: this.joined,
|
|
56
|
+
handle: this.me.handle || null,
|
|
57
|
+
lastSync: this.lastSync || null,
|
|
58
|
+
error: this.error,
|
|
59
|
+
today: this.total(1),
|
|
60
|
+
week: this.total(7),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Counters over the last `n` UTC days, today included.
|
|
65
|
+
total(n) {
|
|
66
|
+
const out = { jobs: 0, tokens: 0, seconds: 0, activeDays: 0 };
|
|
67
|
+
for (let i = 0; i < n; i++) {
|
|
68
|
+
const d = this.days[utcDay(Date.now() - i * 86_400_000)];
|
|
69
|
+
if (!d) continue;
|
|
70
|
+
out.jobs += d.jobs;
|
|
71
|
+
out.tokens += d.tokens;
|
|
72
|
+
out.seconds += Math.round(d.seconds || 0);
|
|
73
|
+
if (d.jobs > 0) out.activeDays++;
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------- the ledger ----------
|
|
79
|
+
|
|
80
|
+
bump(field, n) {
|
|
81
|
+
if (!(n > 0)) return;
|
|
82
|
+
const key = utcDay();
|
|
83
|
+
const d = (this.days[key] ||= { jobs: 0, tokens: 0, seconds: 0 });
|
|
84
|
+
d[field] ||= 0;
|
|
85
|
+
d[field] += n; // seconds stay fractional so short turns add up; they are rounded on the way out
|
|
86
|
+
clearTimeout(this.saveTimer);
|
|
87
|
+
this.saveTimer = setTimeout(() => this.flush(), 2000);
|
|
88
|
+
if (this.joined) { this.dirty = true; this.later(DEBOUNCE_MS); }
|
|
89
|
+
}
|
|
90
|
+
// Input plus output tokens. Cached context is left out: it is the same
|
|
91
|
+
// tokens read again, and counting it would only reward long sessions.
|
|
92
|
+
addTokens(n) { this.bump('tokens', n); }
|
|
93
|
+
addJob() { this.bump('jobs', 1); }
|
|
94
|
+
// Agent time: the time agents spent on turns, summed. Two agents working
|
|
95
|
+
// side by side for an hour is two hours; waiting on someone isn't counted.
|
|
96
|
+
addTime(ms) { this.bump('seconds', ms / 1000); }
|
|
97
|
+
|
|
98
|
+
flush() {
|
|
99
|
+
clearTimeout(this.saveTimer);
|
|
100
|
+
writeAtomic(this.ledgerFile, { days: this.days });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------- sharing ----------
|
|
104
|
+
|
|
105
|
+
later(ms) {
|
|
106
|
+
clearTimeout(this.timer);
|
|
107
|
+
this.timer = setTimeout(() => this.sync().catch(() => {}), ms);
|
|
108
|
+
this.timer.unref?.();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async call(method, route, { body, secret = this.me.secret } = {}) {
|
|
112
|
+
if (!this.url) throw fail(400, 'No leaderboard is set up for this office yet. Set OFFICE_LEADERBOARD_URL.');
|
|
113
|
+
if (!/^https:\/\//.test(this.url) && !/^http:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/.test(this.url)) throw fail(400, 'The leaderboard address must be https.');
|
|
114
|
+
let res;
|
|
115
|
+
try {
|
|
116
|
+
res = await fetch(this.url + route, {
|
|
117
|
+
method,
|
|
118
|
+
headers: { 'content-type': 'application/json', ...(secret ? { authorization: `Bearer ${secret}` } : {}) },
|
|
119
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
120
|
+
signal: AbortSignal.timeout(10000),
|
|
121
|
+
});
|
|
122
|
+
} catch {
|
|
123
|
+
throw fail(502, 'Couldn’t reach the leaderboard.');
|
|
124
|
+
}
|
|
125
|
+
const out = await res.json().catch(() => ({}));
|
|
126
|
+
if (!res.ok) throw fail(res.status, out.error || `The leaderboard answered ${res.status}.`);
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async join(handle) {
|
|
131
|
+
handle = String(handle ?? '').trim();
|
|
132
|
+
if (!HANDLE_RE.test(handle)) throw fail(400, 'A handle is 3 to 20 letters, numbers, - or _.');
|
|
133
|
+
// A secret made once and kept: if the answer to a claim is lost, asking
|
|
134
|
+
// again with the same secret is safe.
|
|
135
|
+
const secret = this.me.secret || crypto.randomBytes(32).toString('hex');
|
|
136
|
+
const out = await this.call('POST', '/v1/claim', { body: { handle }, secret });
|
|
137
|
+
this.me = { ...this.me, secret, handle: out.handle, lastSync: 0 };
|
|
138
|
+
writeAtomic(this.idFile, this.me, 0o600);
|
|
139
|
+
this.error = null;
|
|
140
|
+
this.dirty = true;
|
|
141
|
+
this.later(500);
|
|
142
|
+
return this.status();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async sync() {
|
|
146
|
+
if (!this.joined || !this.dirty || this.busy) return;
|
|
147
|
+
this.busy = true;
|
|
148
|
+
try {
|
|
149
|
+
const since = utcDay(Date.now() - WINDOW_DAYS * 86_400_000);
|
|
150
|
+
const days = Object.entries(this.days)
|
|
151
|
+
.filter(([day]) => day >= since)
|
|
152
|
+
.map(([day, d]) => ({ day, jobs: d.jobs, tokens: d.tokens, seconds: Math.round(d.seconds || 0) }));
|
|
153
|
+
if (days.length) await this.call('PUT', '/v1/days', { body: { days } });
|
|
154
|
+
this.dirty = false;
|
|
155
|
+
this.error = null;
|
|
156
|
+
this.lastSync = Date.now();
|
|
157
|
+
this.me.lastSync = this.lastSync;
|
|
158
|
+
writeAtomic(this.idFile, this.me, 0o600);
|
|
159
|
+
} catch (err) {
|
|
160
|
+
this.error = err.message;
|
|
161
|
+
// Too fast or the network is down: try again in a while. A refusal that
|
|
162
|
+
// won't change (401) is left for the boss to see rather than hammered.
|
|
163
|
+
if (err.code !== 401 && err.code !== 400) this.later(err.code === 429 ? DEBOUNCE_MS : RETRY_MS);
|
|
164
|
+
} finally {
|
|
165
|
+
this.busy = false;
|
|
166
|
+
this.onChange();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Stops sharing and asks the service to forget this office. If it can't be
|
|
171
|
+
// reached nothing local changes, so the boss can try again rather than be
|
|
172
|
+
// left believing it is gone.
|
|
173
|
+
async leave() {
|
|
174
|
+
if (this.joined) {
|
|
175
|
+
try { await this.call('DELETE', '/v1/me'); }
|
|
176
|
+
catch (err) { if (err.code !== 401) throw err; } // 401: the service has already forgotten it
|
|
177
|
+
}
|
|
178
|
+
clearTimeout(this.timer);
|
|
179
|
+
this.me = {};
|
|
180
|
+
try { fs.rmSync(this.idFile, { force: true }); } catch {}
|
|
181
|
+
this.dirty = false;
|
|
182
|
+
this.error = null;
|
|
183
|
+
this.lastSync = 0;
|
|
184
|
+
return this.status();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async board(period) {
|
|
188
|
+
return this.call('GET', `/v1/board?period=${period === 'all' ? 'all' : 'week'}`, { secret: null });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// A tiny MCP server (stdio, JSON-RPC) that gives an agent the office: who's
|
|
2
|
+
// around, messaging colleagues, handing work off, and the chat channels. It
|
|
3
|
+
// just relays to the office server, which knows which job is calling from
|
|
4
|
+
// OFFICE_JOB/OFFICE_TOKEN.
|
|
5
|
+
|
|
6
|
+
import readline from 'node:readline';
|
|
7
|
+
|
|
8
|
+
const URL_BASE = process.env.OFFICE_URL || 'http://127.0.0.1:4317';
|
|
9
|
+
const JOB = process.env.OFFICE_JOB;
|
|
10
|
+
const TOKEN = process.env.OFFICE_TOKEN;
|
|
11
|
+
|
|
12
|
+
const NONE = { type: 'object', properties: {}, additionalProperties: false };
|
|
13
|
+
const obj = (properties, required) => ({ type: 'object', properties, required, additionalProperties: false });
|
|
14
|
+
const names = { type: 'array', items: { type: 'string' } };
|
|
15
|
+
|
|
16
|
+
const TOOLS = [
|
|
17
|
+
{ name: 'office_roster', description: 'Who is in the office and what each is working on, or free.', inputSchema: NONE },
|
|
18
|
+
{
|
|
19
|
+
name: 'message_colleague',
|
|
20
|
+
description: 'Short message to a colleague. Reaches them mid-task, or reopens their last job if finished within hours. wait_for_reply pauses you until they answer (10 min max); use only when blocked.',
|
|
21
|
+
inputSchema: obj({ to: { type: 'string' }, message: { type: 'string' }, wait_for_reply: { type: 'boolean' } }, ['to', 'message']),
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: 'hand_off',
|
|
25
|
+
description: 'Give work to a free colleague, in this or another project. Their answer returns as a message. wait=false to carry on meanwhile. Fails if nobody is free.',
|
|
26
|
+
inputSchema: obj({
|
|
27
|
+
project: { type: 'string', description: 'Folder name, per office_roster' },
|
|
28
|
+
brief: { type: 'string', description: 'Everything they need, self-contained' },
|
|
29
|
+
to: { type: 'string', description: 'Omit for whoever is free' },
|
|
30
|
+
wait: { type: 'boolean' },
|
|
31
|
+
}, ['project', 'brief']),
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: 'flag_for_boss',
|
|
35
|
+
description: 'Surface something in the main chat for the boss: a question, decision, blocker, or work ready to review. Not for routine progress. One or two self-contained sentences.',
|
|
36
|
+
inputSchema: obj({ kind: { type: 'string', enum: ['question', 'decision', 'blocker', 'review'] }, summary: { type: 'string' } }, ['kind', 'summary']),
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: 'restart_office',
|
|
40
|
+
description: 'Restart the office server so changes to its own code (agent-office/server) take effect. Waits until nobody is mid-turn, so end your turn right after; you are messaged when it is back. Not needed for web changes: a build is enough.',
|
|
41
|
+
inputSchema: obj({ now: { type: 'boolean', description: 'Skip the wait and interrupt anyone mid-turn. Only if asked to.' } }, []),
|
|
42
|
+
},
|
|
43
|
+
{ name: 'list_channels', description: 'List chat channels: purpose, members, and which one your work is in.', inputSchema: NONE },
|
|
44
|
+
{
|
|
45
|
+
name: 'create_channel',
|
|
46
|
+
description: 'Open a channel for a distinct strand of work (release, incident, long project) and invite colleagues. Everyday talk stays in #general.',
|
|
47
|
+
inputSchema: obj({ name: { type: 'string' }, purpose: { type: 'string' }, invite: names }, ['name']),
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'invite_to_channel',
|
|
51
|
+
description: 'Add colleagues to a channel; they see it from now on, not earlier history.',
|
|
52
|
+
inputSchema: obj({ channel: { type: 'string' }, who: names }, ['channel', 'who']),
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'post_to_channel',
|
|
56
|
+
description: 'Say something to everyone in a channel (you join it). Working members get it at once. For one person use message_colleague.',
|
|
57
|
+
inputSchema: obj({ channel: { type: 'string' }, message: { type: 'string' } }, ['channel', 'message']),
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
const out = (msg) => process.stdout.write(JSON.stringify(msg) + '\n');
|
|
62
|
+
|
|
63
|
+
async function call(name, args) {
|
|
64
|
+
const res = await fetch(`${URL_BASE}/api/agent/${name}`, {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: { 'content-type': 'application/json', 'x-office-job': JOB, 'x-office-token': TOKEN },
|
|
67
|
+
body: JSON.stringify(args || {}),
|
|
68
|
+
});
|
|
69
|
+
const data = await res.json().catch(() => ({}));
|
|
70
|
+
return { text: data.text || data.error || `Office error ${res.status}`, isError: !res.ok };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const rl = readline.createInterface({ input: process.stdin });
|
|
74
|
+
rl.on('line', async (line) => {
|
|
75
|
+
let msg;
|
|
76
|
+
try { msg = JSON.parse(line); } catch { return; }
|
|
77
|
+
const { id, method, params } = msg;
|
|
78
|
+
if (id === undefined) return; // notifications need no answer
|
|
79
|
+
try {
|
|
80
|
+
if (method === 'initialize') {
|
|
81
|
+
out({ jsonrpc: '2.0', id, result: {
|
|
82
|
+
protocolVersion: params?.protocolVersion || '2025-06-18',
|
|
83
|
+
capabilities: { tools: {} },
|
|
84
|
+
serverInfo: { name: 'office', version: '1.0.0' },
|
|
85
|
+
} });
|
|
86
|
+
} else if (method === 'tools/list') {
|
|
87
|
+
out({ jsonrpc: '2.0', id, result: { tools: TOOLS } });
|
|
88
|
+
} else if (method === 'tools/call') {
|
|
89
|
+
const tool = TOOLS.find((t) => t.name === params?.name);
|
|
90
|
+
if (!tool) throw new Error(`Unknown tool ${params?.name}`);
|
|
91
|
+
const r = await call(tool.name, params.arguments);
|
|
92
|
+
out({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: r.text }], isError: r.isError } });
|
|
93
|
+
} else if (method === 'ping') {
|
|
94
|
+
out({ jsonrpc: '2.0', id, result: {} });
|
|
95
|
+
} else {
|
|
96
|
+
out({ jsonrpc: '2.0', id, error: { code: -32601, message: `No method ${method}` } });
|
|
97
|
+
}
|
|
98
|
+
} catch (err) {
|
|
99
|
+
out({ jsonrpc: '2.0', id, error: { code: -32000, message: err.message } });
|
|
100
|
+
}
|
|
101
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// What each agent is costing this machine: CPU and memory for the whole
|
|
2
|
+
// process tree under its `claude` process — the agent itself, its MCP
|
|
3
|
+
// servers, and anything a tool it ran is still doing.
|
|
4
|
+
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import { execFile } from 'node:child_process';
|
|
7
|
+
|
|
8
|
+
// The user's PATH may shadow ps with something else; ask for the real one.
|
|
9
|
+
const PS = '/bin/ps';
|
|
10
|
+
|
|
11
|
+
// "45:01.95", "1:02:03.45" and "2-01:02:03" all mean seconds here.
|
|
12
|
+
function cpuSeconds(s) {
|
|
13
|
+
const [days, rest] = s.includes('-') ? s.split('-') : [0, s];
|
|
14
|
+
return Number(days) * 86400 + rest.split(':').reduce((a, p) => a * 60 + parseFloat(p), 0);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function ps() {
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
execFile(PS, ['-axo', 'pid=,ppid=,rss=,cputime='], { timeout: 4000, maxBuffer: 8 << 20 }, (err, out) => {
|
|
20
|
+
if (err) return resolve(null);
|
|
21
|
+
const procs = new Map();
|
|
22
|
+
const kids = new Map();
|
|
23
|
+
for (const line of out.split('\n')) {
|
|
24
|
+
const m = line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+(\S+)$/);
|
|
25
|
+
if (!m) continue;
|
|
26
|
+
const pid = Number(m[1]), ppid = Number(m[2]);
|
|
27
|
+
procs.set(pid, { pid, ppid, rss: Number(m[3]) * 1024, cpu: cpuSeconds(m[4]) });
|
|
28
|
+
if (!kids.has(ppid)) kids.set(ppid, []);
|
|
29
|
+
kids.get(ppid).push(pid);
|
|
30
|
+
}
|
|
31
|
+
resolve({ procs, kids, at: Date.now() });
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function tree(snap, root) {
|
|
37
|
+
const out = [];
|
|
38
|
+
const queue = [root];
|
|
39
|
+
const seen = new Set();
|
|
40
|
+
while (queue.length) {
|
|
41
|
+
const pid = queue.pop();
|
|
42
|
+
if (seen.has(pid) || !snap.procs.has(pid)) continue;
|
|
43
|
+
seen.add(pid);
|
|
44
|
+
out.push(snap.procs.get(pid));
|
|
45
|
+
for (const kid of snap.kids.get(pid) || []) queue.push(kid);
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ps reports CPU time since the process started, so a percentage only exists
|
|
51
|
+
// between two samples: busy seconds over wall-clock seconds, per core.
|
|
52
|
+
let prev = null;
|
|
53
|
+
|
|
54
|
+
export const machine = { cores: os.cpus().length, mem: os.totalmem() };
|
|
55
|
+
|
|
56
|
+
export async function sampleProcs(roots) {
|
|
57
|
+
const snap = await ps();
|
|
58
|
+
if (!snap) { prev = null; return null; }
|
|
59
|
+
const span = prev ? (snap.at - prev.at) / 1000 : 0;
|
|
60
|
+
const by = {};
|
|
61
|
+
for (const [key, pid] of roots) {
|
|
62
|
+
const procs = tree(snap, pid);
|
|
63
|
+
if (!procs.length) continue;
|
|
64
|
+
const busy = procs.reduce((a, p) => {
|
|
65
|
+
const was = prev?.procs.get(p.pid);
|
|
66
|
+
// A process that started since the last sample counts all of its time.
|
|
67
|
+
return a + (was && was.cpu <= p.cpu ? p.cpu - was.cpu : p.cpu);
|
|
68
|
+
}, 0);
|
|
69
|
+
by[key] = {
|
|
70
|
+
// Null until a span exists to measure over, so the UI can wait rather
|
|
71
|
+
// than flash a zero.
|
|
72
|
+
cpu: span >= 0.5 ? Math.round((busy / span) * 1000) / 10 : null,
|
|
73
|
+
mem: procs.reduce((a, p) => a + p.rss, 0),
|
|
74
|
+
procs: procs.length,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
prev = snap;
|
|
78
|
+
return by;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Starting fresh after a quiet spell: the next sample has nothing to diff
|
|
82
|
+
// against, so don't report a percentage for it.
|
|
83
|
+
export function resetProcs() {
|
|
84
|
+
prev = null;
|
|
85
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// The daily standup: everyone on the floor says what they got done, what's
|
|
2
|
+
// next and what's in their way, and the chief wraps up. One model call
|
|
3
|
+
// writes every update from the job history, so it costs about one chat
|
|
4
|
+
// message. If that call fails, the updates are built from the facts alone.
|
|
5
|
+
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
|
|
8
|
+
const SCHEMA = {
|
|
9
|
+
type: 'object',
|
|
10
|
+
properties: {
|
|
11
|
+
updates: {
|
|
12
|
+
type: 'array',
|
|
13
|
+
items: {
|
|
14
|
+
type: 'object',
|
|
15
|
+
properties: {
|
|
16
|
+
agentId: { type: 'string' },
|
|
17
|
+
done: { type: 'string' },
|
|
18
|
+
today: { type: 'string' },
|
|
19
|
+
blockers: { type: 'string' },
|
|
20
|
+
},
|
|
21
|
+
required: ['agentId', 'done', 'today', 'blockers'],
|
|
22
|
+
additionalProperties: false,
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
wrap: { type: 'string' },
|
|
26
|
+
},
|
|
27
|
+
required: ['updates', 'wrap'],
|
|
28
|
+
additionalProperties: false,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const SYSTEM = `You run the daily standup in an office of coding agents. You get each agent's facts: jobs they finished since the last standup, what they're on now, what's queued for them, and anything that went wrong.
|
|
32
|
+
|
|
33
|
+
Write one update per agent, in that agent's own voice (first person, casual, like a real startup standup):
|
|
34
|
+
- "done": what they finished, in a sentence or two. Name the project. Say plainly if something failed or was stopped. "Nothing shipped since last standup." if there's nothing.
|
|
35
|
+
- "today": what they're on now or picking up next. If nothing, they're free and say so.
|
|
36
|
+
- "blockers": what's in their way (failed jobs, missing clearance, waiting on someone), or "" if nothing.
|
|
37
|
+
|
|
38
|
+
Then "wrap": the chief of staff closing the standup in one to three short sentences: what the office is focused on, anything at risk, and who's free to take work. If two agents are in the same project, point it out so they can sync.
|
|
39
|
+
|
|
40
|
+
Use only the facts given; never invent work, results or blockers. Refer to agents by name or as "they"; never "he", "she", "him" or "her". Plain words, no markdown, no emoji.`;
|
|
41
|
+
|
|
42
|
+
export function writeStandup(context, { bin = 'claude', model = 'sonnet', timeoutMs = 90000, env = process.env } = {}) {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const args = [
|
|
45
|
+
'-p', '--model', model, '--output-format', 'json', '--json-schema', JSON.stringify(SCHEMA),
|
|
46
|
+
'--tools', '', '--setting-sources', '', '--strict-mcp-config', '--no-session-persistence',
|
|
47
|
+
'--system-prompt', SYSTEM,
|
|
48
|
+
];
|
|
49
|
+
const child = spawn(bin, args, { env, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
50
|
+
let out = '', err = '';
|
|
51
|
+
child.stdout.on('data', (d) => (out += d));
|
|
52
|
+
child.stderr.on('data', (d) => (err += d));
|
|
53
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs);
|
|
54
|
+
child.on('error', (e) => { clearTimeout(timer); reject(e); });
|
|
55
|
+
child.on('close', () => {
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
try {
|
|
58
|
+
const r = JSON.parse(out);
|
|
59
|
+
const notes = r.structured_output || JSON.parse(r.result);
|
|
60
|
+
resolve({ ...notes, cost: r.total_cost_usd || 0 });
|
|
61
|
+
} catch {
|
|
62
|
+
reject(new Error(err.trim().split('\n').pop() || 'Could not write the standup.'));
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
child.stdin.end(context);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// When today's standup is due, as a timestamp, or null if there's none today.
|
|
70
|
+
// `at` is "HH:MM" local time; standups are weekdays only unless `weekends`.
|
|
71
|
+
export function dueToday(at, weekends, now = new Date()) {
|
|
72
|
+
const m = /^(\d{1,2}):(\d{2})$/.exec(String(at || '').trim());
|
|
73
|
+
if (!m) return null;
|
|
74
|
+
if (!weekends && (now.getDay() === 0 || now.getDay() === 6)) return null;
|
|
75
|
+
const due = new Date(now);
|
|
76
|
+
due.setHours(Number(m[1]), Number(m[2]), 0, 0);
|
|
77
|
+
return due.getTime();
|
|
78
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Keeps the office running across restarts it asks for. The server can't
|
|
2
|
+
// start its own successor (its shutdown ends every agent, and the shell that
|
|
3
|
+
// launched it is gone), so this sits above it: when the server exits with
|
|
4
|
+
// RESTART, start it again. A clean exit or a signal is the boss closing up,
|
|
5
|
+
// so go too; a crash gets another go.
|
|
6
|
+
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
|
|
11
|
+
const RESTART = 75;
|
|
12
|
+
const SERVER = path.join(path.dirname(fileURLToPath(import.meta.url)), 'index.js');
|
|
13
|
+
// A server that falls over this soon after a restart won't fare better on
|
|
14
|
+
// the next go, so a few of those in a row ends it rather than spinning.
|
|
15
|
+
const BOOT_MS = 10 * 1000;
|
|
16
|
+
const MAX_QUICK_FAILS = 3;
|
|
17
|
+
|
|
18
|
+
let child = null;
|
|
19
|
+
let quickFails = 0;
|
|
20
|
+
|
|
21
|
+
function run() {
|
|
22
|
+
const startedAt = Date.now();
|
|
23
|
+
child = spawn(process.execPath, [SERVER, ...process.argv.slice(2)], {
|
|
24
|
+
stdio: 'inherit',
|
|
25
|
+
env: { ...process.env, OFFICE_SUPERVISED: '1' },
|
|
26
|
+
});
|
|
27
|
+
child.on('exit', (code, signal) => {
|
|
28
|
+
child = null;
|
|
29
|
+
if (code === RESTART) {
|
|
30
|
+
quickFails = 0;
|
|
31
|
+
console.log('Restarting the office…');
|
|
32
|
+
return run();
|
|
33
|
+
}
|
|
34
|
+
// Ctrl-C reaches the server too, and it exits 0: that's closing time.
|
|
35
|
+
if (code === 0 || signal) return process.exit(code ?? 0);
|
|
36
|
+
if (Date.now() - startedAt < BOOT_MS && ++quickFails >= MAX_QUICK_FAILS) {
|
|
37
|
+
console.error(`The office failed to start ${quickFails} times in a row. Giving up.`);
|
|
38
|
+
return process.exit(code);
|
|
39
|
+
}
|
|
40
|
+
console.error(`The office stopped unexpectedly (exit ${code}). Starting it again…`);
|
|
41
|
+
setTimeout(run, 1000);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The server handles these itself and saves on the way out; this only waits
|
|
46
|
+
// for it. A signal sent to this process alone is passed on.
|
|
47
|
+
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
48
|
+
process.on(sig, () => { if (child) child.kill(sig); else process.exit(0); });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
run();
|