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.
package/server/index.js CHANGED
@@ -16,6 +16,8 @@ import { Terminals } from './terminal.js';
16
16
  import { snapshotTree, summarize, fileDiff } from './changes.js';
17
17
  import { Auth } from './auth.js';
18
18
  import { Leaderboard } from './leaderboard.js';
19
+ import { Links } from './links.js';
20
+ import { Guests } from './guests.js';
19
21
 
20
22
  const HERE = path.dirname(fileURLToPath(import.meta.url));
21
23
  const APP = path.resolve(HERE, '..');
@@ -26,6 +28,12 @@ const DATA = path.resolve(process.env.OFFICE_DATA || path.join(APP, 'data'));
26
28
  const STATE_FILE = path.join(DATA, 'state.json');
27
29
  const DIST = path.join(APP, 'dist');
28
30
  const UPLOADS = path.join(DATA, 'uploads');
31
+ // Each running job's office MCP config, token and all. On the command line it
32
+ // would be readable by every process on the machine (`ps`); here it is a 0600
33
+ // file in a 0700 folder, gone when the job's session closes. Leftovers from a
34
+ // crash hold tokens that died with that server, so they are cleared at start.
35
+ const MCP_DIR = path.join(DATA, 'mcp');
36
+ fs.rmSync(MCP_DIR, { recursive: true, force: true });
29
37
  const auth = new Auth(DATA);
30
38
  const board = new Leaderboard(DATA, { onChange: () => send({ t: 'leaderboard', leaderboard: board.status() }) });
31
39
  const MAX_UPLOAD = 25 * 1024 * 1024;
