agent-office-studio 0.3.0 → 0.5.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';
@@ -12,18 +13,20 @@ import { writeStandup, dueToday } from './standup.js';
12
13
  import { writeBanter } from './banter.js';
13
14
  import { sampleProcs, resetProcs, machine } from './procstat.js';
14
15
  import { transcribe, vocabulary, voiceReady, speak, piperReady } from './voice.js';
15
- 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
19
  import { Links } from './links.js';
20
+ import { Settings } from './settings.js';
20
21
  import { Guests } from './guests.js';
22
+ import { findIcon } from './icons.js';
21
23
 
22
24
  const HERE = path.dirname(fileURLToPath(import.meta.url));
23
25
  const APP = path.resolve(HERE, '..');
24
26
  const ROOT = path.resolve(process.env.OFFICE_ROOT || path.join(APP, '..'));
25
27
  const PORT = Number(process.env.PORT || 4317);
26
28
  const CLAUDE = process.env.CLAUDE_BIN || 'claude';
29
+ // OFFICE_DATA is set by the agentoffice command; a clone keeps it beside the code.
27
30
  const DATA = path.resolve(process.env.OFFICE_DATA || path.join(APP, 'data'));
28
31
  const STATE_FILE = path.join(DATA, 'state.json');
29
32
  const DIST = path.join(APP, 'dist');
