agent-office-studio 0.2.0 → 0.3.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.
@@ -0,0 +1,391 @@
1
+ // Guests: another office running jobs with our agents over a guest link, and
2
+ // the other way round, us visiting an office that made us its guest.
3
+ //
4
+ // As host, everything a guest asks for comes in as a `req` frame and is
5
+ // checked here against the limits the boss set on the invite (repos,
6
+ // clearance cap, tokens per day). The guest's office is never trusted: it
7
+ // only ever sees its own jobs, and the feed it gets is cut down to them.
8
+ //
9
+ // As guest, the browser opens /ws/visit/:link. We ask the host for a hello,
10
+ // then pass on its feed; the composer's posts become `req` frames.
11
+ // See docs/proposals/linked-offices.md, "Guests".
12
+
13
+ import crypto from 'node:crypto';
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+
17
+ export const CLEARANCE = ['plan', 'edit', 'auto'];
18
+ const MAX_OPEN_JOBS = 3; // queued or working, per guest
19
+ const MAX_PROMPT = 20_000;
20
+ const REQ_TIMEOUT_MS = 15_000;
21
+ const CONNECT_WAIT_MS = 5_000;
22
+ const FEED_EVERY_MS = 250;
23
+ const FEED_BYTES = 48 * 1024; // under the relay's 64 KB a frame
24
+ const FEED_TYPES = new Set(['job', 'log', 'staff']);
25
+ const utcDay = (ms = Date.now()) => new Date(ms).toISOString().slice(0, 10);
26
+ const fail = (code, message) => Object.assign(new Error(message), { code });
27
+
28
+ // ---------- what a host may put on our page ----------
29
+ //
30
+ // As guest, everything from the host lands in our own localhost page, which
31
+ // can start jobs on this machine. So nothing it sends is passed on as it is:
32
+ // each message is rebuilt from known fields, text is capped, ids and colours
33
+ // must match their patterns, enums must be ours, and anything that doesn't fit
34
+ // is dropped.
35
+
36
+ const ID = /^[A-Za-z0-9_-]{1,64}$/;
37
+ const COLOUR = /^#[0-9a-fA-F]{6}$/;
38
+ const REPO = /^[^/\\\0<>"'`]{1,100}$/;
39
+ const HANDLE = /^@?[a-z0-9][a-z0-9_-]{2,19}$/i;
40
+ const STATUSES = new Set(['queued', 'working', 'done', 'failed', 'stopped']);
41
+ const LOG_KINDS = new Set(['user', 'system', 'thinking', 'tool', 'text', 'error', 'result']);
42
+ const STATIONS = new Set(['library', 'servers', 'window', 'whiteboard', 'desk']);
43
+ const FILE_STATUSES = new Set(['added', 'modified', 'deleted']);
44
+ const ASKING = new Set(['question', 'decision', 'blocker']);
45
+
46
+ const str = (v, max) => (typeof v === 'string' ? v.slice(0, max) : '');
47
+ const optStr = (v, max) => (typeof v === 'string' ? v.slice(0, max) : null);
48
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
49
+ const count = (v) => (Number.isInteger(v) && v >= 0 ? v : 0);
50
+ const id = (v) => (typeof v === 'string' && ID.test(v) ? v : null);
51
+ const bool = (v) => v === true;
52
+ const drop = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));
53
+
54
+ export function cleanStaff(s) {
55
+ if (!s || typeof s !== 'object' || !id(s.id)) return null;
56
+ const colour = (v) => (typeof v === 'string' && COLOUR.test(v) ? v : undefined);
57
+ return drop({
58
+ id: s.id, name: str(s.name, 40) || s.id, role: str(s.role, 60),
59
+ shirt: colour(s.shirt), hair: colour(s.hair), skin: colour(s.skin),
60
+ personality: str(s.personality, 300), background: str(s.background, 300),
61
+ });
62
+ }
63
+
64
+ export function cleanEntry(e) {
65
+ if (!e || typeof e !== 'object' || !LOG_KINDS.has(e.kind)) return null;
66
+ return drop({
67
+ kind: e.kind, summary: str(e.summary, 8000), ts: num(e.ts) ?? undefined,
68
+ ok: e.kind === 'result' ? bool(e.ok) : undefined,
69
+ ms: num(e.ms) ?? undefined, cost: num(e.cost) ?? undefined,
70
+ station: STATIONS.has(e.station) ? e.station : undefined,
71
+ blocked: e.blocked === true ? true : undefined, wait: e.wait === true ? true : undefined,
72
+ });
73
+ }
74
+
75
+ function cleanChanges(c) {
76
+ if (!c || typeof c !== 'object' || !Array.isArray(c.files)) return null;
77
+ const sha = (v) => (typeof v === 'string' && /^[0-9a-f]{4,64}$/.test(v) ? v : null);
78
+ return {
79
+ base: sha(c.base), end: sha(c.end), count: count(c.count), add: count(c.add), del: count(c.del),
80
+ files: c.files.slice(0, 200).filter((f) => f && typeof f.path === 'string' && f.path && !f.path.includes('\0')).map((f) => ({
81
+ path: f.path.slice(0, 500), status: FILE_STATUSES.has(f.status) ? f.status : 'modified', add: count(f.add), del: count(f.del), binary: bool(f.binary),
82
+ })),
83
+ };
84
+ }
85
+
86
+ export function cleanJob(j) {
87
+ if (!j || typeof j !== 'object' || !id(j.id) || !STATUSES.has(j.status)) return null;
88
+ const from = typeof j.from === 'string' && HANDLE.test(j.from) ? j.from : undefined;
89
+ return drop({
90
+ id: j.id, repo: typeof j.repo === 'string' && REPO.test(j.repo) ? j.repo : '', title: str(j.title, 200),
91
+ mode: CLEARANCE.includes(j.mode) ? j.mode : 'plan', model: typeof j.model === 'string' && /^[\w.:-]{0,60}$/.test(j.model) ? j.model : '',
92
+ status: j.status, createdAt: num(j.createdAt), queuedAt: num(j.queuedAt), startedAt: num(j.startedAt), endedAt: num(j.endedAt),
93
+ agentId: id(j.agentId), turns: count(j.turns), tools: count(j.tools), context: count(j.context),
94
+ error: optStr(j.error, 500), waiting: bool(j.waiting), awaitingBoss: bool(j.awaitingBoss),
95
+ asking: j.asking && ASKING.has(j.asking.kind) ? { kind: j.asking.kind, summary: str(j.asking.summary, 500) } : null,
96
+ changes: cleanChanges(j.changes), from, guest: j.guest && id(j.guest.link) ? { link: j.guest.link } : undefined,
97
+ log: Array.isArray(j.log) ? j.log.slice(-500).map(cleanEntry).filter(Boolean) : [],
98
+ });
99
+ }
100
+
101
+ // One feed message, or null.
102
+ export function cleanFeed(m) {
103
+ if (!m || typeof m !== 'object') return null;
104
+ if (m.t === 'job') { const job = cleanJob(m.job); return job && { t: 'job', job }; }
105
+ if (m.t === 'log') { const entry = cleanEntry(m.entry); return entry && id(m.jobId) && { t: 'log', jobId: m.jobId, entry }; }
106
+ if (m.t === 'staff') return Array.isArray(m.dismissed) ? { t: 'staff', dismissed: m.dismissed.filter((x) => id(x)).slice(0, 50) } : null;
107
+ return null;
108
+ }
109
+
110
+ // The host's answer to each request, rebuilt.
111
+ const CLEAN_BODY = {
112
+ createJob: (b) => { const job = cleanJob(b.job); if (!job) throw fail(502, 'The host sent back a job that doesn’t look right.'); return { job }; },
113
+ job: (b) => { const job = cleanJob(b.job); if (!job) throw fail(502, 'The host sent back a job that doesn’t look right.'); return { job }; },
114
+ diff: (b) => ({ diff: str(b.diff, 2_000_000) }),
115
+ reply: () => ({ ok: true }),
116
+ stop: () => ({ ok: true }),
117
+ hello: (b) => b,
118
+ };
119
+
120
+ // What a guest may see of one of its own jobs.
121
+ export function guestJob(j, tail = 40) {
122
+ const { id, repo, title, mode, model, status, createdAt, queuedAt, startedAt, endedAt, agentId, turns, tools, error, waiting, asking, awaitingBoss, changes, context, from, guest } = j;
123
+ return { id, repo, title, mode, model, status, createdAt, queuedAt, startedAt, endedAt, agentId, turns, tools, error, waiting, asking, awaitingBoss, changes, context, from, guest, log: (j.log || []).slice(-tail) };
124
+ }
125
+
126
+ export class Guests {
127
+ // office: { jobs(), createJob(body, extra), replyJob(job, body), stopJob(job), diff(job, path), staff(), modes() }
128
+ constructor(dataDir, { links, office }) {
129
+ this.links = links;
130
+ this.office = office;
131
+ this.file = path.join(dataDir, 'guests.json');
132
+ this.used = {};
133
+ try { this.used = JSON.parse(fs.readFileSync(this.file, 'utf8')).used || {}; } catch {}
134
+ this.queue = new Map(); // link id -> messages waiting for the next feed frame
135
+ this.flushTimer = null;
136
+ this.visits = new Map(); // link id -> Set of browser sockets (as guest)
137
+ this.pending = new Map(); // req id -> { resolve, reject, timer } (as guest)
138
+ }
139
+
140
+ // Every req, res and feed frame on a guest link comes through here.
141
+ onGuest(link, msg) {
142
+ if (link.role === 'host' && msg.t === 'req') return this.answer(link, msg);
143
+ if (link.role === 'guest' && msg.t === 'res') return this.settle(msg);
144
+ if (link.role === 'guest' && msg.t === 'feed') return this.fromHost(link, msg);
145
+ }
146
+
147
+ // ---------- as host ----------
148
+
149
+ policy(link) {
150
+ const p = this.links.guestPolicy(link.id);
151
+ if (!p) throw fail(403, 'This office has no guest access set up for you.');
152
+ return p;
153
+ }
154
+
155
+ tokensToday(linkId) {
156
+ const u = this.used[linkId];
157
+ return u?.day === utcDay() ? u.tokens : 0;
158
+ }
159
+
160
+ // The guest's own jobs, open ones only if asked.
161
+ jobsOf(linkId, open = false) {
162
+ return this.office.jobs().filter((j) => j.guest?.link === linkId && (!open || j.status === 'queued' || j.status === 'working'));
163
+ }
164
+
165
+ ownJob(link, id) {
166
+ const job = this.office.jobs().find((j) => j.id === String(id ?? '') && j.guest?.link === link.id);
167
+ if (!job) throw fail(404, 'No such job of yours.');
168
+ return job;
169
+ }
170
+
171
+ capMode(p, mode = 'edit') {
172
+ if (!CLEARANCE.includes(mode)) throw fail(400, 'Unknown mode.');
173
+ if (CLEARANCE.indexOf(mode) > CLEARANCE.indexOf(p.clearance)) throw fail(403, `Your host allows up to ${p.clearance === 'plan' ? 'Plan only' : 'Edit files'}.`);
174
+ return mode;
175
+ }
176
+
177
+ checkTokens(link, p) {
178
+ if (this.tokensToday(link.id) >= p.tokensPerDay) throw fail(429, 'You have used today’s tokens for this office. They reset at midnight UTC.');
179
+ }
180
+
181
+ async answer(link, msg) {
182
+ const id = String(msg.id ?? '').slice(0, 64);
183
+ const args = msg.args && typeof msg.args === 'object' ? msg.args : {};
184
+ let body;
185
+ try {
186
+ const p = this.policy(link);
187
+ body = await this.op(link, p, String(msg.op ?? ''), args);
188
+ } catch (err) {
189
+ if (!err.code) console.error('[guests] request failed', link.id, msg.op, err);
190
+ return this.links.send({ t: 'res', link: link.id, id, ok: false, code: err.code || 400, error: err.message });
191
+ }
192
+ this.links.send({ t: 'res', link: link.id, id, ok: true, body });
193
+ }
194
+
195
+ async op(link, p, op, args) {
196
+ switch (op) {
197
+ case 'hello':
198
+ return {
199
+ staff: this.office.staff(),
200
+ modes: Object.fromEntries(Object.entries(this.office.modes()).filter(([k]) => CLEARANCE.indexOf(k) <= CLEARANCE.indexOf(p.clearance))),
201
+ jobs: this.jobsOf(link.id).map((j) => guestJob(j)),
202
+ policy: { repos: p.repos, clearance: p.clearance, tokensPerDay: p.tokensPerDay, tokensToday: this.tokensToday(link.id) },
203
+ };
204
+ case 'createJob': {
205
+ if (!p.repos.includes(args.repo)) throw fail(403, 'That project isn’t shared with you.');
206
+ const mode = this.capMode(p, args.mode);
207
+ this.checkTokens(link, p);
208
+ if (this.jobsOf(link.id, true).length >= MAX_OPEN_JOBS) throw fail(429, `You can have ${MAX_OPEN_JOBS} jobs going at once here. Wait for one to finish.`);
209
+ const prompt = String(args.prompt ?? '').slice(0, MAX_PROMPT);
210
+ const job = this.office.createJob(
211
+ { repo: args.repo, prompt, mode, model: typeof args.model === 'string' ? args.model : '', agentId: typeof args.agentId === 'string' ? args.agentId : '' },
212
+ { from: `@${link.peer}`, guest: { link: link.id } },
213
+ );
214
+ return { job: guestJob(job) };
215
+ }
216
+ case 'reply': {
217
+ const job = this.ownJob(link, args.job);
218
+ const mode = args.mode === undefined ? undefined : this.capMode(p, args.mode);
219
+ this.checkTokens(link, p);
220
+ this.office.replyJob(job, { message: String(args.text ?? '').slice(0, MAX_PROMPT), mode });
221
+ return { ok: true };
222
+ }
223
+ case 'stop':
224
+ this.office.stopJob(this.ownJob(link, args.job));
225
+ return { ok: true };
226
+ case 'job':
227
+ return { job: guestJob(this.ownJob(link, args.job), 500) };
228
+ case 'diff':
229
+ return { diff: await this.office.diff(this.ownJob(link, args.job), String(args.path ?? '')) };
230
+ default:
231
+ throw fail(400, 'Guests can’t do that here.');
232
+ }
233
+ }
234
+
235
+ // Tokens a guest job used: counted against its guest, and a guest over its
236
+ // day's allowance has its running jobs stopped.
237
+ addTokens(job, n) {
238
+ const linkId = job.guest?.link;
239
+ if (!linkId || !(n > 0)) return;
240
+ const day = utcDay();
241
+ const u = this.used[linkId]?.day === day ? this.used[linkId] : { day, tokens: 0 };
242
+ u.tokens += n;
243
+ this.used[linkId] = u;
244
+ this.save();
245
+ const p = this.links.guestPolicy(linkId);
246
+ if (p && u.tokens >= p.tokensPerDay) {
247
+ for (const j of this.jobsOf(linkId, true)) this.office.stopJob(j, 'Stopped: the guest used today’s tokens.');
248
+ }
249
+ }
250
+
251
+ save() {
252
+ // Old days and gone links aren't worth keeping.
253
+ const day = utcDay();
254
+ for (const [id, u] of Object.entries(this.used)) if (u.day !== day) delete this.used[id];
255
+ try {
256
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
257
+ fs.writeFileSync(this.file + '.tmp', JSON.stringify({ used: this.used }));
258
+ fs.renameSync(this.file + '.tmp', this.file);
259
+ } catch (err) { console.error('[guests] could not save', err.message); }
260
+ }
261
+
262
+ // Everything the office broadcasts passes here; a guest gets only its own
263
+ // jobs (and staff changes), batched so a busy log stays under the relay's
264
+ // frame rate.
265
+ feed(msg) {
266
+ if (!FEED_TYPES.has(msg.t)) return;
267
+ const hosting = this.links.links.filter((l) => l.kind === 'guest' && l.role === 'host');
268
+ if (!hosting.length) return;
269
+ let linkId = null;
270
+ if (msg.t === 'job') linkId = msg.job?.guest?.link;
271
+ if (msg.t === 'log') linkId = this.office.jobs().find((j) => j.id === msg.jobId)?.guest?.link;
272
+ for (const l of hosting) {
273
+ if (msg.t !== 'staff' && l.id !== linkId) continue;
274
+ const out = msg.t === 'job' ? { t: 'job', job: guestJob(msg.job, 0) } : msg;
275
+ this.queue.set(l.id, [...(this.queue.get(l.id) || []), out]);
276
+ }
277
+ if (this.queue.size && !this.flushTimer) this.flushTimer = setTimeout(() => this.flush(), FEED_EVERY_MS);
278
+ }
279
+
280
+ flush() {
281
+ this.flushTimer = null;
282
+ for (const [linkId, msgs] of this.queue) {
283
+ let batch = [], size = 0;
284
+ for (const m of msgs) {
285
+ const n = JSON.stringify(m).length;
286
+ if (n > FEED_BYTES) continue; // one oversized log entry is not worth a dropped frame
287
+ if (size + n > FEED_BYTES) { this.links.send({ t: 'feed', link: linkId, msgs: batch }); batch = []; size = 0; }
288
+ batch.push(m);
289
+ size += n;
290
+ }
291
+ if (batch.length) this.links.send({ t: 'feed', link: linkId, msgs: batch });
292
+ }
293
+ this.queue.clear();
294
+ }
295
+
296
+ // The relay's list changed. A guest whose link has gone loses its running
297
+ // jobs at once, and anyone visiting over a gone link is sent home.
298
+ linksChanged() {
299
+ const live = new Set(this.links.links.map((l) => l.id));
300
+ for (const j of this.office.jobs()) {
301
+ if (j.guest && !live.has(j.guest.link) && (j.status === 'queued' || j.status === 'working')) this.office.stopJob(j, 'Stopped: the guest was unlinked.');
302
+ }
303
+ for (const [linkId, socks] of this.visits) {
304
+ if (live.has(linkId)) continue;
305
+ for (const ws of socks) ws.close(4404, 'This office unlinked you.');
306
+ this.visits.delete(linkId);
307
+ }
308
+ }
309
+
310
+ // ---------- as guest ----------
311
+
312
+ guestLink(linkId) {
313
+ const link = this.links.links.find((l) => l.id === linkId && l.kind === 'guest' && l.role === 'guest');
314
+ if (!link) throw fail(404, 'You aren’t a guest of that office.');
315
+ return link;
316
+ }
317
+
318
+ async request(linkId, op, args = {}) {
319
+ const link = this.guestLink(linkId);
320
+ // Just after linking (or a reconnect) the relay socket can still be
321
+ // opening; a visit started then shouldn't fail for it.
322
+ for (const end = Date.now() + CONNECT_WAIT_MS; !this.links.status().connected && Date.now() < end;) await new Promise((r) => setTimeout(r, 100));
323
+ const id = crypto.randomUUID();
324
+ return new Promise((resolve, reject) => {
325
+ const timer = setTimeout(() => { this.pending.delete(id); reject(fail(504, `@${link.peer} didn’t answer.`)); }, REQ_TIMEOUT_MS);
326
+ const clean = CLEAN_BODY[op] || (() => ({}));
327
+ this.pending.set(id, { resolve: (body) => { try { resolve(clean(body)); } catch (err) { reject(err); } }, reject, timer });
328
+ if (!this.links.send({ t: 'req', link: link.id, id, op, args })) {
329
+ clearTimeout(timer);
330
+ this.pending.delete(id);
331
+ reject(fail(502, 'Not connected to the relay.'));
332
+ }
333
+ });
334
+ }
335
+
336
+ settle(msg) {
337
+ const p = this.pending.get(String(msg.id));
338
+ if (!p) return;
339
+ this.pending.delete(String(msg.id));
340
+ clearTimeout(p.timer);
341
+ if (msg.ok) p.resolve(msg.body && typeof msg.body === 'object' ? msg.body : {});
342
+ else p.reject(fail(Number.isInteger(msg.code) && msg.code >= 400 && msg.code < 600 ? msg.code : 400, typeof msg.error === 'string' && msg.error ? msg.error.slice(0, 300) : 'The host refused.'));
343
+ }
344
+
345
+ fromHost(link, msg) {
346
+ const socks = this.visits.get(link.id);
347
+ if (!socks?.size || !Array.isArray(msg.msgs)) return;
348
+ for (const raw of msg.msgs.slice(0, 500)) {
349
+ const m = cleanFeed(raw);
350
+ if (!m) continue;
351
+ const data = JSON.stringify(m);
352
+ for (const ws of socks) if (ws.readyState === 1) ws.send(data);
353
+ }
354
+ }
355
+
356
+ // The host's hello, rebuilt. Mode labels are ours, not the host's.
357
+ cleanHello(link, hello) {
358
+ const policy = hello?.policy && typeof hello.policy === 'object' ? hello.policy : {};
359
+ const clearance = CLEARANCE.includes(policy.clearance) ? policy.clearance : 'plan';
360
+ const offered = hello?.modes && typeof hello.modes === 'object' ? hello.modes : {};
361
+ const ours = this.office.modes();
362
+ return {
363
+ t: 'hello',
364
+ staff: (Array.isArray(hello?.staff) ? hello.staff : []).slice(0, 50).map(cleanStaff).filter(Boolean),
365
+ modes: Object.fromEntries(CLEARANCE.filter((k) => Object.hasOwn(offered, k) && CLEARANCE.indexOf(k) <= CLEARANCE.indexOf(clearance)).map((k) => [k, ours[k]])),
366
+ jobs: (Array.isArray(hello?.jobs) ? hello.jobs : []).slice(0, 200).map(cleanJob).filter(Boolean),
367
+ chat: [],
368
+ channels: [],
369
+ visiting: {
370
+ link: link.id, host: link.peer,
371
+ repos: (Array.isArray(policy.repos) ? policy.repos : []).filter((r) => typeof r === 'string' && REPO.test(r)).slice(0, 50),
372
+ clearance, tokensPerDay: count(policy.tokensPerDay), tokensToday: count(policy.tokensToday),
373
+ },
374
+ };
375
+ }
376
+
377
+ // A browser opened /ws/visit/:link. The host's hello, cut down and labelled.
378
+ async visit(ws, linkId, extra = () => ({})) {
379
+ let link;
380
+ try { link = this.guestLink(linkId); } catch { return ws.close(4404, 'You aren’t a guest of that office.'); }
381
+ const socks = this.visits.get(link.id) || new Set();
382
+ this.visits.set(link.id, socks);
383
+ socks.add(ws);
384
+ ws.on('close', () => { socks.delete(ws); if (!socks.size) this.visits.delete(link.id); });
385
+ let hello;
386
+ try { hello = await this.request(link.id, 'hello'); }
387
+ catch (err) { return ws.close(4502, String(err.message).slice(0, 120)); }
388
+ if (ws.readyState !== 1) return;
389
+ ws.send(JSON.stringify({ ...extra(), ...this.cleanHello(link, hello) }));
390
+ }
391
+ }