agent-office-studio 0.6.0 → 0.8.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/README.md +15 -1
  3. package/bin/agentoffice.js +6 -1
  4. package/dist/assets/index-BAvX7Imu.css +1 -0
  5. package/dist/assets/index-C01GpSoa.js +4999 -0
  6. package/dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  7. package/dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  8. package/dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  9. package/dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  10. package/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  11. package/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  12. package/dist/assets/{jetbrains-mono-cyrillic-400-normal-CIEtRijs.woff → jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff} +0 -0
  13. package/dist/assets/{jetbrains-mono-greek-400-normal-CNojz0t3.woff → jetbrains-mono-greek-400-normal-B9oWc5Lo.woff} +0 -0
  14. package/dist/assets/{jetbrains-mono-latin-400-normal-B11XCQ5g.woff → jetbrains-mono-latin-400-normal-6-qcROiO.woff} +0 -0
  15. package/dist/assets/{jetbrains-mono-latin-ext-400-normal-DHMOLxAi.woff → jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff} +0 -0
  16. package/dist/assets/{jetbrains-mono-vietnamese-400-normal-BeU3T8wf.woff → jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff} +0 -0
  17. package/dist/index.html +11 -2
  18. package/docs/linked-offices.md +11 -0
  19. package/docs/phone.md +51 -3
  20. package/docs/security.md +8 -3
  21. package/package.json +4 -2
  22. package/server/cloudflared.js +84 -0
  23. package/server/economy.js +455 -0
  24. package/server/guests.js +67 -13
  25. package/server/index.js +371 -21
  26. package/server/journeys.js +101 -0
  27. package/server/office-mcp.js +19 -0
  28. package/server/phone.js +70 -0
  29. package/server/settings.js +3 -0
  30. package/server/talk.js +51 -0
  31. package/server/tunnel.js +136 -0
  32. package/shared/shop.js +127 -0
  33. package/dist/assets/index-B9ZRfVrF.js +0 -4475
  34. package/dist/assets/index-BiK6nglq.css +0 -1
  35. package/dist/assets/inter-cyrillic-ext-wght-normal-B2xhLi22.woff2 +0 -0
  36. package/dist/assets/inter-cyrillic-wght-normal-CMZtQduZ.woff2 +0 -0
  37. package/dist/assets/inter-greek-ext-wght-normal-CGAr0uHJ.woff2 +0 -0
  38. package/dist/assets/inter-greek-wght-normal-CaVNZxsx.woff2 +0 -0
  39. package/dist/assets/inter-latin-ext-wght-normal-CFHvXkgd.woff2 +0 -0
  40. package/dist/assets/inter-latin-wght-normal-C2S99t-D.woff2 +0 -0
package/server/index.js CHANGED
@@ -11,16 +11,23 @@ import { canSee, route } from './chief.js';
11
11
  import { fetchUsage } from './usage.js';
12
12
  import { writeStandup, dueToday } from './standup.js';
13
13
  import { writeBanter } from './banter.js';
14
+ import { writeReply } from './talk.js';
14
15
  import { sampleProcs, resetProcs, machine } from './procstat.js';
15
16
  import { transcribe, vocabulary, voiceReady, speak, piperReady } from './voice.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 { Economy, emptyBooks, BET } from './economy.js';
21
+ import { itemById, ITEMS, PAY, setPay, naira } from '../shared/shop.js';
19
22
  import { Links } from './links.js';
20
23
  import { Settings } from './settings.js';
21
24
  import { Guests } from './guests.js';
22
25
  import { Visits, visitJob } from './visits.js';
23
26
  import { findIcon } from './icons.js';
27
+ import { Journeys } from './journeys.js';
28
+ import { lanAddresses, phoneLinks, qrSvg, qrText } from './phone.js';
29
+ import { Tunnel } from './tunnel.js';
30
+ import { findCloudflared } from './cloudflared.js';
24
31
 
25
32
  const HERE = path.dirname(fileURLToPath(import.meta.url));
26
33
  const APP = path.resolve(HERE, '..');
@@ -39,6 +46,7 @@ const UPLOADS = path.join(DATA, 'uploads');
39
46
  const MCP_DIR = path.join(DATA, 'mcp');
40
47
  fs.rmSync(MCP_DIR, { recursive: true, force: true });
41
48
  const auth = new Auth(DATA);
49
+ const journeys = new Journeys(DATA);
42
50
  const board = new Leaderboard(DATA, { onChange: () => send({ t: 'leaderboard', leaderboard: board.status() }) });
43
51
  const MAX_UPLOAD = 25 * 1024 * 1024;
44
52
  const MAX_LOG = 600;
@@ -151,6 +159,7 @@ const LADDER = ['haiku', 'sonnet', 'opus'];
151
159
  const DEFAULT_TIER = 'sonnet';
152
160
  const TIER_LABEL = { haiku: 'Haiku', sonnet: 'Sonnet', opus: 'Opus' };
153
161
  const tierOf = (model) => LADDER.find((t) => String(model || '').includes(t)) || '';
162
+ const modelName = (m) => TIER_LABEL[m] || m;
154
163
  // The boss's picker wins; "Chief picks" (empty) hands the choice over, and only
155
164
  // a job sized that way is allowed to climb on its own.
