agent-office-studio 0.7.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 (38) hide show
  1. package/CHANGELOG.md +13 -1
  2. package/README.md +1 -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/phone.md +51 -3
  19. package/docs/security.md +8 -3
  20. package/package.json +4 -2
  21. package/server/cloudflared.js +84 -0
  22. package/server/economy.js +455 -0
  23. package/server/index.js +361 -16
  24. package/server/journeys.js +101 -0
  25. package/server/office-mcp.js +19 -0
  26. package/server/phone.js +70 -0
  27. package/server/settings.js +3 -0
  28. package/server/talk.js +51 -0
  29. package/server/tunnel.js +136 -0
  30. package/shared/shop.js +127 -0
  31. package/dist/assets/index-BtHSEs3B.js +0 -4475
  32. package/dist/assets/index-DVuFVmWF.css +0 -1
  33. package/dist/assets/inter-cyrillic-ext-wght-normal-B2xhLi22.woff2 +0 -0
  34. package/dist/assets/inter-cyrillic-wght-normal-CMZtQduZ.woff2 +0 -0
  35. package/dist/assets/inter-greek-ext-wght-normal-CGAr0uHJ.woff2 +0 -0
  36. package/dist/assets/inter-greek-wght-normal-CaVNZxsx.woff2 +0 -0
  37. package/dist/assets/inter-latin-ext-wght-normal-CFHvXkgd.woff2 +0 -0
  38. 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.
@@ -361,7 +381,7 @@ guests = new Guests(DATA, {
361
381
  },
362
382
  });
363
383
  // Broadcasts that can't change what an agent is doing don't need presence checked.
364
- 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']);
365
385
 
