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/server/chief.js CHANGED
@@ -24,7 +24,7 @@ export function canSee(files) {
24
24
  const SCHEMA = {
25
25
  type: 'object',
26
26
  properties: {
27
- action: { type: 'string', enum: ['followup', 'new', 'dismiss', 'recall', 'standup', 'ask', 'chat'] },
27
+ action: { type: 'string', enum: ['followup', 'new', 'dismiss', 'recall', 'clear', 'standup', 'ask', 'chat'] },
28
28
  jobId: { type: 'string' },
29
29
  agentId: { type: 'string' },
30
30
  agentIds: { type: 'array', items: { type: 'string' } },
@@ -45,6 +45,7 @@ Actions:
45
45
  - "new": a new piece of work. Give "repo" (exactly one name from Projects), "brief" (the task in the boss's own words, plus any context from recent chat that the agent needs; never add requirements), "agentId" (the tagged agent's id; otherwise a free agent whose role in Staff clearly fits the work; otherwise "" for whoever is free), and "model" (see Sizing the work). Never pick a busy or dismissed agent unless the boss tagged them.
46
46
  - "dismiss": the boss wants agents sent home (dismissed, let go for now, off the floor). Give "agentIds": the ids of exactly who they mean, worked out from Staff (e.g. "everyone who's free except Mira" means every free agent but Mira). Only free agents can go; leave out anyone working.
47
47
  - "recall": the boss wants dismissed agents back in. Give "agentIds" (every dismissed agent if they say "everyone").
48
+ - "clear": the boss wants agents to clear their context (start fresh, forget the conversation so far). Do it yourself; never hand it out as a job. Give "agentIds" for the agents they name, or leave it empty when they mean everyone.
48
49
  - "standup": the boss wants the team to hold a standup now (e.g. "standup time", "let's do standup", "gather everyone for a status update").
49
50
  - "ask": you can't tell which project new work belongs to, or the request is too unclear to start. Ask one short question.
50
51
  - "chat": greetings, thanks, or questions about the office itself (who is doing what). Answer from the context given. If your answer is about one particular job, give its jobId too, so the boss gets a link to where that job's updates are collecting.
@@ -90,7 +91,8 @@ function ask(context, { bin, model, timeoutMs, env, images }) {
90
91
  let out = '', err = '';
91
92
  child.stdout.on('data', (d) => (out += d));
92
93
  child.stderr.on('data', (d) => (err += d));
93
- const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs);
94
+ let timedOut = false;
95
+ const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, timeoutMs);
94
96
  child.on('error', (e) => { clearTimeout(timer); reject(e); });
95
97
  child.on('close', () => {
96
98
  clearTimeout(timer);
@@ -99,7 +101,7 @@ function ask(context, { bin, model, timeoutMs, env, images }) {
99
101
  const decision = r.structured_output || JSON.parse(r.result);
100
102
  resolve({ ...decision, cost: r.total_cost_usd || 0 });
101
103
  } catch {
102
- reject(new Error(err.trim().split('\n').pop() || 'The chief could not decide.'));
104
+ reject(new Error(whyNot(out, err, streamed, timedOut, timeoutMs)));
103
105
  }
104
106
  });
105
107
  child.stdin.on('error', () => {});
@@ -119,6 +121,21 @@ function message(context, images) {
119
121
  }
120
122
 
121
123
  // The decision is in the last line of the stream, the one the run ends on.
124
+ // Why a routing call gave no decision, in words the boss can act on. claude
125
+ // reports most failures (not signed in, out of credit, a model the account
126
+ // can't use) inside its JSON on stdout with nothing on stderr, so read that
127
+ // before falling back.
128
+ function whyNot(out, err, streamed, timedOut, timeoutMs) {
129
+ if (timedOut) return `The chief took longer than ${Math.round(timeoutMs / 1000)} seconds to answer. Check that \`claude -p "hi"\` answers in a terminal.`;
130
+ let r = null;
131
+ try { r = streamed ? result(out) : JSON.parse(out); } catch {}
132
+ const said = String(r?.is_error ? r.result || r.subtype || '' : '').trim();
133
+ if (said) return `claude said: ${said.split('\n')[0].slice(0, 300)}`;
134
+ const last = err.trim().split('\n').pop();
135
+ if (last) return last.slice(0, 300);
136
+ return out.trim() ? 'The chief could not decide.' : 'claude exited without answering. Check that `claude -p "hi"` answers in a terminal.';
137
+ }
138
+
122
139
  function result(out) {
123
140
  for (const line of out.trim().split('\n').reverse()) {
124
141
  try {
@@ -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
+ }