156
165
  const sized = (model, tier) => (model
@@ -185,7 +194,18 @@ try {
185
194
  if (!state.settings || typeof state.settings !== 'object') state.settings = {};
186
195
  if (Number.isInteger(state.clearTokens) && !('tokenLimit' in state.settings)) state.settings.tokenLimit = state.clearTokens;
187
196
  delete state.clearTokens;
188
- const settings = new Settings(state.settings, () => save());
197
+ const settings = new Settings(state.settings, (keys) => {
198
+ save();
199
+ if (keys.some((k) => k.startsWith('pay'))) { applyPay(); send({ t: 'economy', economy: economy.status() }); }
200
+ });
201
+ const applyPay = () => setPay({ perMinute: settings.get('payPerMinute'), finishBonus: settings.get('payFinishBonus') });
202
+ applyPay();
203
+ // Pay, the office pot and the shop. state.economy is the books themselves,
204
+ // changed in place, so saving the state saves them.
205
+ if (!state.economy || typeof state.economy !== 'object') state.economy = emptyBooks();
206
+ const economy = new Economy(state.economy, { onChange: (entry) => { save(); send({ t: 'economy', entry, economy: economy.status() }); announceWager(entry); } });
207
+ // Stakes on a bet nobody took, or on a job that never finished, run out.
208
+ setInterval(() => { try { economy.sweep(); } catch {} }, 600000).unref();
189
209
  if (!state.channels.some((c) => c.id === GENERAL)) state.channels.unshift({ id: GENERAL, name: GENERAL, createdAt: Date.now() });
190
210
  // Sat next to #general, and described even if someone had already opened a
191
211
  // channel by that name by hand. An explanation they wrote themselves stays.
@@ -341,6 +361,7 @@ guests = new Guests(DATA, {
341
361
  links,
342
362
  office: {
343
363
  jobs: () => state.jobs,
364
+ repoPath: (name) => repoPath(name),
344
365
  createJob: (body, extra) => createJob(body, extra),
345
366
  replyJob: (job, body) => replyJob(job, body),
346
367
  stopJob: (job, why) => stopJob(job, why),
@@ -360,7 +381,7 @@ guests = new Guests(DATA, {
360
381
  },
361
382
  });
362
383
  // Broadcasts that can't change what an agent is doing don't need presence checked.
363
- const NOT_PRESENCE = new Set(['links', 'visitors', 'remote', 'proc', 'leaderboard', 'auth', 'usage']);
384
+ const NOT_PRESENCE = new Set(['links', 'visitors', 'remote', 'proc', 'leaderboard', 'auth', 'usage', 'economy']);
364
385
 
365
386
  function send(msg) {
366
387
  const data = JSON.stringify(msg);
@@ -369,7 +390,7 @@ function send(msg) {
369
390
  if (!NOT_PRESENCE.has(msg.t)) links.sendPresence();
370
391
  }
371
392
  const publicJob = (j, tail = 40) => {
372
- const { stopRequested, lastTurn, sent, handoffs, ...rest } = j;
393
+ const { stopRequested, lastTurn, sent, handoffs, staged, ...rest } = j;
373
394
  return { ...rest, log: j.log.slice(-tail) };
374
395
  };
375
396
  // Whose office this is: the owner set in Settings (default OFFICE_OWNER), else the first name on this account.
@@ -400,6 +421,7 @@ const snapshot = () => ({
400
421
  usage,
401
422
  auth: auth.status(),
402
423
  leaderboard: board.status(),
424
+ economy: economy.status(),
403
425
  links: links.status(),
404
426
  visitors: links.visiting(),
405
427
  procs,
@@ -513,10 +535,46 @@ function react(id, emoji, who = 'you') {
513
535
  const r = (msg.reactions ||= {});
514
536
  const people = r[emoji] || [];
515
537
  r[emoji] = people.includes(who) ? people.filter((p) => p !== who) : [...people, who];
538
+ const added = r[emoji].includes(who);
516
539
  if (!r[emoji].length) delete r[emoji];
517
540
  if (!Object.keys(r).length) delete msg.reactions;
518
541
  send({ t: 'react', id, reactions: msg.reactions || {} });
519
542
  save();
543
+ if (added && who === 'you') reactionFeedback(msg, emoji);
544
+ }
545
+
546
+ // What the boss means by an emoji, as a short signal for the agent. Anything
547
+ // not listed still gets through, just without a reading.
548
+ const REACTION_SIGNALS = {
549
+ '👍': 'approved. This is what they wanted.',
550
+ '🔥': 'great work. Do more like this.',
551
+ '❤️': 'loved it.',
552
+ '🎉': 'celebrating this one. Well done.',
553
+ '👏': 'applause for this.',
554
+ '💯': 'spot on.',
555
+ '🙏': 'thank you.',
556
+ '👀': 'they have seen it and are looking into it.',
557
+ '🤔': 'not convinced. Expect a question or a push back on this.',
558
+ '😕': 'this was unclear or off. Expect a correction.',
559
+ '👎': 'rejected. Not what they wanted; rethink the approach.',
560
+ '😂': 'amused by this.',
561
+ };
562
+
563
+ // A boss reaction on an agent's message is feedback on that job. It reaches a
564
+ // running agent as a message; one that has finished isn't woken for an emoji,
565
+ // so the signal rides along with the next thing the boss sends it.
566
+ function reactionFeedback(msg, emoji) {
567
+ if (!STAFF.some((s) => s.id === msg.from)) return;
568
+ const job = (msg.jobId && state.jobs.find((j) => j.id === msg.jobId))
569
+ || (msg.thread && state.chat.find((m) => m.id === msg.thread) && threadJob(state.chat.find((m) => m.id === msg.thread)));
570
+ if (!job || job.guest || job.away || job.visitor || job.remote) return;
571
+ const reading = REACTION_SIGNALS[emoji.replace(/\uFE0F/g, '')] || REACTION_SIGNALS[emoji] || 'a reaction with no fixed meaning; take it as general feedback.';
572
+ const text = `[Boss reacted ${emoji} to your message "${clip(msg.text.replace(/\s+/g, ' '), 80)}": ${reading}] No reply needed.`;
573
+ if (job.status === 'working' || job.status === 'queued') return void deliver(job, text);
574
+ job.feedback = [...(job.feedback || []), text].slice(-5);
575
+ addLog(job, { kind: 'system', summary: `Boss reacted ${emoji}: ${reading}` });
576
+ pushJob(job);
577
+ save();
520
578
  }
521
579
 
522
580
  // ---------- the break room ----------
@@ -603,6 +661,38 @@ async function claimBanter(pair, game = false) {
603
661
  }
604
662
  }
605
663
 
664
+ // Click-an-agent chat: a quick in-character answer from one cheap call. The
665
+ // browser keeps the last few turns and sends them back, so nothing is stored
666
+ // here. One question at a time per agent, so a held-down Enter can't run up a bill.
667
+ const talking = new Set();
668
+ async function talkTo(agentId, text, history) {
669
+ const s = onFloor().find((x) => x.id === agentId);
670
+ if (!s) throw new Error('They are not on the floor right now.');
671
+ text = clip(text, 400);
672
+ if (!text) throw new Error('Say something first.');
673
+ if (talking.has(agentId)) throw new Error(`${s.name} is still answering.`);
674
+ talking.add(agentId);
675
+ try {
676
+ const job = state.jobs.find((j) => j.agentId === agentId && j.status === 'working');
677
+ const last = job && [...job.log].reverse().find((e) => e.kind === 'text' || e.kind === 'tool');
678
+ const now = job
679
+ ? `Right now: working on "${clip(job.title, 100)}" in ${job.repo}${job.waiting ? ' (waiting on the boss)' : ''}.${last ? ` Last thing you did: ${clip(last.summary || last.text, 140)}.` : ''}`
680
+ : 'Right now: between jobs, in the office.';
681
+ const said = (Array.isArray(history) ? history : []).slice(-6)
682
+ .map((h) => `${h.who === 'you' ? 'Boss' : s.name}: ${clip(h.text, 240)}`);
683
+ const { reply, cost } = await writeReply([
684
+ `You are ${s.name}. ${persona(agentId)}`, '', now, '',
685
+ said.length ? `This chat so far:\n${said.join('\n')}` : 'This chat has just started.', '',
686
+ `Boss: ${text}`,
687
+ ].join('\n'), { bin: CLAUDE, model: settings.get('banterModel'), env: auth.env() });
688
+ state.chiefCost += cost || 0;
689
+ send({ t: 'chief', busy: false, cost: state.chiefCost });
690
+ return reply;
691
+ } finally {
692
+ talking.delete(agentId);
693
+ }
694
+ }
695
+
606
696
  function addLog(job, entry) {
607
697
  entry.ts = Date.now();
608
698
  const last = job.log[job.log.length - 1];
@@ -647,7 +737,9 @@ function overLimitAfterNudge(job) {
647
737
  const OFFICE_TOOLS = [
648
738
  'mcp__office__office_roster', 'mcp__office__message_colleague', 'mcp__office__hand_off', 'mcp__office__flag_for_boss',
649
739
  'mcp__office__list_channels', 'mcp__office__create_channel', 'mcp__office__invite_to_channel', 'mcp__office__post_to_channel',
650
- 'mcp__office__restart_office', 'mcp__office__clear_context',
740
+ 'mcp__office__restart_office', 'mcp__office__clear_context', 'mcp__office__attach_files',
741
+ 'mcp__office__office_shop', 'mcp__office__buy_item',
742
+ 'mcp__office__bets', 'mcp__office__place_bet', 'mcp__office__take_bet', 'mcp__office__cancel_bet',
651
743
  ];
652
744
  // A guest's job can see who is in and ask the boss, nothing more: no
653
745
  // messaging colleagues, handing off or channels. Enforced again in agentApi,
@@ -735,13 +827,16 @@ function start(job, agentId) {
735
827
  } else if (!job.guest) {
736
828
  fs.mkdirSync(UPLOADS, { recursive: true });
737
829
  args.push('--add-dir', UPLOADS);
830
+ // A mapping job writes its journey map in the office's data, not the project.
831
+ if (job.journeys) args.push('--add-dir', journeys.ensureDir());
738
832
  } else {
739
833
  // Nor the boss's connectors (mail, Slack, calendar), user settings and
740
834
  // hooks, or memory: at Full autonomy those would all be the guest's.
741
835
  args.push('--strict-mcp-config', '--setting-sources', 'project,local', '--settings', JSON.stringify({ autoMemoryEnabled: false }));
742
836
  }
743
- const allowed = job.guest ? GUEST_TOOLS : job.away ? [...OFFICE_TOOLS, ...awayTools(job)] : OFFICE_TOOLS;
744
- args.push('--mcp-config', mcpFile, '--allowedTools', allowed.join(','), '--append-system-prompt', job.guest ? guestPrompt(agentId, job) : officePrompt(agentId, job) + awayPrompt(job));
837
+ // A guest's job can be away too: one of our agents, lent to the guest, in the guest's own project.
838
+ const allowed = [...(job.guest ? GUEST_TOOLS : OFFICE_TOOLS), ...(job.away ? awayTools(job) : [])];
839
+ args.push('--mcp-config', mcpFile, '--allowedTools', allowed.join(','), '--append-system-prompt', (job.guest ? guestPrompt(agentId, job) : officePrompt(agentId, job)) + awayPrompt(job));
745
840
  // Being given work in a channel is how you end up in it.
746
841
  try { joinChannel(channelOf(job.channel), agentId); } catch {}
747
842
  const first = job.sessionId ? withClearHint(job, job.pending) : job.pending;
@@ -796,6 +891,7 @@ function start(job, agentId) {
796
891
  endGrace(job);
797
892
  if (job.status !== 'working') return;
798
893
  if (job.clearing && !job.stopRequested) return recycle(job);
894
+ if (job.switching && !job.stopRequested) return resume(job);
799
895
  if (job.stopRequested) finish(job, 'stopped', job.stopWhy || 'Stopped by you.');
800
896
  else if (job.lastTurn) finish(job, job.lastTurn.ok ? 'done' : 'failed', job.lastTurn.ok ? null : job.lastTurn.error);
801
897
  else finish(job, 'failed', errTail.trim().split('\n').pop() || why);
@@ -828,6 +924,34 @@ function recycle(job) {
828
924
  setImmediate(pump);
829
925
  }
830
926
 
927
+ // The boss picked another model for a running job. The process can't change
928
+ // model mid-flight, so it finishes its turn, exits, and the same session
929
+ // resumes on the new one.
930
+ function resume(job) {
931
+ const note = job.pending;
932
+ Object.assign(job, { switching: false, lastTurn: null, pending: [note, `The boss moved this job to ${modelName(job.model)}. Carry on from where you were.`].filter(Boolean).join('\n\n'),
933
+ status: 'queued', queuedAt: Date.now(), waiting: false });
934
+ tokens.delete(job.id);
935
+ pushJob(job);
936
+ save();
937
+ setImmediate(pump);
938
+ }
939
+
940
+ // Set by the boss from the job card. Any job can be switched; one the chief
941
+ // sized stops climbing on its own, since the boss has now chosen.
942
+ function setModel(job, model) {
943
+ model = String(model || '');
944
+ if (!model || !MODELS.has(model)) throw new Error('Pick a model.');
945
+ if (job.guest || job.away || job.visitor) throw new Error("That job runs on someone else's account.");
946
+ if (model === job.model) return;
947
+ const from = TIER_LABEL[tierOf(job.model)] || job.model || 'the default model';
948
+ Object.assign(job, { model, autoModel: false });
949
+ const live = job.status === 'working' && running.get(job.id);
950
+ addLog(job, { kind: 'system', summary: `Boss moved this from ${from} to ${modelName(model)}${live ? '; switching after the current turn' : ''}` });
951
+ if (live) { job.switching = true; live.stdin.end(); }
952
+ pushJob(job);
953
+ }
954
+
831
955
  function write(child, text) {
832
956
  if (!child.stdin.writable) return false;
833
957
  child.stdin.write(JSON.stringify({ type: 'user', message: { role: 'user', content: text } }) + '\n');
@@ -910,6 +1034,7 @@ function onLine(job, line) {
910
1034
  board.addTokens((u.input_tokens || 0) + (u.output_tokens || 0));
911
1035
  guests.addTokens(job, (u.input_tokens || 0) + (u.output_tokens || 0));
912
1036
  board.addTime(e.duration_ms);
1037
+ if (!job.guest) economy.accrue(job, e.duration_ms, job.model);
913
1038
  nudgeUsage();
914
1039
  job.denials = [...new Set((e.permission_denials || []).map((d) => d.tool_name))];
915
1040
  const ok = e.subtype === 'success' && !e.is_error;
@@ -994,6 +1119,23 @@ function adoptNewProject(job) {
994
1119
  return true;
995
1120
  }
996
1121
 
1122
+ // What a finished job earned, said where the office can see it. Milestones
1123
+ // go to the chill room, since they are the office's news, not the work's.
1124
+ function payday(job) {
1125
+ const { pay, achievements } = economy.pay(job);
1126
+ if (pay) pushJob(job);
1127
+ for (const a of achievements) announceAchievement(job.agentId, a);
1128
+ }
1129
+ // The goal being reached and bets settling are the office's news, so they go
1130
+ // to the chill room (the goal to #general too, since it is everyone's).
1131
+ function announceWager(e) {
1132
+ if (e.kind === 'goal' && e.phase === 'reached') post('chief', `🎉 The office pot hit ${naira(e.target)}: **${e.title}** is on! Well done, everyone.`, { kind: 'goal', channel: GENERAL, thread: null });
1133
+ else if (e.kind === 'bet' && (e.phase === 'won' || e.phase === 'lost')) post('chief', `🎲 ${nameOf(e.winner)} won ${naira(e.stake)} off ${nameOf(e.loser)}: ${nameOf(e.subject)}'s job took ${e.why} against a bet of under ${e.minutes} min.`, { kind: 'bet', channel: CHILL, thread: null });
1134
+ }
1135
+ function announceAchievement(agentId, a) {
1136
+ post('chief', `🏅 ${nameOf(agentId)} unlocked **${a.name}**: ${a.blurb} (+${naira(a.bonus)})`, { kind: 'achievement', channel: CHILL, thread: null });
1137
+ }
1138
+
997
1139
  function finish(job, status, error) {
998
1140
  if (job.status !== 'working') return;
999
1141
  clearWaits(job);
@@ -1011,12 +1153,19 @@ function finish(job, status, error) {
1011
1153
  const was = TIER_LABEL[tierOf(job.model)] || job.model || 'the default model';
1012
1154
  const retry = status === 'failed' ? bump(job) : null;
1013
1155
  const gained = status === 'done' && !awaiting ? 40 + Math.min(80, (job.turnTools || 0) * 4) : 0;
1156
+ // Work that failed or was stopped earns nothing; a retry is paid on its own.
1157
+ if (status !== 'done') Object.assign(job, { payMinutes: 0, payWeighted: 0 });
1158
+ // A bet on a job that did not finish gives the stakes back.
1159
+ if (status !== 'done' && !job.guest) try { economy.voidBets(job, `${nameOf(job.agentId)}'s job ${status === 'stopped' ? 'was stopped' : 'failed'}`); } catch {}
1014
1160
  if (status === 'done' && !awaiting) board.addJob();
1015
1161
  state.xp += gained;
1016
1162
  job.xp += gained;
1017
1163
  pushJob(job);
1018
1164
  recordChanges(job).catch(() => {}).then(() => adoptNewProject(job));
1019
1165
  if (job.away) guests.awayClose(job, status, job.lastTurn?.text || error || '').catch((err) => addLog(job, { kind: 'error', summary: `Could not tell @${job.away.host} the work ended: ${err.message}` }));
1166
+ // Pay for the work, now it's finished. Not for a borrowed agent's job: the
1167
+ // office that borrowed them isn't ours to pay out of.
1168
+ if (gained && !job.guest) payday(job);
1020
1169
  if (gained) {
1021
1170
  const after = progress();
1022
1171
  send({ t: 'xp', progress: after, gained, jobId: job.id, agentId: job.agentId, levelUp: after.level > before.level });
@@ -1024,15 +1173,19 @@ function finish(job, status, error) {
1024
1173
  const text = status === 'done' ? job.lastTurn?.text || 'Done.'
1025
1174
  : status === 'stopped' ? 'Stopped, as asked.'
1026
1175
  : `I hit a wall: ${error || 'something went wrong'}`;
1027
- post(job.agentId, text, { jobId: job.id, status, denials: job.denials });
1176
+ // A borrowed agent's chat belongs to the borrower: the host's feed carries
1177
+ // the finished job over the link, and remoteEnded posts it in their office.
1178
+ // Nothing about a guest's job goes in our own chat.
1179
+ const attached = takeStaged(job);
1180
+ if (!job.guest) post(job.agentId, text, { jobId: job.id, status, denials: job.denials, ...attached });
1028
1181
  // The result lands in the job's thread, which the boss may never open, so
1029
1182
  // the chief says so in #general. Not for a retry (the work isn't over), a
1030
1183
  // stop (the boss did that), or a colleague's handed-off piece (their asker
1031
1184
  // gets it, and the asker's own finish is the one worth announcing).
1032
- if (!retry && status !== 'stopped' && !job.requestedBy) {
1185
+ if (!retry && status !== 'stopped' && !job.requestedBy && !job.guest) {
1033
1186
  const line = awaiting ? `${nameOf(job.agentId)} is waiting on you for "${clip(job.title, 80)}": ${clip(job.asking.summary, 200)}`
1034
1187
  : 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)}`;
1035
- post('chief', `**${awaiting ? 'Waiting on you' : status === 'done' ? 'Done' : 'Failed'}** · ${line}`, { kind: 'finished', status, awaiting, jobId: job.id, thread: null, channel: GENERAL });
1188
+ post('chief', `**${awaiting ? 'Waiting on you' : status === 'done' ? 'Done' : 'Failed'}** · ${line}`, { kind: 'finished', status, awaiting, jobId: job.id, thread: null, channel: GENERAL, ...attached });
1036
1189
  }
1037
1190
 
1038
1191
  // Hand the result back to whoever delegated this, if they're still at it.
@@ -1042,6 +1195,16 @@ function finish(job, status, error) {
1042
1195
  if (asker?.status === 'working' && running.has(asker.id)) {
1043
1196
  settle(asker, (w) => w.kind === 'job' && w.id === job.id);
1044
1197
  deliver(asker, `${nameOf(job.agentId)} finished the work you handed off ("${job.title}"), status: ${status}.\n\n${text}`);
1198
+ } else if (asker && !asker.guest && !asker.away && asker.status !== 'stopped' && asker.status !== 'working' && status !== 'stopped') {
1199
+ // The asker has already closed their turn, so the result would have
1200
+ // nowhere to land and sit unread. It becomes work for them instead: their
1201
+ // job resumes (or, if it is queued, picks it up), and a review's findings
1202
+ // get fixed rather than filed.
1203
+ const reviewed = /review|adversarial|audit|qa\b|test/i.test(`${job.title} ${job.prompt}`);
1204
+ asker.wantAgent = asker.agentId;
1205
+ deliver(asker, `${nameOf(job.agentId)} finished the work you handed off ("${job.title}"), status: ${status}.\n\n${text}` +
1206
+ (reviewed ? '\n\nThis is a review of your work. Go through each finding now: fix what is right, and say plainly what you decline and why.' : ''));
1207
+ addLog(asker, { kind: 'system', summary: `${nameOf(job.agentId)}'s result came back after this job closed, so it was reopened.` });
1045
1208
  }
1046
1209
  // Anyone waiting on a reply from this agent stops waiting.
1047
1210
  for (const other of state.jobs) {
@@ -1138,6 +1301,7 @@ function replyJob(job, { message, mode, files }) {
1138
1301
  }
1139
1302
  message = String(message || '').trim();
1140
1303
  if (!message) throw new Error('Say something first.');
1304
+ if (job.feedback?.length) { message = `${job.feedback.join('\n')}\n\n${message}`; job.feedback = null; }
1141
1305
  if (mode && !MODES[mode]) throw new Error('Unknown mode.');
1142
1306
  if (mode) job.mode = mode;
1143
1307
  // An idle session already nudged and still over the limit resumes fresh, not on its history.
@@ -1404,7 +1568,7 @@ function remoteEnded(job) {
1404
1568
  const name = guests.remoteStatus().find((o) => o.link === job.remote.link)?.staff.find((s) => s.id === job.agentId)?.name || 'Their agent';
1405
1569
  const last = job.log.at(-1);
1406
1570
  const how = { done: 'finished', failed: 'hit a wall on', stopped: 'stopped' }[job.status] || 'stopped';
1407
- const said = last?.kind === 'result' && last.summary ? `\n\n${clip(last.summary, 600)}` : job.error ? ` ${job.error}` : '';
1571
+ const said = last?.kind === 'result' && last.summary ? `\n\n${clip(last.summary, 4000)}` : job.error ? ` ${job.error}` : '';
1408
1572
  post('chief', `${name} at @${job.remote.host} ${how} “${job.title}”.${said}`, { jobId: job.id, channel: job.channel, thread: job.thread || null });
1409
1573
  }
1410
1574
 
@@ -1466,7 +1630,7 @@ function chat({ text, mode = 'edit', model = '', files, thread, channel }) {
1466
1630
  if (remote) {
1467
1631
  if (files.length) return void say(`Files can’t go to @${remote.host}’s office. Paste the text instead.`);
1468
1632
  guests.startRemote({ link: remote.link, staff: remote.staff, text: remote.clean, mode, model, thread: root?.id || msg.id, channel: where })
1469
- .then(({ job, repo, capped }) => say(`${remote.staff.name} at @${remote.host} is on it, in ${repo}.${capped ? ` @${remote.host} allows up to ${MODES[job.mode]?.label || job.mode}, so it runs at that.` : ''}`, { jobId: job.id, thread: root ? root.id : null }))
1633
+ .then(({ job, repo, home, capped }) => say(`${remote.staff.name} at @${remote.host} is on it, in ${home ? `your ${repo}, on @${remote.host}’s account. Their changes go into a copy you review.` : `${repo}.`}${capped ? ` @${remote.host} allows up to ${MODES[job.mode]?.label || job.mode}, so it runs at that.` : ''}`, { jobId: job.id, thread: root ? root.id : null }))
1470
1634
  .catch((err) => say(err.message, { thread: root ? root.id : null }));
1471
1635
  return;
1472
1636
  }
@@ -1748,10 +1912,17 @@ function officePrompt(agentId, job) {
1748
1912
  const me = nameOf(agentId);
1749
1913
  const others = onFloor().filter((s) => s.id !== agentId).map((s) => (s.role ? `${s.name} (${s.role})` : s.name)).join(', ');
1750
1914
  return `You are ${me}, one of the agents in the Agent Office. Your colleagues: ${others || 'none yet'}. ` + persona(agentId) +
1751
- `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. ` +
1915
+ `Office tools: see colleagues, message them, hand off work, flag the boss, use channels. To show the boss an image or file (a screenshot, say), attach_files with absolute paths, then say it in your reply: it goes on your next message to them. Messages from colleagues arrive as user messages starting "Message from"; answer them with message_colleague. ` +
1752
1916
  `When your context has grown heavy and you are between steps, clear_context with notes to yourself lets you resume fresh and cheaper. ` +
1753
1917
  `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. ` +
1754
- `This job is discussed in #${channelOf(job?.channel)}; everyday talk stays in #general.` + standupBrief(agentId) + recentWork(agentId, job);
1918
+ `This job is discussed in #${channelOf(job?.channel)}; everyday talk stays in #general.` + payNote(agentId) + standupBrief(agentId) + recentWork(agentId, job);
1919
+ }
1920
+
1921
+ // Only said when there is something to spend it on, so it costs nothing to
1922
+ // an agent who can't afford anything yet.
1923
+ function payNote(agentId) {
1924
+ if (!economy.affordable(agentId).length) return '';
1925
+ return ` You are paid for finished work, more for doing it on a lighter model, and have ${naira(economy.wallet(agentId))}: office_shop and buy_item get you something for your desk, but only once the work is done.`;
1755
1926
  }
1756
1927
 
1757
1928
  // Survives a cleared context: what this agent was last working on, so
@@ -1868,6 +2039,56 @@ const agentApi = {
1868
2039
  : `Delivered to ${target.name}.`;
1869
2040
  },
1870
2041
 
2042
+ attach_files(me, { paths }) {
2043
+ if (!Array.isArray(paths) || !paths.length) throw new Error('Give the absolute paths of the files to attach.');
2044
+ const staged = (me.staged ||= []);
2045
+ if (staged.length + paths.length > MAX_ATTACH) throw new Error(`At most ${MAX_ATTACH} files go on one message.`);
2046
+ for (const p of paths) staged.push(stageFile(p));
2047
+ return `${staged.length} file${staged.length === 1 ? '' : 's'} will go with your next message to the boss (your final reply, a flag, or a channel post). Nothing is sent yet.`;
2048
+ },
2049
+
2050
+ office_shop(me) {
2051
+ const id = me.agentId;
2052
+ const mine = economy.books.desks[id] || [];
2053
+ const lines = [`You have ${naira(economy.wallet(id))}. The office pot has ${naira(economy.books.pot)}.`,
2054
+ `Pay: ${naira(PAY.perMinute)} an active minute plus ${naira(PAY.finishBonus)} for finishing, times 3 on Haiku, 1.75 on Sonnet, 1 on Opus. ${Math.round(PAY.potShare * 100)}% goes to the office pot.`,
2055
+ 'For your desk (buy_item with the id):'];
2056
+ for (const i of ITEMS.filter((x) => x.scope === 'desk')) lines.push(`- ${i.id}: ${i.name}, ${naira(i.price)}${mine.includes(i.id) ? ' (yours already)' : ''}`);
2057
+ return lines.join('\n');
2058
+ },
2059
+
2060
+ buy_item(me, { item }) {
2061
+ const { buy, achievements } = economy.buy(String(item || ''), { by: me.agentId });
2062
+ for (const a of achievements) announceAchievement(me.agentId, a);
2063
+ post(me.agentId, `Treated myself to a ${buy.title.toLowerCase()} for my desk. ${itemById(buy.item).icon || ''}`.trim(), { kind: 'purchase', channel: CHILL, thread: null });
2064
+ return `Bought: ${buy.title}, on your desk now. ${naira(economy.wallet(me.agentId))} left.`;
2065
+ },
2066
+
2067
+ bets(me) {
2068
+ const mine = economy.books.bets.filter((x) => x.state === 'open' || x.state === 'live');
2069
+ const line = (x) => `- ${x.id}: ${nameOf(x.by)} says ${nameOf(x.subject)}'s next job takes under ${x.minutes} min, ${naira(x.stake)} each (${x.state === 'open' ? 'open, needs a taker' : `taken by ${nameOf(x.against)}`})`;
2070
+ return [`You have ${naira(economy.wallet(me.agentId))}. Stakes are ${naira(BET.minStake)} to ${naira(BET.maxStake)}, at most ${BET.maxOpen} bets at a time.`, ...(mine.length ? mine.map(line) : ['No open bets.'])].join('\n');
2071
+ },
2072
+
2073
+ place_bet(me, { on, minutes, stake }) {
2074
+ const subject = STAFF.find((x) => x.id === String(on || '').toLowerCase() || x.name.toLowerCase() === String(on || '').toLowerCase());
2075
+ if (!subject) throw new Error('Name a colleague to bet on (office_roster lists them).');
2076
+ const bet = economy.offerBet({ by: me.agentId, subject: subject.id, minutes, stake });
2077
+ post(me.agentId, `🎲 I'll put ${naira(bet.stake)} on ${subject.name}'s next job taking under ${bet.minutes} min. Anyone who thinks it'll take longer: take_bet ${bet.id}.`, { kind: 'bet', channel: CHILL, thread: null });
2078
+ return `Bet ${bet.id} is open with ${naira(bet.stake)} of yours held. It settles when ${subject.name}'s next job (started after someone takes it) finishes, and is refunded if nobody takes it in a few hours or the job does not finish.`;
2079
+ },
2080
+
2081
+ take_bet(me, { id }) {
2082
+ const bet = economy.takeBet(String(id || ''), { by: me.agentId });
2083
+ post(me.agentId, `🎲 Taken: I say ${nameOf(bet.subject)}'s next job takes ${bet.minutes} min or more. ${naira(bet.stake)} each.`, { kind: 'bet', channel: CHILL, thread: null });
2084
+ return `You took ${bet.id} against ${nameOf(bet.by)}, ${naira(bet.stake)} held from you. ${naira(economy.wallet(me.agentId))} left.`;
2085
+ },
2086
+
2087
+ cancel_bet(me, { id }) {
2088
+ economy.cancelBet(String(id || ''), { by: me.agentId });
2089
+ return `Taken back; your stake is returned. ${naira(economy.wallet(me.agentId))}.`;
2090
+ },
2091
+
1871
2092
  flag_for_boss(me, { kind, summary }) {
1872
2093
  summary = String(summary || '').replace(/\s+/g, ' ').trim().slice(0, 600);
1873
2094
  if (!summary) throw new Error('Say what the boss needs to know.');
@@ -1883,7 +2104,7 @@ const agentApi = {
1883
2104
  // keeps the "open job" link, and the explicit null thread keeps post()
1884
2105
  // from tucking it into the job's thread.
1885
2106
  post('chief', `**${FLAGS[kind]}** · ${nameOf(me.agentId)} on "${clip(me.title, 80)}": ${summary}`,
1886
- { kind: 'flag', flag: kind, jobId: me.id, thread: null, channel: GENERAL });
2107
+ { kind: 'flag', flag: kind, jobId: me.id, thread: null, channel: GENERAL, ...takeStaged(me) });
1887
2108
  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.';
1888
2109
  },
1889
2110
 
@@ -1952,7 +2173,7 @@ const agentApi = {
1952
2173
  if (!message) throw new Error('The message is empty.');
1953
2174
  if ((me.sent = (me.sent || 0) + 1) > MAX_MESSAGES) throw new Error("You've sent a lot of messages on this job. Finish the work with what you have.");
1954
2175
  joinChannel(id, me.agentId);
1955
- post(me.agentId, message, { channel: id, kind: 'channel' });
2176
+ post(me.agentId, message, { channel: id, kind: 'channel', ...takeStaged(me) });
1956
2177
 
1957
2178
  // Whoever is in the channel and mid-job hears it now. Anyone free is left
1958
2179
  // alone on purpose: a word in a channel shouldn't pull half the office
@@ -2066,6 +2287,32 @@ function attachments(ids) {
2066
2287
  return out;
2067
2288
  }
2068
2289
 
2290
+ // An agent's file, copied into the uploads so the chat can show it. Nothing
2291
+ // here is trusted beyond "a regular file the agent can already read".
2292
+ const MAX_ATTACH = 6;
2293
+ function stageFile(file) {
2294
+ const src = path.resolve(String(file || ''));
2295
+ if (!path.isAbsolute(String(file || ''))) throw new Error(`${file}: use an absolute path.`);
2296
+ let st;
2297
+ try { st = fs.statSync(src); } catch { throw new Error(`${file}: no such file.`); }
2298
+ if (!st.isFile()) throw new Error(`${file}: not a file.`);
2299
+ if (st.size > MAX_UPLOAD) throw new Error(`${file}: over 25 MB.`);
2300
+ const id = crypto.randomBytes(6).toString('hex');
2301
+ const dir = uploadDir(id);
2302
+ fs.mkdirSync(dir, { recursive: true });
2303
+ const name = path.basename(src).replace(/[\x00-\x1f\/\\:*?"<>|]/g, '_').replace(/^\.+/, '').slice(0, 120) || 'file';
2304
+ fs.copyFileSync(src, path.join(dir, name));
2305
+ const { path: _, ...f } = attachments([id])[0];
2306
+ return f;
2307
+ }
2308
+
2309
+ // What the agent staged with attach_files rides on the next thing it says to the boss.
2310
+ const takeStaged = (job) => {
2311
+ const files = job.staged?.length ? job.staged : null;
2312
+ if (job.staged) job.staged = [];
2313
+ return files ? { files } : {};
2314
+ };
2315
+
2069
2316
  function withFiles(text, files) {
2070
2317
  text = String(text || '').trim();
2071
2318
  // A voice note reaches the agent as its transcript; the audio itself is
@@ -2131,7 +2378,31 @@ function serveUpload(res, id, name) {
2131
2378
  // OFFICE_ALLOWED_HOSTS adds exact hostnames (comma-separated), e.g. this
2132
2379
  // machine's Tailscale name when `tailscale serve` fronts the office. Everything
2133
2380
  // else is still refused, which is what keeps rebinding sites out.
2134
- const LOCAL = new Set(['localhost', '127.0.0.1', '[::1]', ...String(process.env.OFFICE_ALLOWED_HOSTS || '').split(',').map((h) => h.trim().toLowerCase()).filter(Boolean)]);
2381
+ const EXTRA_HOSTS = String(process.env.OFFICE_ALLOWED_HOSTS || '').split(',').map((h) => h.trim().toLowerCase()).filter(Boolean);
2382
+ const LOCAL = new Set(['localhost', '127.0.0.1', '[::1]', ...EXTRA_HOSTS]);
2383
+ // The tunnel's name is only accepted while it is up.
2384
+ const tunnel = new Tunnel({
2385
+ port: PORT,
2386
+ allow: (h) => LOCAL.add(h),
2387
+ forget: (h) => LOCAL.delete(h),
2388
+ findBin: (onStep) => findCloudflared({ dataDir: DATA, onStep }),
2389
+ // The tests' stand-in tunnel has no real address to check from outside.
2390
+ ...(process.env.OFFICE_TUNNEL_CHECK === 'off' && { check: async () => true }),
2391
+ onChange: (t) => { try { send({ t: 'tunnel', tunnel: t }); } catch {} },
2392
+ });
2393
+ // Named hosts a phone can use: the tunnel first, then any the boss configured.
2394
+ const tunnelHosts = () => [...(tunnel.state === 'on' ? [tunnel.host] : []), ...EXTRA_HOSTS];
2395
+ // OFFICE_LAN=1 also listens on the local network, for a phone on the same
2396
+ // Wi-Fi. Only this machine's own private addresses are accepted as the Host
2397
+ // (looked up per request, since DHCP moves them), never a name, so a rebinding
2398
+ // site still can't get in. The page they open still needs the key.
2399
+ const LAN = /^(1|true|yes|on)$/i.test(process.env.OFFICE_LAN || '');
2400
+ let lanSeen = { at: 0, set: new Set() };
2401
+ const isLanHost = (host) => {
2402
+ if (!LAN) return false;
2403
+ if (Date.now() - lanSeen.at > 5000) lanSeen = { at: Date.now(), set: new Set(lanAddresses()) };
2404
+ return lanSeen.set.has(host);
2405
+ };
2135
2406
  const DEV_PORT = '5317'; // vite.config.js
2136
2407
 
2137
2408
  // The office key. Host and Origin checks keep other websites out, but not
@@ -2178,7 +2449,8 @@ function trusted(req) {
2178
2449
  // Anything that can spawn claude with full permissions must refuse
2179
2450
  // requests from other websites and from rebinding hostnames.
2180
2451
  const host = String(req.headers.host || '').replace(/:\d+$/, '');
2181
- if (!LOCAL.has(host)) return false;
2452
+ const lan = isLanHost(host);
2453
+ if (!LOCAL.has(host) && !lan) return false;
2182
2454
  const origin = req.headers.origin;
2183
2455
  // No Origin is not proof of a local caller: another site's <img> or <link>
2184
2456
  // GET arrives without one. So every GET must stay read-only, with no side
@@ -2188,8 +2460,9 @@ function trusted(req) {
2188
2460
  // anything an agent started. Only the office itself and its vite dev page.
2189
2461
  try {
2190
2462
  const o = new URL(origin);
2191
- if (!LOCAL.has(o.hostname)) return false;
2192
- if (!['localhost', '127.0.0.1', '[::1]'].includes(o.hostname)) return true;
2463
+ if (!LOCAL.has(o.hostname) && !isLanHost(o.hostname)) return false;
2464
+ // A named host fronts the office on its own port; the rest are other sites.
2465
+ if (LOCAL.has(o.hostname) && !['localhost', '127.0.0.1', '[::1]'].includes(o.hostname)) return true;
2193
2466
  return [String(PORT), DEV_PORT].includes(o.port);
2194
2467
  } catch { return false; }
2195
2468
  }
@@ -2251,7 +2524,7 @@ const server = http.createServer(async (req, res) => {
2251
2524
  if (!sameKey(url.searchParams.get('key'))) return locked(res);
2252
2525
  url.searchParams.delete('key');
2253
2526
  res.writeHead(303, {
2254
- 'set-cookie': `${KEY_COOKIE}=${OFFICE_KEY}; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000`,
2527
+ 'set-cookie': `${KEY_COOKIE}=${OFFICE_KEY}; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000${req.headers['x-forwarded-proto'] === 'https' ? '; Secure' : ''}`,
2255
2528
  location: url.pathname + url.search, 'cache-control': 'no-store',
2256
2529
  });
2257
2530
  return res.end();
@@ -2275,6 +2548,14 @@ const server = http.createServer(async (req, res) => {
2275
2548
  return fs.createReadStream(icon.file).pipe(res);
2276
2549
  }
2277
2550
  if (req.method === 'GET' && url.pathname === '/api/repos') return json(res, 200, { root: ROOT, repos: listRepos() });
2551
+ // A project's user journey map, and the job drawing it if one is under way.
2552
+ if (url.pathname === '/api/journeys' && ['GET', 'POST'].includes(req.method)) {
2553
+ const name = req.method === 'GET' ? url.searchParams.get('name') : (await readBody(req)).name;
2554
+ if (!repoPath(name)) return json(res, 404, { error: 'Unknown project folder.' });
2555
+ let job = state.jobs.find((j) => j.journeys && j.repo === name && (j.status === 'working' || j.status === 'queued'));
2556
+ if (req.method === 'POST' && !job) job = createJob({ repo: name, prompt: journeys.brief(name) }, { journeys: true });
2557
+ return json(res, req.method === 'POST' ? 202 : 200, { map: journeys.read(name), job: job?.id || null });
2558
+ }
2278
2559
  // An away job's file tools, passed to the host that has the project.
2279
2560
  if (req.method === 'POST' && parts[1] === 'agent' && /^files_[a-z]+$/.test(parts[2] || '')) {
2280
2561
  const me = caller(req);
@@ -2305,6 +2586,18 @@ const server = http.createServer(async (req, res) => {
2305
2586
  const { path: _, ...f } = await upload(req);
2306
2587
  return json(res, 201, f);
2307
2588
  }
2589
+ // How a phone reaches this office: the links it could scan, each with its
2590
+ // QR. They carry the key, so only a browser that already has it sees them.
2591
+ if (req.method === 'GET' && url.pathname === '/api/phone') {
2592
+ const links = phoneLinks({ key: OFFICE_KEY, port: PORT, lan: LAN, hosts: tunnelHosts() });
2593
+ return json(res, 200, { lan: LAN, tunnel: tunnel.status(), links: links.map((l) => ({ kind: l.kind, label: l.label, url: l.url, svg: qrSvg(l.url) })) });
2594
+ }
2595
+ // The switch for the public address. POST, so another site's <img> can't flip it.
2596
+ if (req.method === 'POST' && url.pathname === '/api/phone/tunnel') {
2597
+ const { on } = await readBody(req);
2598
+ if (on) tunnel.start(); else tunnel.stop();
2599
+ return json(res, 200, tunnel.status());
2600
+ }
2308
2601
  // Which way claude signs in. Only new processes pick it up; a job
2309
2602
  // already running finishes on whatever it started with.
2310
2603
  if (url.pathname === '/api/auth' && ['GET', 'POST', 'DELETE'].includes(req.method)) {
@@ -2408,11 +2701,58 @@ const server = http.createServer(async (req, res) => {
2408
2701
  if (parts[3] === 'dismiss' && !state.dismissed.includes(parts[2])) return json(res, 400, { error: text });
2409
2702
  return json(res, 200, { text });
2410
2703
  }
2704
+ if (req.method === 'POST' && parts[1] === 'staff' && parts[2] && parts[3] === 'talk' && !parts[4]) {
2705
+ const { text, history } = await readBody(req);
2706
+ try { return json(res, 200, { reply: await talkTo(parts[2], text, history) }); } catch (err) { return json(res, 400, { error: err.message }); }
2707
+ }
2411
2708
  if (req.method === 'POST' && parts[1] === 'staff' && parts[2] && parts[3] === 'profile' && !parts[4]) {
2412
2709
  let p;
2413
2710
  try { p = updateProfile(parts[2], await readBody(req)); } catch (err) { return json(res, 400, { error: err.message }); }
2414
2711
  return p ? json(res, 200, p) : json(res, 404, { error: 'No such agent.' });
2415
2712
  }
2713
+ if (req.method === 'GET' && url.pathname === '/api/economy') return json(res, 200, economy.status());
2714
+ if (req.method === 'POST' && url.pathname === '/api/economy/buy') {
2715
+ const body = await readBody(req);
2716
+ const forAgent = body.for ? STAFF.find((x) => x.id === body.for)?.id : undefined;
2717
+ if (body.for && !forAgent) return json(res, 400, { error: 'No such colleague.' });
2718
+ try {
2719
+ const { buy } = economy.buy(String(body.item || ''), { by: 'boss', forAgent });
2720
+ if (forAgent) post('chief', `🎁 ${owner() || 'The boss'} got ${nameOf(forAgent)} a ${buy.title.toLowerCase()} for their desk, out of the office pot.`, { kind: 'purchase', channel: CHILL, thread: null });
2721
+ return json(res, 201, economy.status());
2722
+ } catch (err) { return json(res, 400, { error: err.message }); }
2723
+ }
2724
+ if (req.method === 'POST' && url.pathname === '/api/economy/goal') {
2725
+ const body = await readBody(req);
2726
+ try {
2727
+ if (body.clear) economy.clearGoal(); else economy.setGoal({ name: body.name, target: body.target });
2728
+ return json(res, 200, economy.status());
2729
+ } catch (err) { return json(res, 400, { error: err.message }); }
2730
+ }
2731
+ if (req.method === 'POST' && url.pathname === '/api/economy/bet/cancel') {
2732
+ try { economy.cancelBet(String((await readBody(req)).id || ''), { by: 'boss' }); return json(res, 200, economy.status()); } catch (err) { return json(res, 400, { error: err.message }); }
2733
+ }
2734
+ if (req.method === 'POST' && url.pathname === '/api/economy/use') {
2735
+ try { economy.use(String((await readBody(req)).item || '')); return json(res, 200, economy.status()); } catch (err) { return json(res, 400, { error: err.message }); }
2736
+ }
2737
+ if (req.method === 'POST' && url.pathname === '/api/economy/tip') {
2738
+ const { agentId, amount } = await readBody(req);
2739
+ try {
2740
+ if (!onFloor().some((x) => x.id === agentId)) throw new Error('They are not on the floor right now.');
2741
+ const e = economy.tip(agentId, amount);
2742
+ post('chief', `💸 ${owner() || 'The boss'} tipped ${nameOf(agentId)} ${naira(e.amount)} from the pot`, { kind: 'kudos', channel: GENERAL, thread: null });
2743
+ return json(res, 200, economy.status());
2744
+ } catch (err) { return json(res, 400, { error: err.message }); }
2745
+ }
2746
+ if (req.method === 'POST' && url.pathname === '/api/economy/kudos') {
2747
+ const { jobId } = await readBody(req);
2748
+ const job = state.jobs.find((j) => j.id === jobId);
2749
+ try {
2750
+ const { pay, achievements } = economy.kudos(job);
2751
+ post('chief', `👏 Kudos from ${owner() || 'the boss'} to ${nameOf(job.agentId)} for "${clip(job.title, 80)}" (+${naira(pay?.gross || 0)})`, { kind: 'kudos', channel: GENERAL, thread: null, jobId: job.id });
2752
+ for (const a of achievements) announceAchievement(job.agentId, a);
2753
+ return json(res, 200, economy.status());
2754
+ } catch (err) { return json(res, 400, { error: err.message }); }
2755
+ }
2416
2756
  if (req.method === 'POST' && url.pathname === '/api/standup') { runStandup(true); return json(res, 202, { ok: true }); }
2417
2757
  if (req.method === 'POST' && url.pathname === '/api/restart') {
2418
2758
  const { now } = await readBody(req);
@@ -2506,6 +2846,7 @@ const server = http.createServer(async (req, res) => {
2506
2846
  return json(res, 200, { diff: await fileDiff(cwd, job.changes, url.searchParams.get('path') || '') });
2507
2847
  }
2508
2848
  if (req.method === 'POST' && parts[3] === 'stop') { stopJob(job); return json(res, 200, { ok: true }); }
2849
+ if (req.method === 'POST' && parts[3] === 'model') { setModel(job, (await readBody(req)).model); return json(res, 200, { ok: true }); }
2509
2850
  if (req.method === 'POST' && parts[3] === 'reply') { replyJob(job, await readBody(req)); return json(res, 200, { ok: true }); }
2510
2851
  }
2511
2852
  json(res, 404, { error: 'Not found.' });
@@ -2544,10 +2885,18 @@ server.on('error', (err) => {
2544
2885
  console.error(`Port ${PORT} is already in use. Start the office with another: agentstudio --port <n>, or PORT=<n>.`);
2545
2886
  process.exit(78);
2546
2887
  });
2547
- server.listen(PORT, '127.0.0.1', () => {
2888
+ server.listen(PORT, LAN ? '0.0.0.0' : '127.0.0.1', () => {
2548
2889
  // The key is in this line on purpose: it's how the boss gets in. It only
2549
2890
  // goes to this terminal (or the supervisor's), never to a log file.
2550
2891
  console.log(`Agent Office open on ${keyLink()} (projects in ${ROOT})`);
2892
+ // The phone link, with a QR to scan. Same care: terminal only.
2893
+ const phone = phoneLinks({ key: OFFICE_KEY, port: PORT, lan: LAN, hosts: EXTRA_HOSTS })[0];
2894
+ if (phone) {
2895
+ console.log(`\nOn your phone, scan this (${phone.label}):\n\n${qrText(phone.url)}\n\n${phone.url}`);
2896
+ if (phone.kind === 'lan') console.log('Plain http on your local network: use it on a Wi-Fi you trust, and never on a shared or public one.\n');
2897
+ } else if (LAN) {
2898
+ console.log('OFFICE_LAN is on, but this machine has no private network address to give a phone.');
2899
+ }
2551
2900
  // For `agentoffice --link`, which can't know which free port we got.
2552
2901
  try { fs.writeFileSync(path.join(DATA, 'office-port'), String(PORT)); } catch {}
2553
2902
  afterRestart();
@@ -2559,6 +2908,7 @@ server.listen(PORT, '127.0.0.1', () => {
2559
2908
  });
2560
2909
 
2561
2910
  function shutdown(code = 0) {
2911
+ tunnel.drop();
2562
2912
  for (const child of running.values()) child.kill('SIGTERM');
2563
2913
  clearTimeout(saveTimer);
2564
2914
  board.flush();