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.
@@ -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. OFFICE_LEADERBOARD_URL overrides it.
15
- const DEFAULT_URL = '';
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 = !!this.me.secret;
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
- get joined() { return !!(this.me.secret && this.me.handle); }
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
- 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.');
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(this.url + route, {
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
- async join(handle) {
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 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) {
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
  }
@@ -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
+ }
@@ -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
+ }
@@ -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) {
@@ -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
- await run(FFMPEG, ['-nostdin', '-loglevel', 'error', '-y', '-i', file, '-ac', '1', '-ar', '16000', '-c:a', 'pcm_s16le', wav]);
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),