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/index.js CHANGED
@@ -2,6 +2,7 @@ import http from 'node:http';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import crypto from 'node:crypto';
5
+ import os from 'node:os';
5
6
  import { spawn, spawnSync } from 'node:child_process';
6
7
  import { fileURLToPath, pathToFileURL } from 'node:url';
7
8
  import { WebSocketServer } from 'ws';
@@ -16,25 +17,31 @@ import { Terminals } from './terminal.js';
16
17
  import { snapshotTree, summarize, fileDiff } from './changes.js';
17
18
  import { Auth } from './auth.js';
18
19
  import { Leaderboard } from './leaderboard.js';
20
+ import { Links } from './links.js';
21
+ import { Settings } from './settings.js';
22
+ import { Guests } from './guests.js';
19
23
 
20
24
  const HERE = path.dirname(fileURLToPath(import.meta.url));
21
25
  const APP = path.resolve(HERE, '..');
22
26
  const ROOT = path.resolve(process.env.OFFICE_ROOT || path.join(APP, '..'));
23
27
  const PORT = Number(process.env.PORT || 4317);
24
28
  const CLAUDE = process.env.CLAUDE_BIN || 'claude';
29
+ // OFFICE_DATA is set by the agentoffice command; a clone keeps it beside the code.
25
30
  const DATA = path.resolve(process.env.OFFICE_DATA || path.join(APP, 'data'));
26
31
  const STATE_FILE = path.join(DATA, 'state.json');
27
32
  const DIST = path.join(APP, 'dist');
28
33
  const UPLOADS = path.join(DATA, 'uploads');
34
+ // Each running job's office MCP config, token and all. On the command line it
35
+ // would be readable by every process on the machine (`ps`); here it is a 0600
36
+ // file in a 0700 folder, gone when the job's session closes. Leftovers from a
37
+ // crash hold tokens that died with that server, so they are cleared at start.
38
+ const MCP_DIR = path.join(DATA, 'mcp');
39
+ fs.rmSync(MCP_DIR, { recursive: true, force: true });
29
40
  const auth = new Auth(DATA);
30
41
  const board = new Leaderboard(DATA, { onChange: () => send({ t: 'leaderboard', leaderboard: board.status() }) });
31
42
  const MAX_UPLOAD = 25 * 1024 * 1024;
32
43
  const MAX_LOG = 600;
33
44
  const MAX_CHAT = 300;
34
- const CHIEF_MODEL = process.env.CHIEF_MODEL || 'sonnet';
35
- // The daily standup, local time; "off" turns it off. Weekdays unless STANDUP_WEEKENDS=1.
36
- const STANDUP_AT = process.env.STANDUP_AT ?? '09:30';
37
- const STANDUP_WEEKENDS = process.env.STANDUP_WEEKENDS === '1';
38
45
  // After a turn ends, keep the session open briefly: a message that landed
39
46
  // at the last moment starts another turn instead of being lost.
40
47
  const GRACE_MS = 2500;