366
386
  function send(msg) {
367
387
  const data = JSON.stringify(msg);
@@ -370,7 +390,7 @@ function send(msg) {
370
390
  if (!NOT_PRESENCE.has(msg.t)) links.sendPresence();
371
391
  }
372
392
  const publicJob = (j, tail = 40) => {
373
- const { stopRequested, lastTurn, sent, handoffs, ...rest } = j;
393
+ const { stopRequested, lastTurn, sent, handoffs, staged, ...rest } = j;
374
394
  return { ...rest, log: j.log.slice(-tail) };
375
395
  };
376
396
  // Whose office this is: the owner set in Settings (default OFFICE_OWNER), else the first name on this account.
@@ -401,6 +421,7 @@ const snapshot = () => ({
401
421
  usage,
402
422
  auth: auth.status(),
403
423
  leaderboard: board.status(),
424
+ economy: economy.status(),
404
425
  links: links.status(),
405
426
  visitors: links.visiting(),
406
427
  procs,
@@ -514,10 +535,46 @@ function react(id, emoji, who = 'you') {
514
535
  const r = (msg.reactions ||= {});
515
536
  const people = r[emoji] || [];
516
537
  r[emoji] = people.includes(who) ? people.filter((p) => p !== who) : [...people, who];
538
+ const added = r[emoji].includes(who);
517
539
  if (!r[emoji].length) delete r[emoji];
518
540
  if (!Object.keys(r).length) delete msg.reactions;
519
541
  send({ t: 'react', id, reactions: msg.reactions || {} });
520
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();
521
578
  }
522
579
 
523
580
  // ---------- the break room ----------
@@ -604,6 +661,38 @@ async function claimBanter(pair, game = false) {
604
661
  }
605
662
  }
606
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
+
607
696
  function addLog(job, entry) {
608
697
  entry.ts = Date.now();
609
698
  const last = job.log[job.log.length - 1];
@@ -648,7 +737,9 @@ function overLimitAfterNudge(job) {
648
737
  const OFFICE_TOOLS = [
649
738
  'mcp__office__office_roster', 'mcp__office__message_colleague', 'mcp__office__hand_off', 'mcp__office__flag_for_boss',
650
739
  'mcp__office__list_channels', 'mcp__office__create_channel', 'mcp__office__invite_to_channel', 'mcp__office__post_to_channel',
651
- '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',
652
743
  ];
653
744
  // A guest's job can see who is in and ask the boss, nothing more: no
654
745
  // messaging colleagues, handing off or channels. Enforced again in agentApi,
@@ -736,6 +827,8 @@ function start(job, agentId) {
736
827
  } else if (!job.guest) {
737
828
  fs.mkdirSync(UPLOADS, { recursive: true });
738
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());
739
832
  } else {
740
833
  // Nor the boss's connectors (mail, Slack, calendar), user settings and
741
834
  // hooks, or memory: at Full autonomy those would all be the guest's.
@@ -798,6 +891,7 @@ function start(job, agentId) {
798
891
  endGrace(job);
799
892
  if (job.status !== 'working') return;
800
893
  if (job.clearing && !job.stopRequested) return recycle(job);
894
+ if (job.switching && !job.stopRequested) return resume(job);
801
895
  if (job.stopRequested) finish(job, 'stopped', job.stopWhy || 'Stopped by you.');
802
896
  else if (job.lastTurn) finish(job, job.lastTurn.ok ? 'done' : 'failed', job.lastTurn.ok ? null : job.lastTurn.error);
803
897
  else finish(job, 'failed', errTail.trim().split('\n').pop() || why);
@@ -830,6 +924,34 @@ function recycle(job) {
830
924
  setImmediate(pump);
831
925
  }
832
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
+
833
955
  function write(child, text) {
834
956
  if (!child.stdin.writable) return false;
835
957
  child.stdin.write(JSON.stringify({ type: 'user', message: { role: 'user', content: text } }) + '\n');
@@ -912,6 +1034,7 @@ function onLine(job, line) {
912
1034
  board.addTokens((u.input_tokens || 0) + (u.output_tokens || 0));
913
1035
  guests.addTokens(job, (u.input_tokens || 0) + (u.output_tokens || 0));
914
1036
  board.addTime(e.duration_ms);
1037
+ if (!job.guest) economy.accrue(job, e.duration_ms, job.model);
915
1038
  nudgeUsage();
916
1039
  job.denials = [...new Set((e.permission_denials || []).map((d) => d.tool_name))];
917
1040
  const ok = e.subtype === 'success' && !e.is_error;
@@ -996,6 +1119,23 @@ function adoptNewProject(job) {
996
1119
  return true;
997
1120
  }
998
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
+
999
1139
  function finish(job, status, error) {
1000
1140
  if (job.status !== 'working') return;
1001
1141
  clearWaits(job);
@@ -1013,12 +1153,19 @@ function finish(job, status, error) {
1013
1153
  const was = TIER_LABEL[tierOf(job.model)] || job.model || 'the default model';
1014
1154
  const retry = status === 'failed' ? bump(job) : null;
1015
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 {}
1016
1160
  if (status === 'done' && !awaiting) board.addJob();
1017
1161
  state.xp += gained;
1018
1162
  job.xp += gained;
1019
1163
  pushJob(job);
1020
1164
  recordChanges(job).catch(() => {}).then(() => adoptNewProject(job));
1021
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);
1022
1169
  if (gained) {
1023
1170
  const after = progress();
1024
1171
  send({ t: 'xp', progress: after, gained, jobId: job.id, agentId: job.agentId, levelUp: after.level > before.level });
@@ -1029,7 +1176,8 @@ function finish(job, status, error) {
1029
1176
  // A borrowed agent's chat belongs to the borrower: the host's feed carries
1030
1177
  // the finished job over the link, and remoteEnded posts it in their office.
1031
1178
  // Nothing about a guest's job goes in our own chat.
1032
- if (!job.guest) post(job.agentId, text, { jobId: job.id, status, denials: job.denials });
1179
+ const attached = takeStaged(job);
1180
+ if (!job.guest) post(job.agentId, text, { jobId: job.id, status, denials: job.denials, ...attached });
1033
1181
  // The result lands in the job's thread, which the boss may never open, so
1034
1182
  // the chief says so in #general. Not for a retry (the work isn't over), a
1035
1183
  // stop (the boss did that), or a colleague's handed-off piece (their asker
@@ -1037,7 +1185,7 @@ function finish(job, status, error) {
1037
1185
  if (!retry && status !== 'stopped' && !job.requestedBy && !job.guest) {
1038
1186
  const line = awaiting ? `${nameOf(job.agentId)} is waiting on you for "${clip(job.title, 80)}": ${clip(job.asking.summary, 200)}`
1039
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)}`;
1040
- 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 });
1041
1189
  }
1042
1190
 
1043
1191
  // Hand the result back to whoever delegated this, if they're still at it.
@@ -1047,6 +1195,16 @@ function finish(job, status, error) {
1047
1195
  if (asker?.status === 'working' && running.has(asker.id)) {
1048
1196
  settle(asker, (w) => w.kind === 'job' && w.id === job.id);
1049
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.` });
1050
1208
  }
1051
1209
  // Anyone waiting on a reply from this agent stops waiting.
1052
1210
  for (const other of state.jobs) {
@@ -1143,6 +1301,7 @@ function replyJob(job, { message, mode, files }) {
1143
1301
  }
1144
1302
  message = String(message || '').trim();
1145
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; }
1146
1305
  if (mode && !MODES[mode]) throw new Error('Unknown mode.');
1147
1306
  if (mode) job.mode = mode;
1148
1307
  // An idle session already nudged and still over the limit resumes fresh, not on its history.
@@ -1753,10 +1912,17 @@ function officePrompt(agentId, job) {
1753
1912
  const me = nameOf(agentId);
1754
1913
  const others = onFloor().filter((s) => s.id !== agentId).map((s) => (s.role ? `${s.name} (${s.role})` : s.name)).join(', ');
1755
1914
  return `You are ${me}, one of the agents in the Agent Office. Your colleagues: ${others || 'none yet'}. ` + persona(agentId) +
1756
- `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. ` +
1757
1916
  `When your context has grown heavy and you are between steps, clear_context with notes to yourself lets you resume fresh and cheaper. ` +
1758
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. ` +
1759
- `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.`;
1760
1926
  }
1761
1927
 
1762
1928
  // Survives a cleared context: what this agent was last working on, so
@@ -1873,6 +2039,56 @@ const agentApi = {
1873
2039
  : `Delivered to ${target.name}.`;
1874
2040
  },
1875
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
+
1876
2092
  flag_for_boss(me, { kind, summary }) {
1877
2093
  summary = String(summary || '').replace(/\s+/g, ' ').trim().slice(0, 600);
1878
2094
  if (!summary) throw new Error('Say what the boss needs to know.');
@@ -1888,7 +2104,7 @@ const agentApi = {
1888
2104
  // keeps the "open job" link, and the explicit null thread keeps post()
1889
2105
  // from tucking it into the job's thread.
1890
2106
  post('chief', `**${FLAGS[kind]}** · ${nameOf(me.agentId)} on "${clip(me.title, 80)}": ${summary}`,
1891
- { 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) });
1892
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.';
1893
2109
  },
1894
2110
 
@@ -1957,7 +2173,7 @@ const agentApi = {
1957
2173
  if (!message) throw new Error('The message is empty.');
1958
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.");
1959
2175
  joinChannel(id, me.agentId);
1960
- post(me.agentId, message, { channel: id, kind: 'channel' });
2176
+ post(me.agentId, message, { channel: id, kind: 'channel', ...takeStaged(me) });
1961
2177
 
1962
2178
  // Whoever is in the channel and mid-job hears it now. Anyone free is left
1963
2179
  // alone on purpose: a word in a channel shouldn't pull half the office
@@ -2071,6 +2287,32 @@ function attachments(ids) {
2071
2287
  return out;
2072
2288
  }
2073
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
+
2074
2316
  function withFiles(text, files) {
2075
2317
  text = String(text || '').trim();
2076
2318
  // A voice note reaches the agent as its transcript; the audio itself is
@@ -2136,7 +2378,31 @@ function serveUpload(res, id, name) {
2136
2378
  // OFFICE_ALLOWED_HOSTS adds exact hostnames (comma-separated), e.g. this
2137
2379
  // machine's Tailscale name when `tailscale serve` fronts the office. Everything
2138
2380
  // else is still refused, which is what keeps rebinding sites out.
2139
- 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
+ };
2140
2406
  const DEV_PORT = '5317'; // vite.config.js
2141
2407
 
2142
2408
  // The office key. Host and Origin checks keep other websites out, but not
@@ -2183,7 +2449,8 @@ function trusted(req) {
2183
2449
  // Anything that can spawn claude with full permissions must refuse
2184
2450
  // requests from other websites and from rebinding hostnames.
2185
2451
  const host = String(req.headers.host || '').replace(/:\d+$/, '');
2186
- if (!LOCAL.has(host)) return false;
2452
+ const lan = isLanHost(host);
2453
+ if (!LOCAL.has(host) && !lan) return false;
2187
2454
  const origin = req.headers.origin;
2188
2455
  // No Origin is not proof of a local caller: another site's <img> or <link>
2189
2456
  // GET arrives without one. So every GET must stay read-only, with no side
@@ -2193,8 +2460,9 @@ function trusted(req) {
2193
2460
  // anything an agent started. Only the office itself and its vite dev page.
2194
2461
  try {
2195
2462
  const o = new URL(origin);
2196
- if (!LOCAL.has(o.hostname)) return false;
2197
- 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;
2198
2466
  return [String(PORT), DEV_PORT].includes(o.port);
2199
2467
  } catch { return false; }
2200
2468
  }
@@ -2256,7 +2524,7 @@ const server = http.createServer(async (req, res) => {
2256
2524
  if (!sameKey(url.searchParams.get('key'))) return locked(res);
2257
2525
  url.searchParams.delete('key');
2258
2526
  res.writeHead(303, {
2259
- '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' : ''}`,
2260
2528
  location: url.pathname + url.search, 'cache-control': 'no-store',
2261
2529
  });
2262
2530
  return res.end();
@@ -2280,6 +2548,14 @@ const server = http.createServer(async (req, res) => {
2280
2548
  return fs.createReadStream(icon.file).pipe(res);
2281
2549
  }
2282
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
+ }
2283
2559
  // An away job's file tools, passed to the host that has the project.
2284
2560
  if (req.method === 'POST' && parts[1] === 'agent' && /^files_[a-z]+$/.test(parts[2] || '')) {
2285
2561
  const me = caller(req);
@@ -2310,6 +2586,18 @@ const server = http.createServer(async (req, res) => {
2310
2586
  const { path: _, ...f } = await upload(req);
2311
2587
  return json(res, 201, f);
2312
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
+ }
2313
2601
  // Which way claude signs in. Only new processes pick it up; a job
2314
2602
  // already running finishes on whatever it started with.
2315
2603
  if (url.pathname === '/api/auth' && ['GET', 'POST', 'DELETE'].includes(req.method)) {
@@ -2413,11 +2701,58 @@ const server = http.createServer(async (req, res) => {
2413
2701
  if (parts[3] === 'dismiss' && !state.dismissed.includes(parts[2])) return json(res, 400, { error: text });
2414
2702
  return json(res, 200, { text });
2415
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
+ }
2416
2708
  if (req.method === 'POST' && parts[1] === 'staff' && parts[2] && parts[3] === 'profile' && !parts[4]) {
2417
2709
  let p;
2418
2710
  try { p = updateProfile(parts[2], await readBody(req)); } catch (err) { return json(res, 400, { error: err.message }); }
2419
2711
  return p ? json(res, 200, p) : json(res, 404, { error: 'No such agent.' });
2420
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
+ }
2421
2756
  if (req.method === 'POST' && url.pathname === '/api/standup') { runStandup(true); return json(res, 202, { ok: true }); }
2422
2757
  if (req.method === 'POST' && url.pathname === '/api/restart') {
2423
2758
  const { now } = await readBody(req);
@@ -2511,6 +2846,7 @@ const server = http.createServer(async (req, res) => {
2511
2846
  return json(res, 200, { diff: await fileDiff(cwd, job.changes, url.searchParams.get('path') || '') });
2512
2847
  }
2513
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 }); }
2514
2850
  if (req.method === 'POST' && parts[3] === 'reply') { replyJob(job, await readBody(req)); return json(res, 200, { ok: true }); }
2515
2851
  }
2516
2852
  json(res, 404, { error: 'Not found.' });
@@ -2549,10 +2885,18 @@ server.on('error', (err) => {
2549
2885
  console.error(`Port ${PORT} is already in use. Start the office with another: agentstudio --port <n>, or PORT=<n>.`);
2550
2886
  process.exit(78);
2551
2887
  });
2552
- server.listen(PORT, '127.0.0.1', () => {
2888
+ server.listen(PORT, LAN ? '0.0.0.0' : '127.0.0.1', () => {
2553
2889
  // The key is in this line on purpose: it's how the boss gets in. It only
2554
2890
  // goes to this terminal (or the supervisor's), never to a log file.
2555
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
+ }
2556
2900
  // For `agentoffice --link`, which can't know which free port we got.
2557
2901
  try { fs.writeFileSync(path.join(DATA, 'office-port'), String(PORT)); } catch {}
2558
2902
  afterRestart();
@@ -2564,6 +2908,7 @@ server.listen(PORT, '127.0.0.1', () => {
2564
2908
  });
2565
2909
 
2566
2910
  function shutdown(code = 0) {
2911
+ tunnel.drop();
2567
2912
  for (const child of running.values()) child.kill('SIGTERM');
2568
2913
  clearTimeout(saveTimer);
2569
2914
  board.flush();