agent-office-studio 0.3.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 +28 -0
- package/dist/assets/index-CiEWLydu.css +32 -0
- package/dist/assets/{index-DgXF8Kqx.js → index-S_6NhxZ0.js} +220 -220
- package/dist/index.html +18 -9
- package/docs/claude-access.md +2 -2
- package/docs/configuration.md +3 -0
- package/docs/development.md +16 -0
- package/docs/guides/updating.md +26 -20
- package/docs/jobs.md +21 -0
- package/docs/phone.md +2 -2
- package/docs/security.md +3 -3
- package/docs/terminal.md +3 -3
- package/package.json +10 -10
- package/server/chief.js +2 -1
- package/server/index.js +186 -27
- package/server/office-mcp.js +5 -0
- package/server/settings.js +85 -0
- package/server/terminal.js +1 -1
- package/dist/assets/index-D5IzEWKU.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';
|
|
@@ -17,6 +18,7 @@ import { snapshotTree, summarize, fileDiff } from './changes.js';
|
|
|
17
18
|
import { Auth } from './auth.js';
|
|
18
19
|
import { Leaderboard } from './leaderboard.js';
|
|
19
20
|
import { Links } from './links.js';
|
|
21
|
+
import { Settings } from './settings.js';
|
|
20
22
|
import { Guests } from './guests.js';
|
|
21
23
|
|
|
22
24
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -24,6 +26,7 @@ const APP = path.resolve(HERE, '..');
|
|
|
24
26
|
const ROOT = path.resolve(process.env.OFFICE_ROOT || path.join(APP, '..'));
|
|
25
27
|
const PORT = Number(process.env.PORT || 4317);
|
|
26
28
|
const CLAUDE = process.env.CLAUDE_BIN || 'claude';
|
|
29
|
+
// OFFICE_DATA is set by the agentoffice command; a clone keeps it beside the code.
|
|
27
30
|
const DATA = path.resolve(process.env.OFFICE_DATA || path.join(APP, 'data'));
|
|
28
31
|
const STATE_FILE = path.join(DATA, 'state.json');
|
|
29
32
|
const DIST = path.join(APP, 'dist');
|
|
@@ -39,10 +42,6 @@ const board = new Leaderboard(DATA, { onChange: () => send({ t: 'leaderboard', l
|
|
|
39
42
|
const MAX_UPLOAD = 25 * 1024 * 1024;
|
|
40
43
|
const MAX_LOG = 600;
|
|
41
44
|
const MAX_CHAT = 300;
|
|
42
|
-
const CHIEF_MODEL = process.env.CHIEF_MODEL || 'sonnet';
|
|
43
|
-
// The daily standup, local time; "off" turns it off. Weekdays unless STANDUP_WEEKENDS=1.
|
|
44
|
-
const STANDUP_AT = process.env.STANDUP_AT ?? '09:30';
|
|
45
|
-
const STANDUP_WEEKENDS = process.env.STANDUP_WEEKENDS === '1';
|
|
46
45
|
// After a turn ends, keep the session open briefly: a message that landed
|
|
47
46
|
// at the last moment starts another turn instead of being lost.
|
|
48
47
|
const GRACE_MS = 2500;
|
|
@@ -173,6 +172,12 @@ let state = { xp: 0, jobs: [], chat: [], channels: [], chiefCost: 0, dismissed:
|
|
|
173
172
|
try {
|
|
174
173
|
state = { ...state, ...JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')) };
|
|
175
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());
|
|
176
181
|
if (!state.channels.some((c) => c.id === GENERAL)) state.channels.unshift({ id: GENERAL, name: GENERAL, createdAt: Date.now() });
|
|
177
182
|
// Sat next to #general, and described even if someone had already opened a
|
|
178
183
|
// channel by that name by hand. An explanation they wrote themselves stays.
|
|
@@ -242,7 +247,7 @@ for (const j of state.jobs) {
|
|
|
242
247
|
if (j.status !== 'working') continue;
|
|
243
248
|
if (j.sessionId) {
|
|
244
249
|
Object.assign(j, {
|
|
245
|
-
status: 'queued', queuedAt: Date.now(), wantAgent: j.agentId, waiting: false,
|
|
250
|
+
status: 'queued', queuedAt: Date.now(), wantAgent: j.agentId, waiting: false, clearing: false,
|
|
246
251
|
// An unread message stashed by deliver() comes along too, rather than
|
|
247
252
|
// being written over by the notice about the restart.
|
|
248
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'),
|
|
@@ -286,7 +291,6 @@ const clients = new Set();
|
|
|
286
291
|
// A job's title is the first line of its prompt, which can say more than the
|
|
287
292
|
// boss means to share, so linked offices see only that an agent is busy.
|
|
288
293
|
// OFFICE_SHARE_JOB_TITLES=1 sends the titles too.
|
|
289
|
-
const SHARE_TITLES = process.env.OFFICE_SHARE_JOB_TITLES === '1';
|
|
290
294
|
function presence() {
|
|
291
295
|
return hired().map((s) => {
|
|
292
296
|
const job = state.jobs.find((j) => j.agentId === s.id && j.status === 'working');
|
|
@@ -296,7 +300,7 @@ function presence() {
|
|
|
296
300
|
: job ? 'working'
|
|
297
301
|
: chill?.who.includes(s.id) ? 'break'
|
|
298
302
|
: 'idle';
|
|
299
|
-
return { id: s.id, name: s.name, role: s.role, look: { shirt: s.shirt, hair: s.hair, skin: s.skin }, state: doing, job: job &&
|
|
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 };
|
|
300
304
|
});
|
|
301
305
|
}
|
|
302
306
|
// Set just below; anything broadcast before then has no guests to tell.
|
|
@@ -341,8 +345,20 @@ const publicJob = (j, tail = 40) => {
|
|
|
341
345
|
const { stopRequested, lastTurn, sent, handoffs, ...rest } = j;
|
|
342
346
|
return { ...rest, log: j.log.slice(-tail) };
|
|
343
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
|
+
}
|
|
344
358
|
const snapshot = () => ({
|
|
345
359
|
t: 'hello',
|
|
360
|
+
owner: owner(),
|
|
361
|
+
settings: settings.status(),
|
|
346
362
|
staff: STAFF,
|
|
347
363
|
modes: Object.fromEntries(Object.entries(MODES).map(([k, v]) => [k, v.label])),
|
|
348
364
|
progress: progress(),
|
|
@@ -362,6 +378,7 @@ const snapshot = () => ({
|
|
|
362
378
|
dismissed: state.dismissed,
|
|
363
379
|
voice: voiceReady(DATA),
|
|
364
380
|
tts: { ...piperReady(DATA), replies: voiceOn() },
|
|
381
|
+
contextLimit: contextLimit(),
|
|
365
382
|
});
|
|
366
383
|
|
|
367
384
|
// ---------- what the agents are costing this machine ----------
|
|
@@ -524,9 +541,7 @@ const endChill = (id) => { if (chill?.id === id) chill = null; };
|
|
|
524
541
|
// The model writes the small talk, but not for every chat: at most one per
|
|
525
542
|
// BANTER_GAP, and BANTER=off turns it off. Anything else, or any failure,
|
|
526
543
|
// comes back with no script and the browser uses a canned chat instead.
|
|
527
|
-
const
|
|
528
|
-
const BANTER_GAP = Number(process.env.BANTER_GAP_SECONDS ?? 120) * 1000;
|
|
529
|
-
const BANTER_MODEL = process.env.BANTER_MODEL || 'haiku';
|
|
544
|
+
const BANTER_GAP = () => settings.get('banterGap') * 1000;
|
|
530
545
|
let banterAt = 0;
|
|
531
546
|
|
|
532
547
|
function banterContext(pair) {
|
|
@@ -545,16 +560,16 @@ async function claimBanter(pair, game = false) {
|
|
|
545
560
|
const c = claimChill(pair, game);
|
|
546
561
|
if (!c) return { id: null };
|
|
547
562
|
if (game) return { id: c.id };
|
|
548
|
-
if (!
|
|
563
|
+
if (!settings.get('banter') || Date.now() - banterAt < BANTER_GAP()) return { id: c.id };
|
|
549
564
|
banterAt = Date.now();
|
|
550
565
|
try {
|
|
551
|
-
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() });
|
|
552
567
|
state.chiefCost += cost || 0;
|
|
553
568
|
send({ t: 'chief', busy: false, cost: state.chiefCost });
|
|
554
569
|
c.at = Date.now();
|
|
555
570
|
return { id: c.id, script: lines };
|
|
556
571
|
} catch {
|
|
557
|
-
banterAt = Date.now() - BANTER_GAP + 30 * 1000;
|
|
572
|
+
banterAt = Date.now() - BANTER_GAP() + 30 * 1000;
|
|
558
573
|
return { id: c.id };
|
|
559
574
|
}
|
|
560
575
|
}
|
|
@@ -581,11 +596,24 @@ const waits = new Map();
|
|
|
581
596
|
const REPLY_WAIT_MS = 10 * 60 * 1000;
|
|
582
597
|
// Long-lived jobs get resumed on every reply and pay for the whole history each
|
|
583
598
|
// turn, so agents auto-compact earlier than the default. AGENT_COMPACT_PCT to tune.
|
|
584
|
-
|
|
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
|
+
}
|
|
585
613
|
const OFFICE_TOOLS = [
|
|
586
614
|
'mcp__office__office_roster', 'mcp__office__message_colleague', 'mcp__office__hand_off', 'mcp__office__flag_for_boss',
|
|
587
615
|
'mcp__office__list_channels', 'mcp__office__create_channel', 'mcp__office__invite_to_channel', 'mcp__office__post_to_channel',
|
|
588
|
-
'mcp__office__restart_office',
|
|
616
|
+
'mcp__office__restart_office', 'mcp__office__clear_context',
|
|
589
617
|
];
|
|
590
618
|
// A guest's job can see who is in and ask the boss, nothing more: no
|
|
591
619
|
// messaging colleagues, handing off or channels. Enforced again in agentApi,
|
|
@@ -671,7 +699,7 @@ function start(job, agentId) {
|
|
|
671
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));
|
|
672
700
|
// Being given work in a channel is how you end up in it.
|
|
673
701
|
try { joinChannel(channelOf(job.channel), agentId); } catch {}
|
|
674
|
-
const first = job.pending;
|
|
702
|
+
const first = job.sessionId ? withClearHint(job, job.pending) : job.pending;
|
|
675
703
|
|
|
676
704
|
Object.assign(job, {
|
|
677
705
|
status: 'working', agentId, pending: null, denials: [], error: null,
|
|
@@ -694,7 +722,7 @@ function start(job, agentId) {
|
|
|
694
722
|
} catch (err) {
|
|
695
723
|
return finish(job, 'failed', `Could not write the office tools' config: ${err.message}`);
|
|
696
724
|
}
|
|
697
|
-
const child = spawn(CLAUDE, args, { cwd, env: { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE:
|
|
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'] });
|
|
698
726
|
running.set(job.id, child);
|
|
699
727
|
startProcs();
|
|
700
728
|
child.stdin.on('error', () => {});
|
|
@@ -721,6 +749,7 @@ function start(job, agentId) {
|
|
|
721
749
|
if (procs) { delete procs[job.id]; }
|
|
722
750
|
endGrace(job);
|
|
723
751
|
if (job.status !== 'working') return;
|
|
752
|
+
if (job.clearing && !job.stopRequested) return recycle(job);
|
|
724
753
|
if (job.stopRequested) finish(job, 'stopped', job.stopWhy || 'Stopped by you.');
|
|
725
754
|
else if (job.lastTurn) finish(job, job.lastTurn.ok ? 'done' : 'failed', job.lastTurn.ok ? null : job.lastTurn.error);
|
|
726
755
|
else finish(job, 'failed', errTail.trim().split('\n').pop() || why);
|
|
@@ -729,6 +758,30 @@ function start(job, agentId) {
|
|
|
729
758
|
child.on('close', (code) => ended(`claude exited with code ${code}`));
|
|
730
759
|
}
|
|
731
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
|
+
|
|
732
785
|
function write(child, text) {
|
|
733
786
|
if (!child.stdin.writable) return false;
|
|
734
787
|
child.stdin.write(JSON.stringify({ type: 'user', message: { role: 'user', content: text } }) + '\n');
|
|
@@ -743,7 +796,7 @@ function deliver(job, text) {
|
|
|
743
796
|
job.lastTurn = null;
|
|
744
797
|
if (job.waiting) { job.waiting = false; pushJob(job); }
|
|
745
798
|
addLog(job, { kind: 'user', summary: text });
|
|
746
|
-
if (child && write(child, text)) return 'live';
|
|
799
|
+
if (child && write(child, withClearHint(job, text))) return 'live';
|
|
747
800
|
// The session ended between the check and the write, so there is nobody
|
|
748
801
|
// to hand it to. Hold it for the next turn: passing it to replyJob would
|
|
749
802
|
// only hand it straight back here, for as long as the stack allows.
|
|
@@ -773,6 +826,12 @@ function onLine(job, line) {
|
|
|
773
826
|
job.sessionId = e.session_id;
|
|
774
827
|
addLog(job, { kind: 'system', summary: `Clocked in · ${e.model}` });
|
|
775
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
|
+
}
|
|
776
835
|
for (const c of e.message?.content || []) {
|
|
777
836
|
if (c.type === 'text' && c.text.trim()) {
|
|
778
837
|
addLog(job, { kind: 'text', summary: c.text.trim().slice(0, 6000) });
|
|
@@ -798,10 +857,10 @@ function onLine(job, line) {
|
|
|
798
857
|
} else if (e.type === 'result') {
|
|
799
858
|
job.cost += e.total_cost_usd || 0;
|
|
800
859
|
job.turns++;
|
|
801
|
-
// The
|
|
802
|
-
//
|
|
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).
|
|
803
863
|
const u = e.usage || {};
|
|
804
|
-
job.context = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0);
|
|
805
864
|
board.addTokens((u.input_tokens || 0) + (u.output_tokens || 0));
|
|
806
865
|
guests.addTokens(job, (u.input_tokens || 0) + (u.output_tokens || 0));
|
|
807
866
|
board.addTime(e.duration_ms);
|
|
@@ -811,6 +870,13 @@ function onLine(job, line) {
|
|
|
811
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 });
|
|
812
871
|
job.lastTurn = { ok, error: ok ? null : `Ended with ${e.subtype}`, text: String(e.result || '').trim() };
|
|
813
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
|
+
}
|
|
814
880
|
const pending = waits.get(job.id) || [];
|
|
815
881
|
if (ok && pending.length && running.has(job.id)) {
|
|
816
882
|
// Keep the session open; whatever they're waiting for will wake it.
|
|
@@ -998,6 +1064,10 @@ function replyJob(job, { message, mode, files }) {
|
|
|
998
1064
|
if (!message) throw new Error('Say something first.');
|
|
999
1065
|
if (mode && !MODES[mode]) throw new Error('Unknown mode.');
|
|
1000
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
|
+
}
|
|
1001
1071
|
Object.assign(job, { status: 'queued', queuedAt: Date.now(), pending: message, error: null, endedAt: null, asking: null, awaitingBoss: false });
|
|
1002
1072
|
addLog(job, { kind: 'user', summary: message });
|
|
1003
1073
|
pushJob(job);
|
|
@@ -1028,6 +1098,51 @@ function dismiss(ids) {
|
|
|
1028
1098
|
].filter(Boolean).join(' ');
|
|
1029
1099
|
}
|
|
1030
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
|
+
|
|
1031
1146
|
function recall(ids) {
|
|
1032
1147
|
const back = ids.filter((id) => state.dismissed.includes(id));
|
|
1033
1148
|
if (!back.length) return 'Everyone you named is already in.';
|
|
@@ -1141,7 +1256,7 @@ async function dispatch(text, mode, model, files, root, inThread, channel) {
|
|
|
1141
1256
|
const said = files.length
|
|
1142
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.` : ''})`
|
|
1143
1258
|
: text;
|
|
1144
|
-
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() });
|
|
1145
1260
|
state.chiefCost += d.cost || 0;
|
|
1146
1261
|
nudgeUsage();
|
|
1147
1262
|
} finally {
|
|
@@ -1181,6 +1296,10 @@ async function dispatch(text, mode, model, files, root, inThread, channel) {
|
|
|
1181
1296
|
const ids = [...new Set([...(d.agentIds || []), ...(agent ? [agent.id] : [])])];
|
|
1182
1297
|
return say(d.action === 'dismiss' ? dismiss(ids) : recall(ids));
|
|
1183
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
|
+
}
|
|
1184
1303
|
if (d.action === 'standup') return void runStandup(true);
|
|
1185
1304
|
// An answer that is merely about a job gets the same link a follow-up does.
|
|
1186
1305
|
// Asking "how did that go?" from another channel shouldn't dead-end just
|
|
@@ -1407,7 +1526,7 @@ async function runStandup(byBoss) {
|
|
|
1407
1526
|
try {
|
|
1408
1527
|
// A quiet office has nothing worth a model call. Otherwise it writes
|
|
1409
1528
|
// while everyone walks over.
|
|
1410
|
-
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);
|
|
1411
1530
|
await sleep(GATHER_MS);
|
|
1412
1531
|
const written = writing && (await writing);
|
|
1413
1532
|
if (written) {
|
|
@@ -1439,8 +1558,9 @@ async function runStandup(byBoss) {
|
|
|
1439
1558
|
// Checked every minute. Missed standups still happen later that day, but
|
|
1440
1559
|
// not after 6pm, and a day with nothing to talk about is skipped.
|
|
1441
1560
|
function standupDue() {
|
|
1442
|
-
|
|
1443
|
-
|
|
1561
|
+
const at = settings.get('standupAt');
|
|
1562
|
+
if (!at || standupNow) return;
|
|
1563
|
+
const due = dueToday(at, settings.get('standupWeekends'));
|
|
1444
1564
|
const now = Date.now();
|
|
1445
1565
|
if (!due || now < due || (state.lastStandup || 0) >= due || new Date().getHours() >= 18) return;
|
|
1446
1566
|
const since = state.lastStandup || now - DAY_MS;
|
|
@@ -1488,8 +1608,18 @@ function officePrompt(agentId, job) {
|
|
|
1488
1608
|
const others = onFloor().filter((s) => s.id !== agentId).map((s) => (s.role ? `${s.name} (${s.role})` : s.name)).join(', ');
|
|
1489
1609
|
return `You are ${me}, one of the agents in the Agent Office. Your colleagues: ${others || 'none yet'}. ` + persona(agentId) +
|
|
1490
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. ` +
|
|
1491
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. ` +
|
|
1492
|
-
`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);
|
|
1614
|
+
}
|
|
1615
|
+
|
|
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(' | ');
|
|
1493
1623
|
}
|
|
1494
1624
|
|
|
1495
1625
|
// A guest's job: the agent works for someone from another office, so it is
|
|
@@ -1500,7 +1630,7 @@ function guestPrompt(agentId, job) {
|
|
|
1500
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.`;
|
|
1501
1631
|
}
|
|
1502
1632
|
|
|
1503
|
-
const MAX_MESSAGES = 20, MAX_HANDOFFS = 5, MAX_DEPTH = 2, MAX_FLAGS = 5;
|
|
1633
|
+
const MAX_MESSAGES = 20, MAX_HANDOFFS = 5, MAX_DEPTH = 2, MAX_FLAGS = 5, MAX_CLEARS = 5;
|
|
1504
1634
|
const FLAGS = { question: 'Question for you', decision: 'Needs your decision', blocker: 'Blocked', review: 'Ready for your review' };
|
|
1505
1635
|
|
|
1506
1636
|
function caller(req) {
|
|
@@ -1602,6 +1732,14 @@ const agentApi = {
|
|
|
1602
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.';
|
|
1603
1733
|
},
|
|
1604
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
|
+
|
|
1605
1743
|
restart_office(me, { now = false } = {}) {
|
|
1606
1744
|
requestRestart({ by: me.agentId, jobId: me.id, now: !!now });
|
|
1607
1745
|
return 'The office will restart once everyone is between turns, and that includes you: end your turn now. ' +
|
|
@@ -2003,6 +2141,27 @@ const server = http.createServer(async (req, res) => {
|
|
|
2003
2141
|
if (req.method !== 'GET') { send({ t: 'auth', auth: status }); refreshUsage(); }
|
|
2004
2142
|
return json(res, 200, status);
|
|
2005
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
|
+
}
|
|
2006
2165
|
// The leaderboard. Joining claims a handle with the service and starts
|
|
2007
2166
|
// sharing day counters. Leaving deletes the counters; the handle goes too
|
|
2008
2167
|
// unless linked offices still need it.
|
package/server/office-mcp.js
CHANGED
|
@@ -40,6 +40,11 @@ const TOOLS = [
|
|
|
40
40
|
description: 'Restart the office server so changes to its own code (agent-office/server) take effect. Waits until nobody is mid-turn, so end your turn right after; you are messaged when it is back. Not needed for web changes: a build is enough.',
|
|
41
41
|
inputSchema: obj({ now: { type: 'boolean', description: 'Skip the wait and interrupt anyone mid-turn. Only if asked to.' } }, []),
|
|
42
42
|
},
|
|
43
|
+
{
|
|
44
|
+
name: 'clear_context',
|
|
45
|
+
description: 'Drop this session\'s history to cut token use, at a natural break between steps. Give notes to yourself: what is done, what is left, what you learned (files, decisions, gotchas). End your turn right after; you resume fresh from the brief and your notes. Not for the end of a job.',
|
|
46
|
+
inputSchema: obj({ notes: { type: 'string' } }, ['notes']),
|
|
47
|
+
},
|
|
43
48
|
{ name: 'list_channels', description: 'List chat channels: purpose, members, and which one your work is in.', inputSchema: NONE },
|
|
44
49
|
{
|
|
45
50
|
name: 'create_channel',
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// The office's own settings: what the boss can change from the Settings page.
|
|
2
|
+
// Each one has a default taken from the environment (or built in); a value set
|
|
3
|
+
// in the office is kept in state.settings and wins over it. Clearing a value
|
|
4
|
+
// goes back to the default. Anything not listed here stays environment-only.
|
|
5
|
+
|
|
6
|
+
const env = (k) => process.env[k];
|
|
7
|
+
const on = (v) => v !== 'off' && v !== '0' && v !== 'false';
|
|
8
|
+
|
|
9
|
+
// kind: text | number | bool | choice | time. `live` settings take effect at
|
|
10
|
+
// once; the rest are read when the thing they configure next starts.
|
|
11
|
+
export const SETTINGS = [
|
|
12
|
+
{ key: 'owner', group: 'Office', label: 'Office owner', kind: 'text', max: 40, hint: 'Whose office this is. It names the office in the header. Blank uses the name on this account.', fallback: () => String(env('OFFICE_OWNER') || '').trim() },
|
|
13
|
+
{ key: 'shareTitles', group: 'Office', label: 'Share job titles with linked offices', kind: 'bool', hint: 'Off: linked offices only see that an agent is busy.', fallback: () => env('OFFICE_SHARE_JOB_TITLES') === '1' },
|
|
14
|
+
|
|
15
|
+
{ key: 'tokenLimit', group: 'Agents', label: 'Token limit', kind: 'number', min: 0, max: 10_000_000, step: 10000, unit: 'tokens', hint: 'Past this much context, an agent is nudged to clear it and start fresh. 0 turns the nudge off.', fallback: () => Math.max(0, parseInt(env('AGENT_CLEAR_TOKENS') ?? '150000', 10) || 0) },
|
|
16
|
+
{ key: 'compactPct', group: 'Agents', label: 'Compact at', kind: 'number', min: 10, max: 95, step: 5, unit: '%', hint: 'How full an agent’s context gets before Claude Code compacts it. Applies to jobs that start after you save.', fallback: () => Math.min(95, Math.max(10, parseInt(env('AGENT_COMPACT_PCT') ?? '60', 10) || 60)) },
|
|
17
|
+
{ key: 'chiefModel', group: 'Agents', label: 'Chief of staff model', kind: 'choice', options: ['haiku', 'sonnet', 'opus'], hint: 'Routes your messages and writes the standup.', fallback: () => env('CHIEF_MODEL') || 'sonnet' },
|
|
18
|
+
|
|
19
|
+
{ key: 'standupAt', group: 'Standup', label: 'Standup time', kind: 'time', hint: 'Local time, 24-hour. Blank turns the daily standup off.', fallback: () => (env('STANDUP_AT') ?? '09:30') === 'off' ? '' : (env('STANDUP_AT') ?? '09:30') },
|
|
20
|
+
{ key: 'standupWeekends', group: 'Standup', label: 'Hold standups on weekends', kind: 'bool', fallback: () => env('STANDUP_WEEKENDS') === '1' },
|
|
21
|
+
|
|
22
|
+
{ key: 'banter', group: 'Break room', label: 'Written break-room talk', kind: 'bool', hint: 'Off: the break room uses canned lines only.', fallback: () => on(env('BANTER')) },
|
|
23
|
+
{ key: 'banterModel', group: 'Break room', label: 'Break-room model', kind: 'choice', options: ['haiku', 'sonnet', 'opus'], fallback: () => env('BANTER_MODEL') || 'haiku' },
|
|
24
|
+
{ key: 'banterGap', group: 'Break room', label: 'Time between conversations', kind: 'number', min: 10, max: 86400, step: 10, unit: 'seconds', hint: 'At most one written conversation per this long.', fallback: () => Math.max(10, Number(env('BANTER_GAP_SECONDS') ?? 120) || 120) },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const BY_KEY = new Map(SETTINGS.map((s) => [s.key, s]));
|
|
28
|
+
|
|
29
|
+
// Returns the cleaned value, or throws with something the boss can act on.
|
|
30
|
+
function clean(def, v) {
|
|
31
|
+
const bad = (m) => Object.assign(new Error(m), { code: 400 });
|
|
32
|
+
switch (def.kind) {
|
|
33
|
+
case 'text': {
|
|
34
|
+
if (typeof v !== 'string') throw bad(`${def.label}: use text.`);
|
|
35
|
+
v = v.trim().replace(/\s+/g, ' ');
|
|
36
|
+
if (v.length > def.max) throw bad(`${def.label} is limited to ${def.max} characters.`);
|
|
37
|
+
return v;
|
|
38
|
+
}
|
|
39
|
+
case 'bool':
|
|
40
|
+
if (typeof v !== 'boolean') throw bad(`${def.label}: use on or off.`);
|
|
41
|
+
return v;
|
|
42
|
+
case 'number':
|
|
43
|
+
if (!Number.isInteger(v) || v < def.min || v > def.max) throw bad(`${def.label}: use a whole number from ${def.min.toLocaleString()} to ${def.max.toLocaleString()}.`);
|
|
44
|
+
return v;
|
|
45
|
+
case 'choice':
|
|
46
|
+
if (!def.options.includes(v)) throw bad(`${def.label}: pick one of ${def.options.join(', ')}.`);
|
|
47
|
+
return v;
|
|
48
|
+
case 'time':
|
|
49
|
+
if (v !== '' && !/^([01]\d|2[0-3]):[0-5]\d$/.test(v)) throw bad(`${def.label}: use HH:MM, like 09:30, or leave it blank.`);
|
|
50
|
+
return v;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class Settings {
|
|
55
|
+
// `store` is the object persisted with the office's state; `onChange` saves it.
|
|
56
|
+
constructor(store, onChange = () => {}) {
|
|
57
|
+
this.store = store;
|
|
58
|
+
this.onChange = onChange;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
get(key) {
|
|
62
|
+
const def = BY_KEY.get(key);
|
|
63
|
+
return key in this.store ? this.store[key] : def.fallback();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Apply { key: value | null }. All or nothing: one bad value saves none.
|
|
67
|
+
set(patch) {
|
|
68
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw Object.assign(new Error('Nothing to save.'), { code: 400 });
|
|
69
|
+
const next = {};
|
|
70
|
+
for (const [key, v] of Object.entries(patch)) {
|
|
71
|
+
const def = BY_KEY.get(key);
|
|
72
|
+
if (!def) throw Object.assign(new Error(`Unknown setting: ${key}.`), { code: 400 });
|
|
73
|
+
next[key] = v === null ? null : clean(def, v);
|
|
74
|
+
}
|
|
75
|
+
for (const [key, v] of Object.entries(next)) {
|
|
76
|
+
if (v === null) delete this.store[key]; else this.store[key] = v;
|
|
77
|
+
}
|
|
78
|
+
this.onChange(Object.keys(next));
|
|
79
|
+
return this.status();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
status() {
|
|
83
|
+
return SETTINGS.map(({ fallback, ...def }) => ({ ...def, value: this.get(def.key), default: fallback(), custom: def.key in this.store }));
|
|
84
|
+
}
|
|
85
|
+
}
|
package/server/terminal.js
CHANGED
|
@@ -78,7 +78,7 @@ export class Terminals {
|
|
|
78
78
|
|
|
79
79
|
async status() {
|
|
80
80
|
if (!this.on) {
|
|
81
|
-
return { enabled: false, reason: 'The terminal is off. Start the office with OFFICE_TERMINAL=1 to open it.' };
|
|
81
|
+
return { enabled: false, reason: 'The terminal is off. Start the office with --terminal (or OFFICE_TERMINAL=1) to open it.' };
|
|
82
82
|
}
|
|
83
83
|
if (!(await loadPty())) return { enabled: false, reason: ptyError };
|
|
84
84
|
let folders = [];
|