agent-office-studio 0.2.0 → 0.4.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 +47 -1
- package/README.md +1 -1
- package/bin/agentoffice.js +65 -5
- package/dist/assets/index-CiEWLydu.css +32 -0
- package/dist/assets/index-S_6NhxZ0.js +4457 -0
- package/dist/index.html +23 -9
- package/docs/README.md +1 -0
- package/docs/claude-access.md +2 -2
- package/docs/configuration.md +6 -2
- package/docs/development.md +16 -0
- package/docs/getting-started.md +4 -1
- package/docs/guides/troubleshooting.md +13 -2
- package/docs/guides/updating.md +26 -20
- package/docs/jobs.md +21 -0
- package/docs/leaderboard.md +8 -6
- package/docs/linked-offices.md +81 -0
- package/docs/phone.md +6 -2
- package/docs/security.md +37 -5
- package/docs/terminal.md +3 -3
- package/package.json +12 -11
- package/server/changes.js +4 -2
- package/server/chief.js +20 -3
- package/server/guests.js +391 -0
- package/server/index.js +468 -42
- package/server/leaderboard.js +63 -14
- package/server/links.js +350 -0
- package/server/office-mcp.js +5 -0
- package/server/settings.js +85 -0
- package/server/supervise.js +2 -0
- package/server/terminal.js +1 -1
- package/server/voice.js +3 -1
- package/dist/assets/index-CozPk6R9.js +0 -4457
- package/dist/assets/index-i7sU9SN-.css +0 -32
package/server/leaderboard.js
CHANGED
|
@@ -11,8 +11,9 @@ import fs from 'node:fs';
|
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import crypto from 'node:crypto';
|
|
13
13
|
|
|
14
|
-
// Where the hosted service lives
|
|
15
|
-
|
|
14
|
+
// Where the hosted service lives (leaderboard/, deployed on Cloudflare).
|
|
15
|
+
// OFFICE_LEADERBOARD_URL overrides it, for a self-hosted board or a test.
|
|
16
|
+
const DEFAULT_URL = 'https://agent-office-leaderboard.agent-office-leaderboard.workers.dev';
|
|
16
17
|
const WINDOW_DAYS = 14;
|
|
17
18
|
const DEBOUNCE_MS = 45 * 1000;
|
|
18
19
|
const RETRY_MS = 10 * 60 * 1000;
|
|
@@ -39,7 +40,7 @@ export class Leaderboard {
|
|
|
39
40
|
try { this.me = JSON.parse(fs.readFileSync(this.idFile, 'utf8')); } catch {}
|
|
40
41
|
this.error = null;
|
|
41
42
|
this.lastSync = this.me.lastSync || 0;
|
|
42
|
-
this.dirty =
|
|
43
|
+
this.dirty = this.joined;
|
|
43
44
|
this.timer = null;
|
|
44
45
|
this.saveTimer = null;
|
|
45
46
|
this.busy = false;
|
|
@@ -47,12 +48,18 @@ export class Leaderboard {
|
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
get url() { return (process.env.OFFICE_LEADERBOARD_URL || DEFAULT_URL).replace(/\/+$/, ''); }
|
|
50
|
-
|
|
51
|
+
// A handle is this office's name to the service, and linked offices need
|
|
52
|
+
// one too. Being on the board is a separate choice: `share` is false for an
|
|
53
|
+
// office that only claimed a handle. Older files have no `share`, and they
|
|
54
|
+
// all joined to share.
|
|
55
|
+
get named() { return !!(this.me.secret && this.me.handle); }
|
|
56
|
+
get joined() { return this.named && this.me.share !== false; }
|
|
51
57
|
|
|
52
58
|
status() {
|
|
53
59
|
return {
|
|
54
60
|
configured: !!this.url,
|
|
55
61
|
joined: this.joined,
|
|
62
|
+
sharing: this.joined,
|
|
56
63
|
handle: this.me.handle || null,
|
|
57
64
|
lastSync: this.lastSync || null,
|
|
58
65
|
error: this.error,
|
|
@@ -108,12 +115,31 @@ export class Leaderboard {
|
|
|
108
115
|
this.timer.unref?.();
|
|
109
116
|
}
|
|
110
117
|
|
|
118
|
+
// The service address, checked, for anything that talks to it: https (or
|
|
119
|
+
// this machine), and, when the office's secret goes along, the very service
|
|
120
|
+
// that secret was made for. Older files don't say which that was, so the
|
|
121
|
+
// first service they are used with is taken as it.
|
|
122
|
+
endpoint({ withSecret = true } = {}) {
|
|
123
|
+
const url = this.url;
|
|
124
|
+
if (!url) throw fail(400, 'No leaderboard is set up for this office yet. Set OFFICE_LEADERBOARD_URL.');
|
|
125
|
+
if (!/^https:\/\//.test(url) && !/^http:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/.test(url)) throw fail(400, 'The leaderboard address must be https.');
|
|
126
|
+
if (withSecret && this.me.secret) {
|
|
127
|
+
if (!this.me.service) {
|
|
128
|
+
this.me.service = url;
|
|
129
|
+
writeAtomic(this.idFile, this.me, 0o600);
|
|
130
|
+
} else if (this.me.service !== url) {
|
|
131
|
+
throw fail(421, `This office’s handle belongs to ${this.me.service}, so its secret isn’t sent to ${url}. Set OFFICE_LEADERBOARD_URL back to use it.`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return url;
|
|
135
|
+
}
|
|
136
|
+
|
|
111
137
|
async call(method, route, { body, secret = this.me.secret } = {}) {
|
|
112
|
-
|
|
113
|
-
|
|
138
|
+
// A brand-new secret (a first claim) is made for whichever service this is.
|
|
139
|
+
const url = this.endpoint({ withSecret: !!secret && secret === this.me.secret });
|
|
114
140
|
let res;
|
|
115
141
|
try {
|
|
116
|
-
res = await fetch(
|
|
142
|
+
res = await fetch(url + route, {
|
|
117
143
|
method,
|
|
118
144
|
headers: { 'content-type': 'application/json', ...(secret ? { authorization: `Bearer ${secret}` } : {}) },
|
|
119
145
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
@@ -127,16 +153,27 @@ export class Leaderboard {
|
|
|
127
153
|
return out;
|
|
128
154
|
}
|
|
129
155
|
|
|
130
|
-
|
|
156
|
+
// Claims a handle without joining the board. An office that already has
|
|
157
|
+
// one keeps it; the service holds one handle per secret.
|
|
158
|
+
async claim(handle) {
|
|
159
|
+
if (this.named && (handle === undefined || String(handle).trim().toLowerCase() === this.me.handle.toLowerCase())) return this.status();
|
|
131
160
|
handle = String(handle ?? '').trim();
|
|
132
161
|
if (!HANDLE_RE.test(handle)) throw fail(400, 'A handle is 3 to 20 letters, numbers, - or _.');
|
|
133
162
|
// A secret made once and kept: if the answer to a claim is lost, asking
|
|
134
163
|
// again with the same secret is safe.
|
|
135
164
|
const secret = this.me.secret || crypto.randomBytes(32).toString('hex');
|
|
136
165
|
const out = await this.call('POST', '/v1/claim', { body: { handle }, secret });
|
|
137
|
-
this.me = { ...this.me, secret, handle: out.handle, lastSync: 0 };
|
|
166
|
+
this.me = { share: false, ...this.me, secret, service: this.url, handle: out.handle, lastSync: 0 };
|
|
138
167
|
writeAtomic(this.idFile, this.me, 0o600);
|
|
139
168
|
this.error = null;
|
|
169
|
+
this.onChange();
|
|
170
|
+
return this.status();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async join(handle) {
|
|
174
|
+
await this.claim(handle);
|
|
175
|
+
this.me.share = true;
|
|
176
|
+
writeAtomic(this.idFile, this.me, 0o600);
|
|
140
177
|
this.dirty = true;
|
|
141
178
|
this.later(500);
|
|
142
179
|
return this.status();
|
|
@@ -167,11 +204,23 @@ export class Leaderboard {
|
|
|
167
204
|
}
|
|
168
205
|
}
|
|
169
206
|
|
|
170
|
-
// Stops sharing and asks the service to
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
207
|
+
// Stops sharing and asks the service to delete the counters. With
|
|
208
|
+
// `keepHandle` (the office has linked offices) the handle stays, so the
|
|
209
|
+
// links do; otherwise the service forgets this office entirely. If it can't
|
|
210
|
+
// be reached nothing local changes, so the boss can try again rather than
|
|
211
|
+
// be left believing it is gone.
|
|
212
|
+
async leave({ keepHandle = false } = {}) {
|
|
213
|
+
if (keepHandle && this.named) {
|
|
214
|
+
try { await this.call('DELETE', '/v1/days'); }
|
|
215
|
+
catch (err) { if (err.code !== 401) throw err; }
|
|
216
|
+
clearTimeout(this.timer);
|
|
217
|
+
this.me.share = false;
|
|
218
|
+
writeAtomic(this.idFile, this.me, 0o600);
|
|
219
|
+
this.dirty = false;
|
|
220
|
+
this.error = null;
|
|
221
|
+
return this.status();
|
|
222
|
+
}
|
|
223
|
+
if (this.named) {
|
|
175
224
|
try { await this.call('DELETE', '/v1/me'); }
|
|
176
225
|
catch (err) { if (err.code !== 401) throw err; } // 401: the service has already forgotten it
|
|
177
226
|
}
|
package/server/links.js
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
// Linked offices, from this machine's side.
|
|
2
|
+
//
|
|
3
|
+
// One socket, opened outwards to the relay (the leaderboard service), carries
|
|
4
|
+
// everything: our presence out to linked offices, theirs back in, and links
|
|
5
|
+
// being made and broken. Nothing listens for the outside world. What goes out
|
|
6
|
+
// is names, roles, looks and states, so a linked office sees whether an agent
|
|
7
|
+
// is busy but not on what; job titles only with OFFICE_SHARE_JOB_TITLES=1
|
|
8
|
+
// (presence() in index.js decides). No repos, logs, prompts or chat. Everything that comes in is checked against the shapes in
|
|
9
|
+
// docs/proposals/linked-offices.md and cut down to size before the browser
|
|
10
|
+
// sees it.
|
|
11
|
+
//
|
|
12
|
+
// Guest frames (req, res, feed) are handed to `onGuest`; this file only
|
|
13
|
+
// carries them.
|
|
14
|
+
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import WebSocket from 'ws';
|
|
18
|
+
|
|
19
|
+
const PRESENCE_GAP_MS = 5000;
|
|
20
|
+
const BACKOFF_MIN_MS = 1000;
|
|
21
|
+
const BACKOFF_MAX_MS = 60_000;
|
|
22
|
+
const REPLACED = 4000; // the relay closes with this when another copy of this office connects
|
|
23
|
+
const DELETED = 4001; // and with this when the office deleted itself on the service
|
|
24
|
+
const PING_MS = 30_000; // the relay answers { t: 'ping' } without waking up; silence means a dead socket
|
|
25
|
+
const MAX_AGENTS = 16;
|
|
26
|
+
const STATES = new Set(['working', 'idle', 'break', 'meeting', 'away']);
|
|
27
|
+
const CODE_RE = /^[0-9A-HJKMNP-TV-Z]{8}$/;
|
|
28
|
+
const ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
29
|
+
const HEX_RE = /^#[0-9a-f]{6}$/i;
|
|
30
|
+
const CLEARANCES = new Set(['plan', 'edit', 'auto']); // MODES in index.js: auto is Full autonomy
|
|
31
|
+
|
|
32
|
+
const fail = (code, message) => Object.assign(new Error(message), { code });
|
|
33
|
+
// Plain text of at most `n` characters, with control characters dropped.
|
|
34
|
+
const text = (v, n) => (typeof v === 'string' ? v.replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, n) : '');
|
|
35
|
+
|
|
36
|
+
// Codes are typed by people: any case, dashes and spaces are fine, and the
|
|
37
|
+
// letters people mix up with digits read as those digits (Crockford).
|
|
38
|
+
export function normaliseCode(code) {
|
|
39
|
+
return String(code ?? '').toUpperCase().replace(/[\s-]/g, '').replace(/[IL]/g, '1').replace(/O/g, '0');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function cleanLink(l) {
|
|
43
|
+
if (!l || !ID_RE.test(String(l.id))) return null;
|
|
44
|
+
return {
|
|
45
|
+
id: String(l.id),
|
|
46
|
+
peer: text(l.peer, 20),
|
|
47
|
+
kind: l.kind === 'guest' ? 'guest' : 'link',
|
|
48
|
+
role: ['host', 'guest'].includes(l.role) ? l.role : 'peer',
|
|
49
|
+
invite: ID_RE.test(String(l.invite ?? '')) ? String(l.invite) : null,
|
|
50
|
+
createdAt: Number.isFinite(l.createdAt) ? l.createdAt : null,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function cleanAgent(a) {
|
|
55
|
+
if (!a || !ID_RE.test(String(a.id))) return null;
|
|
56
|
+
const look = {};
|
|
57
|
+
for (const k of ['shirt', 'hair', 'skin']) if (HEX_RE.test(a.look?.[k] ?? '')) look[k] = a.look[k];
|
|
58
|
+
const title = text(a.job?.title, 120);
|
|
59
|
+
return {
|
|
60
|
+
id: String(a.id),
|
|
61
|
+
name: text(a.name, 40) || String(a.id),
|
|
62
|
+
role: text(a.role, 60),
|
|
63
|
+
look,
|
|
64
|
+
state: STATES.has(a.state) ? a.state : 'idle',
|
|
65
|
+
job: title ? { title } : null,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class Links {
|
|
70
|
+
// board: the Leaderboard, which owns the handle, the secret and the service address.
|
|
71
|
+
// presence(): this office's agents, as the presence frame lists them.
|
|
72
|
+
constructor(dataDir, { board, presence, onChange = () => {}, onGuest = () => {} }) {
|
|
73
|
+
this.file = path.join(dataDir, 'links.json');
|
|
74
|
+
this.board = board;
|
|
75
|
+
this.presence = presence;
|
|
76
|
+
this.onChange = onChange;
|
|
77
|
+
this.onGuest = onGuest;
|
|
78
|
+
// What we keep: the last list the relay gave us, so the panel has
|
|
79
|
+
// something to show offline; guest limits by invite id, then by link id
|
|
80
|
+
// once the guest arrives; and when our newest open invite runs out, so we
|
|
81
|
+
// stay connected to hear it being used.
|
|
82
|
+
this.saved = { links: [], guests: {}, invitesUntil: 0 };
|
|
83
|
+
try { this.saved = { ...this.saved, ...JSON.parse(fs.readFileSync(this.file, 'utf8')) }; } catch {}
|
|
84
|
+
this.visitors = new Map(); // link id -> { link, handle, agents }
|
|
85
|
+
this.ws = null;
|
|
86
|
+
this.listed = false; // the relay has sent this socket its list of links
|
|
87
|
+
this.backoff = BACKOFF_MIN_MS;
|
|
88
|
+
this.retry = null;
|
|
89
|
+
this.replaced = false;
|
|
90
|
+
this.lastSent = '';
|
|
91
|
+
this.sentAt = 0;
|
|
92
|
+
this.presenceTimer = null;
|
|
93
|
+
this.pingTimer = null;
|
|
94
|
+
this.error = null;
|
|
95
|
+
this.connect();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
get links() { return this.saved.links; }
|
|
99
|
+
|
|
100
|
+
status() {
|
|
101
|
+
return {
|
|
102
|
+
handle: this.board.me.handle || null,
|
|
103
|
+
connected: this.ws?.readyState === WebSocket.OPEN,
|
|
104
|
+
error: this.error,
|
|
105
|
+
links: this.links.map((l) => ({ ...l, guest: l.role === 'host' ? this.saved.guests[l.id] || null : undefined })),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
visiting() {
|
|
110
|
+
return [...this.visitors.values()];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// A guest link's limits, as the host set them. Null if this isn't one we host.
|
|
114
|
+
guestPolicy(linkId) {
|
|
115
|
+
const l = this.links.find((x) => x.id === linkId);
|
|
116
|
+
return l?.role === 'host' ? this.saved.guests[l.id] || null : null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
save() {
|
|
120
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
|
121
|
+
const tmp = this.file + '.tmp';
|
|
122
|
+
fs.writeFileSync(tmp, JSON.stringify(this.saved), { mode: 0o600 });
|
|
123
|
+
fs.renameSync(tmp, this.file);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------- the socket ----------
|
|
127
|
+
|
|
128
|
+
// Worth holding a socket only if there is someone to talk to, or someone
|
|
129
|
+
// who might turn up with one of our codes.
|
|
130
|
+
get wanted() {
|
|
131
|
+
return this.board.named && !this.replaced && (this.links.length > 0 || this.saved.invitesUntil > Date.now());
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
connect() {
|
|
135
|
+
clearTimeout(this.retry);
|
|
136
|
+
if (this.ws || !this.wanted) return;
|
|
137
|
+
let base;
|
|
138
|
+
try { base = this.board.endpoint(); } catch (err) { this.error = err.message; return; }
|
|
139
|
+
const url = base.replace(/^http/, 'ws') + '/v1/socket';
|
|
140
|
+
this.listed = false;
|
|
141
|
+
const ws = new WebSocket(url, { headers: { authorization: `Bearer ${this.board.me.secret}` }, maxPayload: 64 * 1024, handshakeTimeout: 10_000 });
|
|
142
|
+
this.ws = ws;
|
|
143
|
+
ws.on('open', () => {
|
|
144
|
+
let unanswered = 0;
|
|
145
|
+
ws.on('message', () => { unanswered = 0; });
|
|
146
|
+
this.pingTimer = setInterval(() => {
|
|
147
|
+
if (++unanswered > 2) return ws.terminate();
|
|
148
|
+
ws.send('{"t":"ping"}');
|
|
149
|
+
}, PING_MS);
|
|
150
|
+
this.pingTimer.unref?.();
|
|
151
|
+
this.backoff = BACKOFF_MIN_MS;
|
|
152
|
+
this.error = null;
|
|
153
|
+
this.lastSent = '';
|
|
154
|
+
this.sendPresence(true);
|
|
155
|
+
this.onChange();
|
|
156
|
+
});
|
|
157
|
+
ws.on('message', (data) => {
|
|
158
|
+
let msg;
|
|
159
|
+
try { msg = JSON.parse(data); } catch { return; }
|
|
160
|
+
if (msg && typeof msg === 'object') this.receive(msg);
|
|
161
|
+
});
|
|
162
|
+
ws.on('unexpected-response', (_req, res) => {
|
|
163
|
+
this.error = res.statusCode === 401 ? 'The relay doesn’t know this office.' : `The relay answered ${res.statusCode}.`;
|
|
164
|
+
});
|
|
165
|
+
ws.on('error', () => { this.error ||= 'Couldn’t reach the relay.'; });
|
|
166
|
+
ws.on('close', (code) => {
|
|
167
|
+
clearInterval(this.pingTimer);
|
|
168
|
+
if (this.ws === ws) this.ws = null;
|
|
169
|
+
if (code === REPLACED || code === DELETED) {
|
|
170
|
+
// Another copy of this office took over, and connecting again would
|
|
171
|
+
// only knock it off in turn; or the office is gone from the service.
|
|
172
|
+
this.replaced = true;
|
|
173
|
+
this.error = code === DELETED ? 'This office was deleted from the relay.' : 'Another copy of this office is connected to the relay.';
|
|
174
|
+
}
|
|
175
|
+
this.visitors.clear();
|
|
176
|
+
this.onChange();
|
|
177
|
+
if (!this.wanted) return;
|
|
178
|
+
const wait = this.backoff * (0.75 + Math.random() / 2);
|
|
179
|
+
this.backoff = Math.min(this.backoff * 2, BACKOFF_MAX_MS);
|
|
180
|
+
this.retry = setTimeout(() => this.connect(), wait);
|
|
181
|
+
this.retry.unref?.();
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
disconnect() {
|
|
186
|
+
clearTimeout(this.retry);
|
|
187
|
+
clearInterval(this.pingTimer);
|
|
188
|
+
const ws = this.ws;
|
|
189
|
+
this.ws = null;
|
|
190
|
+
ws?.close(1000);
|
|
191
|
+
this.visitors.clear();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
send(frame) {
|
|
195
|
+
if (this.ws?.readyState !== WebSocket.OPEN) return false;
|
|
196
|
+
this.ws.send(JSON.stringify(frame));
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
receive(msg) {
|
|
201
|
+
switch (msg.t) {
|
|
202
|
+
case 'links':
|
|
203
|
+
this.listed = true;
|
|
204
|
+
this.setLinks((Array.isArray(msg.links) ? msg.links : []).map(cleanLink).filter(Boolean));
|
|
205
|
+
break;
|
|
206
|
+
case 'link': {
|
|
207
|
+
const l = cleanLink(msg.link);
|
|
208
|
+
if (l) this.setLinks([...this.links.filter((x) => x.id !== l.id), l]);
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
case 'unlink':
|
|
212
|
+
this.setLinks(this.links.filter((x) => x.id !== String(msg.link?.id)));
|
|
213
|
+
break;
|
|
214
|
+
case 'presence': {
|
|
215
|
+
const link = this.links.find((x) => x.id === String(msg.link));
|
|
216
|
+
if (!link) return;
|
|
217
|
+
const agents = (Array.isArray(msg.agents) ? msg.agents : []).slice(0, MAX_AGENTS).map(cleanAgent).filter(Boolean);
|
|
218
|
+
this.visitors.set(link.id, { link: link.id, handle: text(msg.from, 20) || link.peer, agents });
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
case 'want-presence':
|
|
222
|
+
this.sendPresence(true);
|
|
223
|
+
return;
|
|
224
|
+
case 'req': case 'res': case 'feed': {
|
|
225
|
+
// Only on a guest link the relay listed for this socket, and only the
|
|
226
|
+
// way round the roles allow: what we saved from an earlier socket may
|
|
227
|
+
// hold a link that has since been ended.
|
|
228
|
+
if (!this.listed) return;
|
|
229
|
+
const link = this.links.find((x) => x.id === String(msg.link));
|
|
230
|
+
if (link?.kind !== 'guest') return;
|
|
231
|
+
if (msg.t === 'req' ? link.role !== 'host' : link.role !== 'guest') return;
|
|
232
|
+
this.onGuest(link, msg);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
case 'error':
|
|
236
|
+
console.warn('[links] the relay dropped a frame:', text(msg.error, 200));
|
|
237
|
+
return;
|
|
238
|
+
default:
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
this.onChange();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// The relay's list is the truth. Guest limits move from the invite that
|
|
245
|
+
// made a link to the link itself, and go when the link does.
|
|
246
|
+
setLinks(links) {
|
|
247
|
+
const guests = {};
|
|
248
|
+
for (const l of links) {
|
|
249
|
+
if (l.role !== 'host') continue;
|
|
250
|
+
const policy = this.saved.guests[l.id] || (l.invite && this.saved.guests[`invite:${l.invite}`]);
|
|
251
|
+
if (policy) guests[l.id] = policy;
|
|
252
|
+
}
|
|
253
|
+
for (const [k, v] of Object.entries(this.saved.guests)) {
|
|
254
|
+
if (k.startsWith('invite:') && v.expiresAt > Date.now() && !links.some((l) => `invite:${l.invite}` === k)) guests[k] = v;
|
|
255
|
+
}
|
|
256
|
+
const ids = new Set(links.map((l) => l.id));
|
|
257
|
+
for (const id of this.visitors.keys()) if (!ids.has(id)) this.visitors.delete(id);
|
|
258
|
+
const grew = links.length > this.links.length;
|
|
259
|
+
this.saved = { ...this.saved, links, guests };
|
|
260
|
+
this.save();
|
|
261
|
+
if (grew) this.sendPresence(true);
|
|
262
|
+
if (!this.wanted) this.disconnect();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Called whenever something an agent is doing might have changed. Sends
|
|
266
|
+
// only real changes, at most once per PRESENCE_GAP_MS, unless `now`.
|
|
267
|
+
sendPresence(now = false) {
|
|
268
|
+
if (!this.links.length) return;
|
|
269
|
+
const agents = this.presence().slice(0, MAX_AGENTS).map(cleanAgent).filter(Boolean);
|
|
270
|
+
const body = JSON.stringify(agents);
|
|
271
|
+
if (!now && body === this.lastSent) return;
|
|
272
|
+
clearTimeout(this.presenceTimer);
|
|
273
|
+
const wait = now ? 0 : this.sentAt + PRESENCE_GAP_MS - Date.now();
|
|
274
|
+
if (wait > 0) {
|
|
275
|
+
this.presenceTimer = setTimeout(() => this.sendPresence(), wait);
|
|
276
|
+
this.presenceTimer.unref?.();
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (this.send({ t: 'presence', all: true, agents })) {
|
|
280
|
+
this.lastSent = body;
|
|
281
|
+
this.sentAt = Date.now();
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---------- what the panel asks for ----------
|
|
286
|
+
|
|
287
|
+
async ensureHandle(handle) {
|
|
288
|
+
if (!this.board.named) await this.board.claim(handle);
|
|
289
|
+
if (!this.board.named) throw fail(400, 'Pick a handle first.');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async refresh() {
|
|
293
|
+
if (!this.board.named) return this.status();
|
|
294
|
+
const out = await this.board.call('GET', '/v1/links');
|
|
295
|
+
this.setLinks((out.links || []).map(cleanLink).filter(Boolean));
|
|
296
|
+
this.connect();
|
|
297
|
+
return this.status();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async invite({ kind = 'link', guest, handle } = {}) {
|
|
301
|
+
await this.ensureHandle(handle);
|
|
302
|
+
kind = kind === 'guest' ? 'guest' : 'link';
|
|
303
|
+
let policy = null;
|
|
304
|
+
if (kind === 'guest') {
|
|
305
|
+
const repos = Array.isArray(guest?.repos) ? [...new Set(guest.repos.map(String).filter(Boolean))] : [];
|
|
306
|
+
if (!repos.length) throw fail(400, 'Pick at least one project the guest can use.');
|
|
307
|
+
const clearance = CLEARANCES.has(guest?.clearance) ? guest.clearance : 'edit';
|
|
308
|
+
const tokensPerDay = Number.isInteger(guest?.tokensPerDay) && guest.tokensPerDay > 0 ? guest.tokensPerDay : 2_000_000;
|
|
309
|
+
policy = { repos, clearance, tokensPerDay };
|
|
310
|
+
}
|
|
311
|
+
const out = await this.board.call('POST', '/v1/invites', { body: { kind } });
|
|
312
|
+
const expiresAt = Number(out.expiresAt) || Date.now() + 86_400_000;
|
|
313
|
+
if (policy) this.saved.guests[`invite:${out.id}`] = { ...policy, expiresAt };
|
|
314
|
+
this.saved.invitesUntil = Math.max(this.saved.invitesUntil, expiresAt);
|
|
315
|
+
this.save();
|
|
316
|
+
this.connect();
|
|
317
|
+
const code = normaliseCode(out.code);
|
|
318
|
+
return { id: out.id, code: `${code.slice(0, 4)}-${code.slice(4)}`, kind, expiresAt, guest: policy };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async redeem({ code, handle } = {}) {
|
|
322
|
+
code = normaliseCode(code);
|
|
323
|
+
if (!CODE_RE.test(code)) throw fail(400, 'A code is 8 letters and numbers, like K7QX-M2PD.');
|
|
324
|
+
await this.ensureHandle(handle);
|
|
325
|
+
const out = await this.board.call('POST', '/v1/invites/redeem', { body: { code } });
|
|
326
|
+
const link = cleanLink(out.link);
|
|
327
|
+
if (link) this.setLinks([...this.links.filter((x) => x.id !== link.id), link]);
|
|
328
|
+
this.connect();
|
|
329
|
+
return this.status();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async unlink(id) {
|
|
333
|
+
id = String(id);
|
|
334
|
+
if (!this.links.some((l) => l.id === id)) throw fail(404, 'No such link.');
|
|
335
|
+
try { await this.board.call('DELETE', `/v1/links/${encodeURIComponent(id)}`); }
|
|
336
|
+
catch (err) { if (err.code !== 404) throw err; } // already gone on the relay's side
|
|
337
|
+
this.setLinks(this.links.filter((l) => l.id !== id));
|
|
338
|
+
this.onChange();
|
|
339
|
+
return this.status();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// The handle was forgotten (left the board without keeping it): the relay
|
|
343
|
+
// has dropped our links with it.
|
|
344
|
+
forget() {
|
|
345
|
+
this.disconnect();
|
|
346
|
+
this.saved = { links: [], guests: {}, invitesUntil: 0 };
|
|
347
|
+
try { fs.rmSync(this.file, { force: true }); } catch {}
|
|
348
|
+
this.onChange();
|
|
349
|
+
}
|
|
350
|
+
}
|
package/server/office-mcp.js
CHANGED
|
@@ -40,6 +40,11 @@ const TOOLS = [
|
|
|
40
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
41
|
inputSchema: obj({ now: { type: 'boolean', description: 'Skip the wait and interrupt anyone mid-turn. Only if asked to.' } }, []),
|
|
42
42
|
},
|
|
43
|
+
{
|
|
44
|
+
name: 'clear_context',
|
|
45
|
+
description: 'Drop this session\'s history to cut token use, at a natural break between steps. Give notes to yourself: what is done, what is left, what you learned (files, decisions, gotchas). End your turn right after; you resume fresh from the brief and your notes. Not for the end of a job.',
|
|
46
|
+
inputSchema: obj({ notes: { type: 'string' } }, ['notes']),
|
|
47
|
+
},
|
|
43
48
|
{ name: 'list_channels', description: 'List chat channels: purpose, members, and which one your work is in.', inputSchema: NONE },
|
|
44
49
|
{
|
|
45
50
|
name: 'create_channel',
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// The office's own settings: what the boss can change from the Settings page.
|
|
2
|
+
// Each one has a default taken from the environment (or built in); a value set
|
|
3
|
+
// in the office is kept in state.settings and wins over it. Clearing a value
|
|
4
|
+
// goes back to the default. Anything not listed here stays environment-only.
|
|
5
|
+
|
|
6
|
+
const env = (k) => process.env[k];
|
|
7
|
+
const on = (v) => v !== 'off' && v !== '0' && v !== 'false';
|
|
8
|
+
|
|
9
|
+
// kind: text | number | bool | choice | time. `live` settings take effect at
|
|
10
|
+
// once; the rest are read when the thing they configure next starts.
|
|
11
|
+
export const SETTINGS = [
|
|
12
|
+
{ key: 'owner', group: 'Office', label: 'Office owner', kind: 'text', max: 40, hint: 'Whose office this is. It names the office in the header. Blank uses the name on this account.', fallback: () => String(env('OFFICE_OWNER') || '').trim() },
|
|
13
|
+
{ key: 'shareTitles', group: 'Office', label: 'Share job titles with linked offices', kind: 'bool', hint: 'Off: linked offices only see that an agent is busy.', fallback: () => env('OFFICE_SHARE_JOB_TITLES') === '1' },
|
|
14
|
+
|
|
15
|
+
{ key: 'tokenLimit', group: 'Agents', label: 'Token limit', kind: 'number', min: 0, max: 10_000_000, step: 10000, unit: 'tokens', hint: 'Past this much context, an agent is nudged to clear it and start fresh. 0 turns the nudge off.', fallback: () => Math.max(0, parseInt(env('AGENT_CLEAR_TOKENS') ?? '150000', 10) || 0) },
|
|
16
|
+
{ key: 'compactPct', group: 'Agents', label: 'Compact at', kind: 'number', min: 10, max: 95, step: 5, unit: '%', hint: 'How full an agent’s context gets before Claude Code compacts it. Applies to jobs that start after you save.', fallback: () => Math.min(95, Math.max(10, parseInt(env('AGENT_COMPACT_PCT') ?? '60', 10) || 60)) },
|
|
17
|
+
{ key: 'chiefModel', group: 'Agents', label: 'Chief of staff model', kind: 'choice', options: ['haiku', 'sonnet', 'opus'], hint: 'Routes your messages and writes the standup.', fallback: () => env('CHIEF_MODEL') || 'sonnet' },
|
|
18
|
+
|
|
19
|
+
{ key: 'standupAt', group: 'Standup', label: 'Standup time', kind: 'time', hint: 'Local time, 24-hour. Blank turns the daily standup off.', fallback: () => (env('STANDUP_AT') ?? '09:30') === 'off' ? '' : (env('STANDUP_AT') ?? '09:30') },
|
|
20
|
+
{ key: 'standupWeekends', group: 'Standup', label: 'Hold standups on weekends', kind: 'bool', fallback: () => env('STANDUP_WEEKENDS') === '1' },
|
|
21
|
+
|
|
22
|
+
{ key: 'banter', group: 'Break room', label: 'Written break-room talk', kind: 'bool', hint: 'Off: the break room uses canned lines only.', fallback: () => on(env('BANTER')) },
|
|
23
|
+
{ key: 'banterModel', group: 'Break room', label: 'Break-room model', kind: 'choice', options: ['haiku', 'sonnet', 'opus'], fallback: () => env('BANTER_MODEL') || 'haiku' },
|
|
24
|
+
{ key: 'banterGap', group: 'Break room', label: 'Time between conversations', kind: 'number', min: 10, max: 86400, step: 10, unit: 'seconds', hint: 'At most one written conversation per this long.', fallback: () => Math.max(10, Number(env('BANTER_GAP_SECONDS') ?? 120) || 120) },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const BY_KEY = new Map(SETTINGS.map((s) => [s.key, s]));
|
|
28
|
+
|
|
29
|
+
// Returns the cleaned value, or throws with something the boss can act on.
|
|
30
|
+
function clean(def, v) {
|
|
31
|
+
const bad = (m) => Object.assign(new Error(m), { code: 400 });
|
|
32
|
+
switch (def.kind) {
|
|
33
|
+
case 'text': {
|
|
34
|
+
if (typeof v !== 'string') throw bad(`${def.label}: use text.`);
|
|
35
|
+
v = v.trim().replace(/\s+/g, ' ');
|
|
36
|
+
if (v.length > def.max) throw bad(`${def.label} is limited to ${def.max} characters.`);
|
|
37
|
+
return v;
|
|
38
|
+
}
|
|
39
|
+
case 'bool':
|
|
40
|
+
if (typeof v !== 'boolean') throw bad(`${def.label}: use on or off.`);
|
|
41
|
+
return v;
|
|
42
|
+
case 'number':
|
|
43
|
+
if (!Number.isInteger(v) || v < def.min || v > def.max) throw bad(`${def.label}: use a whole number from ${def.min.toLocaleString()} to ${def.max.toLocaleString()}.`);
|
|
44
|
+
return v;
|
|
45
|
+
case 'choice':
|
|
46
|
+
if (!def.options.includes(v)) throw bad(`${def.label}: pick one of ${def.options.join(', ')}.`);
|
|
47
|
+
return v;
|
|
48
|
+
case 'time':
|
|
49
|
+
if (v !== '' && !/^([01]\d|2[0-3]):[0-5]\d$/.test(v)) throw bad(`${def.label}: use HH:MM, like 09:30, or leave it blank.`);
|
|
50
|
+
return v;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class Settings {
|
|
55
|
+
// `store` is the object persisted with the office's state; `onChange` saves it.
|
|
56
|
+
constructor(store, onChange = () => {}) {
|
|
57
|
+
this.store = store;
|
|
58
|
+
this.onChange = onChange;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
get(key) {
|
|
62
|
+
const def = BY_KEY.get(key);
|
|
63
|
+
return key in this.store ? this.store[key] : def.fallback();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Apply { key: value | null }. All or nothing: one bad value saves none.
|
|
67
|
+
set(patch) {
|
|
68
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw Object.assign(new Error('Nothing to save.'), { code: 400 });
|
|
69
|
+
const next = {};
|
|
70
|
+
for (const [key, v] of Object.entries(patch)) {
|
|
71
|
+
const def = BY_KEY.get(key);
|
|
72
|
+
if (!def) throw Object.assign(new Error(`Unknown setting: ${key}.`), { code: 400 });
|
|
73
|
+
next[key] = v === null ? null : clean(def, v);
|
|
74
|
+
}
|
|
75
|
+
for (const [key, v] of Object.entries(next)) {
|
|
76
|
+
if (v === null) delete this.store[key]; else this.store[key] = v;
|
|
77
|
+
}
|
|
78
|
+
this.onChange(Object.keys(next));
|
|
79
|
+
return this.status();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
status() {
|
|
83
|
+
return SETTINGS.map(({ fallback, ...def }) => ({ ...def, value: this.get(def.key), default: fallback(), custom: def.key in this.store }));
|
|
84
|
+
}
|
|
85
|
+
}
|
package/server/supervise.js
CHANGED
|
@@ -9,6 +9,7 @@ import { spawn } from 'node:child_process';
|
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
10
|
|
|
11
11
|
const RESTART = 75;
|
|
12
|
+
const FATAL = 78; // a setup problem (port taken) that another go won't fix
|
|
12
13
|
const SERVER = path.join(path.dirname(fileURLToPath(import.meta.url)), 'index.js');
|
|
13
14
|
// A server that falls over this soon after a restart won't fare better on
|
|
14
15
|
// the next go, so a few of those in a row ends it rather than spinning.
|
|
@@ -31,6 +32,7 @@ function run() {
|
|
|
31
32
|
console.log('Restarting the office…');
|
|
32
33
|
return run();
|
|
33
34
|
}
|
|
35
|
+
if (code === FATAL) return process.exit(1);
|
|
34
36
|
// Ctrl-C reaches the server too, and it exits 0: that's closing time.
|
|
35
37
|
if (code === 0 || signal) return process.exit(code ?? 0);
|
|
36
38
|
if (Date.now() - startedAt < BOOT_MS && ++quickFails >= MAX_QUICK_FAILS) {
|
package/server/terminal.js
CHANGED
|
@@ -78,7 +78,7 @@ export class Terminals {
|
|
|
78
78
|
|
|
79
79
|
async status() {
|
|
80
80
|
if (!this.on) {
|
|
81
|
-
return { enabled: false, reason: 'The terminal is off. Start the office with OFFICE_TERMINAL=1 to open it.' };
|
|
81
|
+
return { enabled: false, reason: 'The terminal is off. Start the office with --terminal (or OFFICE_TERMINAL=1) to open it.' };
|
|
82
82
|
}
|
|
83
83
|
if (!(await loadPty())) return { enabled: false, reason: ptyError };
|
|
84
84
|
let folders = [];
|
package/server/voice.js
CHANGED
|
@@ -89,7 +89,9 @@ export async function transcribe(file, { dataDir, prompt = '', threads = Math.ma
|
|
|
89
89
|
if (!model) throw new Error(`No speech model installed. ${MODEL_HINT}`);
|
|
90
90
|
const wav = path.join(path.dirname(file), 'voice.wav');
|
|
91
91
|
try {
|
|
92
|
-
|
|
92
|
+
// Local files and pipes only: a crafted upload (an HLS playlist, say) could
|
|
93
|
+
// otherwise have ffmpeg fetch URLs, from this machine's network position.
|
|
94
|
+
await run(FFMPEG, ['-nostdin', '-loglevel', 'error', '-y', '-protocol_whitelist', 'file,pipe', '-i', file, '-ac', '1', '-ar', '16000', '-c:a', 'pcm_s16le', wav]);
|
|
93
95
|
const out = await run(WHISPER, [
|
|
94
96
|
'-m', model, '-f', wav,
|
|
95
97
|
'-nt', '-np', '-l', 'en', '-sns', '-t', String(threads),
|