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.
- package/CHANGELOG.md +13 -1
- package/README.md +1 -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/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/index.js +361 -16
- 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-BtHSEs3B.js +0 -4475
- package/dist/assets/index-DVuFVmWF.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.
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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();
|