agent-office-studio 0.2.0 → 0.4.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 +47 -1
- package/README.md +1 -1
- package/bin/agentoffice.js +65 -5
- package/dist/assets/index-CiEWLydu.css +32 -0
- package/dist/assets/index-S_6NhxZ0.js +4457 -0
- package/dist/index.html +23 -9
- package/docs/README.md +1 -0
- package/docs/claude-access.md +2 -2
- package/docs/configuration.md +6 -2
- package/docs/development.md +16 -0
- package/docs/getting-started.md +4 -1
- package/docs/guides/troubleshooting.md +13 -2
- package/docs/guides/updating.md +26 -20
- package/docs/jobs.md +21 -0
- package/docs/leaderboard.md +8 -6
- package/docs/linked-offices.md +81 -0
- package/docs/phone.md +6 -2
- package/docs/security.md +37 -5
- package/docs/terminal.md +3 -3
- package/package.json +12 -11
- package/server/changes.js +4 -2
- package/server/chief.js +20 -3
- package/server/guests.js +391 -0
- package/server/index.js +468 -42
- package/server/leaderboard.js +63 -14
- package/server/links.js +350 -0
- package/server/office-mcp.js +5 -0
- package/server/settings.js +85 -0
- package/server/supervise.js +2 -0
- package/server/terminal.js +1 -1
- package/server/voice.js +3 -1
- package/dist/assets/index-CozPk6R9.js +0 -4457
- package/dist/assets/index-i7sU9SN-.css +0 -32
package/server/index.js
CHANGED
|
@@ -2,6 +2,7 @@ import http from 'node:http';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
|
+
import os from 'node:os';
|
|
5
6
|
import { spawn, spawnSync } from 'node:child_process';
|
|
6
7
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
7
8
|
import { WebSocketServer } from 'ws';
|
|
@@ -16,25 +17,31 @@ import { Terminals } from './terminal.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 { Links } from './links.js';
|
|
21
|
+
import { Settings } from './settings.js';
|
|
22
|
+
import { Guests } from './guests.js';
|
|
19
23
|
|
|
20
24
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
21
25
|
const APP = path.resolve(HERE, '..');
|
|
22
26
|
const ROOT = path.resolve(process.env.OFFICE_ROOT || path.join(APP, '..'));
|
|
23
27
|
const PORT = Number(process.env.PORT || 4317);
|
|
24
28
|
const CLAUDE = process.env.CLAUDE_BIN || 'claude';
|
|
29
|
+
// OFFICE_DATA is set by the agentoffice command; a clone keeps it beside the code.
|
|
25
30
|
const DATA = path.resolve(process.env.OFFICE_DATA || path.join(APP, 'data'));
|
|
26
31
|
const STATE_FILE = path.join(DATA, 'state.json');
|
|
27
32
|
const DIST = path.join(APP, 'dist');
|
|
28
33
|
const UPLOADS = path.join(DATA, 'uploads');
|
|
34
|
+
// Each running job's office MCP config, token and all. On the command line it
|
|
35
|
+
// would be readable by every process on the machine (`ps`); here it is a 0600
|
|
36
|
+
// file in a 0700 folder, gone when the job's session closes. Leftovers from a
|
|
37
|
+
// crash hold tokens that died with that server, so they are cleared at start.
|
|
38
|
+
const MCP_DIR = path.join(DATA, 'mcp');
|
|
39
|
+
fs.rmSync(MCP_DIR, { recursive: true, force: true });
|
|
29
40
|
const auth = new Auth(DATA);
|
|
30
41
|
const board = new Leaderboard(DATA, { onChange: () => send({ t: 'leaderboard', leaderboard: board.status() }) });
|
|
31
42
|
const MAX_UPLOAD = 25 * 1024 * 1024;
|
|
32
43
|
const MAX_LOG = 600;
|
|
33
44
|
const MAX_CHAT = 300;
|
|
34
|
-
const CHIEF_MODEL = process.env.CHIEF_MODEL || 'sonnet';
|
|
35
|
-
// The daily standup, local time; "off" turns it off. Weekdays unless STANDUP_WEEKENDS=1.
|
|
36
|
-
const STANDUP_AT = process.env.STANDUP_AT ?? '09:30';
|
|
37
|
-
const STANDUP_WEEKENDS = process.env.STANDUP_WEEKENDS === '1';
|
|
38
45
|
// After a turn ends, keep the session open briefly: a message that landed
|
|
39
46
|
// at the last moment starts another turn instead of being lost.
|
|
40
47
|
const GRACE_MS = 2500;
|
|
@@ -165,6 +172,12 @@ let state = { xp: 0, jobs: [], chat: [], channels: [], chiefCost: 0, dismissed:
|
|
|
165
172
|
try {
|
|
166
173
|
state = { ...state, ...JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')) };
|
|
167
174
|
} catch {}
|
|
175
|
+
// What the boss sets on the Settings page, over the environment's defaults.
|
|
176
|
+
// The context limit used to live at state.clearTokens.
|
|
177
|
+
if (!state.settings || typeof state.settings !== 'object') state.settings = {};
|
|
178
|
+
if (Number.isInteger(state.clearTokens) && !('tokenLimit' in state.settings)) state.settings.tokenLimit = state.clearTokens;
|
|
179
|
+
delete state.clearTokens;
|
|
180
|
+
const settings = new Settings(state.settings, () => save());
|
|
168
181
|
if (!state.channels.some((c) => c.id === GENERAL)) state.channels.unshift({ id: GENERAL, name: GENERAL, createdAt: Date.now() });
|
|
169
182
|
// Sat next to #general, and described even if someone had already opened a
|
|
170
183
|
// channel by that name by hand. An explanation they wrote themselves stays.
|
|
@@ -234,7 +247,7 @@ for (const j of state.jobs) {
|
|
|
234
247
|
if (j.status !== 'working') continue;
|
|
235
248
|
if (j.sessionId) {
|
|
236
249
|
Object.assign(j, {
|
|
237
|
-
status: 'queued', queuedAt: Date.now(), wantAgent: j.agentId, waiting: false,
|
|
250
|
+
status: 'queued', queuedAt: Date.now(), wantAgent: j.agentId, waiting: false, clearing: false,
|
|
238
251
|
// An unread message stashed by deliver() comes along too, rather than
|
|
239
252
|
// being written over by the notice about the restart.
|
|
240
253
|
pending: [j.pending, 'The office restarted while you were working, which interrupted you. Pick up exactly where you left off.'].filter(Boolean).join('\n\n'),
|
|
@@ -272,16 +285,80 @@ function save() {
|
|
|
272
285
|
// ---------- broadcasting ----------
|
|
273
286
|
|
|
274
287
|
const clients = new Set();
|
|
288
|
+
|
|
289
|
+
// Linked offices. Our agents as other offices see them: who, what they look
|
|
290
|
+
// like, and what they're doing, down to the job title.
|
|
291
|
+
// A job's title is the first line of its prompt, which can say more than the
|
|
292
|
+
// boss means to share, so linked offices see only that an agent is busy.
|
|
293
|
+
// OFFICE_SHARE_JOB_TITLES=1 sends the titles too.
|
|
294
|
+
function presence() {
|
|
295
|
+
return hired().map((s) => {
|
|
296
|
+
const job = state.jobs.find((j) => j.agentId === s.id && j.status === 'working');
|
|
297
|
+
const doing =
|
|
298
|
+
state.dismissed.includes(s.id) ? 'away'
|
|
299
|
+
: standupNow?.who.includes(s.id) ? 'meeting'
|
|
300
|
+
: job ? 'working'
|
|
301
|
+
: chill?.who.includes(s.id) ? 'break'
|
|
302
|
+
: 'idle';
|
|
303
|
+
return { id: s.id, name: s.name, role: s.role, look: { shirt: s.shirt, hair: s.hair, skin: s.skin }, state: doing, job: job && settings.get('shareTitles') ? { title: job.title } : null };
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
// Set just below; anything broadcast before then has no guests to tell.
|
|
307
|
+
let guests = null;
|
|
308
|
+
const links = new Links(DATA, {
|
|
309
|
+
board,
|
|
310
|
+
presence,
|
|
311
|
+
onChange: () => {
|
|
312
|
+
guests?.linksChanged();
|
|
313
|
+
send({ t: 'links', ...links.status() });
|
|
314
|
+
send({ t: 'visitors', offices: links.visiting() });
|
|
315
|
+
},
|
|
316
|
+
onGuest: (link, msg) => guests?.onGuest(link, msg),
|
|
317
|
+
});
|
|
318
|
+
// Guest offices running jobs here, and our visits to offices that host us.
|
|
319
|
+
guests = new Guests(DATA, {
|
|
320
|
+
links,
|
|
321
|
+
office: {
|
|
322
|
+
jobs: () => state.jobs,
|
|
323
|
+
createJob: (body, extra) => createJob(body, extra),
|
|
324
|
+
replyJob: (job, body) => replyJob(job, body),
|
|
325
|
+
stopJob: (job, why) => stopJob(job, why),
|
|
326
|
+
diff: async (job, p) => {
|
|
327
|
+
const cwd = repoPath(job.repo);
|
|
328
|
+
if (!cwd) throw Object.assign(new Error('That project folder is gone.'), { code: 404 });
|
|
329
|
+
return fileDiff(cwd, job.changes, p);
|
|
330
|
+
},
|
|
331
|
+
staff: () => STAFF,
|
|
332
|
+
modes: () => Object.fromEntries(Object.entries(MODES).map(([k, v]) => [k, v.label])),
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
// Broadcasts that can't change what an agent is doing don't need presence checked.
|
|
336
|
+
const NOT_PRESENCE = new Set(['links', 'visitors', 'proc', 'leaderboard', 'auth', 'usage']);
|
|
337
|
+
|
|
275
338
|
function send(msg) {
|
|
276
339
|
const data = JSON.stringify(msg);
|
|
277
340
|
for (const ws of clients) if (ws.readyState === 1) ws.send(data);
|
|
341
|
+
guests?.feed(msg);
|
|
342
|
+
if (!NOT_PRESENCE.has(msg.t)) links.sendPresence();
|
|
278
343
|
}
|
|
279
344
|
const publicJob = (j, tail = 40) => {
|
|
280
345
|
const { stopRequested, lastTurn, sent, handoffs, ...rest } = j;
|
|
281
346
|
return { ...rest, log: j.log.slice(-tail) };
|
|
282
347
|
};
|
|
348
|
+
// Whose office this is: the owner set in Settings (default OFFICE_OWNER), else the first name on this account.
|
|
349
|
+
const ACCOUNT_NAME = (() => {
|
|
350
|
+
let full = '';
|
|
351
|
+
if (process.platform === 'darwin') full = spawnSync('/usr/bin/id', ['-F'], { encoding: 'utf8' }).stdout?.trim() || '';
|
|
352
|
+
return (full.split(/\s+/)[0] || os.userInfo().username || '').slice(0, 40);
|
|
353
|
+
})();
|
|
354
|
+
const owner = () => settings.get('owner') || ACCOUNT_NAME;
|
|
355
|
+
function announceSettings() {
|
|
356
|
+
send({ t: 'settings', owner: owner(), settings: settings.status(), contextLimit: contextLimit() });
|
|
357
|
+
}
|
|
283
358
|
const snapshot = () => ({
|
|
284
359
|
t: 'hello',
|
|
360
|
+
owner: owner(),
|
|
361
|
+
settings: settings.status(),
|
|
285
362
|
staff: STAFF,
|
|
286
363
|
modes: Object.fromEntries(Object.entries(MODES).map(([k, v]) => [k, v.label])),
|
|
287
364
|
progress: progress(),
|
|
@@ -294,11 +371,14 @@ const snapshot = () => ({
|
|
|
294
371
|
usage,
|
|
295
372
|
auth: auth.status(),
|
|
296
373
|
leaderboard: board.status(),
|
|
374
|
+
links: links.status(),
|
|
375
|
+
visitors: links.visiting(),
|
|
297
376
|
procs,
|
|
298
377
|
machine,
|
|
299
378
|
dismissed: state.dismissed,
|
|
300
379
|
voice: voiceReady(DATA),
|
|
301
380
|
tts: { ...piperReady(DATA), replies: voiceOn() },
|
|
381
|
+
contextLimit: contextLimit(),
|
|
302
382
|
});
|
|
303
383
|
|
|
304
384
|
// ---------- what the agents are costing this machine ----------
|
|
@@ -461,9 +541,7 @@ const endChill = (id) => { if (chill?.id === id) chill = null; };
|
|
|
461
541
|
// The model writes the small talk, but not for every chat: at most one per
|
|
462
542
|
// BANTER_GAP, and BANTER=off turns it off. Anything else, or any failure,
|
|
463
543
|
// comes back with no script and the browser uses a canned chat instead.
|
|
464
|
-
const
|
|
465
|
-
const BANTER_GAP = Number(process.env.BANTER_GAP_SECONDS ?? 120) * 1000;
|
|
466
|
-
const BANTER_MODEL = process.env.BANTER_MODEL || 'haiku';
|
|
544
|
+
const BANTER_GAP = () => settings.get('banterGap') * 1000;
|
|
467
545
|
let banterAt = 0;
|
|
468
546
|
|
|
469
547
|
function banterContext(pair) {
|
|
@@ -482,16 +560,16 @@ async function claimBanter(pair, game = false) {
|
|
|
482
560
|
const c = claimChill(pair, game);
|
|
483
561
|
if (!c) return { id: null };
|
|
484
562
|
if (game) return { id: c.id };
|
|
485
|
-
if (!
|
|
563
|
+
if (!settings.get('banter') || Date.now() - banterAt < BANTER_GAP()) return { id: c.id };
|
|
486
564
|
banterAt = Date.now();
|
|
487
565
|
try {
|
|
488
|
-
const { lines, cost } = await writeBanter(banterContext(pair), { bin: CLAUDE, model:
|
|
566
|
+
const { lines, cost } = await writeBanter(banterContext(pair), { bin: CLAUDE, model: settings.get('banterModel'), env: auth.env() });
|
|
489
567
|
state.chiefCost += cost || 0;
|
|
490
568
|
send({ t: 'chief', busy: false, cost: state.chiefCost });
|
|
491
569
|
c.at = Date.now();
|
|
492
570
|
return { id: c.id, script: lines };
|
|
493
571
|
} catch {
|
|
494
|
-
banterAt = Date.now() - BANTER_GAP + 30 * 1000;
|
|
572
|
+
banterAt = Date.now() - BANTER_GAP() + 30 * 1000;
|
|
495
573
|
return { id: c.id };
|
|
496
574
|
}
|
|
497
575
|
}
|
|
@@ -518,12 +596,29 @@ const waits = new Map();
|
|
|
518
596
|
const REPLY_WAIT_MS = 10 * 60 * 1000;
|
|
519
597
|
// Long-lived jobs get resumed on every reply and pay for the whole history each
|
|
520
598
|
// turn, so agents auto-compact earlier than the default. AGENT_COMPACT_PCT to tune.
|
|
521
|
-
|
|
599
|
+
// Past this many tokens of context, the next message an agent gets carries a
|
|
600
|
+
// nudge to clear_context. 0 turns the nudge off. AGENT_CLEAR_TOKENS to tune.
|
|
601
|
+
// The limit set in the office wins over the environment's, which is the default.
|
|
602
|
+
const clearTokens = () => settings.get('tokenLimit');
|
|
603
|
+
const contextLimit = () => { const t = settings.status().find((x) => x.key === 'tokenLimit'); return { tokens: t.value, default: t.default, custom: t.custom }; };
|
|
604
|
+
// The nudge goes with a message the session is about to pay its whole history
|
|
605
|
+
// to read, once per session, and never to a guest (who has no such tool).
|
|
606
|
+
function withClearHint(job, text) {
|
|
607
|
+
const limit = clearTokens();
|
|
608
|
+
if (!limit || job.guest || job.hinted || !(job.context >= limit)) return text;
|
|
609
|
+
job.hinted = true;
|
|
610
|
+
const k = Math.round(job.context / 1000);
|
|
611
|
+
return `${text}\n\n(Office note: your context is at ${k}k tokens, and every turn pays for all of it. If you are between steps, call clear_context with notes to yourself now, then end your turn.)`;
|
|
612
|
+
}
|
|
522
613
|
const OFFICE_TOOLS = [
|
|
523
614
|
'mcp__office__office_roster', 'mcp__office__message_colleague', 'mcp__office__hand_off', 'mcp__office__flag_for_boss',
|
|
524
615
|
'mcp__office__list_channels', 'mcp__office__create_channel', 'mcp__office__invite_to_channel', 'mcp__office__post_to_channel',
|
|
525
|
-
'mcp__office__restart_office',
|
|
616
|
+
'mcp__office__restart_office', 'mcp__office__clear_context',
|
|
526
617
|
];
|
|
618
|
+
// A guest's job can see who is in and ask the boss, nothing more: no
|
|
619
|
+
// messaging colleagues, handing off or channels. Enforced again in agentApi,
|
|
620
|
+
// since full autonomy ignores --allowedTools.
|
|
621
|
+
const GUEST_TOOLS = ['mcp__office__office_roster', 'mcp__office__flag_for_boss'];
|
|
527
622
|
const endGrace = (job) => { clearTimeout(graces.get(job.id)); graces.delete(job.id); };
|
|
528
623
|
|
|
529
624
|
function repoPath(name) {
|
|
@@ -540,7 +635,8 @@ function listRepos() {
|
|
|
540
635
|
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
|
|
541
636
|
}
|
|
542
637
|
|
|
543
|
-
|
|
638
|
+
// `extra` tags a job, as a guest's (`from`, `guest`); it must be set before the job can start.
|
|
639
|
+
function createJob({ repo, prompt, mode = 'edit', model = '', auto = false, agentId = '' }, extra = {}) {
|
|
544
640
|
if (!repoPath(repo)) throw new Error('Unknown project folder.');
|
|
545
641
|
prompt = String(prompt || '').trim();
|
|
546
642
|
if (!prompt) throw new Error('Give the job a brief.');
|
|
@@ -555,6 +651,7 @@ function createJob({ repo, prompt, mode = 'edit', model = '', auto = false, agen
|
|
|
555
651
|
sessionId: null, agentId: null, cost: 0, turns: 0, tools: 0, xp: 0,
|
|
556
652
|
pending: prompt, denials: [], error: null, log: [],
|
|
557
653
|
wantAgent: STAFF.some((st) => st.id === agentId) ? agentId : null,
|
|
654
|
+
...extra,
|
|
558
655
|
};
|
|
559
656
|
state.jobs.push(job);
|
|
560
657
|
if (job.wantAgent && state.dismissed.includes(job.wantAgent)) recall([job.wantAgent]);
|
|
@@ -588,13 +685,21 @@ function start(job, agentId) {
|
|
|
588
685
|
tokens.set(job.id, token);
|
|
589
686
|
const mcp = { mcpServers: { office: { command: process.execPath, args: [path.join(HERE, 'office-mcp.js')],
|
|
590
687
|
env: { OFFICE_URL: `http://127.0.0.1:${PORT}`, OFFICE_JOB: job.id, OFFICE_TOKEN: token } } } };
|
|
688
|
+
const mcpFile = path.join(MCP_DIR, `${job.id}-${crypto.randomUUID().slice(0, 8)}.json`);
|
|
591
689
|
// Attachments live outside the project, so agents need leave to read them.
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
690
|
+
// A guest's job gets none of them: they are the boss's files.
|
|
691
|
+
if (!job.guest) {
|
|
692
|
+
fs.mkdirSync(UPLOADS, { recursive: true });
|
|
693
|
+
args.push('--add-dir', UPLOADS);
|
|
694
|
+
} else {
|
|
695
|
+
// Nor the boss's connectors (mail, Slack, calendar), user settings and
|
|
696
|
+
// hooks, or memory: at Full autonomy those would all be the guest's.
|
|
697
|
+
args.push('--strict-mcp-config', '--setting-sources', 'project,local', '--settings', JSON.stringify({ autoMemoryEnabled: false }));
|
|
698
|
+
}
|
|
699
|
+
args.push('--mcp-config', mcpFile, '--allowedTools', (job.guest ? GUEST_TOOLS : OFFICE_TOOLS).join(','), '--append-system-prompt', job.guest ? guestPrompt(agentId, job) : officePrompt(agentId, job));
|
|
595
700
|
// Being given work in a channel is how you end up in it.
|
|
596
701
|
try { joinChannel(channelOf(job.channel), agentId); } catch {}
|
|
597
|
-
const first = job.pending;
|
|
702
|
+
const first = job.sessionId ? withClearHint(job, job.pending) : job.pending;
|
|
598
703
|
|
|
599
704
|
Object.assign(job, {
|
|
600
705
|
status: 'working', agentId, pending: null, denials: [], error: null,
|
|
@@ -610,7 +715,14 @@ function start(job, agentId) {
|
|
|
610
715
|
|
|
611
716
|
// stdin stays open for the whole job, so the chief can hand the agent
|
|
612
717
|
// new messages while it works.
|
|
613
|
-
|
|
718
|
+
try {
|
|
719
|
+
fs.mkdirSync(MCP_DIR, { recursive: true, mode: 0o700 });
|
|
720
|
+
fs.chmodSync(MCP_DIR, 0o700);
|
|
721
|
+
fs.writeFileSync(mcpFile, JSON.stringify(mcp), { mode: 0o600, flag: 'wx' });
|
|
722
|
+
} catch (err) {
|
|
723
|
+
return finish(job, 'failed', `Could not write the office tools' config: ${err.message}`);
|
|
724
|
+
}
|
|
725
|
+
const child = spawn(CLAUDE, args, { cwd, env: { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: String(settings.get('compactPct')), ...(job.guest ? { CLAUDE_CODE_DISABLE_AUTO_MEMORY: '1' } : {}), ...auth.env() }, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
614
726
|
running.set(job.id, child);
|
|
615
727
|
startProcs();
|
|
616
728
|
child.stdin.on('error', () => {});
|
|
@@ -633,10 +745,12 @@ function start(job, agentId) {
|
|
|
633
745
|
|
|
634
746
|
const ended = (why) => {
|
|
635
747
|
running.delete(job.id);
|
|
748
|
+
fs.rm(mcpFile, { force: true }, () => {});
|
|
636
749
|
if (procs) { delete procs[job.id]; }
|
|
637
750
|
endGrace(job);
|
|
638
751
|
if (job.status !== 'working') return;
|
|
639
|
-
if (job.stopRequested)
|
|
752
|
+
if (job.clearing && !job.stopRequested) return recycle(job);
|
|
753
|
+
if (job.stopRequested) finish(job, 'stopped', job.stopWhy || 'Stopped by you.');
|
|
640
754
|
else if (job.lastTurn) finish(job, job.lastTurn.ok ? 'done' : 'failed', job.lastTurn.ok ? null : job.lastTurn.error);
|
|
641
755
|
else finish(job, 'failed', errTail.trim().split('\n').pop() || why);
|
|
642
756
|
};
|
|
@@ -644,6 +758,30 @@ function start(job, agentId) {
|
|
|
644
758
|
child.on('close', (code) => ended(`claude exited with code ${code}`));
|
|
645
759
|
}
|
|
646
760
|
|
|
761
|
+
// Start a job over in a fresh session: the history is what costs tokens on
|
|
762
|
+
// every turn, so the agent's own handoff note, the brief and anything that
|
|
763
|
+
// arrived meanwhile are all the new session carries.
|
|
764
|
+
function recycle(job) {
|
|
765
|
+
const note = job.clearNote;
|
|
766
|
+
const carried = job.context;
|
|
767
|
+
const wake = [
|
|
768
|
+
`Your original brief:\n${job.prompt}`,
|
|
769
|
+
`Your context has just been cleared (${(job.clears || 0) + 1} time${job.clears ? 's' : ''} on this job), ${job.clearedBy === 'boss' ? 'at the boss\'s request' : 'by you, to save tokens'}. Your notes:\n${note}`,
|
|
770
|
+
'The clear is DONE. If the brief or your notes only asked you to clear your context, that step is complete: do not call clear_context again, just confirm in one line that you are on a fresh context and finish. ' +
|
|
771
|
+
'The project still holds everything you changed. Check where the work stands, then carry on.',
|
|
772
|
+
job.pending,
|
|
773
|
+
].filter(Boolean).join('\n\n');
|
|
774
|
+
Object.assign(job, { sessionId: null, clearNote: null, clearedBy: null, clearing: false, hinted: false, context: null, lastTurn: null, pending: wake,
|
|
775
|
+
status: 'queued', queuedAt: Date.now(), waiting: false });
|
|
776
|
+
job.clears = (job.clears || 0) + 1;
|
|
777
|
+
job.lastClear = { note, by: job.clearedBy || 'self', at: Date.now() };
|
|
778
|
+
tokens.delete(job.id);
|
|
779
|
+
addLog(job, { kind: 'system', summary: `Cleared context${carried ? ` (${Math.round(carried / 1000)}k tokens)` : ''} and carried on fresh` });
|
|
780
|
+
pushJob(job);
|
|
781
|
+
save();
|
|
782
|
+
setImmediate(pump);
|
|
783
|
+
}
|
|
784
|
+
|
|
647
785
|
function write(child, text) {
|
|
648
786
|
if (!child.stdin.writable) return false;
|
|
649
787
|
child.stdin.write(JSON.stringify({ type: 'user', message: { role: 'user', content: text } }) + '\n');
|
|
@@ -658,7 +796,7 @@ function deliver(job, text) {
|
|
|
658
796
|
job.lastTurn = null;
|
|
659
797
|
if (job.waiting) { job.waiting = false; pushJob(job); }
|
|
660
798
|
addLog(job, { kind: 'user', summary: text });
|
|
661
|
-
if (child && write(child, text)) return 'live';
|
|
799
|
+
if (child && write(child, withClearHint(job, text))) return 'live';
|
|
662
800
|
// The session ended between the check and the write, so there is nobody
|
|
663
801
|
// to hand it to. Hold it for the next turn: passing it to replyJob would
|
|
664
802
|
// only hand it straight back here, for as long as the stack allows.
|
|
@@ -688,6 +826,12 @@ function onLine(job, line) {
|
|
|
688
826
|
job.sessionId = e.session_id;
|
|
689
827
|
addLog(job, { kind: 'system', summary: `Clocked in · ${e.model}` });
|
|
690
828
|
} else if (e.type === 'assistant') {
|
|
829
|
+
// Each assistant message carries the usage of one API call, whose input side
|
|
830
|
+
// is the real context size right now. (A subagent's calls are its own context.)
|
|
831
|
+
const au = e.message?.usage;
|
|
832
|
+
if (au && !e.parent_tool_use_id) {
|
|
833
|
+
job.context = (au.input_tokens || 0) + (au.cache_read_input_tokens || 0) + (au.cache_creation_input_tokens || 0);
|
|
834
|
+
}
|
|
691
835
|
for (const c of e.message?.content || []) {
|
|
692
836
|
if (c.type === 'text' && c.text.trim()) {
|
|
693
837
|
addLog(job, { kind: 'text', summary: c.text.trim().slice(0, 6000) });
|
|
@@ -713,11 +857,12 @@ function onLine(job, line) {
|
|
|
713
857
|
} else if (e.type === 'result') {
|
|
714
858
|
job.cost += e.total_cost_usd || 0;
|
|
715
859
|
job.turns++;
|
|
716
|
-
// The
|
|
717
|
-
//
|
|
860
|
+
// The result's usage is summed over every API call in the turn, so its cache
|
|
861
|
+
// reads count the whole history once per tool call: not the context size.
|
|
862
|
+
// job.context is kept from the last assistant message instead (see above).
|
|
718
863
|
const u = e.usage || {};
|
|
719
|
-
job.context = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0);
|
|
720
864
|
board.addTokens((u.input_tokens || 0) + (u.output_tokens || 0));
|
|
865
|
+
guests.addTokens(job, (u.input_tokens || 0) + (u.output_tokens || 0));
|
|
721
866
|
board.addTime(e.duration_ms);
|
|
722
867
|
nudgeUsage();
|
|
723
868
|
job.denials = [...new Set((e.permission_denials || []).map((d) => d.tool_name))];
|
|
@@ -725,6 +870,13 @@ function onLine(job, line) {
|
|
|
725
870
|
addLog(job, { kind: 'result', ok, summary: String(e.result || e.subtype || '').trim().slice(0, 8000), cost: e.total_cost_usd, ms: e.duration_ms });
|
|
726
871
|
job.lastTurn = { ok, error: ok ? null : `Ended with ${e.subtype}`, text: String(e.result || '').trim() };
|
|
727
872
|
endGrace(job);
|
|
873
|
+
// The agent asked for a clean slate: close this session now, and ended()
|
|
874
|
+
// starts the job again without its history.
|
|
875
|
+
if (ok && job.clearNote && running.has(job.id)) {
|
|
876
|
+
job.clearing = true;
|
|
877
|
+
running.get(job.id).stdin.end();
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
728
880
|
const pending = waits.get(job.id) || [];
|
|
729
881
|
if (ok && pending.length && running.has(job.id)) {
|
|
730
882
|
// Keep the session open; whatever they're waiting for will wake it.
|
|
@@ -803,7 +955,7 @@ function finish(job, status, error) {
|
|
|
803
955
|
// gets it, and the asker's own finish is the one worth announcing).
|
|
804
956
|
if (!retry && status !== 'stopped' && !job.requestedBy) {
|
|
805
957
|
const line = awaiting ? `${nameOf(job.agentId)} is waiting on you for "${clip(job.title, 80)}": ${clip(job.asking.summary, 200)}`
|
|
806
|
-
: status === 'done' ? `${nameOf(job.agentId)} finished "${clip(job.title, 80)}"
|
|
958
|
+
: 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)}`;
|
|
807
959
|
post('chief', `**${awaiting ? 'Waiting on you' : status === 'done' ? 'Done' : 'Failed'}** · ${line}`, { kind: 'finished', status, awaiting, jobId: job.id, thread: null, channel: GENERAL });
|
|
808
960
|
}
|
|
809
961
|
|
|
@@ -882,20 +1034,21 @@ function addWait(job, w) {
|
|
|
882
1034
|
waits.set(job.id, [...(waits.get(job.id) || []), w]);
|
|
883
1035
|
}
|
|
884
1036
|
|
|
885
|
-
function stopJob(job) {
|
|
1037
|
+
function stopJob(job, why) {
|
|
886
1038
|
if (job.status === 'queued') {
|
|
887
1039
|
job.status = 'stopped';
|
|
888
|
-
job.error = 'Pulled from the queue.';
|
|
1040
|
+
job.error = why || 'Pulled from the queue.';
|
|
889
1041
|
job.endedAt = Date.now();
|
|
890
1042
|
pushJob(job);
|
|
891
1043
|
} else if (job.status === 'working') {
|
|
892
1044
|
job.stopRequested = true;
|
|
1045
|
+
job.stopWhy = why || null;
|
|
893
1046
|
const child = running.get(job.id);
|
|
894
1047
|
if (child) {
|
|
895
1048
|
child.kill('SIGTERM');
|
|
896
1049
|
setTimeout(() => running.get(job.id) === child && child.kill('SIGKILL'), 5000);
|
|
897
1050
|
} else {
|
|
898
|
-
finish(job, 'stopped', 'Stopped by you.');
|
|
1051
|
+
finish(job, 'stopped', why || 'Stopped by you.');
|
|
899
1052
|
}
|
|
900
1053
|
}
|
|
901
1054
|
}
|
|
@@ -911,6 +1064,10 @@ function replyJob(job, { message, mode, files }) {
|
|
|
911
1064
|
if (!message) throw new Error('Say something first.');
|
|
912
1065
|
if (mode && !MODES[mode]) throw new Error('Unknown mode.');
|
|
913
1066
|
if (mode) job.mode = mode;
|
|
1067
|
+
// A cleared session has no history, so what it was doing comes along with the reply.
|
|
1068
|
+
if (!job.sessionId && job.lastClear) {
|
|
1069
|
+
message = `Your context was cleared (${job.lastClear.by === 'boss' ? "at the boss's request" : 'by you'}). What you were on:\n${job.lastClear.note}\n\nOriginal brief:\n${job.prompt}\n\nNow: ${message}`;
|
|
1070
|
+
}
|
|
914
1071
|
Object.assign(job, { status: 'queued', queuedAt: Date.now(), pending: message, error: null, endedAt: null, asking: null, awaitingBoss: false });
|
|
915
1072
|
addLog(job, { kind: 'user', summary: message });
|
|
916
1073
|
pushJob(job);
|
|
@@ -941,6 +1098,51 @@ function dismiss(ids) {
|
|
|
941
1098
|
].filter(Boolean).join(' ');
|
|
942
1099
|
}
|
|
943
1100
|
|
|
1101
|
+
// The boss's own "clear context", with no job to hand out. A live session is
|
|
1102
|
+
// closed once its turn ends and restarted fresh by ended(); a finished job
|
|
1103
|
+
// just forgets its session so the next reply starts clean.
|
|
1104
|
+
// What an agent was up to, kept when a context is wiped so neither they nor
|
|
1105
|
+
// the boss are left guessing: their last report, or failing that the brief.
|
|
1106
|
+
function whereItStood(job) {
|
|
1107
|
+
const lastReply = [...(job.log || [])].reverse().find((e) => e.kind === 'result' && e.summary)?.summary;
|
|
1108
|
+
const said = (job.lastTurn?.text || lastReply || '').trim().slice(0, 2000);
|
|
1109
|
+
const state_ = job.status === 'working' ? 'in progress' : job.awaitingBoss ? 'waiting on the boss' : job.status;
|
|
1110
|
+
return `Job "${job.title}" in ${job.repo} (${state_}). ` + (said ? `Last report:\n${said}` : `No report yet; the brief was:\n${String(job.prompt || '').slice(0, 1000)}`);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function clearContext(ids) {
|
|
1114
|
+
const cleared = [], none = [];
|
|
1115
|
+
for (const id of ids) {
|
|
1116
|
+
const s = hired().find((x) => x.id === id);
|
|
1117
|
+
if (!s) continue;
|
|
1118
|
+
const job = workingJobOf(id);
|
|
1119
|
+
if (job) {
|
|
1120
|
+
if (job.clearNote || job.clearing) { cleared.push(s.name); continue; }
|
|
1121
|
+
job.clearNote = whereItStood(job);
|
|
1122
|
+
job.clearedBy = 'boss';
|
|
1123
|
+
job.lastClear = { note: job.clearNote, by: 'boss', at: Date.now() };
|
|
1124
|
+
// Idle with the session held open: nothing will end the turn, so close it now.
|
|
1125
|
+
if (job.waiting && running.has(job.id)) { job.clearing = true; running.get(job.id).stdin.end(); }
|
|
1126
|
+
cleared.push(s.name);
|
|
1127
|
+
continue;
|
|
1128
|
+
}
|
|
1129
|
+
const last = state.jobs.filter((j) => j.agentId === id && j.sessionId).sort((a, b) => (b.queuedAt || 0) - (a.queuedAt || 0))[0];
|
|
1130
|
+
if (last) {
|
|
1131
|
+
const note = whereItStood(last);
|
|
1132
|
+
// Held for the next reply, which starts a fresh session, so it carries on knowing.
|
|
1133
|
+
Object.assign(last, { sessionId: null, context: null, hinted: false, lastClear: { note, by: 'boss', at: Date.now() } });
|
|
1134
|
+
addLog(last, { kind: 'system', summary: `Context cleared by the boss. Kept: ${note.slice(0, 300)}` });
|
|
1135
|
+
pushJob(last);
|
|
1136
|
+
cleared.push(s.name);
|
|
1137
|
+
} else none.push(s.name);
|
|
1138
|
+
}
|
|
1139
|
+
save();
|
|
1140
|
+
return [
|
|
1141
|
+
cleared.length ? `Cleared context for ${names(cleared)}.` : 'Nobody had a context to clear.',
|
|
1142
|
+
none.length ? `${names(none)} had nothing to clear.` : '',
|
|
1143
|
+
].filter(Boolean).join(' ');
|
|
1144
|
+
}
|
|
1145
|
+
|
|
944
1146
|
function recall(ids) {
|
|
945
1147
|
const back = ids.filter((id) => state.dismissed.includes(id));
|
|
946
1148
|
if (!back.length) return 'Everyone you named is already in.';
|
|
@@ -1054,7 +1256,7 @@ async function dispatch(text, mode, model, files, root, inThread, channel) {
|
|
|
1054
1256
|
const said = files.length
|
|
1055
1257
|
? `${text}\n(Attached: ${files.map((f) => f.name).join(', ')}${shown.length ? `. ${shown.length === 1 ? 'The image is' : 'The images are'} attached for you to look at.` : ''})`
|
|
1056
1258
|
: text;
|
|
1057
|
-
d = await route(chiefContext(said, agent, repo, channel), { bin: CLAUDE, model:
|
|
1259
|
+
d = await route(chiefContext(said, agent, repo, channel), { bin: CLAUDE, model: settings.get('chiefModel'), images: shown, env: auth.env() });
|
|
1058
1260
|
state.chiefCost += d.cost || 0;
|
|
1059
1261
|
nudgeUsage();
|
|
1060
1262
|
} finally {
|
|
@@ -1094,6 +1296,10 @@ async function dispatch(text, mode, model, files, root, inThread, channel) {
|
|
|
1094
1296
|
const ids = [...new Set([...(d.agentIds || []), ...(agent ? [agent.id] : [])])];
|
|
1095
1297
|
return say(d.action === 'dismiss' ? dismiss(ids) : recall(ids));
|
|
1096
1298
|
}
|
|
1299
|
+
if (d.action === 'clear') {
|
|
1300
|
+
const ids = [...new Set([...(d.agentIds || []), ...(agent ? [agent.id] : [])])];
|
|
1301
|
+
return say(clearContext(ids.length ? ids : hired().map((x) => x.id)));
|
|
1302
|
+
}
|
|
1097
1303
|
if (d.action === 'standup') return void runStandup(true);
|
|
1098
1304
|
// An answer that is merely about a job gets the same link a follow-up does.
|
|
1099
1305
|
// Asking "how did that go?" from another channel shouldn't dead-end just
|
|
@@ -1235,6 +1441,21 @@ const clip = (s, n) => {
|
|
|
1235
1441
|
s = String(s || '').replace(/\s+/g, ' ').trim();
|
|
1236
1442
|
return s.length > n ? s.slice(0, n - 1) + '…' : s;
|
|
1237
1443
|
};
|
|
1444
|
+
// A short plain-text brief of an agent's final reply: markdown stripped, the
|
|
1445
|
+
// opening sentences kept up to n characters, cut at a sentence end if possible.
|
|
1446
|
+
function briefOf(text, n = 280) {
|
|
1447
|
+
const plain = String(text || '')
|
|
1448
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
1449
|
+
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
1450
|
+
.replace(/^\s*(#{1,6}|[-*>]|\d+\.)\s+/gm, '')
|
|
1451
|
+
.replace(/[*_`]+/g, '')
|
|
1452
|
+
.replace(/\s+/g, ' ')
|
|
1453
|
+
.trim();
|
|
1454
|
+
if (plain.length <= n) return plain;
|
|
1455
|
+
const cut = plain.slice(0, n);
|
|
1456
|
+
const end = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('! '), cut.lastIndexOf('? '));
|
|
1457
|
+
return end > n * 0.4 ? cut.slice(0, end + 1) : clip(plain, n);
|
|
1458
|
+
}
|
|
1238
1459
|
const ENDED = { done: 'Shipped', failed: 'Hit a wall on', stopped: 'Stopped' };
|
|
1239
1460
|
|
|
1240
1461
|
// What each agent can honestly report since the last standup.
|
|
@@ -1305,7 +1526,7 @@ async function runStandup(byBoss) {
|
|
|
1305
1526
|
try {
|
|
1306
1527
|
// A quiet office has nothing worth a model call. Otherwise it writes
|
|
1307
1528
|
// while everyone walks over.
|
|
1308
|
-
const writing = quiet ? null : writeStandup(context, { bin: CLAUDE, model:
|
|
1529
|
+
const writing = quiet ? null : writeStandup(context, { bin: CLAUDE, model: settings.get('chiefModel'), env: auth.env() }).catch(() => null);
|
|
1309
1530
|
await sleep(GATHER_MS);
|
|
1310
1531
|
const written = writing && (await writing);
|
|
1311
1532
|
if (written) {
|
|
@@ -1337,8 +1558,9 @@ async function runStandup(byBoss) {
|
|
|
1337
1558
|
// Checked every minute. Missed standups still happen later that day, but
|
|
1338
1559
|
// not after 6pm, and a day with nothing to talk about is skipped.
|
|
1339
1560
|
function standupDue() {
|
|
1340
|
-
|
|
1341
|
-
|
|
1561
|
+
const at = settings.get('standupAt');
|
|
1562
|
+
if (!at || standupNow) return;
|
|
1563
|
+
const due = dueToday(at, settings.get('standupWeekends'));
|
|
1342
1564
|
const now = Date.now();
|
|
1343
1565
|
if (!due || now < due || (state.lastStandup || 0) >= due || new Date().getHours() >= 18) return;
|
|
1344
1566
|
const since = state.lastStandup || now - DAY_MS;
|
|
@@ -1386,17 +1608,39 @@ function officePrompt(agentId, job) {
|
|
|
1386
1608
|
const others = onFloor().filter((s) => s.id !== agentId).map((s) => (s.role ? `${s.name} (${s.role})` : s.name)).join(', ');
|
|
1387
1609
|
return `You are ${me}, one of the agents in the Agent Office. Your colleagues: ${others || 'none yet'}. ` + persona(agentId) +
|
|
1388
1610
|
`Office tools: see colleagues, message them, hand off work, flag the boss, use channels. Messages from colleagues arrive as user messages starting "Message from"; answer them with message_colleague. ` +
|
|
1611
|
+
`When your context has grown heavy and you are between steps, clear_context with notes to yourself lets you resume fresh and cheaper. ` +
|
|
1389
1612
|
`Your updates collect in your job's thread, which the boss may not open: use flag_for_boss when you need them (question, decision, blocker, review), not for routine progress. If you flag a question, decision or blocker, you are not done: say plainly in your reply that you are waiting on the boss, and do not present the work as finished. ` +
|
|
1390
|
-
`This job is discussed in #${channelOf(job?.channel)}; everyday talk stays in #general.` + standupBrief(agentId);
|
|
1613
|
+
`This job is discussed in #${channelOf(job?.channel)}; everyday talk stays in #general.` + standupBrief(agentId) + recentWork(agentId, job);
|
|
1391
1614
|
}
|
|
1392
1615
|
|
|
1393
|
-
|
|
1616
|
+
// Survives a cleared context: what this agent was last working on, so
|
|
1617
|
+
// "what were you doing?" has an answer even in a brand new session.
|
|
1618
|
+
function recentWork(agentId, job) {
|
|
1619
|
+
const mine = state.jobs.filter((j) => j.agentId === agentId && j.id !== job?.id && j.lastClear)
|
|
1620
|
+
.sort((a, b) => b.lastClear.at - a.lastClear.at).slice(0, 2);
|
|
1621
|
+
if (!mine.length) return '';
|
|
1622
|
+
return ' Before your context was last cleared you were on: ' + mine.map((j) => `[${j.id}] ${clip(j.lastClear.note, 600)}`).join(' | ');
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
// A guest's job: the agent works for someone from another office, so it is
|
|
1626
|
+
// told nothing about this office's other work and can't reach colleagues.
|
|
1627
|
+
function guestPrompt(agentId, job) {
|
|
1628
|
+
return `You are ${nameOf(agentId)}, an agent in the Agent Office, working on a job for ${job.from}, a guest from another office. ` + persona(agentId) +
|
|
1629
|
+
`Work only in this project. Don't share anything about this office's other projects, jobs or people beyond what office_roster shows. ` +
|
|
1630
|
+
`Your only office tools are office_roster and flag_for_boss; the boss here is the host, not ${job.from}. Talk to ${job.from} in your replies.`;
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
const MAX_MESSAGES = 20, MAX_HANDOFFS = 5, MAX_DEPTH = 2, MAX_FLAGS = 5, MAX_CLEARS = 5;
|
|
1394
1634
|
const FLAGS = { question: 'Question for you', decision: 'Needs your decision', blocker: 'Blocked', review: 'Ready for your review' };
|
|
1395
1635
|
|
|
1396
1636
|
function caller(req) {
|
|
1397
1637
|
const id = req.headers['x-office-job'];
|
|
1398
1638
|
const job = state.jobs.find((j) => j.id === id);
|
|
1399
|
-
if (!job || job.status !== 'working' || !tokens.get(id)
|
|
1639
|
+
if (!job || job.status !== 'working' || !tokens.get(id)) return null;
|
|
1640
|
+
// Compared in constant time, so the token can't be guessed a byte at a time.
|
|
1641
|
+
const want = Buffer.from(tokens.get(id));
|
|
1642
|
+
const got = Buffer.from(String(req.headers['x-office-token'] || ''));
|
|
1643
|
+
if (got.length !== want.length || !crypto.timingSafeEqual(got, want)) return null;
|
|
1400
1644
|
return job;
|
|
1401
1645
|
}
|
|
1402
1646
|
|
|
@@ -1412,6 +1656,15 @@ function recentJobOf(agentId) {
|
|
|
1412
1656
|
return last?.status === 'done' && Date.now() - last.endedAt < RESUME_WINDOW_MS ? last : null;
|
|
1413
1657
|
}
|
|
1414
1658
|
|
|
1659
|
+
// Who is in, without what anyone is working on or where: a guest's job
|
|
1660
|
+
// shouldn't learn this office's other work.
|
|
1661
|
+
function guestRoster(me) {
|
|
1662
|
+
const lines = [`You are ${nameOf(me.agentId)}, on a job for ${me.from}.`, 'In the office today:'];
|
|
1663
|
+
for (const s of onFloor()) if (s.id !== me.agentId) lines.push(`- ${s.role ? `${s.name} (${s.role})` : s.name}`);
|
|
1664
|
+
lines.push('', `Project: ${me.repo}`);
|
|
1665
|
+
return lines.join('\n');
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1415
1668
|
const agentApi = {
|
|
1416
1669
|
office_roster(me) {
|
|
1417
1670
|
const lines = [`You are ${nameOf(me.agentId)}.`, 'Colleagues:'];
|
|
@@ -1437,6 +1690,8 @@ const agentApi = {
|
|
|
1437
1690
|
if (!theirs || (theirs.status === 'working' && !running.has(theirs.id))) {
|
|
1438
1691
|
throw new Error(`${target.name} isn't on a job right now. Use hand_off to give them work.`);
|
|
1439
1692
|
}
|
|
1693
|
+
// A guest reads that job's log, so nothing from this office goes into it.
|
|
1694
|
+
if (theirs.guest) throw new Error(`${target.name} is on a job for a guest from another office and can't take messages.`);
|
|
1440
1695
|
|
|
1441
1696
|
const myName = nameOf(me.agentId);
|
|
1442
1697
|
deliver(theirs, `Message from ${myName} (working on "${me.title}" in ${me.repo}): ${message}` +
|
|
@@ -1477,6 +1732,14 @@ const agentApi = {
|
|
|
1477
1732
|
return 'The chief has put it in front of the boss. Carry on, or wrap up this step if you cannot go further without them.';
|
|
1478
1733
|
},
|
|
1479
1734
|
|
|
1735
|
+
clear_context(me, { notes }) {
|
|
1736
|
+
notes = String(notes || '').trim().slice(0, 4000);
|
|
1737
|
+
if (!notes) throw new Error('Write the notes you will need: what is done, what is left, what you learned.');
|
|
1738
|
+
if ((me.clears || 0) >= MAX_CLEARS) throw new Error("You've cleared your context several times on this job. Finish with what you have.");
|
|
1739
|
+
me.clearNote = notes;
|
|
1740
|
+
return 'Your context will clear when you end your turn. End it now: say nothing more of substance, your notes are what you resume with.';
|
|
1741
|
+
},
|
|
1742
|
+
|
|
1480
1743
|
restart_office(me, { now = false } = {}) {
|
|
1481
1744
|
requestRestart({ by: me.agentId, jobId: me.id, now: !!now });
|
|
1482
1745
|
return 'The office will restart once everyone is between turns, and that includes you: end your turn now. ' +
|
|
@@ -1718,14 +1981,66 @@ const terminals = new Terminals({ root: ROOT, dataDir: DATA, folders: listRepos
|
|
|
1718
1981
|
// machine's Tailscale name when `tailscale serve` fronts the office. Everything
|
|
1719
1982
|
// else is still refused, which is what keeps rebinding sites out.
|
|
1720
1983
|
const LOCAL = new Set(['localhost', '127.0.0.1', '[::1]', ...String(process.env.OFFICE_ALLOWED_HOSTS || '').split(',').map((h) => h.trim().toLowerCase()).filter(Boolean)]);
|
|
1984
|
+
const DEV_PORT = '5317'; // vite.config.js
|
|
1985
|
+
|
|
1986
|
+
// The office key. Host and Origin checks keep other websites out, but not
|
|
1987
|
+
// other accounts on this machine (or the tailnet, with OFFICE_ALLOWED_HOSTS):
|
|
1988
|
+
// they can reach 127.0.0.1 too. So the page and its API need a secret only
|
|
1989
|
+
// this account can read. It is opened once with ?key=, which leaves it in an
|
|
1990
|
+
// HttpOnly cookie, and it stays the same across restarts. Delete the file to
|
|
1991
|
+
// sign every browser out. Agents use their job tokens instead.
|
|
1992
|
+
const KEY_FILE = path.join(DATA, 'office-key');
|
|
1993
|
+
const OFFICE_KEY = (() => {
|
|
1994
|
+
try { const k = fs.readFileSync(KEY_FILE, 'utf8').trim(); if (/^[0-9a-f]{64}$/.test(k)) return k; } catch {}
|
|
1995
|
+
const k = crypto.randomBytes(32).toString('hex');
|
|
1996
|
+
fs.mkdirSync(DATA, { recursive: true });
|
|
1997
|
+
fs.writeFileSync(KEY_FILE, k + '\n', { mode: 0o600 });
|
|
1998
|
+
return k;
|
|
1999
|
+
})();
|
|
2000
|
+
try { fs.chmodSync(KEY_FILE, 0o600); } catch {}
|
|
2001
|
+
// Cookies ignore ports, so two offices on one machine each need their own name.
|
|
2002
|
+
const KEY_COOKIE = `office_key_${PORT}`;
|
|
2003
|
+
const sameKey = (k) => {
|
|
2004
|
+
const a = Buffer.from(String(k || '')), b = Buffer.from(OFFICE_KEY);
|
|
2005
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
2006
|
+
};
|
|
2007
|
+
function hasKey(req) {
|
|
2008
|
+
for (const c of String(req.headers.cookie || '').split(';')) {
|
|
2009
|
+
const i = c.indexOf('=');
|
|
2010
|
+
if (i > 0 && c.slice(0, i).trim() === KEY_COOKIE) return sameKey(c.slice(i + 1).trim());
|
|
2011
|
+
}
|
|
2012
|
+
return false;
|
|
2013
|
+
}
|
|
2014
|
+
const keyLink = (host = `localhost:${PORT}`) => `http://${host}/?key=${OFFICE_KEY}`;
|
|
2015
|
+
|
|
2016
|
+
// What a browser without the key sees instead of the office.
|
|
2017
|
+
function locked(res) {
|
|
2018
|
+
res.writeHead(401, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
2019
|
+
res.end(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Agent Office</title>
|
|
2020
|
+
<style>body{font:16px/1.5 system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#11131a;color:#e8e9ee}main{max-width:30rem;padding:2rem}h1{font-size:1.25rem;margin:0 0 .75rem}p{margin:.5rem 0;color:#b4b7c3}code{font:.9em ui-monospace,monospace;background:#1d2030;padding:.1em .35em;border-radius:4px;color:#e8e9ee}</style>
|
|
2021
|
+
<main><h1>This office is locked</h1>
|
|
2022
|
+
<p>Open it with the link it printed when it started: the line that begins <code>Agent Office open on</code>.</p>
|
|
2023
|
+
<p>Lost it? Run <code>agentoffice --link</code> in a terminal on this machine.</p></main>`);
|
|
2024
|
+
}
|
|
2025
|
+
|
|
1721
2026
|
function trusted(req) {
|
|
1722
2027
|
// Anything that can spawn claude with full permissions must refuse
|
|
1723
2028
|
// requests from other websites and from rebinding hostnames.
|
|
1724
2029
|
const host = String(req.headers.host || '').replace(/:\d+$/, '');
|
|
1725
2030
|
if (!LOCAL.has(host)) return false;
|
|
1726
2031
|
const origin = req.headers.origin;
|
|
2032
|
+
// No Origin is not proof of a local caller: another site's <img> or <link>
|
|
2033
|
+
// GET arrives without one. So every GET must stay read-only, with no side
|
|
2034
|
+
// effects; anything that changes state or spawns goes behind another method.
|
|
1727
2035
|
if (!origin) return true;
|
|
1728
|
-
|
|
2036
|
+
// Another port on localhost is another site: a project's dev server, or
|
|
2037
|
+
// anything an agent started. Only the office itself and its vite dev page.
|
|
2038
|
+
try {
|
|
2039
|
+
const o = new URL(origin);
|
|
2040
|
+
if (!LOCAL.has(o.hostname)) return false;
|
|
2041
|
+
if (!['localhost', '127.0.0.1', '[::1]'].includes(o.hostname)) return true;
|
|
2042
|
+
return [String(PORT), DEV_PORT].includes(o.port);
|
|
2043
|
+
} catch { return false; }
|
|
1729
2044
|
}
|
|
1730
2045
|
|
|
1731
2046
|
function readBody(req) {
|
|
@@ -1750,8 +2065,18 @@ const json = (res, code, body) => {
|
|
|
1750
2065
|
};
|
|
1751
2066
|
|
|
1752
2067
|
const TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.woff2': 'font/woff2', '.woff': 'font/woff', '.svg': 'image/svg+xml', '.png': 'image/png', '.webmanifest': 'application/manifest+json', '.ico': 'image/x-icon' };
|
|
2068
|
+
// Build files are public code with nothing of the boss's in them; anything
|
|
2069
|
+
// else falls back to the page, which needs the key.
|
|
2070
|
+
function isAsset(pathname) {
|
|
2071
|
+
let f;
|
|
2072
|
+
try { f = path.join(DIST, path.normalize(decodeURIComponent(pathname)).replace(/^(\.\.[\/\\])+/, '')); } catch { return false; }
|
|
2073
|
+
if (!f.startsWith(DIST + path.sep) || f === path.join(DIST, 'index.html')) return false;
|
|
2074
|
+
try { return fs.statSync(f).isFile(); } catch { return false; }
|
|
2075
|
+
}
|
|
1753
2076
|
function serveStatic(req, res) {
|
|
1754
|
-
|
|
2077
|
+
let url;
|
|
2078
|
+
// A bad escape (`/%E0`) throws; outside a try that would take the office down.
|
|
2079
|
+
try { url = decodeURIComponent(new URL(req.url, 'http://x').pathname); } catch { return json(res, 400, { error: 'Bad path.' }); }
|
|
1755
2080
|
let file = path.join(DIST, path.normalize(url).replace(/^(\.\.[\/\\])+/, ''));
|
|
1756
2081
|
if (!file.startsWith(DIST)) return json(res, 403, { error: 'No.' });
|
|
1757
2082
|
if (!fs.existsSync(file) || fs.statSync(file).isDirectory()) file = path.join(DIST, 'index.html');
|
|
@@ -1770,7 +2095,22 @@ const server = http.createServer(async (req, res) => {
|
|
|
1770
2095
|
if (!trusted(req)) return json(res, 403, { error: 'Local only.' });
|
|
1771
2096
|
const url = new URL(req.url, 'http://x');
|
|
1772
2097
|
const parts = url.pathname.split('/').filter(Boolean);
|
|
1773
|
-
if (parts[0] !== 'api')
|
|
2098
|
+
if (parts[0] !== 'api') {
|
|
2099
|
+
if (url.searchParams.has('key')) {
|
|
2100
|
+
if (!sameKey(url.searchParams.get('key'))) return locked(res);
|
|
2101
|
+
url.searchParams.delete('key');
|
|
2102
|
+
res.writeHead(303, {
|
|
2103
|
+
'set-cookie': `${KEY_COOKIE}=${OFFICE_KEY}; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000`,
|
|
2104
|
+
location: url.pathname + url.search, 'cache-control': 'no-store',
|
|
2105
|
+
});
|
|
2106
|
+
return res.end();
|
|
2107
|
+
}
|
|
2108
|
+
if (!hasKey(req) && !isAsset(url.pathname)) return locked(res);
|
|
2109
|
+
return serveStatic(req, res);
|
|
2110
|
+
}
|
|
2111
|
+
// Agents prove who they are with their job token (caller); everyone else
|
|
2112
|
+
// with the office key.
|
|
2113
|
+
if (parts[1] !== 'agent' && !hasKey(req)) return json(res, 401, { error: 'Open the office with its link first.' });
|
|
1774
2114
|
|
|
1775
2115
|
try {
|
|
1776
2116
|
if (req.method === 'GET' && url.pathname === '/api/repos') return json(res, 200, { root: ROOT, repos: listRepos() });
|
|
@@ -1784,6 +2124,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
1784
2124
|
if (req.method === 'POST' && parts[1] === 'agent' && agentApi[parts[2]]) {
|
|
1785
2125
|
const me = caller(req);
|
|
1786
2126
|
if (!me) return json(res, 403, { error: 'Not on the clock.' });
|
|
2127
|
+
if (me.guest && !GUEST_TOOLS.includes(`mcp__office__${parts[2]}`)) return json(res, 403, { error: 'Not on a guest’s job.' });
|
|
2128
|
+
if (me.guest && parts[2] === 'office_roster') return json(res, 200, { text: guestRoster(me) });
|
|
1787
2129
|
return json(res, 200, { text: agentApi[parts[2]](me, await readBody(req)) });
|
|
1788
2130
|
}
|
|
1789
2131
|
if (req.method === 'POST' && url.pathname === '/api/uploads') {
|
|
@@ -1799,15 +2141,62 @@ const server = http.createServer(async (req, res) => {
|
|
|
1799
2141
|
if (req.method !== 'GET') { send({ t: 'auth', auth: status }); refreshUsage(); }
|
|
1800
2142
|
return json(res, 200, status);
|
|
1801
2143
|
}
|
|
2144
|
+
// The context limit: past it, an agent is nudged to clear_context. 0 turns
|
|
2145
|
+
// the nudge off; null goes back to the AGENT_CLEAR_TOKENS default.
|
|
2146
|
+
if (url.pathname === '/api/context-limit' && ['GET', 'POST'].includes(req.method)) {
|
|
2147
|
+
if (req.method === 'POST') {
|
|
2148
|
+
const { tokens } = await readBody(req);
|
|
2149
|
+
if (tokens !== null && !(Number.isInteger(tokens) && tokens >= 0 && tokens <= 10_000_000)) return json(res, 400, { error: 'Use a whole number of tokens, or 0 to turn it off.' });
|
|
2150
|
+
settings.set({ tokenLimit: tokens });
|
|
2151
|
+
announceSettings();
|
|
2152
|
+
}
|
|
2153
|
+
return json(res, 200, contextLimit());
|
|
2154
|
+
}
|
|
2155
|
+
// The Settings page: every value, its default, and whether it is overridden.
|
|
2156
|
+
// A POST of { key: value | null } saves them all or none; null is the default.
|
|
2157
|
+
if (url.pathname === '/api/settings' && ['GET', 'POST'].includes(req.method)) {
|
|
2158
|
+
if (req.method === 'POST') {
|
|
2159
|
+
try { settings.set(await readBody(req)); }
|
|
2160
|
+
catch (err) { return json(res, err.code || 400, { error: err.message }); }
|
|
2161
|
+
announceSettings();
|
|
2162
|
+
}
|
|
2163
|
+
return json(res, 200, { owner: owner(), settings: settings.status() });
|
|
2164
|
+
}
|
|
1802
2165
|
// The leaderboard. Joining claims a handle with the service and starts
|
|
1803
|
-
// sharing day counters
|
|
2166
|
+
// sharing day counters. Leaving deletes the counters; the handle goes too
|
|
2167
|
+
// unless linked offices still need it.
|
|
1804
2168
|
if (url.pathname === '/api/leaderboard' && ['GET', 'POST', 'DELETE'].includes(req.method)) {
|
|
1805
2169
|
let status;
|
|
1806
|
-
try {
|
|
1807
|
-
|
|
2170
|
+
try {
|
|
2171
|
+
if (req.method === 'GET') status = board.status();
|
|
2172
|
+
else if (req.method === 'POST') status = await board.join((await readBody(req)).handle);
|
|
2173
|
+
else {
|
|
2174
|
+
const keepHandle = links.links.length > 0;
|
|
2175
|
+
status = await board.leave({ keepHandle });
|
|
2176
|
+
if (!keepHandle) links.forget();
|
|
2177
|
+
}
|
|
2178
|
+
} catch (err) { return json(res, err.code >= 400 && err.code < 600 ? err.code : 400, { error: err.message }); }
|
|
1808
2179
|
if (req.method !== 'GET') send({ t: 'leaderboard', leaderboard: status });
|
|
1809
2180
|
return json(res, 200, status);
|
|
1810
2181
|
}
|
|
2182
|
+
// A handle without joining the board, for linking offices.
|
|
2183
|
+
if (req.method === 'POST' && url.pathname === '/api/leaderboard/handle') {
|
|
2184
|
+
let status;
|
|
2185
|
+
try { status = await board.claim((await readBody(req)).handle); }
|
|
2186
|
+
catch (err) { return json(res, err.code >= 400 && err.code < 600 ? err.code : 400, { error: err.message }); }
|
|
2187
|
+
send({ t: 'leaderboard', leaderboard: status });
|
|
2188
|
+
return json(res, 200, status);
|
|
2189
|
+
}
|
|
2190
|
+
// Linked offices: codes to make and use, and the links they made.
|
|
2191
|
+
if (url.pathname === '/api/links' || parts[1] === 'links') {
|
|
2192
|
+
const linkErr = (err) => json(res, err.code >= 400 && err.code < 600 ? err.code : 502, { error: err.message });
|
|
2193
|
+
try {
|
|
2194
|
+
if (req.method === 'GET' && url.pathname === '/api/links') return json(res, 200, await links.refresh().catch(() => links.status()));
|
|
2195
|
+
if (req.method === 'POST' && url.pathname === '/api/links/invite') return json(res, 201, await links.invite(await readBody(req)));
|
|
2196
|
+
if (req.method === 'POST' && url.pathname === '/api/links/redeem') return json(res, 200, await links.redeem(await readBody(req)));
|
|
2197
|
+
if (req.method === 'DELETE' && parts[2] && !parts[3]) return json(res, 200, await links.unlink(parts[2]));
|
|
2198
|
+
} catch (err) { return linkErr(err); }
|
|
2199
|
+
}
|
|
1811
2200
|
if (req.method === 'GET' && url.pathname === '/api/leaderboard/board') {
|
|
1812
2201
|
try { return json(res, 200, await board.board(url.searchParams.get('period'))); }
|
|
1813
2202
|
catch (err) { return json(res, err.code >= 400 && err.code < 600 ? err.code : 502, { error: err.message }); }
|
|
@@ -1886,6 +2275,20 @@ const server = http.createServer(async (req, res) => {
|
|
|
1886
2275
|
send(snapshot());
|
|
1887
2276
|
return json(res, 200, { ok: true });
|
|
1888
2277
|
}
|
|
2278
|
+
// Visiting an office that made us its guest: each call is a request to
|
|
2279
|
+
// the host over the relay, which checks it against the guest's limits.
|
|
2280
|
+
if (parts[1] === 'visit' && parts[2] && parts[3] === 'jobs') {
|
|
2281
|
+
const link = parts[2], id = parts[4];
|
|
2282
|
+
try {
|
|
2283
|
+
if (req.method === 'POST' && !id) return json(res, 201, await guests.request(link, 'createJob', await readBody(req)));
|
|
2284
|
+
if (req.method === 'GET' && id && !parts[5]) return json(res, 200, await guests.request(link, 'job', { job: id }));
|
|
2285
|
+
if (req.method === 'GET' && id && parts[5] === 'diff') return json(res, 200, await guests.request(link, 'diff', { job: id, path: url.searchParams.get('path') || '' }));
|
|
2286
|
+
if (req.method === 'POST' && id && parts[5] === 'reply') { const b = await readBody(req); return json(res, 200, await guests.request(link, 'reply', { job: id, text: b.text ?? b.message, mode: b.mode })); }
|
|
2287
|
+
if (req.method === 'POST' && id && parts[5] === 'stop') return json(res, 200, await guests.request(link, 'stop', { job: id }));
|
|
2288
|
+
} catch (err) {
|
|
2289
|
+
return json(res, err.code >= 400 && err.code < 600 ? err.code : 400, { error: err.message });
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
1889
2292
|
if (parts[1] === 'jobs' && parts[2]) {
|
|
1890
2293
|
const job = state.jobs.find((j) => j.id === parts[2]);
|
|
1891
2294
|
if (!job) return json(res, 404, { error: 'No such job.' });
|
|
@@ -1911,6 +2314,7 @@ const termWss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024 });
|
|
|
1911
2314
|
server.on('upgrade', (req, socket, head) => {
|
|
1912
2315
|
const url = new URL(req.url, 'http://x');
|
|
1913
2316
|
if (!trusted(req)) return socket.destroy();
|
|
2317
|
+
if (!hasKey(req)) { socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n'); return; }
|
|
1914
2318
|
if (url.pathname === '/ws') {
|
|
1915
2319
|
return wss.handleUpgrade(req, socket, head, (ws) => {
|
|
1916
2320
|
clients.add(ws);
|
|
@@ -1918,6 +2322,14 @@ server.on('upgrade', (req, socket, head) => {
|
|
|
1918
2322
|
ws.send(JSON.stringify(snapshot()));
|
|
1919
2323
|
});
|
|
1920
2324
|
}
|
|
2325
|
+
const visit = /^\/ws\/visit\/([\w-]{1,64})$/.exec(url.pathname);
|
|
2326
|
+
if (visit) {
|
|
2327
|
+
// The host's office, as its guest. Our own fields fill what the host
|
|
2328
|
+
// doesn't send, so the page has everything it expects.
|
|
2329
|
+
return wss.handleUpgrade(req, socket, head, (ws) => guests.visit(ws, visit[1], () => ({
|
|
2330
|
+
...snapshot(), dismissed: [], standup: null, chiefBusy: false, procs: null, visitors: [],
|
|
2331
|
+
})));
|
|
2332
|
+
}
|
|
1921
2333
|
// No terminal, no socket: with the door shut there is nothing to hold open
|
|
1922
2334
|
// while someone guesses at tickets.
|
|
1923
2335
|
if (url.pathname === '/ws/terminal' && terminals.on) {
|
|
@@ -1926,8 +2338,19 @@ server.on('upgrade', (req, socket, head) => {
|
|
|
1926
2338
|
socket.destroy();
|
|
1927
2339
|
});
|
|
1928
2340
|
|
|
2341
|
+
// A taken port won't free up by trying again, so say so and tell the
|
|
2342
|
+
// supervisor not to retry (78).
|
|
2343
|
+
server.on('error', (err) => {
|
|
2344
|
+
if (err.code !== 'EADDRINUSE') throw err;
|
|
2345
|
+
console.error(`Port ${PORT} is already in use. Start the office with another: agentstudio --port <n>, or PORT=<n>.`);
|
|
2346
|
+
process.exit(78);
|
|
2347
|
+
});
|
|
1929
2348
|
server.listen(PORT, '127.0.0.1', () => {
|
|
1930
|
-
|
|
2349
|
+
// The key is in this line on purpose: it's how the boss gets in. It only
|
|
2350
|
+
// goes to this terminal (or the supervisor's), never to a log file.
|
|
2351
|
+
console.log(`Agent Office open on ${keyLink()} (projects in ${ROOT})`);
|
|
2352
|
+
// For `agentoffice --link`, which can't know which free port we got.
|
|
2353
|
+
try { fs.writeFileSync(path.join(DATA, 'office-port'), String(PORT)); } catch {}
|
|
1931
2354
|
// Shell access being open is not something to find out by accident.
|
|
1932
2355
|
terminals.status().then((t) => console.log(t.enabled
|
|
1933
2356
|
? `Terminal ON — anything in this browser gets a shell as ${process.env.USER || 'you'}. Up to ${t.max}, ${t.idleMinutes} min idle.`
|
|
@@ -1948,5 +2371,8 @@ function shutdown(code = 0) {
|
|
|
1948
2371
|
fs.writeFileSync(STATE_FILE, JSON.stringify(state));
|
|
1949
2372
|
process.exit(code);
|
|
1950
2373
|
}
|
|
2374
|
+
// One bad request must not take the office down: a crash skips shutdown(),
|
|
2375
|
+
// so state isn't saved and running agents are orphaned.
|
|
2376
|
+
process.on('unhandledRejection', (err) => console.error('[office] unhandled', err));
|
|
1951
2377
|
process.on('SIGINT', () => shutdown());
|
|
1952
2378
|
process.on('SIGTERM', () => shutdown());
|