@@ -165,6 +172,12 @@ let state = { xp: 0, jobs: [], chat: [], channels: [], chiefCost: 0, dismissed:
165
172
  try {
166
173
  state = { ...state, ...JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')) };
167
174
  } catch {}
175
+ // What the boss sets on the Settings page, over the environment's defaults.
176
+ // The context limit used to live at state.clearTokens.
177
+ if (!state.settings || typeof state.settings !== 'object') state.settings = {};
178
+ if (Number.isInteger(state.clearTokens) && !('tokenLimit' in state.settings)) state.settings.tokenLimit = state.clearTokens;
179
+ delete state.clearTokens;
180
+ const settings = new Settings(state.settings, () => save());
168
181
  if (!state.channels.some((c) => c.id === GENERAL)) state.channels.unshift({ id: GENERAL, name: GENERAL, createdAt: Date.now() });
169
182
  // Sat next to #general, and described even if someone had already opened a
170
183
  // channel by that name by hand. An explanation they wrote themselves stays.
@@ -234,7 +247,7 @@ for (const j of state.jobs) {
234
247
  if (j.status !== 'working') continue;
235
248
  if (j.sessionId) {
236
249
  Object.assign(j, {
237
- status: 'queued', queuedAt: Date.now(), wantAgent: j.agentId, waiting: false,
250
+ status: 'queued', queuedAt: Date.now(), wantAgent: j.agentId, waiting: false, clearing: false,
238
251
  // An unread message stashed by deliver() comes along too, rather than
239
252
  // being written over by the notice about the restart.
240
253
  pending: [j.pending, 'The office restarted while you were working, which interrupted you. Pick up exactly where you left off.'].filter(Boolean).join('\n\n'),
@@ -272,16 +285,80 @@ function save() {
272
285
  // ---------- broadcasting ----------
273
286
 
274
287
  const clients = new Set();
288
+
289
+ // Linked offices. Our agents as other offices see them: who, what they look
290
+ // like, and what they're doing, down to the job title.
291
+ // A job's title is the first line of its prompt, which can say more than the
292
+ // boss means to share, so linked offices see only that an agent is busy.
293
+ // OFFICE_SHARE_JOB_TITLES=1 sends the titles too.
294
+ function presence() {
295
+ return hired().map((s) => {
296
+ const job = state.jobs.find((j) => j.agentId === s.id && j.status === 'working');
297
+ const doing =
298
+ state.dismissed.includes(s.id) ? 'away'
299
+ : standupNow?.who.includes(s.id) ? 'meeting'
300
+ : job ? 'working'
301
+ : chill?.who.includes(s.id) ? 'break'
302
+ : 'idle';
303
+ return { id: s.id, name: s.name, role: s.role, look: { shirt: s.shirt, hair: s.hair, skin: s.skin }, state: doing, job: job && settings.get('shareTitles') ? { title: job.title } : null };
304
+ });
305
+ }
306
+ // Set just below; anything broadcast before then has no guests to tell.
307
+ let guests = null;
308
+ const links = new Links(DATA, {
309
+ board,
310
+ presence,
311
+ onChange: () => {
312
+ guests?.linksChanged();
313
+ send({ t: 'links', ...links.status() });
314
+ send({ t: 'visitors', offices: links.visiting() });
315
+ },
316
+ onGuest: (link, msg) => guests?.onGuest(link, msg),
317
+ });
318
+ // Guest offices running jobs here, and our visits to offices that host us.
319
+ guests = new Guests(DATA, {
320
+ links,
321
+ office: {
322
+ jobs: () => state.jobs,
323
+ createJob: (body, extra) => createJob(body, extra),
324
+ replyJob: (job, body) => replyJob(job, body),
325
+ stopJob: (job, why) => stopJob(job, why),
326
+ diff: async (job, p) => {
327
+ const cwd = repoPath(job.repo);
328
+ if (!cwd) throw Object.assign(new Error('That project folder is gone.'), { code: 404 });
329
+ return fileDiff(cwd, job.changes, p);
330
+ },
331
+ staff: () => STAFF,
332
+ modes: () => Object.fromEntries(Object.entries(MODES).map(([k, v]) => [k, v.label])),
333
+ },
334
+ });
335
+ // Broadcasts that can't change what an agent is doing don't need presence checked.
336
+ const NOT_PRESENCE = new Set(['links', 'visitors', 'proc', 'leaderboard', 'auth', 'usage']);
337
+
275
338
  function send(msg) {
276
339
  const data = JSON.stringify(msg);
277
340
  for (const ws of clients) if (ws.readyState === 1) ws.send(data);
341
+ guests?.feed(msg);
342
+ if (!NOT_PRESENCE.has(msg.t)) links.sendPresence();
278
343
  }
279
344
  const publicJob = (j, tail = 40) => {
280
345
  const { stopRequested, lastTurn, sent, handoffs, ...rest } = j;
281
346
  return { ...rest, log: j.log.slice(-tail) };
282
347
  };
348
+ // Whose office this is: the owner set in Settings (default OFFICE_OWNER), else the first name on this account.
349
+ const ACCOUNT_NAME = (() => {
350
+ let full = '';
351
+ if (process.platform === 'darwin') full = spawnSync('/usr/bin/id', ['-F'], { encoding: 'utf8' }).stdout?.trim() || '';
352
+ return (full.split(/\s+/)[0] || os.userInfo().username || '').slice(0, 40);
353
+ })();
354
+ const owner = () => settings.get('owner') || ACCOUNT_NAME;
355
+ function announceSettings() {
356
+ send({ t: 'settings', owner: owner(), settings: settings.status(), contextLimit: contextLimit() });
357
+ }
283
358
  const snapshot = () => ({
284
359
  t: 'hello',
360
+ owner: owner(),
361
+ settings: settings.status(),
285
362
  staff: STAFF,
286
363
  modes: Object.fromEntries(Object.entries(MODES).map(([k, v]) => [k, v.label])),
287
364
  progress: progress(),
@@ -294,11 +371,14 @@ const snapshot = () => ({
294
371
  usage,
295
372
  auth: auth.status(),
296
373
  leaderboard: board.status(),
374
+ links: links.status(),
375
+ visitors: links.visiting(),
297
376
  procs,
298
377
  machine,
299
378
  dismissed: state.dismissed,
300
379
  voice: voiceReady(DATA),
301
380
  tts: { ...piperReady(DATA), replies: voiceOn() },
381
+ contextLimit: contextLimit(),
302
382
  });
303
383
 
304
384
  // ---------- what the agents are costing this machine ----------
@@ -461,9 +541,7 @@ const endChill = (id) => { if (chill?.id === id) chill = null; };
461
541
  // The model writes the small talk, but not for every chat: at most one per
462
542
  // BANTER_GAP, and BANTER=off turns it off. Anything else, or any failure,
463
543
  // comes back with no script and the browser uses a canned chat instead.
464
- const BANTER_ON = process.env.BANTER !== 'off';
465
- const BANTER_GAP = Number(process.env.BANTER_GAP_SECONDS ?? 120) * 1000;
466
- const BANTER_MODEL = process.env.BANTER_MODEL || 'haiku';
544
+ const BANTER_GAP = () => settings.get('banterGap') * 1000;
467
545
  let banterAt = 0;
468
546
 
469
547
  function banterContext(pair) {
@@ -482,16 +560,16 @@ async function claimBanter(pair, game = false) {
482
560
  const c = claimChill(pair, game);
483
561
  if (!c) return { id: null };
484
562
  if (game) return { id: c.id };
485
- if (!BANTER_ON || Date.now() - banterAt < BANTER_GAP) return { id: c.id };
563
+ if (!settings.get('banter') || Date.now() - banterAt < BANTER_GAP()) return { id: c.id };
486
564
  banterAt = Date.now();
487
565
  try {
488
- const { lines, cost } = await writeBanter(banterContext(pair), { bin: CLAUDE, model: BANTER_MODEL, env: auth.env() });
566
+ const { lines, cost } = await writeBanter(banterContext(pair), { bin: CLAUDE, model: settings.get('banterModel'), env: auth.env() });
489
567
  state.chiefCost += cost || 0;
490
568
  send({ t: 'chief', busy: false, cost: state.chiefCost });
491
569
  c.at = Date.now();
492
570
  return { id: c.id, script: lines };
493
571
  } catch {
494
- banterAt = Date.now() - BANTER_GAP + 30 * 1000;
572
+ banterAt = Date.now() - BANTER_GAP() + 30 * 1000;
495
573
  return { id: c.id };
496
574
  }
497
575
  }
@@ -518,12 +596,29 @@ const waits = new Map();
518
596
  const REPLY_WAIT_MS = 10 * 60 * 1000;
519
597
  // Long-lived jobs get resumed on every reply and pay for the whole history each
520
598
  // turn, so agents auto-compact earlier than the default. AGENT_COMPACT_PCT to tune.
521
- const AUTOCOMPACT_PCT = process.env.AGENT_COMPACT_PCT || '60';
599
+ // Past this many tokens of context, the next message an agent gets carries a
600
+ // nudge to clear_context. 0 turns the nudge off. AGENT_CLEAR_TOKENS to tune.
601
+ // The limit set in the office wins over the environment's, which is the default.
602
+ const clearTokens = () => settings.get('tokenLimit');
603
+ const contextLimit = () => { const t = settings.status().find((x) => x.key === 'tokenLimit'); return { tokens: t.value, default: t.default, custom: t.custom }; };
604
+ // The nudge goes with a message the session is about to pay its whole history
605
+ // to read, once per session, and never to a guest (who has no such tool).
606
+ function withClearHint(job, text) {
607
+ const limit = clearTokens();
608
+ if (!limit || job.guest || job.hinted || !(job.context >= limit)) return text;
609
+ job.hinted = true;
610
+ const k = Math.round(job.context / 1000);
611
+ return `${text}\n\n(Office note: your context is at ${k}k tokens, and every turn pays for all of it. If you are between steps, call clear_context with notes to yourself now, then end your turn.)`;
612
+ }
522
613
  const OFFICE_TOOLS = [
523
614
  'mcp__office__office_roster', 'mcp__office__message_colleague', 'mcp__office__hand_off', 'mcp__office__flag_for_boss',
524
615
  'mcp__office__list_channels', 'mcp__office__create_channel', 'mcp__office__invite_to_channel', 'mcp__office__post_to_channel',
525
- 'mcp__office__restart_office',
616
+ 'mcp__office__restart_office', 'mcp__office__clear_context',
526
617
  ];
618
+ // A guest's job can see who is in and ask the boss, nothing more: no
619
+ // messaging colleagues, handing off or channels. Enforced again in agentApi,
620
+ // since full autonomy ignores --allowedTools.
621
+ const GUEST_TOOLS = ['mcp__office__office_roster', 'mcp__office__flag_for_boss'];
527
622
  const endGrace = (job) => { clearTimeout(graces.get(job.id)); graces.delete(job.id); };
528
623
 
529
624
  function repoPath(name) {
@@ -540,7 +635,8 @@ function listRepos() {
540
635
  .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
541
636
  }
542
637
 
543
- function createJob({ repo, prompt, mode = 'edit', model = '', auto = false, agentId = '' }) {
638
+ // `extra` tags a job, as a guest's (`from`, `guest`); it must be set before the job can start.
639
+ function createJob({ repo, prompt, mode = 'edit', model = '', auto = false, agentId = '' }, extra = {}) {
544
640
  if (!repoPath(repo)) throw new Error('Unknown project folder.');
545
641
  prompt = String(prompt || '').trim();
546
642
  if (!prompt) throw new Error('Give the job a brief.');
@@ -555,6 +651,7 @@ function createJob({ repo, prompt, mode = 'edit', model = '', auto = false, agen
555
651
  sessionId: null, agentId: null, cost: 0, turns: 0, tools: 0, xp: 0,
556
652
  pending: prompt, denials: [], error: null, log: [],
557
653
  wantAgent: STAFF.some((st) => st.id === agentId) ? agentId : null,
654
+ ...extra,
558
655
  };
559
656
  state.jobs.push(job);
560
657
  if (job.wantAgent && state.dismissed.includes(job.wantAgent)) recall([job.wantAgent]);
@@ -588,13 +685,21 @@ function start(job, agentId) {
588
685
  tokens.set(job.id, token);
589
686
  const mcp = { mcpServers: { office: { command: process.execPath, args: [path.join(HERE, 'office-mcp.js')],
590
687
  env: { OFFICE_URL: `http://127.0.0.1:${PORT}`, OFFICE_JOB: job.id, OFFICE_TOKEN: token } } } };
688
+ const mcpFile = path.join(MCP_DIR, `${job.id}-${crypto.randomUUID().slice(0, 8)}.json`);
591
689
  // 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));
690
+ // A guest's job gets none of them: they are the boss's files.
691
+ if (!job.guest) {
692
+ fs.mkdirSync(UPLOADS, { recursive: true });
693
+ args.push('--add-dir', UPLOADS);
694
+ } else {
695
+ // Nor the boss's connectors (mail, Slack, calendar), user settings and
696
+ // hooks, or memory: at Full autonomy those would all be the guest's.
697
+ args.push('--strict-mcp-config', '--setting-sources', 'project,local', '--settings', JSON.stringify({ autoMemoryEnabled: false }));
698
+ }
699
+ 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
700
  // Being given work in a channel is how you end up in it.
596
701
  try { joinChannel(channelOf(job.channel), agentId); } catch {}
597
- const first = job.pending;
702
+ const first = job.sessionId ? withClearHint(job, job.pending) : job.pending;
598
703
 
599
704
  Object.assign(job, {
600
705
  status: 'working', agentId, pending: null, denials: [], error: null,
@@ -610,7 +715,14 @@ function start(job, agentId) {
610
715
 
611
716
  // stdin stays open for the whole job, so the chief can hand the agent
612
717
  // 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'] });
718
+ try {
719
+ fs.mkdirSync(MCP_DIR, { recursive: true, mode: 0o700 });
720
+ fs.chmodSync(MCP_DIR, 0o700);
721
+ fs.writeFileSync(mcpFile, JSON.stringify(mcp), { mode: 0o600, flag: 'wx' });
722
+ } catch (err) {
723
+ return finish(job, 'failed', `Could not write the office tools' config: ${err.message}`);
724
+ }
725
+ const child = spawn(CLAUDE, args, { cwd, env: { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: String(settings.get('compactPct')), ...(job.guest ? { CLAUDE_CODE_DISABLE_AUTO_MEMORY: '1' } : {}), ...auth.env() }, stdio: ['pipe', 'pipe', 'pipe'] });
614
726
  running.set(job.id, child);
615
727
  startProcs();
616
728
  child.stdin.on('error', () => {});
@@ -633,10 +745,12 @@ function start(job, agentId) {
633
745
 
634
746
  const ended = (why) => {
635
747
  running.delete(job.id);
748
+ fs.rm(mcpFile, { force: true }, () => {});
636
749
  if (procs) { delete procs[job.id]; }
637
750
  endGrace(job);
638
751
  if (job.status !== 'working') return;
639
- if (job.stopRequested) finish(job, 'stopped', 'Stopped by you.');
752
+ if (job.clearing && !job.stopRequested) return recycle(job);
753
+ if (job.stopRequested) finish(job, 'stopped', job.stopWhy || 'Stopped by you.');
640
754
  else if (job.lastTurn) finish(job, job.lastTurn.ok ? 'done' : 'failed', job.lastTurn.ok ? null : job.lastTurn.error);
641
755
  else finish(job, 'failed', errTail.trim().split('\n').pop() || why);
642
756
  };
@@ -644,6 +758,30 @@ function start(job, agentId) {
644
758
  child.on('close', (code) => ended(`claude exited with code ${code}`));
645
759
  }
646
760
 
761
+ // Start a job over in a fresh session: the history is what costs tokens on
762
+ // every turn, so the agent's own handoff note, the brief and anything that
763
+ // arrived meanwhile are all the new session carries.
764
+ function recycle(job) {
765
+ const note = job.clearNote;
766
+ const carried = job.context;
767
+ const wake = [
768
+ `Your original brief:\n${job.prompt}`,
769
+ `Your context has just been cleared (${(job.clears || 0) + 1} time${job.clears ? 's' : ''} on this job), ${job.clearedBy === 'boss' ? 'at the boss\'s request' : 'by you, to save tokens'}. Your notes:\n${note}`,
770
+ 'The clear is DONE. If the brief or your notes only asked you to clear your context, that step is complete: do not call clear_context again, just confirm in one line that you are on a fresh context and finish. ' +
771
+ 'The project still holds everything you changed. Check where the work stands, then carry on.',
772
+ job.pending,
773
+ ].filter(Boolean).join('\n\n');
774
+ Object.assign(job, { sessionId: null, clearNote: null, clearedBy: null, clearing: false, hinted: false, context: null, lastTurn: null, pending: wake,
775
+ status: 'queued', queuedAt: Date.now(), waiting: false });
776
+ job.clears = (job.clears || 0) + 1;
777
+ job.lastClear = { note, by: job.clearedBy || 'self', at: Date.now() };
778
+ tokens.delete(job.id);
779
+ addLog(job, { kind: 'system', summary: `Cleared context${carried ? ` (${Math.round(carried / 1000)}k tokens)` : ''} and carried on fresh` });
780
+ pushJob(job);
781
+ save();
782
+ setImmediate(pump);
783
+ }
784
+
647
785
  function write(child, text) {
648
786
  if (!child.stdin.writable) return false;
649
787
  child.stdin.write(JSON.stringify({ type: 'user', message: { role: 'user', content: text } }) + '\n');
@@ -658,7 +796,7 @@ function deliver(job, text) {
658
796
  job.lastTurn = null;
659
797
  if (job.waiting) { job.waiting = false; pushJob(job); }
660
798
  addLog(job, { kind: 'user', summary: text });
661
- if (child && write(child, text)) return 'live';
799
+ if (child && write(child, withClearHint(job, text))) return 'live';
662
800
  // The session ended between the check and the write, so there is nobody
663
801
  // to hand it to. Hold it for the next turn: passing it to replyJob would
664
802
  // only hand it straight back here, for as long as the stack allows.
@@ -688,6 +826,12 @@ function onLine(job, line) {
688
826
  job.sessionId = e.session_id;
689
827
  addLog(job, { kind: 'system', summary: `Clocked in · ${e.model}` });
690
828
  } else if (e.type === 'assistant') {
829
+ // Each assistant message carries the usage of one API call, whose input side
830
+ // is the real context size right now. (A subagent's calls are its own context.)
831
+ const au = e.message?.usage;
832
+ if (au && !e.parent_tool_use_id) {
833
+ job.context = (au.input_tokens || 0) + (au.cache_read_input_tokens || 0) + (au.cache_creation_input_tokens || 0);
834
+ }
691
835
  for (const c of e.message?.content || []) {
692
836
  if (c.type === 'text' && c.text.trim()) {
693
837
  addLog(job, { kind: 'text', summary: c.text.trim().slice(0, 6000) });
@@ -713,11 +857,12 @@ function onLine(job, line) {
713
857
  } else if (e.type === 'result') {
714
858
  job.cost += e.total_cost_usd || 0;
715
859
  job.turns++;
716
- // The input side of the last turn is how much context this session is
717
- // carrying. Worth seeing: a long-resumed job pays it on every turn.
860
+ // The result's usage is summed over every API call in the turn, so its cache
861
+ // reads count the whole history once per tool call: not the context size.
862
+ // job.context is kept from the last assistant message instead (see above).
718
863
  const u = e.usage || {};
719
- job.context = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0);
720
864
  board.addTokens((u.input_tokens || 0) + (u.output_tokens || 0));
865
+ guests.addTokens(job, (u.input_tokens || 0) + (u.output_tokens || 0));
721
866
  board.addTime(e.duration_ms);
722
867
  nudgeUsage();
723
868
  job.denials = [...new Set((e.permission_denials || []).map((d) => d.tool_name))];
@@ -725,6 +870,13 @@ function onLine(job, line) {
725
870
  addLog(job, { kind: 'result', ok, summary: String(e.result || e.subtype || '').trim().slice(0, 8000), cost: e.total_cost_usd, ms: e.duration_ms });
726
871
  job.lastTurn = { ok, error: ok ? null : `Ended with ${e.subtype}`, text: String(e.result || '').trim() };
727
872
  endGrace(job);
873
+ // The agent asked for a clean slate: close this session now, and ended()
874
+ // starts the job again without its history.
875
+ if (ok && job.clearNote && running.has(job.id)) {
876
+ job.clearing = true;
877
+ running.get(job.id).stdin.end();
878
+ return;
879
+ }
728
880
  const pending = waits.get(job.id) || [];
729
881
  if (ok && pending.length && running.has(job.id)) {
730
882
  // Keep the session open; whatever they're waiting for will wake it.
@@ -803,7 +955,7 @@ function finish(job, status, error) {
803
955
  // gets it, and the asker's own finish is the one worth announcing).
804
956
  if (!retry && status !== 'stopped' && !job.requestedBy) {
805
957
  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)}`;
958
+ : 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
959
  post('chief', `**${awaiting ? 'Waiting on you' : status === 'done' ? 'Done' : 'Failed'}** · ${line}`, { kind: 'finished', status, awaiting, jobId: job.id, thread: null, channel: GENERAL });
808
960
  }
809
961
 
@@ -882,20 +1034,21 @@ function addWait(job, w) {
882
1034
  waits.set(job.id, [...(waits.get(job.id) || []), w]);
883
1035
  }
884
1036
 
885
- function stopJob(job) {
1037
+ function stopJob(job, why) {
886
1038
  if (job.status === 'queued') {
887
1039
  job.status = 'stopped';
888
- job.error = 'Pulled from the queue.';
1040
+ job.error = why || 'Pulled from the queue.';
889
1041
  job.endedAt = Date.now();
890
1042
  pushJob(job);
891
1043
  } else if (job.status === 'working') {
892
1044
  job.stopRequested = true;
1045
+ job.stopWhy = why || null;
893
1046
  const child = running.get(job.id);
894
1047
  if (child) {
895
1048
  child.kill('SIGTERM');
896
1049
  setTimeout(() => running.get(job.id) === child && child.kill('SIGKILL'), 5000);
897
1050
  } else {
898
- finish(job, 'stopped', 'Stopped by you.');
1051
+ finish(job, 'stopped', why || 'Stopped by you.');
899
1052
  }
900
1053
  }
901
1054
  }
@@ -911,6 +1064,10 @@ function replyJob(job, { message, mode, files }) {
911
1064
  if (!message) throw new Error('Say something first.');
912
1065
  if (mode && !MODES[mode]) throw new Error('Unknown mode.');
913
1066
  if (mode) job.mode = mode;
1067
+ // A cleared session has no history, so what it was doing comes along with the reply.
1068
+ if (!job.sessionId && job.lastClear) {
1069
+ message = `Your context was cleared (${job.lastClear.by === 'boss' ? "at the boss's request" : 'by you'}). What you were on:\n${job.lastClear.note}\n\nOriginal brief:\n${job.prompt}\n\nNow: ${message}`;
1070
+ }
914
1071
  Object.assign(job, { status: 'queued', queuedAt: Date.now(), pending: message, error: null, endedAt: null, asking: null, awaitingBoss: false });
915
1072
  addLog(job, { kind: 'user', summary: message });
916
1073
  pushJob(job);
@@ -941,6 +1098,51 @@ function dismiss(ids) {
941
1098
  ].filter(Boolean).join(' ');
942
1099
  }
943
1100
 
1101
+ // The boss's own "clear context", with no job to hand out. A live session is
1102
+ // closed once its turn ends and restarted fresh by ended(); a finished job
1103
+ // just forgets its session so the next reply starts clean.
1104
+ // What an agent was up to, kept when a context is wiped so neither they nor
1105
+ // the boss are left guessing: their last report, or failing that the brief.
1106
+ function whereItStood(job) {
1107
+ const lastReply = [...(job.log || [])].reverse().find((e) => e.kind === 'result' && e.summary)?.summary;
1108
+ const said = (job.lastTurn?.text || lastReply || '').trim().slice(0, 2000);
1109
+ const state_ = job.status === 'working' ? 'in progress' : job.awaitingBoss ? 'waiting on the boss' : job.status;
1110
+ return `Job "${job.title}" in ${job.repo} (${state_}). ` + (said ? `Last report:\n${said}` : `No report yet; the brief was:\n${String(job.prompt || '').slice(0, 1000)}`);
1111
+ }
1112
+
1113
+ function clearContext(ids) {
1114
+ const cleared = [], none = [];
1115
+ for (const id of ids) {
1116
+ const s = hired().find((x) => x.id === id);
1117
+ if (!s) continue;
1118
+ const job = workingJobOf(id);
1119
+ if (job) {
1120
+ if (job.clearNote || job.clearing) { cleared.push(s.name); continue; }
1121
+ job.clearNote = whereItStood(job);
1122
+ job.clearedBy = 'boss';
1123
+ job.lastClear = { note: job.clearNote, by: 'boss', at: Date.now() };
1124
+ // Idle with the session held open: nothing will end the turn, so close it now.
1125
+ if (job.waiting && running.has(job.id)) { job.clearing = true; running.get(job.id).stdin.end(); }
1126
+ cleared.push(s.name);
1127
+ continue;
1128
+ }
1129
+ const last = state.jobs.filter((j) => j.agentId === id && j.sessionId).sort((a, b) => (b.queuedAt || 0) - (a.queuedAt || 0))[0];
1130
+ if (last) {
1131
+ const note = whereItStood(last);
1132
+ // Held for the next reply, which starts a fresh session, so it carries on knowing.
1133
+ Object.assign(last, { sessionId: null, context: null, hinted: false, lastClear: { note, by: 'boss', at: Date.now() } });
1134
+ addLog(last, { kind: 'system', summary: `Context cleared by the boss. Kept: ${note.slice(0, 300)}` });
1135
+ pushJob(last);
1136
+ cleared.push(s.name);
1137
+ } else none.push(s.name);
1138
+ }
1139
+ save();
1140
+ return [
1141
+ cleared.length ? `Cleared context for ${names(cleared)}.` : 'Nobody had a context to clear.',
1142
+ none.length ? `${names(none)} had nothing to clear.` : '',
1143
+ ].filter(Boolean).join(' ');
1144
+ }
1145
+
944
1146
  function recall(ids) {
945
1147
  const back = ids.filter((id) => state.dismissed.includes(id));
946
1148
  if (!back.length) return 'Everyone you named is already in.';
@@ -1054,7 +1256,7 @@ async function dispatch(text, mode, model, files, root, inThread, channel) {
1054
1256
  const said = files.length
1055
1257
  ? `${text}\n(Attached: ${files.map((f) => f.name).join(', ')}${shown.length ? `. ${shown.length === 1 ? 'The image is' : 'The images are'} attached for you to look at.` : ''})`
1056
1258
  : text;
1057
- d = await route(chiefContext(said, agent, repo, channel), { bin: CLAUDE, model: CHIEF_MODEL, images: shown, env: auth.env() });
1259
+ d = await route(chiefContext(said, agent, repo, channel), { bin: CLAUDE, model: settings.get('chiefModel'), images: shown, env: auth.env() });
1058
1260
  state.chiefCost += d.cost || 0;
1059
1261
  nudgeUsage();
1060
1262
  } finally {
@@ -1094,6 +1296,10 @@ async function dispatch(text, mode, model, files, root, inThread, channel) {
1094
1296
  const ids = [...new Set([...(d.agentIds || []), ...(agent ? [agent.id] : [])])];
1095
1297
  return say(d.action === 'dismiss' ? dismiss(ids) : recall(ids));
1096
1298
  }
1299
+ if (d.action === 'clear') {
1300
+ const ids = [...new Set([...(d.agentIds || []), ...(agent ? [agent.id] : [])])];
1301
+ return say(clearContext(ids.length ? ids : hired().map((x) => x.id)));
1302
+ }
1097
1303
  if (d.action === 'standup') return void runStandup(true);
1098
1304
  // An answer that is merely about a job gets the same link a follow-up does.
1099
1305
  // Asking "how did that go?" from another channel shouldn't dead-end just
@@ -1235,6 +1441,21 @@ const clip = (s, n) => {
1235
1441
  s = String(s || '').replace(/\s+/g, ' ').trim();
1236
1442
  return s.length > n ? s.slice(0, n - 1) + '…' : s;
1237
1443
  };
1444
+ // A short plain-text brief of an agent's final reply: markdown stripped, the
1445
+ // opening sentences kept up to n characters, cut at a sentence end if possible.
1446
+ function briefOf(text, n = 280) {
1447
+ const plain = String(text || '')
1448
+ .replace(/```[\s\S]*?```/g, ' ')
1449
+ .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
1450
+ .replace(/^\s*(#{1,6}|[-*>]|\d+\.)\s+/gm, '')
1451
+ .replace(/[*_`]+/g, '')
1452
+ .replace(/\s+/g, ' ')
1453
+ .trim();
1454
+ if (plain.length <= n) return plain;
1455
+ const cut = plain.slice(0, n);
1456
+ const end = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('! '), cut.lastIndexOf('? '));
1457
+ return end > n * 0.4 ? cut.slice(0, end + 1) : clip(plain, n);
1458
+ }
1238
1459
  const ENDED = { done: 'Shipped', failed: 'Hit a wall on', stopped: 'Stopped' };
1239
1460
 
1240
1461
  // What each agent can honestly report since the last standup.
@@ -1305,7 +1526,7 @@ async function runStandup(byBoss) {
1305
1526
  try {
1306
1527
  // A quiet office has nothing worth a model call. Otherwise it writes
1307
1528
  // while everyone walks over.
1308
- const writing = quiet ? null : writeStandup(context, { bin: CLAUDE, model: CHIEF_MODEL, env: auth.env() }).catch(() => null);
1529
+ const writing = quiet ? null : writeStandup(context, { bin: CLAUDE, model: settings.get('chiefModel'), env: auth.env() }).catch(() => null);
1309
1530
  await sleep(GATHER_MS);
1310
1531
  const written = writing && (await writing);
1311
1532
  if (written) {
@@ -1337,8 +1558,9 @@ async function runStandup(byBoss) {
1337
1558
  // Checked every minute. Missed standups still happen later that day, but
1338
1559
  // not after 6pm, and a day with nothing to talk about is skipped.
1339
1560
  function standupDue() {
1340
- if (STANDUP_AT === 'off' || standupNow) return;
1341
- const due = dueToday(STANDUP_AT, STANDUP_WEEKENDS);
1561
+ const at = settings.get('standupAt');
1562
+ if (!at || standupNow) return;
1563
+ const due = dueToday(at, settings.get('standupWeekends'));
1342
1564
  const now = Date.now();
1343
1565
  if (!due || now < due || (state.lastStandup || 0) >= due || new Date().getHours() >= 18) return;
1344
1566
  const since = state.lastStandup || now - DAY_MS;
@@ -1386,17 +1608,39 @@ function officePrompt(agentId, job) {
1386
1608
  const others = onFloor().filter((s) => s.id !== agentId).map((s) => (s.role ? `${s.name} (${s.role})` : s.name)).join(', ');
1387
1609
  return `You are ${me}, one of the agents in the Agent Office. Your colleagues: ${others || 'none yet'}. ` + persona(agentId) +
1388
1610
  `Office tools: see colleagues, message them, hand off work, flag the boss, use channels. Messages from colleagues arrive as user messages starting "Message from"; answer them with message_colleague. ` +
1611
+ `When your context has grown heavy and you are between steps, clear_context with notes to yourself lets you resume fresh and cheaper. ` +
1389
1612
  `Your updates collect in your job's thread, which the boss may not open: use flag_for_boss when you need them (question, decision, blocker, review), not for routine progress. If you flag a question, decision or blocker, you are not done: say plainly in your reply that you are waiting on the boss, and do not present the work as finished. ` +
1390
- `This job is discussed in #${channelOf(job?.channel)}; everyday talk stays in #general.` + standupBrief(agentId);
1613
+ `This job is discussed in #${channelOf(job?.channel)}; everyday talk stays in #general.` + standupBrief(agentId) + recentWork(agentId, job);
1391
1614
  }
1392
1615
 
1393
- const MAX_MESSAGES = 20, MAX_HANDOFFS = 5, MAX_DEPTH = 2, MAX_FLAGS = 5;
1616
+ // Survives a cleared context: what this agent was last working on, so
1617
+ // "what were you doing?" has an answer even in a brand new session.
1618
+ function recentWork(agentId, job) {
1619
+ const mine = state.jobs.filter((j) => j.agentId === agentId && j.id !== job?.id && j.lastClear)
1620
+ .sort((a, b) => b.lastClear.at - a.lastClear.at).slice(0, 2);
1621
+ if (!mine.length) return '';
1622
+ return ' Before your context was last cleared you were on: ' + mine.map((j) => `[${j.id}] ${clip(j.lastClear.note, 600)}`).join(' | ');
1623
+ }
1624
+
1625
+ // A guest's job: the agent works for someone from another office, so it is
1626
+ // told nothing about this office's other work and can't reach colleagues.
1627
+ function guestPrompt(agentId, job) {
1628
+ 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) +
1629
+ `Work only in this project. Don't share anything about this office's other projects, jobs or people beyond what office_roster shows. ` +
1630
+ `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.`;
1631
+ }
1632
+
1633
+ const MAX_MESSAGES = 20, MAX_HANDOFFS = 5, MAX_DEPTH = 2, MAX_FLAGS = 5, MAX_CLEARS = 5;
1394
1634
  const FLAGS = { question: 'Question for you', decision: 'Needs your decision', blocker: 'Blocked', review: 'Ready for your review' };
1395
1635
 
1396
1636
  function caller(req) {
1397
1637
  const id = req.headers['x-office-job'];
1398
1638
  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;
1639
+ if (!job || job.status !== 'working' || !tokens.get(id)) return null;
1640
+ // Compared in constant time, so the token can't be guessed a byte at a time.
1641
+ const want = Buffer.from(tokens.get(id));
1642
+ const got = Buffer.from(String(req.headers['x-office-token'] || ''));
1643
+ if (got.length !== want.length || !crypto.timingSafeEqual(got, want)) return null;
1400
1644
  return job;
1401
1645
  }
1402
1646
 
@@ -1412,6 +1656,15 @@ function recentJobOf(agentId) {
1412
1656
  return last?.status === 'done' && Date.now() - last.endedAt < RESUME_WINDOW_MS ? last : null;
1413
1657
  }
1414
1658
 
1659
+ // Who is in, without what anyone is working on or where: a guest's job
1660
+ // shouldn't learn this office's other work.
1661
+ function guestRoster(me) {
1662
+ const lines = [`You are ${nameOf(me.agentId)}, on a job for ${me.from}.`, 'In the office today:'];
1663
+ for (const s of onFloor()) if (s.id !== me.agentId) lines.push(`- ${s.role ? `${s.name} (${s.role})` : s.name}`);
1664
+ lines.push('', `Project: ${me.repo}`);
1665
+ return lines.join('\n');
1666
+ }
1667
+
1415
1668
  const agentApi = {
1416
1669
  office_roster(me) {
1417
1670
  const lines = [`You are ${nameOf(me.agentId)}.`, 'Colleagues:'];
@@ -1437,6 +1690,8 @@ const agentApi = {
1437
1690
  if (!theirs || (theirs.status === 'working' && !running.has(theirs.id))) {
1438
1691
  throw new Error(`${target.name} isn't on a job right now. Use hand_off to give them work.`);
1439
1692
  }
1693
+ // A guest reads that job's log, so nothing from this office goes into it.
1694
+ if (theirs.guest) throw new Error(`${target.name} is on a job for a guest from another office and can't take messages.`);
1440
1695
 
1441
1696
  const myName = nameOf(me.agentId);
1442
1697
  deliver(theirs, `Message from ${myName} (working on "${me.title}" in ${me.repo}): ${message}` +
@@ -1477,6 +1732,14 @@ const agentApi = {
1477
1732
  return 'The chief has put it in front of the boss. Carry on, or wrap up this step if you cannot go further without them.';
1478
1733
  },
1479
1734
 
1735
+ clear_context(me, { notes }) {
1736
+ notes = String(notes || '').trim().slice(0, 4000);
1737
+ if (!notes) throw new Error('Write the notes you will need: what is done, what is left, what you learned.');
1738
+ if ((me.clears || 0) >= MAX_CLEARS) throw new Error("You've cleared your context several times on this job. Finish with what you have.");
1739
+ me.clearNote = notes;
1740
+ return 'Your context will clear when you end your turn. End it now: say nothing more of substance, your notes are what you resume with.';
1741
+ },
1742
+
1480
1743
  restart_office(me, { now = false } = {}) {
1481
1744
  requestRestart({ by: me.agentId, jobId: me.id, now: !!now });
1482
1745
  return 'The office will restart once everyone is between turns, and that includes you: end your turn now. ' +
@@ -1718,14 +1981,66 @@ const terminals = new Terminals({ root: ROOT, dataDir: DATA, folders: listRepos
1718
1981
  // machine's Tailscale name when `tailscale serve` fronts the office. Everything
1719
1982
  // else is still refused, which is what keeps rebinding sites out.
1720
1983
  const LOCAL = new Set(['localhost', '127.0.0.1', '[::1]', ...String(process.env.OFFICE_ALLOWED_HOSTS || '').split(',').map((h) => h.trim().toLowerCase()).filter(Boolean)]);
1984
+ const DEV_PORT = '5317'; // vite.config.js
1985
+
1986
+ // The office key. Host and Origin checks keep other websites out, but not
1987
+ // other accounts on this machine (or the tailnet, with OFFICE_ALLOWED_HOSTS):
1988
+ // they can reach 127.0.0.1 too. So the page and its API need a secret only
1989
+ // this account can read. It is opened once with ?key=, which leaves it in an
1990
+ // HttpOnly cookie, and it stays the same across restarts. Delete the file to
1991
+ // sign every browser out. Agents use their job tokens instead.
1992
+ const KEY_FILE = path.join(DATA, 'office-key');
1993
+ const OFFICE_KEY = (() => {
1994
+ try { const k = fs.readFileSync(KEY_FILE, 'utf8').trim(); if (/^[0-9a-f]{64}$/.test(k)) return k; } catch {}
1995
+ const k = crypto.randomBytes(32).toString('hex');
1996
+ fs.mkdirSync(DATA, { recursive: true });
1997
+ fs.writeFileSync(KEY_FILE, k + '\n', { mode: 0o600 });
1998
+ return k;
1999
+ })();
2000
+ try { fs.chmodSync(KEY_FILE, 0o600); } catch {}
2001
+ // Cookies ignore ports, so two offices on one machine each need their own name.
2002
+ const KEY_COOKIE = `office_key_${PORT}`;
2003
+ const sameKey = (k) => {
2004
+ const a = Buffer.from(String(k || '')), b = Buffer.from(OFFICE_KEY);
2005
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
2006
+ };
2007
+ function hasKey(req) {
2008
+ for (const c of String(req.headers.cookie || '').split(';')) {
2009
+ const i = c.indexOf('=');
2010
+ if (i > 0 && c.slice(0, i).trim() === KEY_COOKIE) return sameKey(c.slice(i + 1).trim());
2011
+ }
2012
+ return false;
2013
+ }
2014
+ const keyLink = (host = `localhost:${PORT}`) => `http://${host}/?key=${OFFICE_KEY}`;
2015
+
2016
+ // What a browser without the key sees instead of the office.
2017
+ function locked(res) {
2018
+ res.writeHead(401, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
2019
+ res.end(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Agent Office</title>
2020
+ <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>
2021
+ <main><h1>This office is locked</h1>
2022
+ <p>Open it with the link it printed when it started: the line that begins <code>Agent Office open on</code>.</p>
2023
+ <p>Lost it? Run <code>agentoffice --link</code> in a terminal on this machine.</p></main>`);
2024
+ }
2025
+
1721
2026
  function trusted(req) {
1722
2027
  // Anything that can spawn claude with full permissions must refuse
1723
2028
  // requests from other websites and from rebinding hostnames.
1724
2029
  const host = String(req.headers.host || '').replace(/:\d+$/, '');
1725
2030
  if (!LOCAL.has(host)) return false;
1726
2031
  const origin = req.headers.origin;
2032
+ // No Origin is not proof of a local caller: another site's <img> or <link>
2033
+ // GET arrives without one. So every GET must stay read-only, with no side
2034
+ // effects; anything that changes state or spawns goes behind another method.
1727
2035
  if (!origin) return true;
1728
- try { return LOCAL.has(new URL(origin).hostname); } catch { return false; }
2036
+ // Another port on localhost is another site: a project's dev server, or
2037
+ // anything an agent started. Only the office itself and its vite dev page.
2038
+ try {
2039
+ const o = new URL(origin);
2040
+ if (!LOCAL.has(o.hostname)) return false;
2041
+ if (!['localhost', '127.0.0.1', '[::1]'].includes(o.hostname)) return true;
2042
+ return [String(PORT), DEV_PORT].includes(o.port);
2043
+ } catch { return false; }
1729
2044
  }
1730
2045
 
1731
2046
  function readBody(req) {
@@ -1750,8 +2065,18 @@ const json = (res, code, body) => {
1750
2065
  };
1751
2066
 
1752
2067
  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' };
2068
+ // Build files are public code with nothing of the boss's in them; anything
2069
+ // else falls back to the page, which needs the key.
2070
+ function isAsset(pathname) {
2071
+ let f;
2072
+ try { f = path.join(DIST, path.normalize(decodeURIComponent(pathname)).replace(/^(\.\.[\/\\])+/, '')); } catch { return false; }
2073
+ if (!f.startsWith(DIST + path.sep) || f === path.join(DIST, 'index.html')) return false;
2074
+ try { return fs.statSync(f).isFile(); } catch { return false; }
2075
+ }
1753
2076
  function serveStatic(req, res) {
1754
- const url = decodeURIComponent(new URL(req.url, 'http://x').pathname);
2077
+ let url;
2078
+ // A bad escape (`/%E0`) throws; outside a try that would take the office down.
2079
+ try { url = decodeURIComponent(new URL(req.url, 'http://x').pathname); } catch { return json(res, 400, { error: 'Bad path.' }); }
1755
2080
  let file = path.join(DIST, path.normalize(url).replace(/^(\.\.[\/\\])+/, ''));
1756
2081
  if (!file.startsWith(DIST)) return json(res, 403, { error: 'No.' });
1757
2082
  if (!fs.existsSync(file) || fs.statSync(file).isDirectory()) file = path.join(DIST, 'index.html');
@@ -1770,7 +2095,22 @@ const server = http.createServer(async (req, res) => {
1770
2095
  if (!trusted(req)) return json(res, 403, { error: 'Local only.' });
1771
2096
  const url = new URL(req.url, 'http://x');
1772
2097
  const parts = url.pathname.split('/').filter(Boolean);
1773
- if (parts[0] !== 'api') return serveStatic(req, res);
2098
+ if (parts[0] !== 'api') {
2099
+ if (url.searchParams.has('key')) {
2100
+ if (!sameKey(url.searchParams.get('key'))) return locked(res);
2101
+ url.searchParams.delete('key');
2102
+ res.writeHead(303, {
2103
+ 'set-cookie': `${KEY_COOKIE}=${OFFICE_KEY}; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000`,
2104
+ location: url.pathname + url.search, 'cache-control': 'no-store',
2105
+ });
2106
+ return res.end();
2107
+ }
2108
+ if (!hasKey(req) && !isAsset(url.pathname)) return locked(res);
2109
+ return serveStatic(req, res);
2110
+ }
2111
+ // Agents prove who they are with their job token (caller); everyone else
2112
+ // with the office key.
2113
+ if (parts[1] !== 'agent' && !hasKey(req)) return json(res, 401, { error: 'Open the office with its link first.' });
1774
2114
 
1775
2115
  try {
1776
2116
  if (req.method === 'GET' && url.pathname === '/api/repos') return json(res, 200, { root: ROOT, repos: listRepos() });
@@ -1784,6 +2124,8 @@ const server = http.createServer(async (req, res) => {
1784
2124
  if (req.method === 'POST' && parts[1] === 'agent' && agentApi[parts[2]]) {
1785
2125
  const me = caller(req);
1786
2126
  if (!me) return json(res, 403, { error: 'Not on the clock.' });
2127
+ if (me.guest && !GUEST_TOOLS.includes(`mcp__office__${parts[2]}`)) return json(res, 403, { error: 'Not on a guest’s job.' });
2128
+ if (me.guest && parts[2] === 'office_roster') return json(res, 200, { text: guestRoster(me) });
1787
2129
  return json(res, 200, { text: agentApi[parts[2]](me, await readBody(req)) });
1788
2130
  }
1789
2131
  if (req.method === 'POST' && url.pathname === '/api/uploads') {
@@ -1799,15 +2141,62 @@ const server = http.createServer(async (req, res) => {
1799
2141
  if (req.method !== 'GET') { send({ t: 'auth', auth: status }); refreshUsage(); }
1800
2142
  return json(res, 200, status);
1801
2143
  }
2144
+ // The context limit: past it, an agent is nudged to clear_context. 0 turns
2145
+ // the nudge off; null goes back to the AGENT_CLEAR_TOKENS default.
2146
+ if (url.pathname === '/api/context-limit' && ['GET', 'POST'].includes(req.method)) {
2147
+ if (req.method === 'POST') {
2148
+ const { tokens } = await readBody(req);
2149
+ if (tokens !== null && !(Number.isInteger(tokens) && tokens >= 0 && tokens <= 10_000_000)) return json(res, 400, { error: 'Use a whole number of tokens, or 0 to turn it off.' });
2150
+ settings.set({ tokenLimit: tokens });
2151
+ announceSettings();
2152
+ }
2153
+ return json(res, 200, contextLimit());
2154
+ }
2155
+ // The Settings page: every value, its default, and whether it is overridden.
2156
+ // A POST of { key: value | null } saves them all or none; null is the default.
2157
+ if (url.pathname === '/api/settings' && ['GET', 'POST'].includes(req.method)) {
2158
+ if (req.method === 'POST') {
2159
+ try { settings.set(await readBody(req)); }
2160
+ catch (err) { return json(res, err.code || 400, { error: err.message }); }
2161
+ announceSettings();
2162
+ }
2163
+ return json(res, 200, { owner: owner(), settings: settings.status() });
2164
+ }
1802
2165
  // The leaderboard. Joining claims a handle with the service and starts
1803
- // sharing day counters; leaving asks it to forget this office.
2166
+ // sharing day counters. Leaving deletes the counters; the handle goes too
2167
+ // unless linked offices still need it.
1804
2168
  if (url.pathname === '/api/leaderboard' && ['GET', 'POST', 'DELETE'].includes(req.method)) {
1805
2169
  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 }); }
2170
+ try {
2171
+ if (req.method === 'GET') status = board.status();
2172
+ else if (req.method === 'POST') status = await board.join((await readBody(req)).handle);
2173
+ else {
2174
+ const keepHandle = links.links.length > 0;
2175
+ status = await board.leave({ keepHandle });
2176
+ if (!keepHandle) links.forget();
2177
+ }
2178
+ } catch (err) { return json(res, err.code >= 400 && err.code < 600 ? err.code : 400, { error: err.message }); }
1808
2179
  if (req.method !== 'GET') send({ t: 'leaderboard', leaderboard: status });
1809
2180
  return json(res, 200, status);
1810
2181
  }
2182
+ // A handle without joining the board, for linking offices.
2183
+ if (req.method === 'POST' && url.pathname === '/api/leaderboard/handle') {
2184
+ let status;
2185
+ try { status = await board.claim((await readBody(req)).handle); }
2186
+ catch (err) { return json(res, err.code >= 400 && err.code < 600 ? err.code : 400, { error: err.message }); }
2187
+ send({ t: 'leaderboard', leaderboard: status });
2188
+ return json(res, 200, status);
2189
+ }
2190
+ // Linked offices: codes to make and use, and the links they made.
2191
+ if (url.pathname === '/api/links' || parts[1] === 'links') {
2192
+ const linkErr = (err) => json(res, err.code >= 400 && err.code < 600 ? err.code : 502, { error: err.message });
2193
+ try {
2194
+ if (req.method === 'GET' && url.pathname === '/api/links') return json(res, 200, await links.refresh().catch(() => links.status()));
2195
+ if (req.method === 'POST' && url.pathname === '/api/links/invite') return json(res, 201, await links.invite(await readBody(req)));
2196
+ if (req.method === 'POST' && url.pathname === '/api/links/redeem') return json(res, 200, await links.redeem(await readBody(req)));
2197
+ if (req.method === 'DELETE' && parts[2] && !parts[3]) return json(res, 200, await links.unlink(parts[2]));
2198
+ } catch (err) { return linkErr(err); }
2199
+ }
1811
2200
  if (req.method === 'GET' && url.pathname === '/api/leaderboard/board') {
1812
2201
  try { return json(res, 200, await board.board(url.searchParams.get('period'))); }
1813
2202
  catch (err) { return json(res, err.code >= 400 && err.code < 600 ? err.code : 502, { error: err.message }); }
@@ -1886,6 +2275,20 @@ const server = http.createServer(async (req, res) => {
1886
2275
  send(snapshot());
1887
2276
  return json(res, 200, { ok: true });
1888
2277
  }
2278
+ // Visiting an office that made us its guest: each call is a request to
2279
+ // the host over the relay, which checks it against the guest's limits.
2280
+ if (parts[1] === 'visit' && parts[2] && parts[3] === 'jobs') {
2281
+ const link = parts[2], id = parts[4];
2282
+ try {
2283
+ if (req.method === 'POST' && !id) return json(res, 201, await guests.request(link, 'createJob', await readBody(req)));
2284
+ if (req.method === 'GET' && id && !parts[5]) return json(res, 200, await guests.request(link, 'job', { job: id }));
2285
+ if (req.method === 'GET' && id && parts[5] === 'diff') return json(res, 200, await guests.request(link, 'diff', { job: id, path: url.searchParams.get('path') || '' }));
2286
+ 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 })); }
2287
+ if (req.method === 'POST' && id && parts[5] === 'stop') return json(res, 200, await guests.request(link, 'stop', { job: id }));
2288
+ } catch (err) {
2289
+ return json(res, err.code >= 400 && err.code < 600 ? err.code : 400, { error: err.message });
2290
+ }
2291
+ }
1889
2292
  if (parts[1] === 'jobs' && parts[2]) {
1890
2293
  const job = state.jobs.find((j) => j.id === parts[2]);
1891
2294
  if (!job) return json(res, 404, { error: 'No such job.' });
@@ -1911,6 +2314,7 @@ const termWss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024 });
1911
2314
  server.on('upgrade', (req, socket, head) => {
1912
2315
  const url = new URL(req.url, 'http://x');
1913
2316
  if (!trusted(req)) return socket.destroy();
2317
+ if (!hasKey(req)) { socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n'); return; }
1914
2318
  if (url.pathname === '/ws') {
1915
2319
  return wss.handleUpgrade(req, socket, head, (ws) => {
1916
2320
  clients.add(ws);
@@ -1918,6 +2322,14 @@ server.on('upgrade', (req, socket, head) => {
1918
2322
  ws.send(JSON.stringify(snapshot()));
1919
2323
  });
1920
2324
  }
2325
+ const visit = /^\/ws\/visit\/([\w-]{1,64})$/.exec(url.pathname);
2326
+ if (visit) {
2327
+ // The host's office, as its guest. Our own fields fill what the host
2328
+ // doesn't send, so the page has everything it expects.
2329
+ return wss.handleUpgrade(req, socket, head, (ws) => guests.visit(ws, visit[1], () => ({
2330
+ ...snapshot(), dismissed: [], standup: null, chiefBusy: false, procs: null, visitors: [],
2331
+ })));
2332
+ }
1921
2333
  // No terminal, no socket: with the door shut there is nothing to hold open
1922
2334
  // while someone guesses at tickets.
1923
2335
  if (url.pathname === '/ws/terminal' && terminals.on) {
@@ -1926,8 +2338,19 @@ server.on('upgrade', (req, socket, head) => {
1926
2338
  socket.destroy();
1927
2339
  });
1928
2340
 
2341
+ // A taken port won't free up by trying again, so say so and tell the
2342
+ // supervisor not to retry (78).
2343
+ server.on('error', (err) => {
2344
+ if (err.code !== 'EADDRINUSE') throw err;
2345
+ console.error(`Port ${PORT} is already in use. Start the office with another: agentstudio --port <n>, or PORT=<n>.`);
2346
+ process.exit(78);
2347
+ });
1929
2348
  server.listen(PORT, '127.0.0.1', () => {
1930
- console.log(`Agent Office open on http://localhost:${PORT} (projects in ${ROOT})`);
2349
+ // The key is in this line on purpose: it's how the boss gets in. It only
2350
+ // goes to this terminal (or the supervisor's), never to a log file.
2351
+ console.log(`Agent Office open on ${keyLink()} (projects in ${ROOT})`);
2352
+ // For `agentoffice --link`, which can't know which free port we got.
2353
+ try { fs.writeFileSync(path.join(DATA, 'office-port'), String(PORT)); } catch {}
1931
2354
  // Shell access being open is not something to find out by accident.
1932
2355
  terminals.status().then((t) => console.log(t.enabled
1933
2356
  ? `Terminal ON — anything in this browser gets a shell as ${process.env.USER || 'you'}. Up to ${t.max}, ${t.idleMinutes} min idle.`
@@ -1948,5 +2371,8 @@ function shutdown(code = 0) {
1948
2371
  fs.writeFileSync(STATE_FILE, JSON.stringify(state));
1949
2372
  process.exit(code);
1950
2373
  }
2374
+ // One bad request must not take the office down: a crash skips shutdown(),
2375
+ // so state isn't saved and running agents are orphaned.
2376
+ process.on('unhandledRejection', (err) => console.error('[office] unhandled', err));
1951
2377
  process.on('SIGINT', () => shutdown());
1952
2378
  process.on('SIGTERM', () => shutdown());