@@ -272,9 +280,62 @@ function save() {
272
280
  // ---------- broadcasting ----------
273
281
 
274
282
  const clients = new Set();
283
+
284
+ // Linked offices. Our agents as other offices see them: who, what they look
285
+ // like, and what they're doing, down to the job title.
286
+ // A job's title is the first line of its prompt, which can say more than the
287
+ // boss means to share, so linked offices see only that an agent is busy.
288
+ // OFFICE_SHARE_JOB_TITLES=1 sends the titles too.
289
+ const SHARE_TITLES = process.env.OFFICE_SHARE_JOB_TITLES === '1';
290
+ function presence() {
291
+ return hired().map((s) => {
292
+ const job = state.jobs.find((j) => j.agentId === s.id && j.status === 'working');
293
+ const doing =
294
+ state.dismissed.includes(s.id) ? 'away'
295
+ : standupNow?.who.includes(s.id) ? 'meeting'
296
+ : job ? 'working'
297
+ : chill?.who.includes(s.id) ? 'break'
298
+ : 'idle';
299
+ return { id: s.id, name: s.name, role: s.role, look: { shirt: s.shirt, hair: s.hair, skin: s.skin }, state: doing, job: job && SHARE_TITLES ? { title: job.title } : null };
300
+ });
301
+ }
302
+ // Set just below; anything broadcast before then has no guests to tell.
303
+ let guests = null;
304
+ const links = new Links(DATA, {
305
+ board,
306
+ presence,
307
+ onChange: () => {
308
+ guests?.linksChanged();
309
+ send({ t: 'links', ...links.status() });
310
+ send({ t: 'visitors', offices: links.visiting() });
311
+ },
312
+ onGuest: (link, msg) => guests?.onGuest(link, msg),
313
+ });
314
+ // Guest offices running jobs here, and our visits to offices that host us.
315
+ guests = new Guests(DATA, {
316
+ links,
317
+ office: {
318
+ jobs: () => state.jobs,
319
+ createJob: (body, extra) => createJob(body, extra),
320
+ replyJob: (job, body) => replyJob(job, body),
321
+ stopJob: (job, why) => stopJob(job, why),
322
+ diff: async (job, p) => {
323
+ const cwd = repoPath(job.repo);
324
+ if (!cwd) throw Object.assign(new Error('That project folder is gone.'), { code: 404 });
325
+ return fileDiff(cwd, job.changes, p);
326
+ },
327
+ staff: () => STAFF,
328
+ modes: () => Object.fromEntries(Object.entries(MODES).map(([k, v]) => [k, v.label])),
329
+ },
330
+ });
331
+ // Broadcasts that can't change what an agent is doing don't need presence checked.
332
+ const NOT_PRESENCE = new Set(['links', 'visitors', 'proc', 'leaderboard', 'auth', 'usage']);
333
+
275
334
  function send(msg) {
276
335
  const data = JSON.stringify(msg);
277
336
  for (const ws of clients) if (ws.readyState === 1) ws.send(data);
337
+ guests?.feed(msg);
338
+ if (!NOT_PRESENCE.has(msg.t)) links.sendPresence();
278
339
  }
279
340
  const publicJob = (j, tail = 40) => {
280
341
  const { stopRequested, lastTurn, sent, handoffs, ...rest } = j;
@@ -294,6 +355,8 @@ const snapshot = () => ({
294
355
  usage,
295
356
  auth: auth.status(),
296
357
  leaderboard: board.status(),
358
+ links: links.status(),
359
+ visitors: links.visiting(),
297
360
  procs,
298
361
  machine,
299
362
  dismissed: state.dismissed,
@@ -524,6 +587,10 @@ const OFFICE_TOOLS = [
524
587
  'mcp__office__list_channels', 'mcp__office__create_channel', 'mcp__office__invite_to_channel', 'mcp__office__post_to_channel',
525
588
  'mcp__office__restart_office',
526
589
  ];
590
+ // A guest's job can see who is in and ask the boss, nothing more: no
591
+ // messaging colleagues, handing off or channels. Enforced again in agentApi,
592
+ // since full autonomy ignores --allowedTools.
593
+ const GUEST_TOOLS = ['mcp__office__office_roster', 'mcp__office__flag_for_boss'];
527
594
  const endGrace = (job) => { clearTimeout(graces.get(job.id)); graces.delete(job.id); };
528
595
 
529
596
  function repoPath(name) {
@@ -540,7 +607,8 @@ function listRepos() {
540
607
  .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
541
608
  }
542
609
 
543
- function createJob({ repo, prompt, mode = 'edit', model = '', auto = false, agentId = '' }) {
610
+ // `extra` tags a job, as a guest's (`from`, `guest`); it must be set before the job can start.
611
+ function createJob({ repo, prompt, mode = 'edit', model = '', auto = false, agentId = '' }, extra = {}) {
544
612
  if (!repoPath(repo)) throw new Error('Unknown project folder.');
545
613
  prompt = String(prompt || '').trim();
546
614
  if (!prompt) throw new Error('Give the job a brief.');
@@ -555,6 +623,7 @@ function createJob({ repo, prompt, mode = 'edit', model = '', auto = false, agen
555
623
  sessionId: null, agentId: null, cost: 0, turns: 0, tools: 0, xp: 0,
556
624
  pending: prompt, denials: [], error: null, log: [],
557
625
  wantAgent: STAFF.some((st) => st.id === agentId) ? agentId : null,
626
+ ...extra,
558
627
  };
559
628
  state.jobs.push(job);
560
629
  if (job.wantAgent && state.dismissed.includes(job.wantAgent)) recall([job.wantAgent]);
@@ -588,10 +657,18 @@ function start(job, agentId) {
588
657
  tokens.set(job.id, token);
589
658
  const mcp = { mcpServers: { office: { command: process.execPath, args: [path.join(HERE, 'office-mcp.js')],
590
659
  env: { OFFICE_URL: `http://127.0.0.1:${PORT}`, OFFICE_JOB: job.id, OFFICE_TOKEN: token } } } };
660
+ const mcpFile = path.join(MCP_DIR, `${job.id}-${crypto.randomUUID().slice(0, 8)}.json`);
591
661
  // Attachments live outside the project, so agents need leave to read them.
592
- fs.mkdirSync(UPLOADS, { recursive: true });
593
- args.push('--add-dir', UPLOADS);
594
- args.push('--mcp-config', JSON.stringify(mcp), '--allowedTools', OFFICE_TOOLS.join(','), '--append-system-prompt', officePrompt(agentId, job));
662
+ // A guest's job gets none of them: they are the boss's files.
663
+ if (!job.guest) {
664
+ fs.mkdirSync(UPLOADS, { recursive: true });
665
+ args.push('--add-dir', UPLOADS);
666
+ } else {
667
+ // Nor the boss's connectors (mail, Slack, calendar), user settings and
668
+ // hooks, or memory: at Full autonomy those would all be the guest's.
669
+ args.push('--strict-mcp-config', '--setting-sources', 'project,local', '--settings', JSON.stringify({ autoMemoryEnabled: false }));
670
+ }
671
+ args.push('--mcp-config', mcpFile, '--allowedTools', (job.guest ? GUEST_TOOLS : OFFICE_TOOLS).join(','), '--append-system-prompt', job.guest ? guestPrompt(agentId, job) : officePrompt(agentId, job));
595
672
  // Being given work in a channel is how you end up in it.
596
673
  try { joinChannel(channelOf(job.channel), agentId); } catch {}
597
674
  const first = job.pending;
@@ -610,7 +687,14 @@ function start(job, agentId) {
610
687
 
611
688
  // stdin stays open for the whole job, so the chief can hand the agent
612
689
  // new messages while it works.
613
- const child = spawn(CLAUDE, args, { cwd, env: { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: AUTOCOMPACT_PCT, ...auth.env() }, stdio: ['pipe', 'pipe', 'pipe'] });
690
+ try {
691
+ fs.mkdirSync(MCP_DIR, { recursive: true, mode: 0o700 });
692
+ fs.chmodSync(MCP_DIR, 0o700);
693
+ fs.writeFileSync(mcpFile, JSON.stringify(mcp), { mode: 0o600, flag: 'wx' });
694
+ } catch (err) {
695
+ return finish(job, 'failed', `Could not write the office tools' config: ${err.message}`);
696
+ }
697
+ const child = spawn(CLAUDE, args, { cwd, env: { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: AUTOCOMPACT_PCT, ...(job.guest ? { CLAUDE_CODE_DISABLE_AUTO_MEMORY: '1' } : {}), ...auth.env() }, stdio: ['pipe', 'pipe', 'pipe'] });
614
698
  running.set(job.id, child);
615
699
  startProcs();
616
700
  child.stdin.on('error', () => {});
@@ -633,10 +717,11 @@ function start(job, agentId) {
633
717
 
634
718
  const ended = (why) => {
635
719
  running.delete(job.id);
720
+ fs.rm(mcpFile, { force: true }, () => {});
636
721
  if (procs) { delete procs[job.id]; }
637
722
  endGrace(job);
638
723
  if (job.status !== 'working') return;
639
- if (job.stopRequested) finish(job, 'stopped', 'Stopped by you.');
724
+ if (job.stopRequested) finish(job, 'stopped', job.stopWhy || 'Stopped by you.');
640
725
  else if (job.lastTurn) finish(job, job.lastTurn.ok ? 'done' : 'failed', job.lastTurn.ok ? null : job.lastTurn.error);
641
726
  else finish(job, 'failed', errTail.trim().split('\n').pop() || why);
642
727
  };
@@ -718,6 +803,7 @@ function onLine(job, line) {
718
803
  const u = e.usage || {};
719
804
  job.context = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0);
720
805
  board.addTokens((u.input_tokens || 0) + (u.output_tokens || 0));
806
+ guests.addTokens(job, (u.input_tokens || 0) + (u.output_tokens || 0));
721
807
  board.addTime(e.duration_ms);
722
808
  nudgeUsage();
723
809
  job.denials = [...new Set((e.permission_denials || []).map((d) => d.tool_name))];
@@ -803,7 +889,7 @@ function finish(job, status, error) {
803
889
  // gets it, and the asker's own finish is the one worth announcing).
804
890
  if (!retry && status !== 'stopped' && !job.requestedBy) {
805
891
  const line = awaiting ? `${nameOf(job.agentId)} is waiting on you for "${clip(job.title, 80)}": ${clip(job.asking.summary, 200)}`
806
- : status === 'done' ? `${nameOf(job.agentId)} finished "${clip(job.title, 80)}".` : `${nameOf(job.agentId)} hit a wall on "${clip(job.title, 80)}": ${clip(error || 'something went wrong', 200)}`;
892
+ : status === 'done' ? `${nameOf(job.agentId)} finished "${clip(job.title, 80)}".${briefOf(job.lastTurn?.text) ? `\n\n${briefOf(job.lastTurn.text)}` : ''}` : `${nameOf(job.agentId)} hit a wall on "${clip(job.title, 80)}": ${clip(error || 'something went wrong', 200)}`;
807
893
  post('chief', `**${awaiting ? 'Waiting on you' : status === 'done' ? 'Done' : 'Failed'}** · ${line}`, { kind: 'finished', status, awaiting, jobId: job.id, thread: null, channel: GENERAL });
808
894
  }
809
895
 
@@ -882,20 +968,21 @@ function addWait(job, w) {
882
968
  waits.set(job.id, [...(waits.get(job.id) || []), w]);
883
969
  }
884
970
 
885
- function stopJob(job) {
971
+ function stopJob(job, why) {
886
972
  if (job.status === 'queued') {
887
973
  job.status = 'stopped';
888
- job.error = 'Pulled from the queue.';
974
+ job.error = why || 'Pulled from the queue.';
889
975
  job.endedAt = Date.now();
890
976
  pushJob(job);
891
977
  } else if (job.status === 'working') {
892
978
  job.stopRequested = true;
979
+ job.stopWhy = why || null;
893
980
  const child = running.get(job.id);
894
981
  if (child) {
895
982
  child.kill('SIGTERM');
896
983
  setTimeout(() => running.get(job.id) === child && child.kill('SIGKILL'), 5000);
897
984
  } else {
898
- finish(job, 'stopped', 'Stopped by you.');
985
+ finish(job, 'stopped', why || 'Stopped by you.');
899
986
  }
900
987
  }
901
988
  }
@@ -1235,6 +1322,21 @@ const clip = (s, n) => {
1235
1322
  s = String(s || '').replace(/\s+/g, ' ').trim();
1236
1323
  return s.length > n ? s.slice(0, n - 1) + '…' : s;
1237
1324
  };
1325
+ // A short plain-text brief of an agent's final reply: markdown stripped, the
1326
+ // opening sentences kept up to n characters, cut at a sentence end if possible.
1327
+ function briefOf(text, n = 280) {
1328
+ const plain = String(text || '')
1329
+ .replace(/```[\s\S]*?```/g, ' ')
1330
+ .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
1331
+ .replace(/^\s*(#{1,6}|[-*>]|\d+\.)\s+/gm, '')
1332
+ .replace(/[*_`]+/g, '')
1333
+ .replace(/\s+/g, ' ')
1334
+ .trim();
1335
+ if (plain.length <= n) return plain;
1336
+ const cut = plain.slice(0, n);
1337
+ const end = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('! '), cut.lastIndexOf('? '));
1338
+ return end > n * 0.4 ? cut.slice(0, end + 1) : clip(plain, n);
1339
+ }
1238
1340
  const ENDED = { done: 'Shipped', failed: 'Hit a wall on', stopped: 'Stopped' };
1239
1341
 
1240
1342
  // What each agent can honestly report since the last standup.
@@ -1390,13 +1492,25 @@ function officePrompt(agentId, job) {
1390
1492
  `This job is discussed in #${channelOf(job?.channel)}; everyday talk stays in #general.` + standupBrief(agentId);
1391
1493
  }
1392
1494
 
1495
+ // A guest's job: the agent works for someone from another office, so it is
1496
+ // told nothing about this office's other work and can't reach colleagues.
1497
+ function guestPrompt(agentId, job) {
1498
+ return `You are ${nameOf(agentId)}, an agent in the Agent Office, working on a job for ${job.from}, a guest from another office. ` + persona(agentId) +
1499
+ `Work only in this project. Don't share anything about this office's other projects, jobs or people beyond what office_roster shows. ` +
1500
+ `Your only office tools are office_roster and flag_for_boss; the boss here is the host, not ${job.from}. Talk to ${job.from} in your replies.`;
1501
+ }
1502
+
1393
1503
  const MAX_MESSAGES = 20, MAX_HANDOFFS = 5, MAX_DEPTH = 2, MAX_FLAGS = 5;
1394
1504
  const FLAGS = { question: 'Question for you', decision: 'Needs your decision', blocker: 'Blocked', review: 'Ready for your review' };
1395
1505
 
1396
1506
  function caller(req) {
1397
1507
  const id = req.headers['x-office-job'];
1398
1508
  const job = state.jobs.find((j) => j.id === id);
1399
- if (!job || job.status !== 'working' || !tokens.get(id) || tokens.get(id) !== req.headers['x-office-token']) return null;
1509
+ if (!job || job.status !== 'working' || !tokens.get(id)) return null;
1510
+ // Compared in constant time, so the token can't be guessed a byte at a time.
1511
+ const want = Buffer.from(tokens.get(id));
1512
+ const got = Buffer.from(String(req.headers['x-office-token'] || ''));
1513
+ if (got.length !== want.length || !crypto.timingSafeEqual(got, want)) return null;
1400
1514
  return job;
1401
1515
  }
1402
1516
 
@@ -1412,6 +1526,15 @@ function recentJobOf(agentId) {
1412
1526
  return last?.status === 'done' && Date.now() - last.endedAt < RESUME_WINDOW_MS ? last : null;
1413
1527
  }
1414
1528
 
1529
+ // Who is in, without what anyone is working on or where: a guest's job
1530
+ // shouldn't learn this office's other work.
1531
+ function guestRoster(me) {
1532
+ const lines = [`You are ${nameOf(me.agentId)}, on a job for ${me.from}.`, 'In the office today:'];
1533
+ for (const s of onFloor()) if (s.id !== me.agentId) lines.push(`- ${s.role ? `${s.name} (${s.role})` : s.name}`);
1534
+ lines.push('', `Project: ${me.repo}`);
1535
+ return lines.join('\n');
1536
+ }
1537
+
1415
1538
  const agentApi = {
1416
1539
  office_roster(me) {
1417
1540
  const lines = [`You are ${nameOf(me.agentId)}.`, 'Colleagues:'];
@@ -1437,6 +1560,8 @@ const agentApi = {
1437
1560
  if (!theirs || (theirs.status === 'working' && !running.has(theirs.id))) {
1438
1561
  throw new Error(`${target.name} isn't on a job right now. Use hand_off to give them work.`);
1439
1562
  }
1563
+ // A guest reads that job's log, so nothing from this office goes into it.
1564
+ if (theirs.guest) throw new Error(`${target.name} is on a job for a guest from another office and can't take messages.`);
1440
1565
 
1441
1566
  const myName = nameOf(me.agentId);
1442
1567
  deliver(theirs, `Message from ${myName} (working on "${me.title}" in ${me.repo}): ${message}` +
@@ -1718,14 +1843,66 @@ const terminals = new Terminals({ root: ROOT, dataDir: DATA, folders: listRepos
1718
1843
  // machine's Tailscale name when `tailscale serve` fronts the office. Everything
1719
1844
  // else is still refused, which is what keeps rebinding sites out.
1720
1845
  const LOCAL = new Set(['localhost', '127.0.0.1', '[::1]', ...String(process.env.OFFICE_ALLOWED_HOSTS || '').split(',').map((h) => h.trim().toLowerCase()).filter(Boolean)]);
1846
+ const DEV_PORT = '5317'; // vite.config.js
1847
+
1848
+ // The office key. Host and Origin checks keep other websites out, but not
1849
+ // other accounts on this machine (or the tailnet, with OFFICE_ALLOWED_HOSTS):
1850
+ // they can reach 127.0.0.1 too. So the page and its API need a secret only
1851
+ // this account can read. It is opened once with ?key=, which leaves it in an
1852
+ // HttpOnly cookie, and it stays the same across restarts. Delete the file to
1853
+ // sign every browser out. Agents use their job tokens instead.
1854
+ const KEY_FILE = path.join(DATA, 'office-key');
1855
+ const OFFICE_KEY = (() => {
1856
+ try { const k = fs.readFileSync(KEY_FILE, 'utf8').trim(); if (/^[0-9a-f]{64}$/.test(k)) return k; } catch {}
1857
+ const k = crypto.randomBytes(32).toString('hex');
1858
+ fs.mkdirSync(DATA, { recursive: true });
1859
+ fs.writeFileSync(KEY_FILE, k + '\n', { mode: 0o600 });
1860
+ return k;
1861
+ })();
1862
+ try { fs.chmodSync(KEY_FILE, 0o600); } catch {}
1863
+ // Cookies ignore ports, so two offices on one machine each need their own name.
1864
+ const KEY_COOKIE = `office_key_${PORT}`;
1865
+ const sameKey = (k) => {
1866
+ const a = Buffer.from(String(k || '')), b = Buffer.from(OFFICE_KEY);
1867
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
1868
+ };
1869
+ function hasKey(req) {
1870
+ for (const c of String(req.headers.cookie || '').split(';')) {
1871
+ const i = c.indexOf('=');
1872
+ if (i > 0 && c.slice(0, i).trim() === KEY_COOKIE) return sameKey(c.slice(i + 1).trim());
1873
+ }
1874
+ return false;
1875
+ }
1876
+ const keyLink = (host = `localhost:${PORT}`) => `http://${host}/?key=${OFFICE_KEY}`;
1877
+
1878
+ // What a browser without the key sees instead of the office.
1879
+ function locked(res) {
1880
+ res.writeHead(401, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
1881
+ res.end(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Agent Office</title>
1882
+ <style>body{font:16px/1.5 system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#11131a;color:#e8e9ee}main{max-width:30rem;padding:2rem}h1{font-size:1.25rem;margin:0 0 .75rem}p{margin:.5rem 0;color:#b4b7c3}code{font:.9em ui-monospace,monospace;background:#1d2030;padding:.1em .35em;border-radius:4px;color:#e8e9ee}</style>
1883
+ <main><h1>This office is locked</h1>
1884
+ <p>Open it with the link it printed when it started: the line that begins <code>Agent Office open on</code>.</p>
1885
+ <p>Lost it? Run <code>agentoffice --link</code> in a terminal on this machine.</p></main>`);
1886
+ }
1887
+
1721
1888
  function trusted(req) {
1722
1889
  // Anything that can spawn claude with full permissions must refuse
1723
1890
  // requests from other websites and from rebinding hostnames.
1724
1891
  const host = String(req.headers.host || '').replace(/:\d+$/, '');
1725
1892
  if (!LOCAL.has(host)) return false;
1726
1893
  const origin = req.headers.origin;
1894
+ // No Origin is not proof of a local caller: another site's <img> or <link>
1895
+ // GET arrives without one. So every GET must stay read-only, with no side
1896
+ // effects; anything that changes state or spawns goes behind another method.
1727
1897
  if (!origin) return true;
1728
- try { return LOCAL.has(new URL(origin).hostname); } catch { return false; }
1898
+ // Another port on localhost is another site: a project's dev server, or
1899
+ // anything an agent started. Only the office itself and its vite dev page.
1900
+ try {
1901
+ const o = new URL(origin);
1902
+ if (!LOCAL.has(o.hostname)) return false;
1903
+ if (!['localhost', '127.0.0.1', '[::1]'].includes(o.hostname)) return true;
1904
+ return [String(PORT), DEV_PORT].includes(o.port);
1905
+ } catch { return false; }
1729
1906
  }
1730
1907
 
1731
1908
  function readBody(req) {
@@ -1750,8 +1927,18 @@ const json = (res, code, body) => {
1750
1927
  };
1751
1928
 
1752
1929
  const TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.woff2': 'font/woff2', '.woff': 'font/woff', '.svg': 'image/svg+xml', '.png': 'image/png', '.webmanifest': 'application/manifest+json', '.ico': 'image/x-icon' };
1930
+ // Build files are public code with nothing of the boss's in them; anything
1931
+ // else falls back to the page, which needs the key.
1932
+ function isAsset(pathname) {
1933
+ let f;
1934
+ try { f = path.join(DIST, path.normalize(decodeURIComponent(pathname)).replace(/^(\.\.[\/\\])+/, '')); } catch { return false; }
1935
+ if (!f.startsWith(DIST + path.sep) || f === path.join(DIST, 'index.html')) return false;
1936
+ try { return fs.statSync(f).isFile(); } catch { return false; }
1937
+ }
1753
1938
  function serveStatic(req, res) {
1754
- const url = decodeURIComponent(new URL(req.url, 'http://x').pathname);
1939
+ let url;
1940
+ // A bad escape (`/%E0`) throws; outside a try that would take the office down.
1941
+ try { url = decodeURIComponent(new URL(req.url, 'http://x').pathname); } catch { return json(res, 400, { error: 'Bad path.' }); }
1755
1942
  let file = path.join(DIST, path.normalize(url).replace(/^(\.\.[\/\\])+/, ''));
1756
1943
  if (!file.startsWith(DIST)) return json(res, 403, { error: 'No.' });
1757
1944
  if (!fs.existsSync(file) || fs.statSync(file).isDirectory()) file = path.join(DIST, 'index.html');
@@ -1770,7 +1957,22 @@ const server = http.createServer(async (req, res) => {
1770
1957
  if (!trusted(req)) return json(res, 403, { error: 'Local only.' });
1771
1958
  const url = new URL(req.url, 'http://x');
1772
1959
  const parts = url.pathname.split('/').filter(Boolean);
1773
- if (parts[0] !== 'api') return serveStatic(req, res);
1960
+ if (parts[0] !== 'api') {
1961
+ if (url.searchParams.has('key')) {
1962
+ if (!sameKey(url.searchParams.get('key'))) return locked(res);
1963
+ url.searchParams.delete('key');
1964
+ res.writeHead(303, {
1965
+ 'set-cookie': `${KEY_COOKIE}=${OFFICE_KEY}; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000`,
1966
+ location: url.pathname + url.search, 'cache-control': 'no-store',
1967
+ });
1968
+ return res.end();
1969
+ }
1970
+ if (!hasKey(req) && !isAsset(url.pathname)) return locked(res);
1971
+ return serveStatic(req, res);
1972
+ }
1973
+ // Agents prove who they are with their job token (caller); everyone else
1974
+ // with the office key.
1975
+ if (parts[1] !== 'agent' && !hasKey(req)) return json(res, 401, { error: 'Open the office with its link first.' });
1774
1976
 
1775
1977
  try {
1776
1978
  if (req.method === 'GET' && url.pathname === '/api/repos') return json(res, 200, { root: ROOT, repos: listRepos() });
@@ -1784,6 +1986,8 @@ const server = http.createServer(async (req, res) => {
1784
1986
  if (req.method === 'POST' && parts[1] === 'agent' && agentApi[parts[2]]) {
1785
1987
  const me = caller(req);
1786
1988
  if (!me) return json(res, 403, { error: 'Not on the clock.' });
1989
+ if (me.guest && !GUEST_TOOLS.includes(`mcp__office__${parts[2]}`)) return json(res, 403, { error: 'Not on a guest’s job.' });
1990
+ if (me.guest && parts[2] === 'office_roster') return json(res, 200, { text: guestRoster(me) });
1787
1991
  return json(res, 200, { text: agentApi[parts[2]](me, await readBody(req)) });
1788
1992
  }
1789
1993
  if (req.method === 'POST' && url.pathname === '/api/uploads') {
@@ -1800,14 +2004,40 @@ const server = http.createServer(async (req, res) => {
1800
2004
  return json(res, 200, status);
1801
2005
  }
1802
2006
  // The leaderboard. Joining claims a handle with the service and starts
1803
- // sharing day counters; leaving asks it to forget this office.
2007
+ // sharing day counters. Leaving deletes the counters; the handle goes too
2008
+ // unless linked offices still need it.
1804
2009
  if (url.pathname === '/api/leaderboard' && ['GET', 'POST', 'DELETE'].includes(req.method)) {
1805
2010
  let status;
1806
- try { status = req.method === 'GET' ? board.status() : req.method === 'DELETE' ? await board.leave() : await board.join((await readBody(req)).handle); }
1807
- catch (err) { return json(res, err.code >= 400 && err.code < 600 ? err.code : 400, { error: err.message }); }
2011
+ try {
2012
+ if (req.method === 'GET') status = board.status();
2013
+ else if (req.method === 'POST') status = await board.join((await readBody(req)).handle);
2014
+ else {
2015
+ const keepHandle = links.links.length > 0;
2016
+ status = await board.leave({ keepHandle });
2017
+ if (!keepHandle) links.forget();
2018
+ }
2019
+ } catch (err) { return json(res, err.code >= 400 && err.code < 600 ? err.code : 400, { error: err.message }); }
1808
2020
  if (req.method !== 'GET') send({ t: 'leaderboard', leaderboard: status });
1809
2021
  return json(res, 200, status);
1810
2022
  }
2023
+ // A handle without joining the board, for linking offices.
2024
+ if (req.method === 'POST' && url.pathname === '/api/leaderboard/handle') {
2025
+ let status;
2026
+ try { status = await board.claim((await readBody(req)).handle); }
2027
+ catch (err) { return json(res, err.code >= 400 && err.code < 600 ? err.code : 400, { error: err.message }); }
2028
+ send({ t: 'leaderboard', leaderboard: status });
2029
+ return json(res, 200, status);
2030
+ }
2031
+ // Linked offices: codes to make and use, and the links they made.
2032
+ if (url.pathname === '/api/links' || parts[1] === 'links') {
2033
+ const linkErr = (err) => json(res, err.code >= 400 && err.code < 600 ? err.code : 502, { error: err.message });
2034
+ try {
2035
+ if (req.method === 'GET' && url.pathname === '/api/links') return json(res, 200, await links.refresh().catch(() => links.status()));
2036
+ if (req.method === 'POST' && url.pathname === '/api/links/invite') return json(res, 201, await links.invite(await readBody(req)));
2037
+ if (req.method === 'POST' && url.pathname === '/api/links/redeem') return json(res, 200, await links.redeem(await readBody(req)));
2038
+ if (req.method === 'DELETE' && parts[2] && !parts[3]) return json(res, 200, await links.unlink(parts[2]));
2039
+ } catch (err) { return linkErr(err); }
2040
+ }
1811
2041
  if (req.method === 'GET' && url.pathname === '/api/leaderboard/board') {
1812
2042
  try { return json(res, 200, await board.board(url.searchParams.get('period'))); }
1813
2043
  catch (err) { return json(res, err.code >= 400 && err.code < 600 ? err.code : 502, { error: err.message }); }
@@ -1886,6 +2116,20 @@ const server = http.createServer(async (req, res) => {
1886
2116
  send(snapshot());
1887
2117
  return json(res, 200, { ok: true });
1888
2118
  }
2119
+ // Visiting an office that made us its guest: each call is a request to
2120
+ // the host over the relay, which checks it against the guest's limits.
2121
+ if (parts[1] === 'visit' && parts[2] && parts[3] === 'jobs') {
2122
+ const link = parts[2], id = parts[4];
2123
+ try {
2124
+ if (req.method === 'POST' && !id) return json(res, 201, await guests.request(link, 'createJob', await readBody(req)));
2125
+ if (req.method === 'GET' && id && !parts[5]) return json(res, 200, await guests.request(link, 'job', { job: id }));
2126
+ if (req.method === 'GET' && id && parts[5] === 'diff') return json(res, 200, await guests.request(link, 'diff', { job: id, path: url.searchParams.get('path') || '' }));
2127
+ if (req.method === 'POST' && id && parts[5] === 'reply') { const b = await readBody(req); return json(res, 200, await guests.request(link, 'reply', { job: id, text: b.text ?? b.message, mode: b.mode })); }
2128
+ if (req.method === 'POST' && id && parts[5] === 'stop') return json(res, 200, await guests.request(link, 'stop', { job: id }));
2129
+ } catch (err) {
2130
+ return json(res, err.code >= 400 && err.code < 600 ? err.code : 400, { error: err.message });
2131
+ }
2132
+ }
1889
2133
  if (parts[1] === 'jobs' && parts[2]) {
1890
2134
  const job = state.jobs.find((j) => j.id === parts[2]);
1891
2135
  if (!job) return json(res, 404, { error: 'No such job.' });
@@ -1911,6 +2155,7 @@ const termWss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024 });
1911
2155
  server.on('upgrade', (req, socket, head) => {
1912
2156
  const url = new URL(req.url, 'http://x');
1913
2157
  if (!trusted(req)) return socket.destroy();
2158
+ if (!hasKey(req)) { socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n'); return; }
1914
2159
  if (url.pathname === '/ws') {
1915
2160
  return wss.handleUpgrade(req, socket, head, (ws) => {
1916
2161
  clients.add(ws);
@@ -1918,6 +2163,14 @@ server.on('upgrade', (req, socket, head) => {
1918
2163
  ws.send(JSON.stringify(snapshot()));
1919
2164
  });
1920
2165
  }
2166
+ const visit = /^\/ws\/visit\/([\w-]{1,64})$/.exec(url.pathname);
2167
+ if (visit) {
2168
+ // The host's office, as its guest. Our own fields fill what the host
2169
+ // doesn't send, so the page has everything it expects.
2170
+ return wss.handleUpgrade(req, socket, head, (ws) => guests.visit(ws, visit[1], () => ({
2171
+ ...snapshot(), dismissed: [], standup: null, chiefBusy: false, procs: null, visitors: [],
2172
+ })));
2173
+ }
1921
2174
  // No terminal, no socket: with the door shut there is nothing to hold open
1922
2175
  // while someone guesses at tickets.
1923
2176
  if (url.pathname === '/ws/terminal' && terminals.on) {
@@ -1926,8 +2179,19 @@ server.on('upgrade', (req, socket, head) => {
1926
2179
  socket.destroy();
1927
2180
  });
1928
2181
 
2182
+ // A taken port won't free up by trying again, so say so and tell the
2183
+ // supervisor not to retry (78).
2184
+ server.on('error', (err) => {
2185
+ if (err.code !== 'EADDRINUSE') throw err;
2186
+ console.error(`Port ${PORT} is already in use. Start the office with another: agentstudio --port <n>, or PORT=<n>.`);
2187
+ process.exit(78);
2188
+ });
1929
2189
  server.listen(PORT, '127.0.0.1', () => {
1930
- console.log(`Agent Office open on http://localhost:${PORT} (projects in ${ROOT})`);
2190
+ // The key is in this line on purpose: it's how the boss gets in. It only
2191
+ // goes to this terminal (or the supervisor's), never to a log file.
2192
+ console.log(`Agent Office open on ${keyLink()} (projects in ${ROOT})`);
2193
+ // For `agentoffice --link`, which can't know which free port we got.
2194
+ try { fs.writeFileSync(path.join(DATA, 'office-port'), String(PORT)); } catch {}
1931
2195
  // Shell access being open is not something to find out by accident.
1932
2196
  terminals.status().then((t) => console.log(t.enabled
1933
2197
  ? `Terminal ON — anything in this browser gets a shell as ${process.env.USER || 'you'}. Up to ${t.max}, ${t.idleMinutes} min idle.`
@@ -1948,5 +2212,8 @@ function shutdown(code = 0) {
1948
2212
  fs.writeFileSync(STATE_FILE, JSON.stringify(state));
1949
2213
  process.exit(code);
1950
2214
  }
2215
+ // One bad request must not take the office down: a crash skips shutdown(),
2216
+ // so state isn't saved and running agents are orphaned.
2217
+ process.on('unhandledRejection', (err) => console.error('[office] unhandled', err));
1951
2218
  process.on('SIGINT', () => shutdown());
1952
2219
  process.on('SIGTERM', () => shutdown());
@@ -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
  }