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.
- package/CHANGELOG.md +18 -1
- package/README.md +15 -1
- package/bin/agentoffice.js +6 -1
- package/dist/assets/index-BAvX7Imu.css +1 -0
- package/dist/assets/index-C01GpSoa.js +4999 -0
- package/dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- package/dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- package/dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- package/dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- package/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/dist/assets/{jetbrains-mono-cyrillic-400-normal-CIEtRijs.woff → jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff} +0 -0
- package/dist/assets/{jetbrains-mono-greek-400-normal-CNojz0t3.woff → jetbrains-mono-greek-400-normal-B9oWc5Lo.woff} +0 -0
- package/dist/assets/{jetbrains-mono-latin-400-normal-B11XCQ5g.woff → jetbrains-mono-latin-400-normal-6-qcROiO.woff} +0 -0
- package/dist/assets/{jetbrains-mono-latin-ext-400-normal-DHMOLxAi.woff → jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff} +0 -0
- package/dist/assets/{jetbrains-mono-vietnamese-400-normal-BeU3T8wf.woff → jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff} +0 -0
- package/dist/index.html +11 -2
- package/docs/linked-offices.md +11 -0
- package/docs/phone.md +51 -3
- package/docs/security.md +8 -3
- package/package.json +4 -2
- package/server/cloudflared.js +84 -0
- package/server/economy.js +455 -0
- package/server/guests.js +67 -13
- package/server/index.js +371 -21
- package/server/journeys.js +101 -0
- package/server/office-mcp.js +19 -0
- package/server/phone.js +70 -0
- package/server/settings.js +3 -0
- package/server/talk.js +51 -0
- package/server/tunnel.js +136 -0
- package/shared/shop.js +127 -0
- package/dist/assets/index-B9ZRfVrF.js +0 -4475
- package/dist/assets/index-BiK6nglq.css +0 -1
- package/dist/assets/inter-cyrillic-ext-wght-normal-B2xhLi22.woff2 +0 -0
- package/dist/assets/inter-cyrillic-wght-normal-CMZtQduZ.woff2 +0 -0
- package/dist/assets/inter-greek-ext-wght-normal-CGAr0uHJ.woff2 +0 -0
- package/dist/assets/inter-greek-wght-normal-CaVNZxsx.woff2 +0 -0
- package/dist/assets/inter-latin-ext-wght-normal-CFHvXkgd.woff2 +0 -0
- 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, () =>
|
|
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
|
-
|
|
744
|
-
|
|
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
|
-
|
|
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,
|
|
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}
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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();
|