@@ -39,10 +42,6 @@ const board = new Leaderboard(DATA, { onChange: () => send({ t: 'leaderboard', l
39
42
  const MAX_UPLOAD = 25 * 1024 * 1024;
40
43
  const MAX_LOG = 600;
41
44
  const MAX_CHAT = 300;
42
- const CHIEF_MODEL = process.env.CHIEF_MODEL || 'sonnet';
43
- // The daily standup, local time; "off" turns it off. Weekdays unless STANDUP_WEEKENDS=1.
44
- const STANDUP_AT = process.env.STANDUP_AT ?? '09:30';
45
- const STANDUP_WEEKENDS = process.env.STANDUP_WEEKENDS === '1';
46
45
  // After a turn ends, keep the session open briefly: a message that landed
47
46
  // at the last moment starts another turn instead of being lost.
48
47
  const GRACE_MS = 2500;
@@ -173,6 +172,12 @@ let state = { xp: 0, jobs: [], chat: [], channels: [], chiefCost: 0, dismissed:
173
172
  try {
174
173
  state = { ...state, ...JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')) };
175
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());
176
181
  if (!state.channels.some((c) => c.id === GENERAL)) state.channels.unshift({ id: GENERAL, name: GENERAL, createdAt: Date.now() });
177
182
  // Sat next to #general, and described even if someone had already opened a
178
183
  // channel by that name by hand. An explanation they wrote themselves stays.
@@ -242,7 +247,7 @@ for (const j of state.jobs) {
242
247
  if (j.status !== 'working') continue;
243
248
  if (j.sessionId) {
244
249
  Object.assign(j, {
245
- status: 'queued', queuedAt: Date.now(), wantAgent: j.agentId, waiting: false,
250
+ status: 'queued', queuedAt: Date.now(), wantAgent: j.agentId, waiting: false, clearing: false,
246
251
  // An unread message stashed by deliver() comes along too, rather than
247
252
  // being written over by the notice about the restart.
248
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'),
@@ -286,7 +291,6 @@ const clients = new Set();
286
291
  // A job's title is the first line of its prompt, which can say more than the
287
292
  // boss means to share, so linked offices see only that an agent is busy.
288
293
  // OFFICE_SHARE_JOB_TITLES=1 sends the titles too.
289
- const SHARE_TITLES = process.env.OFFICE_SHARE_JOB_TITLES === '1';
290
294
  function presence() {
291
295
  return hired().map((s) => {
292
296
  const job = state.jobs.find((j) => j.agentId === s.id && j.status === 'working');
@@ -296,7 +300,7 @@ function presence() {
296
300
  : job ? 'working'
297
301
  : chill?.who.includes(s.id) ? 'break'
298
302
  : '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 };
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 };
300
304
  });
301
305
  }
302
306
  // Set just below; anything broadcast before then has no guests to tell.
@@ -341,8 +345,20 @@ const publicJob = (j, tail = 40) => {
341
345
  const { stopRequested, lastTurn, sent, handoffs, ...rest } = j;
342
346
  return { ...rest, log: j.log.slice(-tail) };
343
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
+ }
344
358
  const snapshot = () => ({
345
359
  t: 'hello',
360
+ owner: owner(),
361
+ settings: settings.status(),
346
362
  staff: STAFF,
347
363
  modes: Object.fromEntries(Object.entries(MODES).map(([k, v]) => [k, v.label])),
348
364
  progress: progress(),
@@ -362,6 +378,7 @@ const snapshot = () => ({
362
378
  dismissed: state.dismissed,
363
379
  voice: voiceReady(DATA),
364
380
  tts: { ...piperReady(DATA), replies: voiceOn() },
381
+ contextLimit: contextLimit(),
365
382
  });
366
383
 
367
384
  // ---------- what the agents are costing this machine ----------
@@ -524,9 +541,7 @@ const endChill = (id) => { if (chill?.id === id) chill = null; };
524
541
  // The model writes the small talk, but not for every chat: at most one per
525
542
  // BANTER_GAP, and BANTER=off turns it off. Anything else, or any failure,
526
543
  // comes back with no script and the browser uses a canned chat instead.
527
- const BANTER_ON = process.env.BANTER !== 'off';
528
- const BANTER_GAP = Number(process.env.BANTER_GAP_SECONDS ?? 120) * 1000;
529
- const BANTER_MODEL = process.env.BANTER_MODEL || 'haiku';
544
+ const BANTER_GAP = () => settings.get('banterGap') * 1000;
530
545
  let banterAt = 0;
531
546
 
532
547
  function banterContext(pair) {
@@ -545,16 +560,16 @@ async function claimBanter(pair, game = false) {
545
560
  const c = claimChill(pair, game);
546
561
  if (!c) return { id: null };
547
562
  if (game) return { id: c.id };
548
- 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 };
549
564
  banterAt = Date.now();
550
565
  try {
551
- 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() });
552
567
  state.chiefCost += cost || 0;
553
568
  send({ t: 'chief', busy: false, cost: state.chiefCost });
554
569
  c.at = Date.now();
555
570
  return { id: c.id, script: lines };
556
571
  } catch {
557
- banterAt = Date.now() - BANTER_GAP + 30 * 1000;
572
+ banterAt = Date.now() - BANTER_GAP() + 30 * 1000;
558
573
  return { id: c.id };
559
574
  }
560
575
  }
@@ -581,11 +596,24 @@ const waits = new Map();
581
596
  const REPLY_WAIT_MS = 10 * 60 * 1000;
582
597
  // Long-lived jobs get resumed on every reply and pay for the whole history each
583
598
  // turn, so agents auto-compact earlier than the default. AGENT_COMPACT_PCT to tune.
584
- 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
+ }
585
613
  const OFFICE_TOOLS = [
586
614
  'mcp__office__office_roster', 'mcp__office__message_colleague', 'mcp__office__hand_off', 'mcp__office__flag_for_boss',
587
615
  'mcp__office__list_channels', 'mcp__office__create_channel', 'mcp__office__invite_to_channel', 'mcp__office__post_to_channel',
588
- 'mcp__office__restart_office',
616
+ 'mcp__office__restart_office', 'mcp__office__clear_context',
589
617
  ];
590
618
  // A guest's job can see who is in and ask the boss, nothing more: no
591
619
  // messaging colleagues, handing off or channels. Enforced again in agentApi,
@@ -671,7 +699,7 @@ function start(job, agentId) {
671
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));
672
700
  // Being given work in a channel is how you end up in it.
673
701
  try { joinChannel(channelOf(job.channel), agentId); } catch {}
674
- const first = job.pending;
702
+ const first = job.sessionId ? withClearHint(job, job.pending) : job.pending;
675
703
 
676
704
  Object.assign(job, {
677
705
  status: 'working', agentId, pending: null, denials: [], error: null,
@@ -694,7 +722,7 @@ function start(job, agentId) {
694
722
  } catch (err) {
695
723
  return finish(job, 'failed', `Could not write the office tools' config: ${err.message}`);
696
724
  }
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'] });
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'] });
698
726
  running.set(job.id, child);
699
727
  startProcs();
700
728
  child.stdin.on('error', () => {});
@@ -721,6 +749,7 @@ function start(job, agentId) {
721
749
  if (procs) { delete procs[job.id]; }
722
750
  endGrace(job);
723
751
  if (job.status !== 'working') return;
752
+ if (job.clearing && !job.stopRequested) return recycle(job);
724
753
  if (job.stopRequested) finish(job, 'stopped', job.stopWhy || 'Stopped by you.');
725
754
  else if (job.lastTurn) finish(job, job.lastTurn.ok ? 'done' : 'failed', job.lastTurn.ok ? null : job.lastTurn.error);
726
755
  else finish(job, 'failed', errTail.trim().split('\n').pop() || why);
@@ -729,6 +758,30 @@ function start(job, agentId) {
729
758
  child.on('close', (code) => ended(`claude exited with code ${code}`));
730
759
  }
731
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
+
732
785
  function write(child, text) {
733
786
  if (!child.stdin.writable) return false;
734
787
  child.stdin.write(JSON.stringify({ type: 'user', message: { role: 'user', content: text } }) + '\n');
@@ -743,7 +796,7 @@ function deliver(job, text) {
743
796
  job.lastTurn = null;
744
797
  if (job.waiting) { job.waiting = false; pushJob(job); }
745
798
  addLog(job, { kind: 'user', summary: text });
746
- if (child && write(child, text)) return 'live';
799
+ if (child && write(child, withClearHint(job, text))) return 'live';
747
800
  // The session ended between the check and the write, so there is nobody
748
801
  // to hand it to. Hold it for the next turn: passing it to replyJob would
749
802
  // only hand it straight back here, for as long as the stack allows.
@@ -773,6 +826,12 @@ function onLine(job, line) {
773
826
  job.sessionId = e.session_id;
774
827
  addLog(job, { kind: 'system', summary: `Clocked in · ${e.model}` });
775
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
+ }
776
835
  for (const c of e.message?.content || []) {
777
836
  if (c.type === 'text' && c.text.trim()) {
778
837
  addLog(job, { kind: 'text', summary: c.text.trim().slice(0, 6000) });
@@ -798,10 +857,10 @@ function onLine(job, line) {
798
857
  } else if (e.type === 'result') {
799
858
  job.cost += e.total_cost_usd || 0;
800
859
  job.turns++;
801
- // The input side of the last turn is how much context this session is
802
- // 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).
803
863
  const u = e.usage || {};
804
- job.context = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0);
805
864
  board.addTokens((u.input_tokens || 0) + (u.output_tokens || 0));
806
865
  guests.addTokens(job, (u.input_tokens || 0) + (u.output_tokens || 0));
807
866
  board.addTime(e.duration_ms);
@@ -811,6 +870,13 @@ function onLine(job, line) {
811
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 });
812
871
  job.lastTurn = { ok, error: ok ? null : `Ended with ${e.subtype}`, text: String(e.result || '').trim() };
813
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
+ }
814
880
  const pending = waits.get(job.id) || [];
815
881
  if (ok && pending.length && running.has(job.id)) {
816
882
  // Keep the session open; whatever they're waiting for will wake it.
@@ -998,6 +1064,10 @@ function replyJob(job, { message, mode, files }) {
998
1064
  if (!message) throw new Error('Say something first.');
999
1065
  if (mode && !MODES[mode]) throw new Error('Unknown mode.');
1000
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
+ }
1001
1071
  Object.assign(job, { status: 'queued', queuedAt: Date.now(), pending: message, error: null, endedAt: null, asking: null, awaitingBoss: false });
1002
1072
  addLog(job, { kind: 'user', summary: message });
1003
1073
  pushJob(job);
@@ -1028,6 +1098,51 @@ function dismiss(ids) {
1028
1098
  ].filter(Boolean).join(' ');
1029
1099
  }
1030
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
+
1031
1146
  function recall(ids) {
1032
1147
  const back = ids.filter((id) => state.dismissed.includes(id));
1033
1148
  if (!back.length) return 'Everyone you named is already in.';
@@ -1141,7 +1256,7 @@ async function dispatch(text, mode, model, files, root, inThread, channel) {
1141
1256
  const said = files.length
1142
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.` : ''})`
1143
1258
  : text;
1144
- 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() });
1145
1260
  state.chiefCost += d.cost || 0;
1146
1261
  nudgeUsage();
1147
1262
  } finally {
@@ -1181,6 +1296,10 @@ async function dispatch(text, mode, model, files, root, inThread, channel) {
1181
1296
  const ids = [...new Set([...(d.agentIds || []), ...(agent ? [agent.id] : [])])];
1182
1297
  return say(d.action === 'dismiss' ? dismiss(ids) : recall(ids));
1183
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
+ }
1184
1303
  if (d.action === 'standup') return void runStandup(true);
1185
1304
  // An answer that is merely about a job gets the same link a follow-up does.
1186
1305
  // Asking "how did that go?" from another channel shouldn't dead-end just
@@ -1407,7 +1526,7 @@ async function runStandup(byBoss) {
1407
1526
  try {
1408
1527
  // A quiet office has nothing worth a model call. Otherwise it writes
1409
1528
  // while everyone walks over.
1410
- 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);
1411
1530
  await sleep(GATHER_MS);
1412
1531
  const written = writing && (await writing);
1413
1532
  if (written) {
@@ -1439,8 +1558,9 @@ async function runStandup(byBoss) {
1439
1558
  // Checked every minute. Missed standups still happen later that day, but
1440
1559
  // not after 6pm, and a day with nothing to talk about is skipped.
1441
1560
  function standupDue() {
1442
- if (STANDUP_AT === 'off' || standupNow) return;
1443
- 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'));
1444
1564
  const now = Date.now();
1445
1565
  if (!due || now < due || (state.lastStandup || 0) >= due || new Date().getHours() >= 18) return;
1446
1566
  const since = state.lastStandup || now - DAY_MS;
@@ -1488,8 +1608,18 @@ function officePrompt(agentId, job) {
1488
1608
  const others = onFloor().filter((s) => s.id !== agentId).map((s) => (s.role ? `${s.name} (${s.role})` : s.name)).join(', ');
1489
1609
  return `You are ${me}, one of the agents in the Agent Office. Your colleagues: ${others || 'none yet'}. ` + persona(agentId) +
1490
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. ` +
1491
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. ` +
1492
- `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);
1614
+ }
1615
+
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(' | ');
1493
1623
  }
1494
1624
 
1495
1625
  // A guest's job: the agent works for someone from another office, so it is
@@ -1500,7 +1630,7 @@ function guestPrompt(agentId, job) {
1500
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.`;
1501
1631
  }
1502
1632
 
1503
- const MAX_MESSAGES = 20, MAX_HANDOFFS = 5, MAX_DEPTH = 2, MAX_FLAGS = 5;
1633
+ const MAX_MESSAGES = 20, MAX_HANDOFFS = 5, MAX_DEPTH = 2, MAX_FLAGS = 5, MAX_CLEARS = 5;
1504
1634
  const FLAGS = { question: 'Question for you', decision: 'Needs your decision', blocker: 'Blocked', review: 'Ready for your review' };
1505
1635
 
1506
1636
  function caller(req) {
@@ -1602,6 +1732,14 @@ const agentApi = {
1602
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.';
1603
1733
  },
1604
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
+
1605
1743
  restart_office(me, { now = false } = {}) {
1606
1744
  requestRestart({ by: me.agentId, jobId: me.id, now: !!now });
1607
1745
  return 'The office will restart once everyone is between turns, and that includes you: end your turn now. ' +
@@ -1835,10 +1973,6 @@ function serveUpload(res, id, name) {
1835
1973
 
1836
1974
  // ---------- http ----------
1837
1975
 
1838
- // The in-app terminal. Off unless OFFICE_TERMINAL=1; see server/terminal.js
1839
- // for why its door is built the way it is.
1840
- const terminals = new Terminals({ root: ROOT, dataDir: DATA, folders: listRepos });
1841
-
1842
1976
  // OFFICE_ALLOWED_HOSTS adds exact hostnames (comma-separated), e.g. this
1843
1977
  // machine's Tailscale name when `tailscale serve` fronts the office. Everything
1844
1978
  // else is still refused, which is what keeps rebinding sites out.
@@ -1975,14 +2109,17 @@ const server = http.createServer(async (req, res) => {
1975
2109
  if (parts[1] !== 'agent' && !hasKey(req)) return json(res, 401, { error: 'Open the office with its link first.' });
1976
2110
 
1977
2111
  try {
1978
- if (req.method === 'GET' && url.pathname === '/api/repos') return json(res, 200, { root: ROOT, repos: listRepos() });
1979
- // The terminal. Both of these ran the host and origin checks above, which
1980
- // is the whole reason the socket can trust a ticket that came from here.
1981
- if (req.method === 'GET' && url.pathname === '/api/terminal') return json(res, 200, await terminals.status());
1982
- if (req.method === 'POST' && url.pathname === '/api/terminal/ticket') {
1983
- try { return json(res, 201, await terminals.ticket(await readBody(req))); }
1984
- catch (err) { return json(res, err.code || 400, { error: err.message }); }
2112
+ if (req.method === 'GET' && url.pathname === '/api/repos/icon') {
2113
+ const dir = repoPath(url.searchParams.get('name'));
2114
+ const icon = dir && findIcon(dir);
2115
+ if (!icon) return json(res, 404, { error: 'No icon.' });
2116
+ res.writeHead(200, {
2117
+ 'content-type': icon.type, 'cache-control': 'private, max-age=300',
2118
+ 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; sandbox", 'x-content-type-options': 'nosniff',
2119
+ });
2120
+ return fs.createReadStream(icon.file).pipe(res);
1985
2121
  }
2122
+ if (req.method === 'GET' && url.pathname === '/api/repos') return json(res, 200, { root: ROOT, repos: listRepos() });
1986
2123
  if (req.method === 'POST' && parts[1] === 'agent' && agentApi[parts[2]]) {
1987
2124
  const me = caller(req);
1988
2125
  if (!me) return json(res, 403, { error: 'Not on the clock.' });
@@ -2003,6 +2140,27 @@ const server = http.createServer(async (req, res) => {
2003
2140
  if (req.method !== 'GET') { send({ t: 'auth', auth: status }); refreshUsage(); }
2004
2141
  return json(res, 200, status);
2005
2142
  }
2143
+ // The context limit: past it, an agent is nudged to clear_context. 0 turns
2144
+ // the nudge off; null goes back to the AGENT_CLEAR_TOKENS default.
2145
+ if (url.pathname === '/api/context-limit' && ['GET', 'POST'].includes(req.method)) {
2146
+ if (req.method === 'POST') {
2147
+ const { tokens } = await readBody(req);
2148
+ 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.' });
2149
+ settings.set({ tokenLimit: tokens });
2150
+ announceSettings();
2151
+ }
2152
+ return json(res, 200, contextLimit());
2153
+ }
2154
+ // The Settings page: every value, its default, and whether it is overridden.
2155
+ // A POST of { key: value | null } saves them all or none; null is the default.
2156
+ if (url.pathname === '/api/settings' && ['GET', 'POST'].includes(req.method)) {
2157
+ if (req.method === 'POST') {
2158
+ try { settings.set(await readBody(req)); }
2159
+ catch (err) { return json(res, err.code || 400, { error: err.message }); }
2160
+ announceSettings();
2161
+ }
2162
+ return json(res, 200, { owner: owner(), settings: settings.status() });
2163
+ }
2006
2164
  // The leaderboard. Joining claims a handle with the service and starts
2007
2165
  // sharing day counters. Leaving deletes the counters; the handle goes too
2008
2166
  // unless linked offices still need it.
@@ -2149,9 +2307,6 @@ const server = http.createServer(async (req, res) => {
2149
2307
  });
2150
2308
 
2151
2309
  const wss = new WebSocketServer({ noServer: true });
2152
- // A keystroke at a time, so nothing here needs a big frame; a terminal that
2153
- // could be fed a megabyte per message is just a memory hole.
2154
- const termWss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024 });
2155
2310
  server.on('upgrade', (req, socket, head) => {
2156
2311
  const url = new URL(req.url, 'http://x');
2157
2312
  if (!trusted(req)) return socket.destroy();
@@ -2171,11 +2326,6 @@ server.on('upgrade', (req, socket, head) => {
2171
2326
  ...snapshot(), dismissed: [], standup: null, chiefBusy: false, procs: null, visitors: [],
2172
2327
  })));
2173
2328
  }
2174
- // No terminal, no socket: with the door shut there is nothing to hold open
2175
- // while someone guesses at tickets.
2176
- if (url.pathname === '/ws/terminal' && terminals.on) {
2177
- return termWss.handleUpgrade(req, socket, head, (ws) => terminals.open(ws, url.searchParams.get('ticket')));
2178
- }
2179
2329
  socket.destroy();
2180
2330
  });
2181
2331
 
@@ -2192,10 +2342,6 @@ server.listen(PORT, '127.0.0.1', () => {
2192
2342
  console.log(`Agent Office open on ${keyLink()} (projects in ${ROOT})`);
2193
2343
  // For `agentoffice --link`, which can't know which free port we got.
2194
2344
  try { fs.writeFileSync(path.join(DATA, 'office-port'), String(PORT)); } catch {}
2195
- // Shell access being open is not something to find out by accident.
2196
- terminals.status().then((t) => console.log(t.enabled
2197
- ? `Terminal ON — anything in this browser gets a shell as ${process.env.USER || 'you'}. Up to ${t.max}, ${t.idleMinutes} min idle.`
2198
- : `Terminal off. ${t.reason}`));
2199
2345
  afterRestart();
2200
2346
  pump();
2201
2347
  refreshUsage();
@@ -2205,7 +2351,6 @@ server.listen(PORT, '127.0.0.1', () => {
2205
2351
 
2206
2352
  function shutdown(code = 0) {
2207
2353
  for (const child of running.values()) child.kill('SIGTERM');
2208
- terminals.killAll();
2209
2354
  clearTimeout(saveTimer);
2210
2355
  board.flush();
2211
2356
  fs.mkdirSync(DATA, { recursive: true });
@@ -40,6 +40,11 @@ const TOOLS = [
40
40
  description: 'Restart the office server so changes to its own code (agent-office/server) take effect. Waits until nobody is mid-turn, so end your turn right after; you are messaged when it is back. Not needed for web changes: a build is enough.',
41
41
  inputSchema: obj({ now: { type: 'boolean', description: 'Skip the wait and interrupt anyone mid-turn. Only if asked to.' } }, []),
42
42
  },
43
+ {
44
+ name: 'clear_context',
45
+ description: 'Drop this session\'s history to cut token use, at a natural break between steps. Give notes to yourself: what is done, what is left, what you learned (files, decisions, gotchas). End your turn right after; you resume fresh from the brief and your notes. Not for the end of a job.',
46
+ inputSchema: obj({ notes: { type: 'string' } }, ['notes']),
47
+ },
43
48
  { name: 'list_channels', description: 'List chat channels: purpose, members, and which one your work is in.', inputSchema: NONE },
44
49
  {
45
50
  name: 'create_channel',
@@ -0,0 +1,85 @@
1
+ // The office's own settings: what the boss can change from the Settings page.
2
+ // Each one has a default taken from the environment (or built in); a value set
3
+ // in the office is kept in state.settings and wins over it. Clearing a value
4
+ // goes back to the default. Anything not listed here stays environment-only.
5
+
6
+ const env = (k) => process.env[k];
7
+ const on = (v) => v !== 'off' && v !== '0' && v !== 'false';
8
+
9
+ // kind: text | number | bool | choice | time. `live` settings take effect at
10
+ // once; the rest are read when the thing they configure next starts.
11
+ export const SETTINGS = [
12
+ { key: 'owner', group: 'Office', label: 'Office owner', kind: 'text', max: 40, hint: 'Whose office this is. It names the office in the header. Blank uses the name on this account.', fallback: () => String(env('OFFICE_OWNER') || '').trim() },
13
+ { key: 'shareTitles', group: 'Office', label: 'Share job titles with linked offices', kind: 'bool', hint: 'Off: linked offices only see that an agent is busy.', fallback: () => env('OFFICE_SHARE_JOB_TITLES') === '1' },
14
+
15
+ { key: 'tokenLimit', group: 'Agents', label: 'Token limit', kind: 'number', min: 0, max: 10_000_000, step: 10000, unit: 'tokens', hint: 'Past this much context, an agent is nudged to clear it and start fresh. 0 turns the nudge off.', fallback: () => Math.max(0, parseInt(env('AGENT_CLEAR_TOKENS') ?? '150000', 10) || 0) },
16
+ { key: 'compactPct', group: 'Agents', label: 'Compact at', kind: 'number', min: 10, max: 95, step: 5, unit: '%', hint: 'How full an agent’s context gets before Claude Code compacts it. Applies to jobs that start after you save.', fallback: () => Math.min(95, Math.max(10, parseInt(env('AGENT_COMPACT_PCT') ?? '60', 10) || 60)) },
17
+ { key: 'chiefModel', group: 'Agents', label: 'Chief of staff model', kind: 'choice', options: ['haiku', 'sonnet', 'opus'], hint: 'Routes your messages and writes the standup.', fallback: () => env('CHIEF_MODEL') || 'sonnet' },
18
+
19
+ { key: 'standupAt', group: 'Standup', label: 'Standup time', kind: 'time', hint: 'Local time, 24-hour. Blank turns the daily standup off.', fallback: () => (env('STANDUP_AT') ?? '09:30') === 'off' ? '' : (env('STANDUP_AT') ?? '09:30') },
20
+ { key: 'standupWeekends', group: 'Standup', label: 'Hold standups on weekends', kind: 'bool', fallback: () => env('STANDUP_WEEKENDS') === '1' },
21
+
22
+ { key: 'banter', group: 'Break room', label: 'Written break-room talk', kind: 'bool', hint: 'Off: the break room uses canned lines only.', fallback: () => on(env('BANTER')) },
23
+ { key: 'banterModel', group: 'Break room', label: 'Break-room model', kind: 'choice', options: ['haiku', 'sonnet', 'opus'], fallback: () => env('BANTER_MODEL') || 'haiku' },
24
+ { key: 'banterGap', group: 'Break room', label: 'Time between conversations', kind: 'number', min: 10, max: 86400, step: 10, unit: 'seconds', hint: 'At most one written conversation per this long.', fallback: () => Math.max(10, Number(env('BANTER_GAP_SECONDS') ?? 120) || 120) },
25
+ ];
26
+
27
+ const BY_KEY = new Map(SETTINGS.map((s) => [s.key, s]));
28
+
29
+ // Returns the cleaned value, or throws with something the boss can act on.
30
+ function clean(def, v) {
31
+ const bad = (m) => Object.assign(new Error(m), { code: 400 });
32
+ switch (def.kind) {
33
+ case 'text': {
34
+ if (typeof v !== 'string') throw bad(`${def.label}: use text.`);
35
+ v = v.trim().replace(/\s+/g, ' ');
36
+ if (v.length > def.max) throw bad(`${def.label} is limited to ${def.max} characters.`);
37
+ return v;
38
+ }
39
+ case 'bool':
40
+ if (typeof v !== 'boolean') throw bad(`${def.label}: use on or off.`);
41
+ return v;
42
+ case 'number':
43
+ if (!Number.isInteger(v) || v < def.min || v > def.max) throw bad(`${def.label}: use a whole number from ${def.min.toLocaleString()} to ${def.max.toLocaleString()}.`);
44
+ return v;
45
+ case 'choice':
46
+ if (!def.options.includes(v)) throw bad(`${def.label}: pick one of ${def.options.join(', ')}.`);
47
+ return v;
48
+ case 'time':
49
+ if (v !== '' && !/^([01]\d|2[0-3]):[0-5]\d$/.test(v)) throw bad(`${def.label}: use HH:MM, like 09:30, or leave it blank.`);
50
+ return v;
51
+ }
52
+ }
53
+
54
+ export class Settings {
55
+ // `store` is the object persisted with the office's state; `onChange` saves it.
56
+ constructor(store, onChange = () => {}) {
57
+ this.store = store;
58
+ this.onChange = onChange;
59
+ }
60
+
61
+ get(key) {
62
+ const def = BY_KEY.get(key);
63
+ return key in this.store ? this.store[key] : def.fallback();
64
+ }
65
+
66
+ // Apply { key: value | null }. All or nothing: one bad value saves none.
67
+ set(patch) {
68
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw Object.assign(new Error('Nothing to save.'), { code: 400 });
69
+ const next = {};
70
+ for (const [key, v] of Object.entries(patch)) {
71
+ const def = BY_KEY.get(key);
72
+ if (!def) throw Object.assign(new Error(`Unknown setting: ${key}.`), { code: 400 });
73
+ next[key] = v === null ? null : clean(def, v);
74
+ }
75
+ for (const [key, v] of Object.entries(next)) {
76
+ if (v === null) delete this.store[key]; else this.store[key] = v;
77
+ }
78
+ this.onChange(Object.keys(next));
79
+ return this.status();
80
+ }
81
+
82
+ status() {
83
+ return SETTINGS.map(({ fallback, ...def }) => ({ ...def, value: this.get(def.key), default: fallback(), custom: def.key in this.store }));
84
+ }
85
+ }
@@ -1 +0,0 @@
1
- function e(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}export{e as g};
@@ -1 +0,0 @@
1
- import{g as f}from"./_commonjsHelpers-CqkleIqs.js";function g(i,n){for(var o=0;o<n.length;o++){const t=n[o];if(typeof t!="string"&&!Array.isArray(t)){for(const e in t)if(e!=="default"&&!(e in i)){const r=Object.getOwnPropertyDescriptor(t,e);r&&Object.defineProperty(i,e,r.get?r:{enumerable:!0,get:()=>t[e]})}}}return Object.freeze(Object.defineProperty(i,Symbol.toStringTag,{value:"Module"}))}var l={exports:{}};(function(i,n){(function(o,t){i.exports=t()})(self,()=>(()=>{var o={};return(()=>{var t=o;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const r=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(r._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal||!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,r=e._renderService.dimensions;if(r.css.cell.width===0||r.css.cell.height===0)return;const c=this._terminal.options.scrollback===0?0:e.viewport.scrollBarWidth,a=window.getComputedStyle(this._terminal.element.parentElement),d=parseInt(a.getPropertyValue("height")),m=Math.max(0,parseInt(a.getPropertyValue("width"))),s=window.getComputedStyle(this._terminal.element),h=d-(parseInt(s.getPropertyValue("padding-top"))+parseInt(s.getPropertyValue("padding-bottom"))),u=m-(parseInt(s.getPropertyValue("padding-right"))+parseInt(s.getPropertyValue("padding-left")))-c;return{cols:Math.max(2,Math.floor(u/r.css.cell.width)),rows:Math.max(1,Math.floor(h/r.css.cell.height))}}}})(),o})())})(l);var p=l.exports;const _=f(p),w=g({__proto__:null,default:_},[p]);export{w as a};