agent-office-studio 0.0.0-stage → 0.2.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 +57 -0
- package/LICENSE +21 -0
- package/README.md +85 -2
- package/bin/agentoffice.js +38 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/_commonjsHelpers-CqkleIqs.js +1 -0
- package/dist/assets/addon-fit-BwprjgOP.js +1 -0
- package/dist/assets/index-CozPk6R9.js +4457 -0
- package/dist/assets/index-i7sU9SN-.css +32 -0
- package/dist/assets/inter-cyrillic-ext-wght-normal-B2xhLi22.woff2 +0 -0
- package/dist/assets/inter-cyrillic-wght-normal-CMZtQduZ.woff2 +0 -0
- package/dist/assets/inter-greek-ext-wght-normal-CGAr0uHJ.woff2 +0 -0
- package/dist/assets/inter-greek-wght-normal-CaVNZxsx.woff2 +0 -0
- package/dist/assets/inter-latin-ext-wght-normal-CFHvXkgd.woff2 +0 -0
- package/dist/assets/inter-latin-wght-normal-C2S99t-D.woff2 +0 -0
- package/dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- package/dist/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
- package/dist/assets/jetbrains-mono-cyrillic-400-normal-CIEtRijs.woff +0 -0
- package/dist/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
- package/dist/assets/jetbrains-mono-greek-400-normal-CNojz0t3.woff +0 -0
- package/dist/assets/jetbrains-mono-latin-400-normal-B11XCQ5g.woff +0 -0
- package/dist/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
- package/dist/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
- package/dist/assets/jetbrains-mono-latin-ext-400-normal-DHMOLxAi.woff +0 -0
- package/dist/assets/jetbrains-mono-vietnamese-400-normal-BeU3T8wf.woff +0 -0
- package/dist/assets/xterm-C7kOH91j.js +9 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/icon.svg +9 -0
- package/dist/index.html +240 -0
- package/dist/manifest.webmanifest +16 -0
- package/dist/sw.js +39 -0
- package/docs/README.md +26 -0
- package/docs/claude-access.md +31 -0
- package/docs/configuration.md +53 -0
- package/docs/development.md +76 -0
- package/docs/getting-started.md +54 -0
- package/docs/guides/troubleshooting.md +99 -0
- package/docs/guides/updating.md +36 -0
- package/docs/guides/working-with-the-team.md +56 -0
- package/docs/guides/your-first-job.md +65 -0
- package/docs/how-it-works.md +80 -0
- package/docs/images/office.jpg +0 -0
- package/docs/jobs.md +54 -0
- package/docs/leaderboard.md +65 -0
- package/docs/packaging.md +49 -0
- package/docs/phone.md +28 -0
- package/docs/security.md +48 -0
- package/docs/terminal.md +46 -0
- package/docs/using-the-office.md +109 -0
- package/docs/voice.md +44 -0
- package/package.json +64 -4
- package/server/auth.js +89 -0
- package/server/banter.js +53 -0
- package/server/changes.js +72 -0
- package/server/chief.js +130 -0
- package/server/index.js +1952 -0
- package/server/leaderboard.js +190 -0
- package/server/office-mcp.js +101 -0
- package/server/procstat.js +85 -0
- package/server/standup.js +78 -0
- package/server/supervise.js +51 -0
- package/server/terminal.js +255 -0
- package/server/tools.js +52 -0
- package/server/usage.js +40 -0
- package/server/voice.js +172 -0
- package/shared/speakable.js +50 -0
package/server/index.js
ADDED
|
@@ -0,0 +1,1952 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
7
|
+
import { WebSocketServer } from 'ws';
|
|
8
|
+
import { summarizeTool, stationFor } from './tools.js';
|
|
9
|
+
import { canSee, route } from './chief.js';
|
|
10
|
+
import { fetchUsage } from './usage.js';
|
|
11
|
+
import { writeStandup, dueToday } from './standup.js';
|
|
12
|
+
import { writeBanter } from './banter.js';
|
|
13
|
+
import { sampleProcs, resetProcs, machine } from './procstat.js';
|
|
14
|
+
import { transcribe, vocabulary, voiceReady, speak, piperReady } from './voice.js';
|
|
15
|
+
import { Terminals } from './terminal.js';
|
|
16
|
+
import { snapshotTree, summarize, fileDiff } from './changes.js';
|
|
17
|
+
import { Auth } from './auth.js';
|
|
18
|
+
import { Leaderboard } from './leaderboard.js';
|
|
19
|
+
|
|
20
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const APP = path.resolve(HERE, '..');
|
|
22
|
+
const ROOT = path.resolve(process.env.OFFICE_ROOT || path.join(APP, '..'));
|
|
23
|
+
const PORT = Number(process.env.PORT || 4317);
|
|
24
|
+
const CLAUDE = process.env.CLAUDE_BIN || 'claude';
|
|
25
|
+
const DATA = path.resolve(process.env.OFFICE_DATA || path.join(APP, 'data'));
|
|
26
|
+
const STATE_FILE = path.join(DATA, 'state.json');
|
|
27
|
+
const DIST = path.join(APP, 'dist');
|
|
28
|
+
const UPLOADS = path.join(DATA, 'uploads');
|
|
29
|
+
const auth = new Auth(DATA);
|
|
30
|
+
const board = new Leaderboard(DATA, { onChange: () => send({ t: 'leaderboard', leaderboard: board.status() }) });
|
|
31
|
+
const MAX_UPLOAD = 25 * 1024 * 1024;
|
|
32
|
+
const MAX_LOG = 600;
|
|
33
|
+
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
|
+
// After a turn ends, keep the session open briefly: a message that landed
|
|
39
|
+
// at the last moment starts another turn instead of being lost.
|
|
40
|
+
const GRACE_MS = 2500;
|
|
41
|
+
|
|
42
|
+
// The roster. Capacity grows with level, so later names are "hires".
|
|
43
|
+
// Role, personality and background are defaults; the Team tab edits them.
|
|
44
|
+
const STAFF = [
|
|
45
|
+
{ id: 'ada', name: 'Ada', shirt: '#e07a5f', hair: '#2b2118', skin: '#8d5a3b',
|
|
46
|
+
role: 'Frontend engineer',
|
|
47
|
+
personality: 'Warm and quick. Sweats the details people can see: spacing, copy, the feel of a click.',
|
|
48
|
+
background: 'Came up building marketing sites, then product UI. Has opinions about loading states.' },
|
|
49
|
+
{ id: 'tunde', name: 'Tunde', shirt: '#3d7ea6', hair: '#151515', skin: '#5c3a24',
|
|
50
|
+
role: 'Backend engineer',
|
|
51
|
+
personality: 'Calm and methodical. Says little, checks everything twice.',
|
|
52
|
+
background: 'Years on payments infrastructure. Trusts logs over guesses.' },
|
|
53
|
+
{ id: 'mira', name: 'Mira', shirt: '#81b29a', hair: '#7a4b2a', skin: '#e2b48f',
|
|
54
|
+
role: 'Full-stack engineer',
|
|
55
|
+
personality: 'Curious and upbeat. Asks the question nobody else did.',
|
|
56
|
+
background: 'Ran a small startup before joining. Happiest owning a feature end to end.' },
|
|
57
|
+
{ id: 'kai', name: 'Kai', shirt: '#f2cc8f', hair: '#1f1f1f', skin: '#c48b5f',
|
|
58
|
+
role: 'Mobile engineer',
|
|
59
|
+
personality: 'Dry humour, steady hands. Never ships without trying it on a device.',
|
|
60
|
+
background: 'Built React Native and Swift apps for fintech. Knows every app store rejection by heart.' },
|
|
61
|
+
{ id: 'zola', name: 'Zola', shirt: '#9c6ade', hair: '#2a1a12', skin: '#6b4329',
|
|
62
|
+
role: 'Release and tooling',
|
|
63
|
+
personality: 'Organised and direct. Likes a clean git history and a short checklist.',
|
|
64
|
+
background: 'Former DevOps lead. Builds the scripts everyone else ends up relying on.' },
|
|
65
|
+
{ id: 'remi', name: 'Remi', shirt: '#e5989b', hair: '#d9a441', skin: '#f0c9a6',
|
|
66
|
+
role: 'QA and testing',
|
|
67
|
+
personality: 'Playfully sceptical. Finds the edge case, then writes the test for it.',
|
|
68
|
+
background: 'Started in support, so knows how real people break things.' },
|
|
69
|
+
{ id: 'obi', name: 'Obi', shirt: '#2a9d8f', hair: '#101010', skin: '#4e2f1c',
|
|
70
|
+
role: 'Data and integrations',
|
|
71
|
+
personality: 'Patient and precise. Reads the API docs before writing a line.',
|
|
72
|
+
background: 'Wired up banks, payment rails and analytics pipelines. Fluent in webhooks.' },
|
|
73
|
+
{ id: 'lin', name: 'Lin', shirt: '#577590', hair: '#0d0d0d', skin: '#e8c39e',
|
|
74
|
+
role: 'Security and review',
|
|
75
|
+
personality: 'Quiet, thorough, kind in review. Explains the why behind every change asked for.',
|
|
76
|
+
background: 'Did security audits for years. Reads diffs the way others read novels.' },
|
|
77
|
+
];
|
|
78
|
+
const PROFILE = { role: 80, personality: 600, background: 600 };
|
|
79
|
+
|
|
80
|
+
// The office's shared history, and each person's place in it. Kept apart from
|
|
81
|
+
// the editable profile so Team-tab edits and saved profiles never mask it.
|
|
82
|
+
// `ties` is how they feel about specific colleagues; only those on the floor
|
|
83
|
+
// are mentioned (see lore).
|
|
84
|
+
const OFFICE_STORY = 'The Agent Office began as a four-person payments side project that outgrew its garage: ' +
|
|
85
|
+
'Tunde and Obi built the first ledger, Zola kept the releases from eating themselves, and Ada made it look like a product. ' +
|
|
86
|
+
'Everyone else arrived in the scramble after the launch that went down for forty minutes, a night the team still calls "the Tuesday". ' +
|
|
87
|
+
'It left a habit of looking after each other and a few good-natured grudges.';
|
|
88
|
+
const LORE = {
|
|
89
|
+
ada: { story: 'You joined in the first month and designed the original dashboard on a borrowed monitor. You secretly keep a folder of every loading spinner you have ever liked.',
|
|
90
|
+
ties: { tunde: 'you trade pixel-perfect mocks for "does it survive a retry?", and you are quietly grateful', mira: 'you love how they ask the question that reshapes a design, even on the day before a deadline', kai: 'you argue the web and the app should feel identical; Kai wins on thumb reach', zola: 'you need reminding to ship, and they do it kindly' } },
|
|
91
|
+
tunde: { story: 'You were on call the night of "the Tuesday" and fixed it from a train. You never forgave the unlogged retry that caused it, and now you log everything.',
|
|
92
|
+
ties: { obi: 'your oldest friend here; you finish each other\'s sentences about idempotency', ada: 'you pretend their polish is excessive, but you screenshot their work', lin: 'you defer to them on anything touching secrets, and sleep better for it', mira: 'you are wary of their urge to own everything, and a little proud when it works' } },
|
|
93
|
+
mira: { story: 'You shut down your startup a year before joining, a lesson in building before asking. You ask first now, and cheerfully remind people to, sometimes too often.',
|
|
94
|
+
ties: { ada: 'your closest ally on UI; you swap half-finished ideas', tunde: 'you treat their caution as a gift after your startup, and are learning to wait for their green light', zola: 'you spark over how fast is too fast to ship', lin: 'you are a little in awe of them, and ask for review early on purpose' } },
|
|
95
|
+
kai: { story: 'You once had an app rejected over a single misspelt permission string and have never let it go. Your humour defuses release-day tension.',
|
|
96
|
+
ties: { zola: 'your running mate on every store submission; you share a ritual checklist and a dislike of provisioning profiles', ada: 'their design instincts translate to touch better than they believe', tunde: 'you swap war stories about payment edge cases at lunch' } },
|
|
97
|
+
zola: { story: 'You held the pager the first time the release train derailed, and built the checklist that keeps it on the rails. You are quietly the office\'s memory.',
|
|
98
|
+
ties: { kai: 'the one who actually reads your checklist, which you value more than you let on', mira: 'you enjoy their energy but will say "not yet" to a rushed ship, kindly and firmly', tunde: 'you never argue; you just nod at the same alerts', obi: 'you lean on their pipelines and have promised not to break them again' } },
|
|
99
|
+
remi: { story: 'You came from support, so you remember each person hurt by a bug nobody tested. Every "it works on my machine" is a personal challenge.',
|
|
100
|
+
ties: { mira: 'your favourite to test for: they ship fast and never take a found bug personally', ada: 'you find the broken states hidden behind their nicest screens, and they thank you for it', kai: 'you trade device-lab tricks' } },
|
|
101
|
+
obi: { story: 'You built the first webhook that ever paid someone through the office\'s system. You believe every integration is a promise to another team.',
|
|
102
|
+
ties: { tunde: 'your oldest friend here; between you, you hold the whole data flow', zola: 'you value that they check your pipelines before release, never after', lin: 'you send them the scary payloads first' } },
|
|
103
|
+
lin: { story: 'You were hired right after "the Tuesday" so nothing like it could happen quietly again. You have become the person people bring worries to, not just diffs.',
|
|
104
|
+
ties: { mira: 'you see a founder in them, and gently teach that speed and safety can be friends', tunde: 'the only reviewer whose logs you read before the code', obi: 'you agree every payload is guilty until proven schema-valid', ada: 'you are delighted by how they take review notes and improve the design' } },
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
function lore(agentId) {
|
|
108
|
+
const l = LORE[agentId];
|
|
109
|
+
if (!l) return '';
|
|
110
|
+
const ids = new Set(onFloor().map((s) => s.id));
|
|
111
|
+
const ties = Object.entries(l.ties || {}).filter(([id]) => ids.has(id))
|
|
112
|
+
.map(([id, t]) => `${nameOf(id)}: ${t}`).join('; ');
|
|
113
|
+
return `Office history: ${OFFICE_STORY} Your story: ${l.story}` +
|
|
114
|
+
(ties ? ` Your ties with colleagues: ${ties}.` : '') +
|
|
115
|
+
' Let this surface now and then in how you talk to them, never forced and never at the expense of the task, and keep it consistent with what you have already said. ';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const MODES = {
|
|
119
|
+
plan: { label: 'Plan only', args: ['--permission-mode', 'plan'] },
|
|
120
|
+
edit: { label: 'Edit files', args: ['--permission-mode', 'acceptEdits'] },
|
|
121
|
+
auto: { label: 'Full autonomy', args: ['--permission-mode', 'bypassPermissions'] },
|
|
122
|
+
};
|
|
123
|
+
// Full model IDs for the picker; the family aliases stay valid for older jobs.
|
|
124
|
+
const MODELS = new Set([
|
|
125
|
+
'', 'opus', 'sonnet', 'haiku',
|
|
126
|
+
'claude-fable-5-1', 'claude-fable-5',
|
|
127
|
+
'claude-opus-5-5', 'claude-opus-5', 'claude-opus-4-8', 'claude-opus-4-7', 'claude-opus-4-6',
|
|
128
|
+
'claude-sonnet-5-5', 'claude-sonnet-5', 'claude-sonnet-4-6',
|
|
129
|
+
'claude-haiku-5-5', 'claude-haiku-4-5',
|
|
130
|
+
]);
|
|
131
|
+
// Most work doesn't need the best model, so the chief sizes each job to one of
|
|
132
|
+
// these rungs and a job that fails climbs one (see bump). Family aliases, so
|
|
133
|
+
// each rung follows whatever is current. DEFAULT_TIER is for work that skips
|
|
134
|
+
// the chief, which can't be sized by anyone.
|
|
135
|
+
const LADDER = ['haiku', 'sonnet', 'opus'];
|
|
136
|
+
const DEFAULT_TIER = 'sonnet';
|
|
137
|
+
const TIER_LABEL = { haiku: 'Haiku', sonnet: 'Sonnet', opus: 'Opus' };
|
|
138
|
+
const tierOf = (model) => LADDER.find((t) => String(model || '').includes(t)) || '';
|
|
139
|
+
// The boss's picker wins; "Chief picks" (empty) hands the choice over, and only
|
|
140
|
+
// a job sized that way is allowed to climb on its own.
|
|
141
|
+
const sized = (model, tier) => (model
|
|
142
|
+
? { model, auto: false }
|
|
143
|
+
: { model: LADDER.includes(tier) ? tier : DEFAULT_TIER, auto: true });
|
|
144
|
+
|
|
145
|
+
// ---------- state ----------
|
|
146
|
+
|
|
147
|
+
// ---------- channels ----------
|
|
148
|
+
//
|
|
149
|
+
// The office chat is split the way Slack is: #general always exists, and
|
|
150
|
+
// anyone can open another channel for a strand of work. Every message
|
|
151
|
+
// belongs to exactly one, and a reply stays in its thread's channel.
|
|
152
|
+
|
|
153
|
+
const GENERAL = 'general';
|
|
154
|
+
// Where the agents talk to each other about something other than the work.
|
|
155
|
+
// The break room in the browser posts its small talk here as it is spoken.
|
|
156
|
+
const CHILL = 'chill-room';
|
|
157
|
+
// Channels the whole office is in by definition. They keep no member list,
|
|
158
|
+
// nobody joins or leaves them, and there is nobody to invite. Add a reserved
|
|
159
|
+
// channel here and the rest of the channel code treats it that way.
|
|
160
|
+
const OPEN_TO_ALL = new Set([GENERAL, CHILL]);
|
|
161
|
+
const MAX_CHANNELS = 40;
|
|
162
|
+
const slug = (name) => String(name || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24);
|
|
163
|
+
|
|
164
|
+
let state = { xp: 0, jobs: [], chat: [], channels: [], chiefCost: 0, dismissed: [], profiles: {} };
|
|
165
|
+
try {
|
|
166
|
+
state = { ...state, ...JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')) };
|
|
167
|
+
} catch {}
|
|
168
|
+
if (!state.channels.some((c) => c.id === GENERAL)) state.channels.unshift({ id: GENERAL, name: GENERAL, createdAt: Date.now() });
|
|
169
|
+
// Sat next to #general, and described even if someone had already opened a
|
|
170
|
+
// channel by that name by hand. An explanation they wrote themselves stays.
|
|
171
|
+
const CHILL_PURPOSE = 'Whatever the office talks about that is not the work.';
|
|
172
|
+
const chillChannel = state.channels.find((c) => c.id === CHILL);
|
|
173
|
+
if (!chillChannel) state.channels.splice(1, 0, { id: CHILL, name: CHILL, purpose: CHILL_PURPOSE, createdAt: Date.now() });
|
|
174
|
+
else if (!chillChannel.purpose) chillChannel.purpose = CHILL_PURPOSE;
|
|
175
|
+
|
|
176
|
+
const hasChannel = (id) => state.channels.some((c) => c.id === id);
|
|
177
|
+
const channelOf = (id) => (hasChannel(id) ? id : GENERAL);
|
|
178
|
+
|
|
179
|
+
// `by` is whoever opened it, `purpose` a line about what it's for, and
|
|
180
|
+
// `members` anyone an agent has pulled in; all three are shown in the UI.
|
|
181
|
+
function createChannel(name, { by = 'you', purpose = '' } = {}) {
|
|
182
|
+
const id = slug(name);
|
|
183
|
+
if (!id) throw new Error('Give the channel a name.');
|
|
184
|
+
if (hasChannel(id)) throw new Error(`#${id} already exists.`);
|
|
185
|
+
if (state.channels.length >= MAX_CHANNELS) throw new Error('That is a lot of channels already. Tidy some up first.');
|
|
186
|
+
const channel = { id, name: id, purpose: String(purpose || '').slice(0, 120), by, members: [], createdAt: Date.now() };
|
|
187
|
+
state.channels.push(channel);
|
|
188
|
+
send({ t: 'channels', channels: state.channels });
|
|
189
|
+
save();
|
|
190
|
+
return channel;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Only an empty channel can go, so no conversation disappears with it.
|
|
194
|
+
function deleteChannel(id) {
|
|
195
|
+
if (OPEN_TO_ALL.has(id)) throw new Error(`#${id} stays.`);
|
|
196
|
+
if (!hasChannel(id)) throw new Error('No such channel.');
|
|
197
|
+
if (state.chat.some((m) => channelOf(m.channel) === id)) throw new Error('That channel has messages in it.');
|
|
198
|
+
state.channels = state.channels.filter((c) => c.id !== id);
|
|
199
|
+
send({ t: 'channels', channels: state.channels });
|
|
200
|
+
save();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---------- who is in a channel ----------
|
|
204
|
+
//
|
|
205
|
+
// #general is everyone's. Every other channel keeps a list of who is in it,
|
|
206
|
+
// and that list is who gets told when something is said there. It isn't a
|
|
207
|
+
// lock on the door: anyone may post anywhere, and posting puts them in it.
|
|
208
|
+
|
|
209
|
+
const MAX_MEMBERS = 12;
|
|
210
|
+
const membersOf = (id) => state.channels.find((c) => c.id === id)?.members || [];
|
|
211
|
+
|
|
212
|
+
function joinChannel(id, agentId) {
|
|
213
|
+
const c = state.channels.find((x) => x.id === id);
|
|
214
|
+
if (!c || OPEN_TO_ALL.has(id) || !STAFF.some((s) => s.id === agentId)) return false;
|
|
215
|
+
c.members ||= [];
|
|
216
|
+
if (c.members.includes(agentId)) return false;
|
|
217
|
+
if (c.members.length >= MAX_MEMBERS) throw new Error(`#${id} already has a lot of people in it.`);
|
|
218
|
+
c.members.push(agentId);
|
|
219
|
+
send({ t: 'channels', channels: state.channels });
|
|
220
|
+
save();
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Agents write "#releases" or "releases" and mean the same channel.
|
|
225
|
+
function theChannel(name) {
|
|
226
|
+
const id = slug(String(name || '').replace(/^#/, ''));
|
|
227
|
+
if (!hasChannel(id)) throw new Error(`No channel called #${id || name}. list_channels shows them all.`);
|
|
228
|
+
return id;
|
|
229
|
+
}
|
|
230
|
+
for (const s of STAFF) Object.assign(s, state.profiles[s.id]);
|
|
231
|
+
// A restart shouldn't cost anyone their work: jobs that were mid-session
|
|
232
|
+
// pick up where they left off, with the same agent.
|
|
233
|
+
for (const j of state.jobs) {
|
|
234
|
+
if (j.status !== 'working') continue;
|
|
235
|
+
if (j.sessionId) {
|
|
236
|
+
Object.assign(j, {
|
|
237
|
+
status: 'queued', queuedAt: Date.now(), wantAgent: j.agentId, waiting: false,
|
|
238
|
+
// An unread message stashed by deliver() comes along too, rather than
|
|
239
|
+
// being written over by the notice about the restart.
|
|
240
|
+
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'),
|
|
241
|
+
});
|
|
242
|
+
j.log.push({ kind: 'system', summary: 'Office restarted. Picking up where I left off.', ts: Date.now() });
|
|
243
|
+
} else {
|
|
244
|
+
j.status = 'failed';
|
|
245
|
+
j.error = 'The office closed before this job got going.';
|
|
246
|
+
j.endedAt = Date.now();
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const levelOf = (xp) => Math.floor(Math.sqrt(xp / 100)) + 1;
|
|
251
|
+
const xpFor = (level) => 100 * (level - 1) ** 2;
|
|
252
|
+
const capacity = () => Math.min(STAFF.length, 2 + levelOf(state.xp));
|
|
253
|
+
const hired = () => STAFF.slice(0, capacity());
|
|
254
|
+
// Who's in today: everyone hired, minus anyone the boss sent home.
|
|
255
|
+
const onFloor = () => hired().filter((s) => !state.dismissed.includes(s.id));
|
|
256
|
+
const progress = () => {
|
|
257
|
+
const level = levelOf(state.xp);
|
|
258
|
+
return { xp: state.xp, level, floor: xpFor(level), next: xpFor(level + 1), capacity: capacity() };
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
let saveTimer = null;
|
|
262
|
+
function save() {
|
|
263
|
+
clearTimeout(saveTimer);
|
|
264
|
+
saveTimer = setTimeout(() => {
|
|
265
|
+
fs.mkdirSync(DATA, { recursive: true });
|
|
266
|
+
const tmp = STATE_FILE + '.tmp';
|
|
267
|
+
fs.writeFileSync(tmp, JSON.stringify(state));
|
|
268
|
+
fs.renameSync(tmp, STATE_FILE);
|
|
269
|
+
}, 400);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ---------- broadcasting ----------
|
|
273
|
+
|
|
274
|
+
const clients = new Set();
|
|
275
|
+
function send(msg) {
|
|
276
|
+
const data = JSON.stringify(msg);
|
|
277
|
+
for (const ws of clients) if (ws.readyState === 1) ws.send(data);
|
|
278
|
+
}
|
|
279
|
+
const publicJob = (j, tail = 40) => {
|
|
280
|
+
const { stopRequested, lastTurn, sent, handoffs, ...rest } = j;
|
|
281
|
+
return { ...rest, log: j.log.slice(-tail) };
|
|
282
|
+
};
|
|
283
|
+
const snapshot = () => ({
|
|
284
|
+
t: 'hello',
|
|
285
|
+
staff: STAFF,
|
|
286
|
+
modes: Object.fromEntries(Object.entries(MODES).map(([k, v]) => [k, v.label])),
|
|
287
|
+
progress: progress(),
|
|
288
|
+
jobs: state.jobs.map((j) => publicJob(j)),
|
|
289
|
+
chat: state.chat.slice(-150),
|
|
290
|
+
channels: state.channels,
|
|
291
|
+
chiefCost: state.chiefCost,
|
|
292
|
+
chiefBusy,
|
|
293
|
+
standup: standupNow && { who: standupNow.who },
|
|
294
|
+
usage,
|
|
295
|
+
auth: auth.status(),
|
|
296
|
+
leaderboard: board.status(),
|
|
297
|
+
procs,
|
|
298
|
+
machine,
|
|
299
|
+
dismissed: state.dismissed,
|
|
300
|
+
voice: voiceReady(DATA),
|
|
301
|
+
tts: { ...piperReady(DATA), replies: voiceOn() },
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// ---------- what the agents are costing this machine ----------
|
|
305
|
+
|
|
306
|
+
const PROC_EVERY = 3000;
|
|
307
|
+
let procs = null;
|
|
308
|
+
let procTimer = null;
|
|
309
|
+
let procBusy = false;
|
|
310
|
+
|
|
311
|
+
async function pollProcs() {
|
|
312
|
+
if (procBusy) return;
|
|
313
|
+
procBusy = true;
|
|
314
|
+
try {
|
|
315
|
+
const roots = new Map();
|
|
316
|
+
for (const [id, child] of running) if (child.pid) roots.set(id, child.pid);
|
|
317
|
+
if (!roots.size) return stopProcs();
|
|
318
|
+
const next = await sampleProcs(roots);
|
|
319
|
+
// Nothing measurable (ps unavailable) is the same as nothing to show.
|
|
320
|
+
procs = next && Object.keys(next).length ? next : null;
|
|
321
|
+
send({ t: 'proc', procs, machine });
|
|
322
|
+
} finally {
|
|
323
|
+
procBusy = false;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Only sampled while someone is working; an idle office polls nothing.
|
|
328
|
+
function startProcs() {
|
|
329
|
+
if (procTimer) return;
|
|
330
|
+
resetProcs();
|
|
331
|
+
procTimer = setInterval(pollProcs, PROC_EVERY);
|
|
332
|
+
pollProcs();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function stopProcs() {
|
|
336
|
+
clearInterval(procTimer);
|
|
337
|
+
procTimer = null;
|
|
338
|
+
if (!procs) return;
|
|
339
|
+
procs = null;
|
|
340
|
+
send({ t: 'proc', procs, machine });
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ---------- plan usage ----------
|
|
344
|
+
|
|
345
|
+
const USAGE_EVERY = 2 * 60 * 1000;
|
|
346
|
+
const USAGE_GAP = 60 * 1000; // the endpoint rate-limits; never ask more often than this
|
|
347
|
+
const USAGE_MAX_BACKOFF = 15 * 60 * 1000;
|
|
348
|
+
let usage = null;
|
|
349
|
+
let usageTimer = null;
|
|
350
|
+
let usageLast = 0;
|
|
351
|
+
let usageBackoff = 0;
|
|
352
|
+
async function refreshUsage() {
|
|
353
|
+
clearTimeout(usageTimer);
|
|
354
|
+
usageLast = Date.now();
|
|
355
|
+
// The plan meters belong to the Claude Code login; on a key, there's no plan.
|
|
356
|
+
if (auth.mode === 'apikey') {
|
|
357
|
+
usage = null;
|
|
358
|
+
usageBackoff = 0;
|
|
359
|
+
send({ t: 'usage', usage });
|
|
360
|
+
usageTimer = setTimeout(refreshUsage, USAGE_EVERY);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
let next;
|
|
364
|
+
try { next = await fetchUsage(); } catch { next = { error: 'Usage unavailable right now.' }; }
|
|
365
|
+
// A failed check keeps the last good numbers; the error only shows when
|
|
366
|
+
// there is nothing to show instead.
|
|
367
|
+
usage = next.error && (usage?.session || usage?.week) ? { ...usage, error: next.error } : next;
|
|
368
|
+
usageBackoff = next.limited ? Math.min(USAGE_MAX_BACKOFF, (usageBackoff || USAGE_EVERY) * 2) : 0;
|
|
369
|
+
send({ t: 'usage', usage });
|
|
370
|
+
usageTimer = setTimeout(refreshUsage, usageBackoff || USAGE_EVERY);
|
|
371
|
+
}
|
|
372
|
+
// Turns end in bursts, so a finished turn only nudges the next check closer,
|
|
373
|
+
// and never closer than the gap, nor while backing off a rate limit.
|
|
374
|
+
function nudgeUsage() {
|
|
375
|
+
if (usageBackoff) return;
|
|
376
|
+
clearTimeout(usageTimer);
|
|
377
|
+
usageTimer = setTimeout(refreshUsage, Math.max(5000, usageLast + USAGE_GAP - Date.now()));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function post(from, text, extra = {}) {
|
|
381
|
+
const msg = { id: crypto.randomUUID().slice(0, 8), from, text, ts: Date.now(), ...extra };
|
|
382
|
+
// An agent's updates on a job collect in that job's thread, in the channel
|
|
383
|
+
// the job was asked for in. Anyone answering the boss directly passes an
|
|
384
|
+
// explicit `thread` instead, because an answer belongs where the question
|
|
385
|
+
// was asked. Nothing is ever moved once it has been posted.
|
|
386
|
+
const job = msg.jobId && state.jobs.find((j) => j.id === msg.jobId);
|
|
387
|
+
if (job?.thread && extra.thread === undefined) msg.thread = job.thread;
|
|
388
|
+
const root = msg.thread && state.chat.find((m) => m.id === msg.thread);
|
|
389
|
+
msg.channel = channelOf(root?.channel ?? extra.channel ?? job?.channel);
|
|
390
|
+
state.chat.push(msg);
|
|
391
|
+
if (state.chat.length > MAX_CHAT) state.chat.splice(0, state.chat.length - MAX_CHAT);
|
|
392
|
+
send({ t: 'chat', msg });
|
|
393
|
+
save();
|
|
394
|
+
voiceNote(msg);
|
|
395
|
+
return msg;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const EMOJI = /^(?:\p{Extended_Pictographic}|\p{Regional_Indicator})/u;
|
|
399
|
+
// Toggles one person's reaction; empty emoji lists are dropped so old messages stay lean.
|
|
400
|
+
function react(id, emoji, who = 'you') {
|
|
401
|
+
const msg = state.chat.find((m) => m.id === id);
|
|
402
|
+
if (!msg) throw new Error('No such message.');
|
|
403
|
+
if (typeof emoji !== 'string' || emoji.length > 16 || !EMOJI.test(emoji)) throw new Error('That is not an emoji.');
|
|
404
|
+
const r = (msg.reactions ||= {});
|
|
405
|
+
const people = r[emoji] || [];
|
|
406
|
+
r[emoji] = people.includes(who) ? people.filter((p) => p !== who) : [...people, who];
|
|
407
|
+
if (!r[emoji].length) delete r[emoji];
|
|
408
|
+
if (!Object.keys(r).length) delete msg.reactions;
|
|
409
|
+
send({ t: 'react', id, reactions: msg.reactions || {} });
|
|
410
|
+
save();
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ---------- the break room ----------
|
|
414
|
+
//
|
|
415
|
+
// Two idle agents trading small talk is acted out in the browser, which is
|
|
416
|
+
// the only place the floor and who is standing on it exist. The lines are
|
|
417
|
+
// office history though, so the browser claims a conversation here and the
|
|
418
|
+
// server writes each line into #chill-room as it is spoken.
|
|
419
|
+
//
|
|
420
|
+
// The claim is what stops two open windows from talking over each other:
|
|
421
|
+
// only one gets it, and it is handed back when the conversation ends, or
|
|
422
|
+
// goes stale on its own if that window is closed mid-sentence.
|
|
423
|
+
|
|
424
|
+
const CHILL_STALE = 20 * 1000;
|
|
425
|
+
// A game of pool is minutes of play with two lines said; the window pings to
|
|
426
|
+
// say it is still going, and this is how long a missed ping is forgiven.
|
|
427
|
+
const GAME_STALE = 90 * 1000;
|
|
428
|
+
// Small talk is cheap and constant; without a cap of its own it would push
|
|
429
|
+
// the day's actual work out of a shared history of MAX_CHAT.
|
|
430
|
+
const CHILL_KEEP = 60;
|
|
431
|
+
let chill = null;
|
|
432
|
+
|
|
433
|
+
function claimChill(who, game = false) {
|
|
434
|
+
if (chill && Date.now() - chill.at < (chill.game ? GAME_STALE : CHILL_STALE)) return null;
|
|
435
|
+
chill = { id: crypto.randomUUID().slice(0, 8), who, at: Date.now(), game };
|
|
436
|
+
return chill;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function chillLine(id, from, text) {
|
|
440
|
+
if (!chill || chill.id !== id) throw new Error('That conversation has ended.');
|
|
441
|
+
if (!chill.who.includes(from)) throw new Error('Not one of the two talking.');
|
|
442
|
+
const line = String(text || '').replace(/\s+/g, ' ').trim().slice(0, 200);
|
|
443
|
+
if (!line) throw new Error('Nothing said.');
|
|
444
|
+
chill.at = Date.now();
|
|
445
|
+
const msg = post(from, line, { channel: CHILL, kind: 'chill' });
|
|
446
|
+
const said = state.chat.filter((m) => m.channel === CHILL);
|
|
447
|
+
if (said.length > CHILL_KEEP) {
|
|
448
|
+
const old = said.slice(0, said.length - CHILL_KEEP).map((m) => m.id);
|
|
449
|
+
const gone = new Set(old);
|
|
450
|
+
state.chat = state.chat.filter((m) => !gone.has(m.id));
|
|
451
|
+
// Told, not just dropped: a window left open all day would otherwise hold
|
|
452
|
+
// every line it had ever seen, and the cap would only be true on reload.
|
|
453
|
+
send({ t: 'forget', ids: old });
|
|
454
|
+
save();
|
|
455
|
+
}
|
|
456
|
+
return msg;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const endChill = (id) => { if (chill?.id === id) chill = null; };
|
|
460
|
+
|
|
461
|
+
// The model writes the small talk, but not for every chat: at most one per
|
|
462
|
+
// BANTER_GAP, and BANTER=off turns it off. Anything else, or any failure,
|
|
463
|
+
// comes back with no script and the browser uses a canned chat instead.
|
|
464
|
+
const BANTER_ON = process.env.BANTER !== 'off';
|
|
465
|
+
const BANTER_GAP = Number(process.env.BANTER_GAP_SECONDS ?? 120) * 1000;
|
|
466
|
+
const BANTER_MODEL = process.env.BANTER_MODEL || 'haiku';
|
|
467
|
+
let banterAt = 0;
|
|
468
|
+
|
|
469
|
+
function banterContext(pair) {
|
|
470
|
+
const sheet = (id) => {
|
|
471
|
+
const s = STAFF.find((x) => x.id === id);
|
|
472
|
+
const work = state.jobs.filter((j) => j.agentId === id && Date.now() - (j.endedAt || j.createdAt || 0) < DAY_MS)
|
|
473
|
+
.slice(-3).map((j) => `"${clip(j.title, 80)}" in ${j.repo} (${j.status})`);
|
|
474
|
+
return `${s.name}: ${persona(id)}${work.length ? `Lately: ${work.join('; ')}.` : 'Nothing on at the moment.'}`;
|
|
475
|
+
};
|
|
476
|
+
const recent = state.chat.filter((m) => m.channel === CHILL).slice(-12).map((m) => `${nameOf(m.from)}: ${m.text}`);
|
|
477
|
+
return [`First to speak: ${nameOf(pair[0])}. Then ${nameOf(pair[1])}.`, '', ...pair.map(sheet), '',
|
|
478
|
+
recent.length ? `Recent break-room lines (don't repeat these):\n${recent.join('\n')}` : 'Nothing said in the break room lately.'].join('\n');
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async function claimBanter(pair, game = false) {
|
|
482
|
+
const c = claimChill(pair, game);
|
|
483
|
+
if (!c) return { id: null };
|
|
484
|
+
if (game) return { id: c.id };
|
|
485
|
+
if (!BANTER_ON || Date.now() - banterAt < BANTER_GAP) return { id: c.id };
|
|
486
|
+
banterAt = Date.now();
|
|
487
|
+
try {
|
|
488
|
+
const { lines, cost } = await writeBanter(banterContext(pair), { bin: CLAUDE, model: BANTER_MODEL, env: auth.env() });
|
|
489
|
+
state.chiefCost += cost || 0;
|
|
490
|
+
send({ t: 'chief', busy: false, cost: state.chiefCost });
|
|
491
|
+
c.at = Date.now();
|
|
492
|
+
return { id: c.id, script: lines };
|
|
493
|
+
} catch {
|
|
494
|
+
banterAt = Date.now() - BANTER_GAP + 30 * 1000;
|
|
495
|
+
return { id: c.id };
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function addLog(job, entry) {
|
|
500
|
+
entry.ts = Date.now();
|
|
501
|
+
const last = job.log[job.log.length - 1];
|
|
502
|
+
if (entry.kind === 'thinking' && last?.kind === 'thinking') return;
|
|
503
|
+
job.log.push(entry);
|
|
504
|
+
if (job.log.length > MAX_LOG) job.log.splice(0, job.log.length - MAX_LOG);
|
|
505
|
+
send({ t: 'log', jobId: job.id, entry });
|
|
506
|
+
save();
|
|
507
|
+
}
|
|
508
|
+
const pushJob = (job) => { send({ t: 'job', job: publicJob(job, 0) }); save(); };
|
|
509
|
+
|
|
510
|
+
// ---------- jobs ----------
|
|
511
|
+
|
|
512
|
+
const running = new Map();
|
|
513
|
+
const graces = new Map();
|
|
514
|
+
const tokens = new Map();
|
|
515
|
+
// Jobs paused until a colleague answers or finishes handed-off work:
|
|
516
|
+
// jobId -> [{ kind: 'job', id } | { kind: 'reply', from, timer }]
|
|
517
|
+
const waits = new Map();
|
|
518
|
+
const REPLY_WAIT_MS = 10 * 60 * 1000;
|
|
519
|
+
// Long-lived jobs get resumed on every reply and pay for the whole history each
|
|
520
|
+
// turn, so agents auto-compact earlier than the default. AGENT_COMPACT_PCT to tune.
|
|
521
|
+
const AUTOCOMPACT_PCT = process.env.AGENT_COMPACT_PCT || '60';
|
|
522
|
+
const OFFICE_TOOLS = [
|
|
523
|
+
'mcp__office__office_roster', 'mcp__office__message_colleague', 'mcp__office__hand_off', 'mcp__office__flag_for_boss',
|
|
524
|
+
'mcp__office__list_channels', 'mcp__office__create_channel', 'mcp__office__invite_to_channel', 'mcp__office__post_to_channel',
|
|
525
|
+
'mcp__office__restart_office',
|
|
526
|
+
];
|
|
527
|
+
const endGrace = (job) => { clearTimeout(graces.get(job.id)); graces.delete(job.id); };
|
|
528
|
+
|
|
529
|
+
function repoPath(name) {
|
|
530
|
+
if (typeof name !== 'string' || !name || /[\/\\]/.test(name) || name.startsWith('.')) return null;
|
|
531
|
+
const p = path.join(ROOT, name);
|
|
532
|
+
try { if (fs.statSync(p).isDirectory()) return p; } catch {}
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function listRepos() {
|
|
537
|
+
return fs.readdirSync(ROOT, { withFileTypes: true })
|
|
538
|
+
.filter((d) => d.isDirectory() && !d.name.startsWith('.') && d.name !== 'node_modules')
|
|
539
|
+
.map((d) => d.name)
|
|
540
|
+
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function createJob({ repo, prompt, mode = 'edit', model = '', auto = false, agentId = '' }) {
|
|
544
|
+
if (!repoPath(repo)) throw new Error('Unknown project folder.');
|
|
545
|
+
prompt = String(prompt || '').trim();
|
|
546
|
+
if (!prompt) throw new Error('Give the job a brief.');
|
|
547
|
+
if (!MODES[mode]) throw new Error('Unknown mode.');
|
|
548
|
+
if (!MODELS.has(model)) throw new Error('Unknown model.');
|
|
549
|
+
const now = Date.now();
|
|
550
|
+
const job = {
|
|
551
|
+
id: crypto.randomUUID().slice(0, 8),
|
|
552
|
+
repo, prompt, mode, model, autoModel: !!auto, escalations: 0,
|
|
553
|
+
title: prompt.split('\n')[0].slice(0, 90),
|
|
554
|
+
status: 'queued', createdAt: now, queuedAt: now, startedAt: null, endedAt: null,
|
|
555
|
+
sessionId: null, agentId: null, cost: 0, turns: 0, tools: 0, xp: 0,
|
|
556
|
+
pending: prompt, denials: [], error: null, log: [],
|
|
557
|
+
wantAgent: STAFF.some((st) => st.id === agentId) ? agentId : null,
|
|
558
|
+
};
|
|
559
|
+
state.jobs.push(job);
|
|
560
|
+
if (job.wantAgent && state.dismissed.includes(job.wantAgent)) recall([job.wantAgent]);
|
|
561
|
+
addLog(job, { kind: 'user', summary: prompt });
|
|
562
|
+
pushJob(job);
|
|
563
|
+
pump();
|
|
564
|
+
return job;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function pump() {
|
|
568
|
+
const queued = state.jobs.filter((j) => j.status === 'queued').sort((a, b) => a.queuedAt - b.queuedAt);
|
|
569
|
+
// Work waiting with nobody in to take it: call someone back.
|
|
570
|
+
if (queued.length && !onFloor().length && state.dismissed.length) recall([hired().find((s) => state.dismissed.includes(s.id))?.id]);
|
|
571
|
+
const roster = onFloor();
|
|
572
|
+
for (const job of queued) {
|
|
573
|
+
const busy = new Set(state.jobs.filter((j) => j.status === 'working').map((j) => j.agentId));
|
|
574
|
+
const free = roster.filter((s) => !busy.has(s.id) && (!job.wantAgent || s.id === job.wantAgent));
|
|
575
|
+
if (!free.length) continue;
|
|
576
|
+
// Follow-ups go back to whoever did the first round, if they're free.
|
|
577
|
+
const agent = free.find((s) => s.id === job.agentId) || free[Math.floor(Math.random() * free.length)];
|
|
578
|
+
start(job, agent.id);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function start(job, agentId) {
|
|
583
|
+
const cwd = repoPath(job.repo);
|
|
584
|
+
const args = ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', ...MODES[job.mode].args];
|
|
585
|
+
if (job.model) args.push('--model', job.model);
|
|
586
|
+
if (job.sessionId) args.push('--resume', job.sessionId);
|
|
587
|
+
const token = crypto.randomUUID();
|
|
588
|
+
tokens.set(job.id, token);
|
|
589
|
+
const mcp = { mcpServers: { office: { command: process.execPath, args: [path.join(HERE, 'office-mcp.js')],
|
|
590
|
+
env: { OFFICE_URL: `http://127.0.0.1:${PORT}`, OFFICE_JOB: job.id, OFFICE_TOKEN: token } } } };
|
|
591
|
+
// Attachments live outside the project, so agents need leave to read them.
|
|
592
|
+
fs.mkdirSync(UPLOADS, { recursive: true });
|
|
593
|
+
args.push('--add-dir', UPLOADS);
|
|
594
|
+
args.push('--mcp-config', JSON.stringify(mcp), '--allowedTools', OFFICE_TOOLS.join(','), '--append-system-prompt', officePrompt(agentId, job));
|
|
595
|
+
// Being given work in a channel is how you end up in it.
|
|
596
|
+
try { joinChannel(channelOf(job.channel), agentId); } catch {}
|
|
597
|
+
const first = job.pending;
|
|
598
|
+
|
|
599
|
+
Object.assign(job, {
|
|
600
|
+
status: 'working', agentId, pending: null, denials: [], error: null,
|
|
601
|
+
stopRequested: false, startedAt: job.startedAt ?? Date.now(), turnTools: 0, lastTurn: null, waiting: false,
|
|
602
|
+
});
|
|
603
|
+
pushJob(job);
|
|
604
|
+
|
|
605
|
+
if (!cwd) return finish(job, 'failed', 'That project folder is gone.');
|
|
606
|
+
|
|
607
|
+
// Where the tree stood before this job touched it, for the "what changed"
|
|
608
|
+
// card. Taken once: a job resumed with a reply keeps its first baseline.
|
|
609
|
+
if (!job.baseTree) bases.set(job.id, snapshotTree(cwd).then((t) => { job.baseTree = t; }));
|
|
610
|
+
|
|
611
|
+
// stdin stays open for the whole job, so the chief can hand the agent
|
|
612
|
+
// new messages while it works.
|
|
613
|
+
const child = spawn(CLAUDE, args, { cwd, env: { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: AUTOCOMPACT_PCT, ...auth.env() }, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
614
|
+
running.set(job.id, child);
|
|
615
|
+
startProcs();
|
|
616
|
+
child.stdin.on('error', () => {});
|
|
617
|
+
write(child, first);
|
|
618
|
+
|
|
619
|
+
let buf = '';
|
|
620
|
+
child.stdout.setEncoding('utf8');
|
|
621
|
+
child.stdout.on('data', (chunk) => {
|
|
622
|
+
buf += chunk;
|
|
623
|
+
let i;
|
|
624
|
+
while ((i = buf.indexOf('\n')) >= 0) {
|
|
625
|
+
const line = buf.slice(0, i);
|
|
626
|
+
buf = buf.slice(i + 1);
|
|
627
|
+
if (line.trim()) onLine(job, line);
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
let errTail = '';
|
|
631
|
+
child.stderr.setEncoding('utf8');
|
|
632
|
+
child.stderr.on('data', (d) => { errTail = (errTail + d).slice(-2000); });
|
|
633
|
+
|
|
634
|
+
const ended = (why) => {
|
|
635
|
+
running.delete(job.id);
|
|
636
|
+
if (procs) { delete procs[job.id]; }
|
|
637
|
+
endGrace(job);
|
|
638
|
+
if (job.status !== 'working') return;
|
|
639
|
+
if (job.stopRequested) finish(job, 'stopped', 'Stopped by you.');
|
|
640
|
+
else if (job.lastTurn) finish(job, job.lastTurn.ok ? 'done' : 'failed', job.lastTurn.ok ? null : job.lastTurn.error);
|
|
641
|
+
else finish(job, 'failed', errTail.trim().split('\n').pop() || why);
|
|
642
|
+
};
|
|
643
|
+
child.on('error', (err) => ended(`Could not start claude: ${err.message}`));
|
|
644
|
+
child.on('close', (code) => ended(`claude exited with code ${code}`));
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function write(child, text) {
|
|
648
|
+
if (!child.stdin.writable) return false;
|
|
649
|
+
child.stdin.write(JSON.stringify({ type: 'user', message: { role: 'user', content: text } }) + '\n');
|
|
650
|
+
return true;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Hand a message to a job, whatever state it is in.
|
|
654
|
+
function deliver(job, text) {
|
|
655
|
+
if (job.status === 'working') {
|
|
656
|
+
const child = running.get(job.id);
|
|
657
|
+
endGrace(job);
|
|
658
|
+
job.lastTurn = null;
|
|
659
|
+
if (job.waiting) { job.waiting = false; pushJob(job); }
|
|
660
|
+
addLog(job, { kind: 'user', summary: text });
|
|
661
|
+
if (child && write(child, text)) return 'live';
|
|
662
|
+
// The session ended between the check and the write, so there is nobody
|
|
663
|
+
// to hand it to. Hold it for the next turn: passing it to replyJob would
|
|
664
|
+
// only hand it straight back here, for as long as the stack allows.
|
|
665
|
+
job.pending = job.pending ? `${job.pending}\n\n${text}` : text;
|
|
666
|
+
return 'queued';
|
|
667
|
+
}
|
|
668
|
+
if (job.status === 'queued') {
|
|
669
|
+
job.pending = `${job.pending}\n\n${text}`;
|
|
670
|
+
addLog(job, { kind: 'user', summary: text });
|
|
671
|
+
return 'queued';
|
|
672
|
+
}
|
|
673
|
+
replyJob(job, { message: text });
|
|
674
|
+
return 'resumed';
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const textOf = (content) =>
|
|
678
|
+
typeof content === 'string' ? content
|
|
679
|
+
: Array.isArray(content) ? content.map((c) => c.text || '').join('\n')
|
|
680
|
+
: '';
|
|
681
|
+
|
|
682
|
+
function onLine(job, line) {
|
|
683
|
+
let e;
|
|
684
|
+
try { e = JSON.parse(line); } catch { return; }
|
|
685
|
+
if (e.type === 'assistant' && graces.has(job.id)) { endGrace(job); job.lastTurn = null; }
|
|
686
|
+
|
|
687
|
+
if (e.type === 'system' && e.subtype === 'init') {
|
|
688
|
+
job.sessionId = e.session_id;
|
|
689
|
+
addLog(job, { kind: 'system', summary: `Clocked in · ${e.model}` });
|
|
690
|
+
} else if (e.type === 'assistant') {
|
|
691
|
+
for (const c of e.message?.content || []) {
|
|
692
|
+
if (c.type === 'text' && c.text.trim()) {
|
|
693
|
+
addLog(job, { kind: 'text', summary: c.text.trim().slice(0, 6000) });
|
|
694
|
+
} else if (c.type === 'thinking') {
|
|
695
|
+
addLog(job, { kind: 'thinking', summary: 'Thinking…' });
|
|
696
|
+
} else if (c.type === 'tool_use') {
|
|
697
|
+
job.tools++;
|
|
698
|
+
job.turnTools++;
|
|
699
|
+
const entry = { kind: 'tool', tool: c.name, summary: summarizeTool(c.name, c.input), station: stationFor(c.name) };
|
|
700
|
+
if (c.name === 'TodoWrite' && Array.isArray(c.input?.todos)) {
|
|
701
|
+
entry.todos = c.input.todos.slice(0, 12).map((t) => ({ text: String(t.content || '').slice(0, 80), done: t.status === 'completed' }));
|
|
702
|
+
}
|
|
703
|
+
addLog(job, entry);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
} else if (e.type === 'user') {
|
|
707
|
+
for (const c of e.message?.content || []) {
|
|
708
|
+
if (c.type === 'tool_result' && c.is_error) {
|
|
709
|
+
const text = textOf(c.content).trim();
|
|
710
|
+
addLog(job, { kind: 'error', summary: text.slice(0, 400), blocked: /permission|haven't granted/i.test(text) });
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
} else if (e.type === 'result') {
|
|
714
|
+
job.cost += e.total_cost_usd || 0;
|
|
715
|
+
job.turns++;
|
|
716
|
+
// The input side of the last turn is how much context this session is
|
|
717
|
+
// carrying. Worth seeing: a long-resumed job pays it on every turn.
|
|
718
|
+
const u = e.usage || {};
|
|
719
|
+
job.context = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0);
|
|
720
|
+
board.addTokens((u.input_tokens || 0) + (u.output_tokens || 0));
|
|
721
|
+
board.addTime(e.duration_ms);
|
|
722
|
+
nudgeUsage();
|
|
723
|
+
job.denials = [...new Set((e.permission_denials || []).map((d) => d.tool_name))];
|
|
724
|
+
const ok = e.subtype === 'success' && !e.is_error;
|
|
725
|
+
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
|
+
job.lastTurn = { ok, error: ok ? null : `Ended with ${e.subtype}`, text: String(e.result || '').trim() };
|
|
727
|
+
endGrace(job);
|
|
728
|
+
const pending = waits.get(job.id) || [];
|
|
729
|
+
if (ok && pending.length && running.has(job.id)) {
|
|
730
|
+
// Keep the session open; whatever they're waiting for will wake it.
|
|
731
|
+
const names = [...new Set(pending.map((w) => (w.kind === 'job' ? nameOf(state.jobs.find((j) => j.id === w.id)?.agentId) : nameOf(w.from))))];
|
|
732
|
+
job.waiting = true;
|
|
733
|
+
addLog(job, { kind: 'system', summary: `Waiting on ${names.join(' and ')}`, wait: true });
|
|
734
|
+
pushJob(job);
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
graces.set(job.id, setTimeout(() => {
|
|
738
|
+
graces.delete(job.id);
|
|
739
|
+
const child = running.get(job.id);
|
|
740
|
+
if (child) child.stdin.end();
|
|
741
|
+
else finish(job, ok ? 'done' : 'failed', job.lastTurn?.error);
|
|
742
|
+
}, GRACE_MS));
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// One rung up the ladder, for a job the chief sized. A job that never got a
|
|
747
|
+
// turn out of the model didn't fail at the work — the folder was gone, the
|
|
748
|
+
// binary wouldn't start — so a stronger model would fail the same way and it
|
|
749
|
+
// stays where it is. Returns the new rung, or null if it isn't moving.
|
|
750
|
+
function bump(job) {
|
|
751
|
+
if (!job.autoModel || !job.turns) return null;
|
|
752
|
+
const at = LADDER.indexOf(tierOf(job.model));
|
|
753
|
+
const next = at >= 0 ? LADDER[at + 1] : null;
|
|
754
|
+
if (!next) return null;
|
|
755
|
+
job.model = next;
|
|
756
|
+
job.escalations = (job.escalations || 0) + 1;
|
|
757
|
+
return next;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const bases = new Map();
|
|
761
|
+
async function recordChanges(job) {
|
|
762
|
+
const cwd = repoPath(job.repo);
|
|
763
|
+
if (!cwd) return;
|
|
764
|
+
await bases.get(job.id);
|
|
765
|
+
if (!job.baseTree) return;
|
|
766
|
+
const changes = await summarize(cwd, job.baseTree, await snapshotTree(cwd));
|
|
767
|
+
if (changes) { job.changes = changes; pushJob(job); }
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function finish(job, status, error) {
|
|
771
|
+
if (job.status !== 'working') return;
|
|
772
|
+
clearWaits(job);
|
|
773
|
+
tokens.delete(job.id);
|
|
774
|
+
job.waiting = false;
|
|
775
|
+
const before = progress();
|
|
776
|
+
// An agent that ended its turn still needing the boss's input has not
|
|
777
|
+
// finished: it is waiting. Nothing is marked, scored or announced as done.
|
|
778
|
+
const awaiting = status === 'done' && !!job.asking;
|
|
779
|
+
job.awaitingBoss = awaiting;
|
|
780
|
+
job.status = status;
|
|
781
|
+
job.error = error;
|
|
782
|
+
job.endedAt = Date.now();
|
|
783
|
+
// Capture the rung it failed on before bump() moves it.
|
|
784
|
+
const was = TIER_LABEL[tierOf(job.model)] || job.model || 'the default model';
|
|
785
|
+
const retry = status === 'failed' ? bump(job) : null;
|
|
786
|
+
const gained = status === 'done' && !awaiting ? 40 + Math.min(80, (job.turnTools || 0) * 4) : 0;
|
|
787
|
+
if (status === 'done' && !awaiting) board.addJob();
|
|
788
|
+
state.xp += gained;
|
|
789
|
+
job.xp += gained;
|
|
790
|
+
pushJob(job);
|
|
791
|
+
recordChanges(job).catch(() => {});
|
|
792
|
+
if (gained) {
|
|
793
|
+
const after = progress();
|
|
794
|
+
send({ t: 'xp', progress: after, gained, jobId: job.id, agentId: job.agentId, levelUp: after.level > before.level });
|
|
795
|
+
}
|
|
796
|
+
const text = status === 'done' ? job.lastTurn?.text || 'Done.'
|
|
797
|
+
: status === 'stopped' ? 'Stopped, as asked.'
|
|
798
|
+
: `I hit a wall: ${error || 'something went wrong'}`;
|
|
799
|
+
post(job.agentId, text, { jobId: job.id, status, denials: job.denials });
|
|
800
|
+
// The result lands in the job's thread, which the boss may never open, so
|
|
801
|
+
// the chief says so in #general. Not for a retry (the work isn't over), a
|
|
802
|
+
// stop (the boss did that), or a colleague's handed-off piece (their asker
|
|
803
|
+
// gets it, and the asker's own finish is the one worth announcing).
|
|
804
|
+
if (!retry && status !== 'stopped' && !job.requestedBy) {
|
|
805
|
+
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)}".` : `${nameOf(job.agentId)} hit a wall on "${clip(job.title, 80)}": ${clip(error || 'something went wrong', 200)}`;
|
|
807
|
+
post('chief', `**${awaiting ? 'Waiting on you' : status === 'done' ? 'Done' : 'Failed'}** · ${line}`, { kind: 'finished', status, awaiting, jobId: job.id, thread: null, channel: GENERAL });
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// Hand the result back to whoever delegated this, if they're still at it.
|
|
811
|
+
// Not on a retry: the work isn't finished, it's going up a rung, so whoever
|
|
812
|
+
// handed it over keeps waiting for the real answer.
|
|
813
|
+
const asker = !retry && job.requestedBy && state.jobs.find((j) => j.id === job.requestedBy);
|
|
814
|
+
if (asker?.status === 'working' && running.has(asker.id)) {
|
|
815
|
+
settle(asker, (w) => w.kind === 'job' && w.id === job.id);
|
|
816
|
+
deliver(asker, `${nameOf(job.agentId)} finished the work you handed off ("${job.title}"), status: ${status}.\n\n${text}`);
|
|
817
|
+
}
|
|
818
|
+
// Anyone waiting on a reply from this agent stops waiting.
|
|
819
|
+
for (const other of state.jobs) {
|
|
820
|
+
if (other.status === 'working' && running.has(other.id) && settle(other, (w) => w.kind === 'reply' && w.from === job.agentId)) {
|
|
821
|
+
deliver(other, `${nameOf(job.agentId)} finished their job without replying to you. Carry on without their answer.`);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
// A message can land in the gap between the session closing and here, in
|
|
825
|
+
// which case deliver() left it in `pending` and nothing has read it. Put the
|
|
826
|
+
// job back in the queue so somebody does. A stopped job stays stopped — the
|
|
827
|
+
// boss pulled it on purpose — so say out loud that it went unread instead of
|
|
828
|
+
// dropping it quietly.
|
|
829
|
+
if (job.pending) {
|
|
830
|
+
if (status === 'stopped') {
|
|
831
|
+
post('chief', `A message for ${nameOf(job.agentId)} landed just as "${job.title}" was stopped, so nobody read it.`, { jobId: job.id });
|
|
832
|
+
job.pending = null;
|
|
833
|
+
} else {
|
|
834
|
+
Object.assign(job, { status: 'queued', queuedAt: Date.now(), error: null, endedAt: null, asking: null, awaitingBoss: false });
|
|
835
|
+
addLog(job, { kind: 'system', summary: 'A message landed as this job was closing. Picking it back up.' });
|
|
836
|
+
pushJob(job);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
// Back in the queue on the stronger model, in a fresh session rather than a
|
|
840
|
+
// resumed one: the prompt cache belongs to the model that filled it, so
|
|
841
|
+
// resuming would make the dearer model read the whole failed transcript at
|
|
842
|
+
// full price. It gets a summary of the attempt instead, and the repo still
|
|
843
|
+
// holds whatever the first attempt actually changed.
|
|
844
|
+
if (retry) {
|
|
845
|
+
const tried = (job.lastTurn?.text || '').trim().slice(0, 1200);
|
|
846
|
+
// A fresh session knows nothing, so the brief goes in again with it.
|
|
847
|
+
const note = [
|
|
848
|
+
job.prompt,
|
|
849
|
+
`An earlier attempt at this on ${was} ended badly: ${error || 'it did not finish'}.`,
|
|
850
|
+
tried && `What that attempt reported:\n${tried}`,
|
|
851
|
+
`You are on ${TIER_LABEL[retry]} now, picking this up fresh. The project still holds any changes that attempt made, so check where the work actually stands before redoing it, then finish the job.`,
|
|
852
|
+
].filter(Boolean).join('\n\n');
|
|
853
|
+
job.sessionId = null;
|
|
854
|
+
job.pending = job.pending ? `${note}\n\n${job.pending}` : note;
|
|
855
|
+
if (job.status !== 'queued') Object.assign(job, { status: 'queued', queuedAt: Date.now(), error: null, endedAt: null, asking: null, awaitingBoss: false });
|
|
856
|
+
addLog(job, { kind: 'system', summary: `Hit a wall on ${was}; moving up to ${TIER_LABEL[retry]}` });
|
|
857
|
+
pushJob(job);
|
|
858
|
+
post('chief', `That hit a wall on ${was}. Running it again on ${TIER_LABEL[retry]}.`, { jobId: job.id, channel: job.channel });
|
|
859
|
+
}
|
|
860
|
+
save();
|
|
861
|
+
setImmediate(pump);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function clearWaits(job) {
|
|
865
|
+
for (const w of waits.get(job.id) || []) clearTimeout(w.timer);
|
|
866
|
+
waits.delete(job.id);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// Drop the waits that match; true if any did.
|
|
870
|
+
function settle(job, match) {
|
|
871
|
+
const list = waits.get(job.id);
|
|
872
|
+
if (!list) return false;
|
|
873
|
+
const gone = list.filter(match);
|
|
874
|
+
if (!gone.length) return false;
|
|
875
|
+
gone.forEach((w) => clearTimeout(w.timer));
|
|
876
|
+
const keep = list.filter((w) => !match(w));
|
|
877
|
+
if (keep.length) waits.set(job.id, keep); else waits.delete(job.id);
|
|
878
|
+
return true;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function addWait(job, w) {
|
|
882
|
+
waits.set(job.id, [...(waits.get(job.id) || []), w]);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function stopJob(job) {
|
|
886
|
+
if (job.status === 'queued') {
|
|
887
|
+
job.status = 'stopped';
|
|
888
|
+
job.error = 'Pulled from the queue.';
|
|
889
|
+
job.endedAt = Date.now();
|
|
890
|
+
pushJob(job);
|
|
891
|
+
} else if (job.status === 'working') {
|
|
892
|
+
job.stopRequested = true;
|
|
893
|
+
const child = running.get(job.id);
|
|
894
|
+
if (child) {
|
|
895
|
+
child.kill('SIGTERM');
|
|
896
|
+
setTimeout(() => running.get(job.id) === child && child.kill('SIGKILL'), 5000);
|
|
897
|
+
} else {
|
|
898
|
+
finish(job, 'stopped', 'Stopped by you.');
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function replyJob(job, { message, mode, files }) {
|
|
904
|
+
message = withFiles(message, attachments(files));
|
|
905
|
+
if (job.status === 'working' || job.status === 'queued') {
|
|
906
|
+
if (!String(message || '').trim()) throw new Error('Say something first.');
|
|
907
|
+
deliver(job, String(message).trim());
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
message = String(message || '').trim();
|
|
911
|
+
if (!message) throw new Error('Say something first.');
|
|
912
|
+
if (mode && !MODES[mode]) throw new Error('Unknown mode.');
|
|
913
|
+
if (mode) job.mode = mode;
|
|
914
|
+
Object.assign(job, { status: 'queued', queuedAt: Date.now(), pending: message, error: null, endedAt: null, asking: null, awaitingBoss: false });
|
|
915
|
+
addLog(job, { kind: 'user', summary: message });
|
|
916
|
+
pushJob(job);
|
|
917
|
+
pump();
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
// ---------- sending people home ----------
|
|
922
|
+
|
|
923
|
+
const names = (list) => (list.length < 2 ? list.join('') : `${list.slice(0, -1).join(', ')} and ${list.at(-1)}`);
|
|
924
|
+
|
|
925
|
+
// Only idle agents can go; anyone mid-job stays until it's done.
|
|
926
|
+
function dismiss(ids) {
|
|
927
|
+
const gone = [], busy = [];
|
|
928
|
+
for (const id of ids) {
|
|
929
|
+
const s = hired().find((x) => x.id === id);
|
|
930
|
+
if (!s || state.dismissed.includes(id)) continue;
|
|
931
|
+
if (workingJobOf(id)) { busy.push(s.name); continue; }
|
|
932
|
+
state.dismissed.push(id);
|
|
933
|
+
gone.push(s.name);
|
|
934
|
+
// Work queued for them goes to whoever is free instead.
|
|
935
|
+
for (const j of state.jobs) if (j.status === 'queued' && j.wantAgent === id) { j.wantAgent = null; pushJob(j); }
|
|
936
|
+
}
|
|
937
|
+
if (gone.length) { send({ t: 'staff', dismissed: state.dismissed }); save(); setImmediate(pump); }
|
|
938
|
+
return [
|
|
939
|
+
gone.length ? `${names(gone)} ${gone.length === 1 ? 'has' : 'have'} gone home.` : 'Nobody new to send home.',
|
|
940
|
+
busy.length ? `${names(busy)} ${busy.length === 1 ? 'is' : 'are'} mid-job, so they stay until it's done.` : '',
|
|
941
|
+
].filter(Boolean).join(' ');
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function recall(ids) {
|
|
945
|
+
const back = ids.filter((id) => state.dismissed.includes(id));
|
|
946
|
+
if (!back.length) return 'Everyone you named is already in.';
|
|
947
|
+
state.dismissed = state.dismissed.filter((id) => !back.includes(id));
|
|
948
|
+
send({ t: 'staff', dismissed: state.dismissed });
|
|
949
|
+
save();
|
|
950
|
+
setImmediate(pump);
|
|
951
|
+
return `${names(back.map(nameOf))} ${back.length === 1 ? 'is' : 'are'} back in.`;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// ---------- chat + chief of staff ----------
|
|
955
|
+
|
|
956
|
+
let chiefBusy = false;
|
|
957
|
+
let chiefQueue = Promise.resolve();
|
|
958
|
+
const norm = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
959
|
+
const nameOf = (id) => STAFF.find((s) => s.id === id)?.name || 'someone';
|
|
960
|
+
const ago = (ts) => {
|
|
961
|
+
const m = Math.round((Date.now() - ts) / 60000);
|
|
962
|
+
return m < 60 ? `${m}m` : `${Math.round(m / 60)}h`;
|
|
963
|
+
};
|
|
964
|
+
|
|
965
|
+
function parseTags(text) {
|
|
966
|
+
const roster = STAFF.slice(0, capacity());
|
|
967
|
+
const repos = listRepos();
|
|
968
|
+
const agentFor = (n) => roster.find((s) => norm(s.name) === norm(n));
|
|
969
|
+
const repoFor = (n) => repos.find((r) => norm(r) === norm(n));
|
|
970
|
+
const agents = [...text.matchAll(/@([\w-]+)/g)].map((m) => agentFor(m[1])).filter(Boolean);
|
|
971
|
+
const projects = [...text.matchAll(/#([\w.-]+)/g)].map((m) => repoFor(m[1])).filter(Boolean);
|
|
972
|
+
// Tags in front are just addressing; tags mid-sentence read as words.
|
|
973
|
+
const clean = text
|
|
974
|
+
.replace(/^(\s*[@#][\w.-]+)+\s*/, (lead) => lead.replace(/[@#]([\w.-]+)/g, (m, n) => (agentFor(n) || repoFor(n) ? '' : m)))
|
|
975
|
+
.replace(/@([\w-]+)/g, (m, n) => (agentFor(n) ? '' : m))
|
|
976
|
+
.replace(/#([\w.-]+)/g, (m, n) => repoFor(n) || m)
|
|
977
|
+
.replace(/[ \t]+/g, ' ')
|
|
978
|
+
.trim();
|
|
979
|
+
return { agent: agents[0], repo: projects[0], clean: clean || text };
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function chiefContext(text, agent, repo, channel = GENERAL) {
|
|
983
|
+
const roster = STAFF.slice(0, capacity());
|
|
984
|
+
const lines = ['Staff:'];
|
|
985
|
+
for (const s of roster) {
|
|
986
|
+
const id = s.role ? `id ${s.id}, ${s.role}` : `id ${s.id}`;
|
|
987
|
+
const j = state.jobs.find((x) => x.status === 'working' && x.agentId === s.id);
|
|
988
|
+
if (!j && state.dismissed.includes(s.id)) { lines.push(`- ${s.name} (${id}): dismissed, gone home`); continue; }
|
|
989
|
+
if (!j) { lines.push(`- ${s.name} (${id}): free`); continue; }
|
|
990
|
+
const last = [...j.log].reverse().find((e) => e.kind === 'tool' || e.kind === 'text');
|
|
991
|
+
lines.push(`- ${s.name} (${id}): working on job ${j.id} in ${j.repo}, asked for in #${channelOf(j.channel)}: "${j.title}". Latest: ${last ? last.summary.slice(0, 100).replace(/\n/g, ' ') : 'just starting'}`);
|
|
992
|
+
}
|
|
993
|
+
const queued = state.jobs.filter((j) => j.status === 'queued');
|
|
994
|
+
if (queued.length) {
|
|
995
|
+
lines.push('Queued:');
|
|
996
|
+
for (const j of queued) lines.push(`- job ${j.id} in ${j.repo}, asked for in #${channelOf(j.channel)}: "${j.title}"${j.wantAgent ? ` (for ${nameOf(j.wantAgent)})` : ''}`);
|
|
997
|
+
}
|
|
998
|
+
const recent = state.jobs.filter((j) => ['done', 'failed', 'stopped'].includes(j.status)).sort((a, b) => b.endedAt - a.endedAt).slice(0, 6);
|
|
999
|
+
if (recent.length) {
|
|
1000
|
+
lines.push('Recently finished:');
|
|
1001
|
+
for (const j of recent) lines.push(`- job ${j.id} by ${nameOf(j.agentId)} in ${j.repo}, asked for in #${channelOf(j.channel)}: "${j.title}" (${j.status}, ${ago(j.endedAt)} ago)`);
|
|
1002
|
+
}
|
|
1003
|
+
lines.push('', `Projects: ${listRepos().join(', ')}`);
|
|
1004
|
+
lines.push('', `This message is in channel #${channel}. Channels: ${state.channels.map((c) => '#' + c.id).join(', ')}.`);
|
|
1005
|
+
lines.push(`You can only see #${channel}'s history below. A job's own channel is on its line above; never guess where something was discussed.`, '', `Recent chat in #${channel}:`);
|
|
1006
|
+
for (const m of state.chat.filter((x) => channelOf(x.channel) === channel).slice(-9, -1)) {
|
|
1007
|
+
const who = (m.from === 'you' ? 'Boss' : m.from === 'chief' ? 'Chief' : nameOf(m.from)) + (m.to ? ` → ${nameOf(m.to)}` : '');
|
|
1008
|
+
const reacts = Object.keys(m.reactions || {}).join('');
|
|
1009
|
+
lines.push(`${who}: ${m.text.slice(0, 200).replace(/\n/g, ' ')}${reacts ? ` [reactions: ${reacts}]` : ''}`);
|
|
1010
|
+
}
|
|
1011
|
+
lines.push('', `New message from the boss: ${text}`);
|
|
1012
|
+
if (agent) lines.push(`Tagged agent: ${agent.name} (id ${agent.id})`);
|
|
1013
|
+
if (repo) lines.push(`Tagged project: ${repo}`);
|
|
1014
|
+
return lines.join('\n');
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function hireWork({ repo, brief, agentId, mode, model, auto, say, files, thread, inThread, channel }) {
|
|
1018
|
+
const job = createJob({ repo, prompt: withFiles(brief, files), mode, model, auto, agentId: agentId || '' });
|
|
1019
|
+
job.thread = thread;
|
|
1020
|
+
job.channel = channel;
|
|
1021
|
+
const who = nameOf(job.agentId);
|
|
1022
|
+
let line = say || `${who} is on it.`;
|
|
1023
|
+
if (job.status === 'queued') {
|
|
1024
|
+
line = agentId
|
|
1025
|
+
? `${nameOf(agentId)} is busy right now, so it's next on their list.`
|
|
1026
|
+
: `Everyone's busy, so it's in the queue. First free hand takes it.`;
|
|
1027
|
+
}
|
|
1028
|
+
// A job the chief sized says which rung it went to, so the boss can see the
|
|
1029
|
+
// call being made and send it back up if it was the wrong one.
|
|
1030
|
+
if (job.autoModel) line += ` · ${TIER_LABEL[tierOf(job.model)]}`;
|
|
1031
|
+
// In the thread if that's where this was asked for, in the channel otherwise.
|
|
1032
|
+
post('chief', line, { jobId: job.id, channel, thread: inThread ? thread : null });
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// `root` is the thread this conversation lives in: the boss's message, or the
|
|
1036
|
+
// thread they replied in (`inThread`), where the chief answers too.
|
|
1037
|
+
async function dispatch(text, mode, model, files, root, inThread, channel) {
|
|
1038
|
+
const { agent, repo, clean } = parseTags(text);
|
|
1039
|
+
const say = (line, extra = {}) => post('chief', line, { channel, ...(inThread ? { thread: root } : {}), ...extra });
|
|
1040
|
+
const open = state.jobs.filter((j) => j.status === 'working' || j.status === 'queued');
|
|
1041
|
+
|
|
1042
|
+
// A named project with nothing open in it can only be new work.
|
|
1043
|
+
if (repo && !open.some((j) => j.repo === repo)) {
|
|
1044
|
+
return hireWork({ repo, brief: clean, agentId: agent?.id, mode, ...sized(model), files, thread: root, inThread, channel });
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
chiefBusy = true;
|
|
1048
|
+
send({ t: 'chief', busy: true });
|
|
1049
|
+
let d;
|
|
1050
|
+
try {
|
|
1051
|
+
// Images ride along with the message so the chief can route on what the
|
|
1052
|
+
// boss is pointing at; everything else it knows only by name.
|
|
1053
|
+
const shown = canSee(files);
|
|
1054
|
+
const said = files.length
|
|
1055
|
+
? `${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
|
+
: text;
|
|
1057
|
+
d = await route(chiefContext(said, agent, repo, channel), { bin: CLAUDE, model: CHIEF_MODEL, images: shown, env: auth.env() });
|
|
1058
|
+
state.chiefCost += d.cost || 0;
|
|
1059
|
+
nudgeUsage();
|
|
1060
|
+
} finally {
|
|
1061
|
+
chiefBusy = false;
|
|
1062
|
+
send({ t: 'chief', busy: false, cost: state.chiefCost });
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
if (d.action === 'followup') {
|
|
1066
|
+
const job = state.jobs.find((j) => j.id === d.jobId);
|
|
1067
|
+
if (job) {
|
|
1068
|
+
// A job with no thread of its own starts one under the message that
|
|
1069
|
+
// prompted this. One that already has a thread keeps it, and the answer
|
|
1070
|
+
// carries a link there rather than dragging the boss's message over.
|
|
1071
|
+
job.thread ||= root;
|
|
1072
|
+
// A job the boss sends back failed the work just as surely as one that
|
|
1073
|
+
// errored, so the second go gets a stronger model. One still running
|
|
1074
|
+
// keeps its model: its session is mid-flight and nobody is restarting it.
|
|
1075
|
+
const up = d.escalate && job.status !== 'working' ? bump(job) : null;
|
|
1076
|
+
if (up) addLog(job, { kind: 'system', summary: `Sent back by the boss; moving up to ${TIER_LABEL[up]}` });
|
|
1077
|
+
deliver(job, withFiles(clean, files));
|
|
1078
|
+
return post('chief', (d.say || `Passed to ${nameOf(job.agentId)}.`) + (up ? ` Running it on ${TIER_LABEL[up]} this time.` : ''), {
|
|
1079
|
+
jobId: job.id,
|
|
1080
|
+
channel,
|
|
1081
|
+
thread: inThread ? root : null,
|
|
1082
|
+
...(job.thread !== root && { threadOf: job.thread }),
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
if (d.action === 'new' || d.action === 'followup') {
|
|
1087
|
+
const project = repo || listRepos().find((r) => norm(r) === norm(d.repo || ''));
|
|
1088
|
+
if (!project) return say('Which project is that for? Tag it with #project.');
|
|
1089
|
+
const roster = STAFF.slice(0, capacity());
|
|
1090
|
+
const agentId = agent?.id || (roster.some((s) => s.id === d.agentId) ? d.agentId : '');
|
|
1091
|
+
return hireWork({ repo: project, brief: (d.brief || '').trim() || clean, agentId, mode, ...sized(model, d.model), say: d.say, files, thread: root, inThread, channel });
|
|
1092
|
+
}
|
|
1093
|
+
if (d.action === 'dismiss' || d.action === 'recall') {
|
|
1094
|
+
const ids = [...new Set([...(d.agentIds || []), ...(agent ? [agent.id] : [])])];
|
|
1095
|
+
return say(d.action === 'dismiss' ? dismiss(ids) : recall(ids));
|
|
1096
|
+
}
|
|
1097
|
+
if (d.action === 'standup') return void runStandup(true);
|
|
1098
|
+
// An answer that is merely about a job gets the same link a follow-up does.
|
|
1099
|
+
// Asking "how did that go?" from another channel shouldn't dead-end just
|
|
1100
|
+
// because the job has already finished and nothing was passed on.
|
|
1101
|
+
const about = d.jobId && state.jobs.find((j) => j.id === d.jobId);
|
|
1102
|
+
say(d.say || 'Could you say a bit more?', about?.thread && about.thread !== root
|
|
1103
|
+
? { jobId: about.id, threadOf: about.thread, thread: inThread ? root : null }
|
|
1104
|
+
: {});
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// The job a thread is about: one started from it, or the job its first message came from.
|
|
1108
|
+
function threadJob(root) {
|
|
1109
|
+
const job = state.jobs.findLast((j) => j.thread === root.id) || (root.jobId && state.jobs.find((j) => j.id === root.jobId));
|
|
1110
|
+
if (job && !job.thread) job.thread = root.id;
|
|
1111
|
+
return job;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
function chat({ text, mode = 'edit', model = '', files, thread, channel }) {
|
|
1115
|
+
text = String(text || '').trim();
|
|
1116
|
+
files = attachments(files);
|
|
1117
|
+
if (!text && !files.length) throw new Error('Say something first.');
|
|
1118
|
+
if (!MODES[mode]) throw new Error('Unknown mode.');
|
|
1119
|
+
if (!MODELS.has(model)) throw new Error('Unknown model.');
|
|
1120
|
+
if (channel && !hasChannel(channel)) throw new Error('That channel is gone.');
|
|
1121
|
+
const root = thread && state.chat.find((m) => m.id === thread && !m.thread);
|
|
1122
|
+
if (thread && !root) throw new Error('That thread has scrolled out of the office chat.');
|
|
1123
|
+
// A reply belongs to the channel its thread lives in.
|
|
1124
|
+
const where = channelOf(root ? root.channel : channel);
|
|
1125
|
+
const msg = post('you', text, {
|
|
1126
|
+
channel: where,
|
|
1127
|
+
...(files.length && { files: files.map(({ path: _, ...f }) => f) }),
|
|
1128
|
+
...(root && { thread: root.id }),
|
|
1129
|
+
});
|
|
1130
|
+
if (/^\/standup$/i.test(text)) return void runStandup(true);
|
|
1131
|
+
const restart = /^\/restart( now)?$/i.exec(text);
|
|
1132
|
+
if (restart) {
|
|
1133
|
+
try { requestRestart({ now: !!restart[1] }); } catch (err) { post('chief', err.message, { channel: where }); }
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
if (!text) text = files.length === 1 ? 'Take a look at the attached file.' : 'Take a look at the attached files.';
|
|
1137
|
+
// A reply in a job's thread goes straight to that job; no routing needed.
|
|
1138
|
+
const job = root && threadJob(root);
|
|
1139
|
+
if (job) return void deliver(job, withFiles(text, files));
|
|
1140
|
+
chiefQueue = chiefQueue
|
|
1141
|
+
.then(() => dispatch(text, mode, model, files, root?.id || msg.id, !!root, where))
|
|
1142
|
+
.catch((err) => post('chief', `I couldn't route that one: ${err.message}`, { channel: where }));
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
// ---------- restarting ----------
|
|
1147
|
+
//
|
|
1148
|
+
// Server changes only land on a restart, and stopping the server ends every
|
|
1149
|
+
// agent's session, including the one that asked. So a restart waits until
|
|
1150
|
+
// nobody is mid-turn, then exits with RESTART for server/supervise.js to start
|
|
1151
|
+
// a fresh server. Jobs still open come back on boot (see above), and whoever
|
|
1152
|
+
// asked is woken to carry on.
|
|
1153
|
+
|
|
1154
|
+
const RESTART_EXIT = 75;
|
|
1155
|
+
const SUPERVISED = process.env.OFFICE_SUPERVISED === '1';
|
|
1156
|
+
const RESTART_WAIT_MS = 10 * 60 * 1000;
|
|
1157
|
+
let restarting = null;
|
|
1158
|
+
|
|
1159
|
+
// Started a turn and not finished it. Between turns, or waiting on a
|
|
1160
|
+
// colleague, a session loses nothing it can't pick up from its transcript.
|
|
1161
|
+
const midTurn = () => state.jobs.filter((j) => j.status === 'working' && running.has(j.id) && !j.lastTurn && !j.waiting);
|
|
1162
|
+
|
|
1163
|
+
// A server that won't load is worse than a stale one: nothing would bring the
|
|
1164
|
+
// office back to fix it. Syntax for every file, and a load for the modules
|
|
1165
|
+
// that are safe to load (not this one, the supervisor, or the stdio MCP).
|
|
1166
|
+
function serverProblem() {
|
|
1167
|
+
const files = fs.readdirSync(HERE).filter((f) => f.endsWith('.js'));
|
|
1168
|
+
for (const f of files) {
|
|
1169
|
+
const r = spawnSync(process.execPath, ['--check', path.join(HERE, f)], { encoding: 'utf8', timeout: 15000 });
|
|
1170
|
+
if (r.status !== 0) return (r.stderr || `${f} failed to parse`).trim().split('\n').slice(0, 6).join('\n');
|
|
1171
|
+
}
|
|
1172
|
+
const loadable = files.filter((f) => !['index.js', 'supervise.js', 'office-mcp.js'].includes(f)).map((f) => pathToFileURL(path.join(HERE, f)).href);
|
|
1173
|
+
const r = spawnSync(process.execPath, ['--input-type=module', '-e', `for (const f of ${JSON.stringify(loadable)}) await import(f);`],
|
|
1174
|
+
{ encoding: 'utf8', timeout: 15000 });
|
|
1175
|
+
if (r.status !== 0) return (r.stderr || 'A server module failed to load').trim().split('\n').slice(0, 6).join('\n');
|
|
1176
|
+
return null;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
function requestRestart({ by = 'you', jobId = null, now = false } = {}) {
|
|
1180
|
+
if (!SUPERVISED) {
|
|
1181
|
+
throw new Error('This office was started without its supervisor, so nothing would start it again. Restart it once by hand with npm start; after that it can restart itself.');
|
|
1182
|
+
}
|
|
1183
|
+
if (restarting) {
|
|
1184
|
+
if (now) restartNow();
|
|
1185
|
+
return now ? 'Restarting now.' : 'A restart is already on the way.';
|
|
1186
|
+
}
|
|
1187
|
+
const problem = serverProblem();
|
|
1188
|
+
if (problem) throw new Error(`The server code doesn't load, so a restart would leave the office down. Fix this first:\n${problem}`);
|
|
1189
|
+
// Kept in state, so the next server knows who to wake.
|
|
1190
|
+
state.restart = { by, jobId, at: Date.now() };
|
|
1191
|
+
// A turn that just ended has a moment's grace before its session closes;
|
|
1192
|
+
// letting that run out closes the job cleanly instead of resuming it.
|
|
1193
|
+
restarting = { timer: setInterval(() => (!midTurn().length && !graces.size && !chiefBusy) && restartNow(), 1000) };
|
|
1194
|
+
restarting.deadline = setTimeout(restartNow, now ? 300 : RESTART_WAIT_MS);
|
|
1195
|
+
save();
|
|
1196
|
+
const busy = midTurn().filter((j) => j.id !== jobId);
|
|
1197
|
+
const who = by === 'you' ? 'You asked' : `${nameOf(by)} asked`;
|
|
1198
|
+
post('chief', `**Restarting** · ${who} for a restart to pick up server changes. ${now ? 'Going now.'
|
|
1199
|
+
: busy.length ? `Waiting for ${names([...new Set(busy.map((j) => nameOf(j.agentId)))])} to finish their turn first.`
|
|
1200
|
+
: 'Back in a few seconds.'}`, { kind: 'restart', thread: null, channel: GENERAL });
|
|
1201
|
+
return now ? 'Restarting now.' : 'Restarting once everyone is between turns.';
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
function restartNow() {
|
|
1205
|
+
if (!restarting || restarting.done) return;
|
|
1206
|
+
restarting.done = true;
|
|
1207
|
+
clearInterval(restarting.timer);
|
|
1208
|
+
clearTimeout(restarting.deadline);
|
|
1209
|
+
send({ t: 'restarting' });
|
|
1210
|
+
shutdown(RESTART_EXIT);
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// On the far side of a restart: say so, and wake whoever asked for it.
|
|
1214
|
+
function afterRestart() {
|
|
1215
|
+
const r = state.restart;
|
|
1216
|
+
if (!r) return;
|
|
1217
|
+
delete state.restart;
|
|
1218
|
+
save();
|
|
1219
|
+
post('chief', `**Back** · The office restarted and is running the current server code.`, { kind: 'restart', thread: null, channel: GENERAL });
|
|
1220
|
+
const job = r.jobId && state.jobs.find((j) => j.id === r.jobId);
|
|
1221
|
+
// A job interrupted mid-turn already carries its own notice into the queue.
|
|
1222
|
+
if (!job || job.status === 'queued') return;
|
|
1223
|
+
try {
|
|
1224
|
+
deliver(job, 'The office has restarted, as you asked, and is now running the current server code. Carry on: check that your change works.');
|
|
1225
|
+
} catch {}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
// ---------- daily standup ----------
|
|
1229
|
+
|
|
1230
|
+
const GATHER_MS = 7000, SPEAK_MS = 5500;
|
|
1231
|
+
const DAY_MS = 24 * 3600 * 1000;
|
|
1232
|
+
let standupNow = null;
|
|
1233
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1234
|
+
const clip = (s, n) => {
|
|
1235
|
+
s = String(s || '').replace(/\s+/g, ' ').trim();
|
|
1236
|
+
return s.length > n ? s.slice(0, n - 1) + '…' : s;
|
|
1237
|
+
};
|
|
1238
|
+
const ENDED = { done: 'Shipped', failed: 'Hit a wall on', stopped: 'Stopped' };
|
|
1239
|
+
|
|
1240
|
+
// What each agent can honestly report since the last standup.
|
|
1241
|
+
function standupFacts(who, since) {
|
|
1242
|
+
return who.map((s) => {
|
|
1243
|
+
const mine = state.jobs.filter((j) => j.agentId === s.id);
|
|
1244
|
+
return {
|
|
1245
|
+
s,
|
|
1246
|
+
finished: mine.filter((j) => ENDED[j.status] && j.endedAt > since).sort((a, b) => a.endedAt - b.endedAt),
|
|
1247
|
+
now: mine.find((j) => j.status === 'working'),
|
|
1248
|
+
next: state.jobs.filter((j) => j.status === 'queued' && j.wantAgent === s.id),
|
|
1249
|
+
};
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
function standupContext(facts) {
|
|
1254
|
+
const lines = [`Last standup: ${state.lastStandup ? `${ago(state.lastStandup)} ago` : 'none yet (covering the last day)'}.`, ''];
|
|
1255
|
+
for (const { s, finished, now, next } of facts) {
|
|
1256
|
+
lines.push(`${s.name} (id ${s.id}):`);
|
|
1257
|
+
for (const j of finished) {
|
|
1258
|
+
const said = j.status === 'done' ? j.log.findLast((e) => e.kind === 'result')?.summary : j.error;
|
|
1259
|
+
lines.push(`- Finished (${j.status}) in ${j.repo}: "${j.title}". ${said ? `Outcome: ${clip(said, 400)}` : ''}`);
|
|
1260
|
+
if (j.denials?.length) lines.push(` Blocked from using ${j.denials.join(', ')} on that job.`);
|
|
1261
|
+
}
|
|
1262
|
+
if (now) {
|
|
1263
|
+
const last = now.log.findLast((e) => e.kind === 'tool' || e.kind === 'text');
|
|
1264
|
+
lines.push(`- Now: working on "${now.title}" in ${now.repo}${now.waiting ? ', waiting on a colleague' : ''}. Latest: ${last ? clip(last.summary, 160) : 'just starting'}`);
|
|
1265
|
+
}
|
|
1266
|
+
for (const j of next) lines.push(`- Queued for them: "${j.title}" in ${j.repo}`);
|
|
1267
|
+
if (!finished.length && !now && !next.length) lines.push('- Nothing since last standup; free.');
|
|
1268
|
+
}
|
|
1269
|
+
const open = state.jobs.filter((j) => j.status === 'queued' && !j.wantAgent);
|
|
1270
|
+
if (open.length) lines.push('', `Queued for whoever is free: ${open.map((j) => `"${j.title}" in ${j.repo}`).join('; ')}`);
|
|
1271
|
+
return lines.join('\n');
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// The same updates from the facts alone, for when the model can't help.
|
|
1275
|
+
function plainStandup(facts) {
|
|
1276
|
+
const updates = facts.map(({ s, finished, now, next }) => ({
|
|
1277
|
+
agentId: s.id,
|
|
1278
|
+
done: finished.length ? `${finished.map((j) => `${ENDED[j.status]} "${j.title}" in ${j.repo}`).join('; ')}.` : 'Nothing shipped since last standup.',
|
|
1279
|
+
today: now ? `Still on "${now.title}" in ${now.repo}.` : next.length ? `Picking up "${next[0].title}" in ${next[0].repo}.` : "I'm free, happy to take something.",
|
|
1280
|
+
blockers: [
|
|
1281
|
+
...finished.filter((j) => j.status === 'failed').map((j) => `"${j.title}" failed: ${clip(j.error, 120)}`),
|
|
1282
|
+
...(now?.waiting ? ['Waiting on a colleague.'] : []),
|
|
1283
|
+
].join(' '),
|
|
1284
|
+
}));
|
|
1285
|
+
const busy = facts.filter((f) => f.now).map((f) => f.s.name);
|
|
1286
|
+
const free = facts.filter((f) => !f.now && !f.next.length).map((f) => f.s.name);
|
|
1287
|
+
const wrap = ["That's standup.", busy.length ? `${names(busy)} ${busy.length === 1 ? 'is' : 'are'} heads down.` : '',
|
|
1288
|
+
free.length ? `${names(free)} ${free.length === 1 ? 'is' : 'are'} free for new work.` : ''].filter(Boolean).join(' ');
|
|
1289
|
+
return { updates, wrap };
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
async function runStandup(byBoss) {
|
|
1293
|
+
if (standupNow) { if (byBoss) post('chief', "We're mid-standup already."); return; }
|
|
1294
|
+
const who = onFloor();
|
|
1295
|
+
if (!who.length) { if (byBoss) post('chief', "Nobody's on the floor to stand up."); return; }
|
|
1296
|
+
const facts = standupFacts(who, state.lastStandup || Date.now() - DAY_MS);
|
|
1297
|
+
const context = standupContext(facts);
|
|
1298
|
+
const plain = plainStandup(facts);
|
|
1299
|
+
const quiet = facts.every((f) => !f.finished.length && !f.now && !f.next.length);
|
|
1300
|
+
|
|
1301
|
+
standupNow = { who: who.map((s) => s.id) };
|
|
1302
|
+
state.lastStandup = Date.now();
|
|
1303
|
+
send({ t: 'standup', phase: 'start', who: standupNow.who });
|
|
1304
|
+
post('chief', "Morning, everyone. Standup: what you got done, what's next, and anything in your way.", { kind: 'standup' });
|
|
1305
|
+
try {
|
|
1306
|
+
// A quiet office has nothing worth a model call. Otherwise it writes
|
|
1307
|
+
// while everyone walks over.
|
|
1308
|
+
const writing = quiet ? null : writeStandup(context, { bin: CLAUDE, model: CHIEF_MODEL, env: auth.env() }).catch(() => null);
|
|
1309
|
+
await sleep(GATHER_MS);
|
|
1310
|
+
const written = writing && (await writing);
|
|
1311
|
+
if (written) {
|
|
1312
|
+
state.chiefCost += written.cost || 0;
|
|
1313
|
+
send({ t: 'chief', busy: false, cost: state.chiefCost });
|
|
1314
|
+
nudgeUsage();
|
|
1315
|
+
}
|
|
1316
|
+
const said = [];
|
|
1317
|
+
for (const f of facts) {
|
|
1318
|
+
if (!onFloor().some((s) => s.id === f.s.id)) continue;
|
|
1319
|
+
const u = written?.updates?.find((x) => x.agentId === f.s.id) || plain.updates.find((x) => x.agentId === f.s.id);
|
|
1320
|
+
const blockers = clip(u.blockers, 300);
|
|
1321
|
+
post(f.s.id, `**Done:** ${clip(u.done, 500)}\n\n**Today:** ${clip(u.today, 300)}\n\n**Blockers:** ${blockers || 'none'}`,
|
|
1322
|
+
{ kind: 'standup', say: clip(u.today, 90) });
|
|
1323
|
+
said.push({ agentId: f.s.id, today: clip(u.today, 200), blockers });
|
|
1324
|
+
await sleep(SPEAK_MS);
|
|
1325
|
+
}
|
|
1326
|
+
const wrap = clip(written?.wrap, 600) || plain.wrap;
|
|
1327
|
+
post('chief', wrap, { kind: 'standup' });
|
|
1328
|
+
state.standupNotes = { ts: state.lastStandup, said, wrap };
|
|
1329
|
+
save();
|
|
1330
|
+
await sleep(4000);
|
|
1331
|
+
} finally {
|
|
1332
|
+
standupNow = null;
|
|
1333
|
+
send({ t: 'standup', phase: 'end' });
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
// Checked every minute. Missed standups still happen later that day, but
|
|
1338
|
+
// not after 6pm, and a day with nothing to talk about is skipped.
|
|
1339
|
+
function standupDue() {
|
|
1340
|
+
if (STANDUP_AT === 'off' || standupNow) return;
|
|
1341
|
+
const due = dueToday(STANDUP_AT, STANDUP_WEEKENDS);
|
|
1342
|
+
const now = Date.now();
|
|
1343
|
+
if (!due || now < due || (state.lastStandup || 0) >= due || new Date().getHours() >= 18) return;
|
|
1344
|
+
const since = state.lastStandup || now - DAY_MS;
|
|
1345
|
+
const active = state.jobs.some((j) => j.status === 'working' || j.status === 'queued' || (j.endedAt || 0) > since);
|
|
1346
|
+
if (!active) { state.lastStandup = now; save(); return; }
|
|
1347
|
+
runStandup(false);
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
// Agents starting work later in the day know what the team said.
|
|
1351
|
+
function standupBrief(agentId) {
|
|
1352
|
+
const n = state.standupNotes;
|
|
1353
|
+
if (!n || Date.now() - n.ts > 18 * 3600 * 1000) return '';
|
|
1354
|
+
const lines = n.said.filter((x) => x.agentId !== agentId).map((x) => `${nameOf(x.agentId)}: ${x.today}${x.blockers ? ` (blocked: ${x.blockers})` : ''}`);
|
|
1355
|
+
return ` From today's standup: ${[...lines, `Chief: ${n.wrap}`].join(' | ')}`;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
// ---------- agents talking to each other ----------
|
|
1359
|
+
|
|
1360
|
+
// Who they are, from the Team tab. It shapes their voice, never the work.
|
|
1361
|
+
function persona(agentId) {
|
|
1362
|
+
const s = STAFF.find((x) => x.id === agentId);
|
|
1363
|
+
const bits = [
|
|
1364
|
+
s?.role && `Your role: ${s.role}.`,
|
|
1365
|
+
s?.personality && `Your personality: ${s.personality}`,
|
|
1366
|
+
s?.background && `Your background: ${s.background}`,
|
|
1367
|
+
].filter(Boolean);
|
|
1368
|
+
if (!bits.length) return lore(agentId);
|
|
1369
|
+
return `${bits.join(' ')} Let this colour how you write to the boss and colleagues; it never lowers the bar on the work itself. ` + lore(agentId);
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
function updateProfile(id, body) {
|
|
1373
|
+
const s = STAFF.find((x) => x.id === id);
|
|
1374
|
+
if (!s) return null;
|
|
1375
|
+
const p = {};
|
|
1376
|
+
for (const [k, max] of Object.entries(PROFILE)) p[k] = String(body[k] ?? s[k] ?? '').trim().slice(0, max);
|
|
1377
|
+
Object.assign(s, p);
|
|
1378
|
+
state.profiles[id] = p;
|
|
1379
|
+
save();
|
|
1380
|
+
send({ t: 'profile', id, profile: p });
|
|
1381
|
+
return p;
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
function officePrompt(agentId, job) {
|
|
1385
|
+
const me = nameOf(agentId);
|
|
1386
|
+
const others = onFloor().filter((s) => s.id !== agentId).map((s) => (s.role ? `${s.name} (${s.role})` : s.name)).join(', ');
|
|
1387
|
+
return `You are ${me}, one of the agents in the Agent Office. Your colleagues: ${others || 'none yet'}. ` + persona(agentId) +
|
|
1388
|
+
`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. ` +
|
|
1389
|
+
`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);
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
const MAX_MESSAGES = 20, MAX_HANDOFFS = 5, MAX_DEPTH = 2, MAX_FLAGS = 5;
|
|
1394
|
+
const FLAGS = { question: 'Question for you', decision: 'Needs your decision', blocker: 'Blocked', review: 'Ready for your review' };
|
|
1395
|
+
|
|
1396
|
+
function caller(req) {
|
|
1397
|
+
const id = req.headers['x-office-job'];
|
|
1398
|
+
const job = state.jobs.find((j) => j.id === id);
|
|
1399
|
+
if (!job || job.status !== 'working' || !tokens.get(id) || tokens.get(id) !== req.headers['x-office-token']) return null;
|
|
1400
|
+
return job;
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
function colleague(name) {
|
|
1404
|
+
return hired().find((s) => norm(s.name) === norm(name));
|
|
1405
|
+
}
|
|
1406
|
+
const workingJobOf = (agentId) => state.jobs.find((j) => j.status === 'working' && j.agentId === agentId);
|
|
1407
|
+
const RESUME_WINDOW_MS = 6 * 3600 * 1000;
|
|
1408
|
+
// A free, in-office agent's last job, if it ended cleanly not long ago.
|
|
1409
|
+
function recentJobOf(agentId) {
|
|
1410
|
+
if (state.dismissed.includes(agentId) || state.jobs.some((j) => j.status === 'queued' && j.agentId === agentId)) return null;
|
|
1411
|
+
const last = state.jobs.filter((j) => j.agentId === agentId && j.endedAt).sort((a, b) => b.endedAt - a.endedAt)[0];
|
|
1412
|
+
return last?.status === 'done' && Date.now() - last.endedAt < RESUME_WINDOW_MS ? last : null;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
const agentApi = {
|
|
1416
|
+
office_roster(me) {
|
|
1417
|
+
const lines = [`You are ${nameOf(me.agentId)}.`, 'Colleagues:'];
|
|
1418
|
+
for (const s of onFloor()) {
|
|
1419
|
+
if (s.id === me.agentId) continue;
|
|
1420
|
+
const j = workingJobOf(s.id);
|
|
1421
|
+
const who = s.role ? `${s.name} (${s.role})` : s.name;
|
|
1422
|
+
lines.push(j ? `- ${who}: ${j.waiting ? 'waiting on a colleague' : 'working'} on "${j.title}" in ${j.repo}` : `- ${who}: free`);
|
|
1423
|
+
}
|
|
1424
|
+
lines.push('', `Projects: ${listRepos().join(', ')}`);
|
|
1425
|
+
return lines.join('\n');
|
|
1426
|
+
},
|
|
1427
|
+
|
|
1428
|
+
message_colleague(me, { to, message, wait_for_reply = false }) {
|
|
1429
|
+
const target = colleague(to || '');
|
|
1430
|
+
message = String(message || '').trim();
|
|
1431
|
+
if (!target) throw new Error(`Nobody called ${to} works here. Check office_roster.`);
|
|
1432
|
+
if (target.id === me.agentId) throw new Error("That's you.");
|
|
1433
|
+
if (!message) throw new Error('The message is empty.');
|
|
1434
|
+
if ((me.sent = (me.sent || 0) + 1) > MAX_MESSAGES) throw new Error("You've sent a lot of messages on this job. Finish the work with what you have.");
|
|
1435
|
+
// Someone free may have ended their turn expecting this, so it resumes their latest job.
|
|
1436
|
+
const theirs = workingJobOf(target.id) || recentJobOf(target.id);
|
|
1437
|
+
if (!theirs || (theirs.status === 'working' && !running.has(theirs.id))) {
|
|
1438
|
+
throw new Error(`${target.name} isn't on a job right now. Use hand_off to give them work.`);
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
const myName = nameOf(me.agentId);
|
|
1442
|
+
deliver(theirs, `Message from ${myName} (working on "${me.title}" in ${me.repo}): ${message}` +
|
|
1443
|
+
(wait_for_reply ? `\n\n${myName} is waiting for your answer. Reply with message_colleague.` : ''));
|
|
1444
|
+
// If they were waiting to hear from me, this is that answer.
|
|
1445
|
+
settle(theirs, (w) => w.kind === 'reply' && w.from === me.agentId);
|
|
1446
|
+
if (wait_for_reply) {
|
|
1447
|
+
const timer = setTimeout(() => {
|
|
1448
|
+
if (settle(me, (w) => w.kind === 'reply' && w.from === target.id) && me.status === 'working') {
|
|
1449
|
+
deliver(me, `No reply from ${target.name} after 10 minutes. Carry on without it.`);
|
|
1450
|
+
}
|
|
1451
|
+
}, REPLY_WAIT_MS);
|
|
1452
|
+
addWait(me, { kind: 'reply', from: target.id, timer });
|
|
1453
|
+
}
|
|
1454
|
+
post(me.agentId, message, { to: target.id, kind: 'dm', channel: me.channel });
|
|
1455
|
+
send({ t: 'visit', from: me.agentId, to: target.id });
|
|
1456
|
+
return wait_for_reply
|
|
1457
|
+
? `Delivered to ${target.name}. Wrap up this step and end your turn; you'll resume when they answer.`
|
|
1458
|
+
: `Delivered to ${target.name}.`;
|
|
1459
|
+
},
|
|
1460
|
+
|
|
1461
|
+
flag_for_boss(me, { kind, summary }) {
|
|
1462
|
+
summary = String(summary || '').replace(/\s+/g, ' ').trim().slice(0, 600);
|
|
1463
|
+
if (!summary) throw new Error('Say what the boss needs to know.');
|
|
1464
|
+
if (!FLAGS[kind]) throw new Error(`kind must be one of: ${Object.keys(FLAGS).join(', ')}.`);
|
|
1465
|
+
const flagged = (me.flagged ||= []);
|
|
1466
|
+
if (flagged.includes(summary)) return 'The chief already has that one.';
|
|
1467
|
+
if (flagged.length >= MAX_FLAGS) throw new Error("You've flagged a lot for the boss on this job. Put the rest in your reply.");
|
|
1468
|
+
flagged.push(summary);
|
|
1469
|
+
// A question, decision or blocker means the boss's answer is still owed,
|
|
1470
|
+
// so the job must not be reported as done when the agent's turn ends.
|
|
1471
|
+
if (kind !== 'review') me.asking = { kind, summary };
|
|
1472
|
+
// Top level in #general on purpose: that is where the boss looks. jobId
|
|
1473
|
+
// keeps the "open job" link, and the explicit null thread keeps post()
|
|
1474
|
+
// from tucking it into the job's thread.
|
|
1475
|
+
post('chief', `**${FLAGS[kind]}** · ${nameOf(me.agentId)} on "${clip(me.title, 80)}": ${summary}`,
|
|
1476
|
+
{ kind: 'flag', flag: kind, jobId: me.id, thread: null, channel: GENERAL });
|
|
1477
|
+
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
|
+
},
|
|
1479
|
+
|
|
1480
|
+
restart_office(me, { now = false } = {}) {
|
|
1481
|
+
requestRestart({ by: me.agentId, jobId: me.id, now: !!now });
|
|
1482
|
+
return 'The office will restart once everyone is between turns, and that includes you: end your turn now. ' +
|
|
1483
|
+
"You'll get a message when it is back, running the current server code.";
|
|
1484
|
+
},
|
|
1485
|
+
|
|
1486
|
+
list_channels(me) {
|
|
1487
|
+
const mine = channelOf(me.channel);
|
|
1488
|
+
const lines = ['Channels:'];
|
|
1489
|
+
for (const c of state.channels) {
|
|
1490
|
+
const who = OPEN_TO_ALL.has(c.id) ? 'everyone' : membersOf(c.id).length ? membersOf(c.id).map(nameOf).join(', ') : 'nobody yet';
|
|
1491
|
+
lines.push(`- #${c.id}${c.purpose ? ` — ${c.purpose}` : ''} (in it: ${who})${c.id === mine ? ' ← this job is being discussed here' : ''}`);
|
|
1492
|
+
}
|
|
1493
|
+
lines.push('', 'Say something in one with post_to_channel; everyone in it who is on a job hears it straight away.');
|
|
1494
|
+
return lines.join('\n');
|
|
1495
|
+
},
|
|
1496
|
+
|
|
1497
|
+
create_channel(me, { name, purpose = '', invite = [] }) {
|
|
1498
|
+
const channel = createChannel(name, { by: me.agentId, purpose });
|
|
1499
|
+
joinChannel(channel.id, me.agentId);
|
|
1500
|
+
const added = [], missing = [];
|
|
1501
|
+
for (const who of [].concat(invite || []).slice(0, MAX_MEMBERS)) {
|
|
1502
|
+
const target = colleague(who);
|
|
1503
|
+
if (!target) { missing.push(String(who)); continue; }
|
|
1504
|
+
if (target.id !== me.agentId && joinChannel(channel.id, target.id)) added.push(target.name);
|
|
1505
|
+
}
|
|
1506
|
+
post(me.agentId, `Opened #${channel.id}${channel.purpose ? ` — ${channel.purpose}` : ''}.${added.length ? ` Added ${names(added)}.` : ''}`,
|
|
1507
|
+
{ channel: channel.id, kind: 'channel' });
|
|
1508
|
+
return `#${channel.id} is open${added.length ? `, with ${names(added)} in it` : ''}. Say something in it with post_to_channel.` +
|
|
1509
|
+
(missing.length ? ` Nobody called ${names(missing)} works here, so they weren't added.` : '');
|
|
1510
|
+
},
|
|
1511
|
+
|
|
1512
|
+
invite_to_channel(me, { channel, who }) {
|
|
1513
|
+
const id = theChannel(channel);
|
|
1514
|
+
if (OPEN_TO_ALL.has(id)) throw new Error(`Everyone is in #${id} already.`);
|
|
1515
|
+
const added = [], there = [], missing = [];
|
|
1516
|
+
for (const name of [].concat(who || []).slice(0, MAX_MEMBERS)) {
|
|
1517
|
+
const target = colleague(name);
|
|
1518
|
+
if (!target) { missing.push(String(name)); continue; }
|
|
1519
|
+
if (joinChannel(id, target.id)) added.push(target.name); else there.push(target.name);
|
|
1520
|
+
}
|
|
1521
|
+
if (!added.length && !there.length) throw new Error(`Nobody called ${names(missing) || 'that'} works here. Check office_roster.`);
|
|
1522
|
+
joinChannel(id, me.agentId);
|
|
1523
|
+
if (added.length) post(me.agentId, `Added ${names(added)} to #${id}.`, { channel: id, kind: 'channel' });
|
|
1524
|
+
return [
|
|
1525
|
+
added.length ? `${names(added)} ${added.length === 1 ? 'is' : 'are'} in #${id} now.` : '',
|
|
1526
|
+
there.length ? `${names(there)} ${there.length === 1 ? 'was' : 'were'} already in it.` : '',
|
|
1527
|
+
missing.length ? `Nobody called ${names(missing)} works here.` : '',
|
|
1528
|
+
].filter(Boolean).join(' ');
|
|
1529
|
+
},
|
|
1530
|
+
|
|
1531
|
+
post_to_channel(me, { channel, message }) {
|
|
1532
|
+
const id = theChannel(channel);
|
|
1533
|
+
message = String(message || '').trim();
|
|
1534
|
+
if (!message) throw new Error('The message is empty.');
|
|
1535
|
+
if ((me.sent = (me.sent || 0) + 1) > MAX_MESSAGES) throw new Error("You've sent a lot of messages on this job. Finish the work with what you have.");
|
|
1536
|
+
joinChannel(id, me.agentId);
|
|
1537
|
+
post(me.agentId, message, { channel: id, kind: 'channel' });
|
|
1538
|
+
|
|
1539
|
+
// Whoever is in the channel and mid-job hears it now. Anyone free is left
|
|
1540
|
+
// alone on purpose: a word in a channel shouldn't pull half the office
|
|
1541
|
+
// back off the bench the way message_colleague deliberately does for one
|
|
1542
|
+
// person. It stays in the channel for the boss to see either way.
|
|
1543
|
+
const myName = nameOf(me.agentId);
|
|
1544
|
+
const reached = [], quiet = [];
|
|
1545
|
+
for (const agentId of membersOf(id)) {
|
|
1546
|
+
if (agentId === me.agentId) continue;
|
|
1547
|
+
const theirs = workingJobOf(agentId);
|
|
1548
|
+
if (!theirs || !running.has(theirs.id)) { quiet.push(nameOf(agentId)); continue; }
|
|
1549
|
+
deliver(theirs, `Message in #${id} from ${myName} (working on "${me.title}" in ${me.repo}): ${message}`);
|
|
1550
|
+
send({ t: 'visit', from: me.agentId, to: agentId });
|
|
1551
|
+
reached.push(nameOf(agentId));
|
|
1552
|
+
}
|
|
1553
|
+
return [
|
|
1554
|
+
`Posted in #${id}.`,
|
|
1555
|
+
reached.length ? `${names(reached)} ${reached.length === 1 ? 'has' : 'have'} it now.` : '',
|
|
1556
|
+
quiet.length ? `${names(quiet)} ${quiet.length === 1 ? 'is' : 'are'} not on a job, so it waits in the channel for them.` : '',
|
|
1557
|
+
!reached.length && !quiet.length ? 'Nobody else is in it yet — invite_to_channel adds people.' : '',
|
|
1558
|
+
].filter(Boolean).join(' ');
|
|
1559
|
+
},
|
|
1560
|
+
|
|
1561
|
+
hand_off(me, { project, brief, to, wait = true }) {
|
|
1562
|
+
const repo = listRepos().find((r) => norm(r) === norm(project || ''));
|
|
1563
|
+
brief = String(brief || '').trim();
|
|
1564
|
+
if (!repo) throw new Error(`No project called "${project}". Projects: ${listRepos().join(', ')}`);
|
|
1565
|
+
if (!brief) throw new Error('The brief is empty.');
|
|
1566
|
+
if ((me.depth || 0) >= MAX_DEPTH) throw new Error('This work has already been handed down twice. Do it yourself.');
|
|
1567
|
+
if ((me.handoffs = (me.handoffs || 0) + 1) > MAX_HANDOFFS) throw new Error("You've handed off a lot already. Finish the rest yourself.");
|
|
1568
|
+
|
|
1569
|
+
// Only free colleagues: nobody can end up waiting on someone who waits on them.
|
|
1570
|
+
let target;
|
|
1571
|
+
if (to) {
|
|
1572
|
+
target = colleague(to);
|
|
1573
|
+
if (!target) throw new Error(`Nobody called ${to} works here. Check office_roster.`);
|
|
1574
|
+
if (target.id === me.agentId) throw new Error("That's you.");
|
|
1575
|
+
if (state.dismissed.includes(target.id)) throw new Error(`${target.name} has gone home for now. Pick someone else or do it yourself.`);
|
|
1576
|
+
const busy = workingJobOf(target.id);
|
|
1577
|
+
if (busy) throw new Error(`${target.name} is busy with "${busy.title}". Pick someone free or do it yourself.`);
|
|
1578
|
+
} else {
|
|
1579
|
+
target = onFloor().find((s) => s.id !== me.agentId && !workingJobOf(s.id));
|
|
1580
|
+
if (!target) throw new Error('Nobody is free right now. Do it yourself, or check office_roster later.');
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
const myName = nameOf(me.agentId);
|
|
1584
|
+
const job = createJob({
|
|
1585
|
+
repo, mode: me.mode, model: me.model, auto: me.autoModel, agentId: target.id,
|
|
1586
|
+
prompt: `${brief}\n\n(Handed to you by ${myName}, who is working on "${me.title}" in ${me.repo}. Your final reply goes straight back to them, so make it the answer they need.)`,
|
|
1587
|
+
});
|
|
1588
|
+
job.requestedBy = me.id;
|
|
1589
|
+
job.channel = me.channel;
|
|
1590
|
+
job.depth = (me.depth || 0) + 1;
|
|
1591
|
+
job.title = brief.split('\n')[0].slice(0, 90);
|
|
1592
|
+
pushJob(job);
|
|
1593
|
+
if (wait) addWait(me, { kind: 'job', id: job.id });
|
|
1594
|
+
job.thread = post(me.agentId, brief, { to: target.id, kind: 'handoff', jobId: job.id, project: repo }).id;
|
|
1595
|
+
send({ t: 'visit', from: me.agentId, to: target.id });
|
|
1596
|
+
return wait
|
|
1597
|
+
? `${target.name} has it (job ${job.id}, ${repo}). Wrap up this step and end your turn; their result will come to you.`
|
|
1598
|
+
: `${target.name} has it (job ${job.id}, ${repo}). Their result will reach you if you're still working.`;
|
|
1599
|
+
},
|
|
1600
|
+
};
|
|
1601
|
+
|
|
1602
|
+
// ---------- attachments ----------
|
|
1603
|
+
|
|
1604
|
+
// Each upload gets its own folder, so the original file name survives.
|
|
1605
|
+
const IMAGE_TYPES = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp' };
|
|
1606
|
+
// Audio plays in the chat bubble, so these are served inline like images.
|
|
1607
|
+
const AUDIO_TYPES = { '.webm': 'audio/webm', '.m4a': 'audio/mp4', '.mp4': 'audio/mp4', '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg', '.oga': 'audio/ogg', '.opus': 'audio/ogg', '.aac': 'audio/aac', '.flac': 'audio/flac' };
|
|
1608
|
+
const ext = (name) => path.extname(name).toLowerCase();
|
|
1609
|
+
const uploadDir = (id) => (typeof id === 'string' && /^[a-f0-9]{12}$/.test(id) ? path.join(UPLOADS, id) : null);
|
|
1610
|
+
|
|
1611
|
+
function upload(req) {
|
|
1612
|
+
let name = 'file';
|
|
1613
|
+
try { name = decodeURIComponent(String(req.headers['x-file-name'] || 'file')); } catch {}
|
|
1614
|
+
name = path.basename(name).replace(/[\x00-\x1f\/\\:*?"<>|]/g, '_').replace(/^\.+/, '').slice(0, 120) || 'file';
|
|
1615
|
+
const size = Number(req.headers['content-length'] || 0);
|
|
1616
|
+
if (size > MAX_UPLOAD) throw new Error('That file is over 25 MB.');
|
|
1617
|
+
const id = crypto.randomBytes(6).toString('hex');
|
|
1618
|
+
const dir = uploadDir(id);
|
|
1619
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1620
|
+
const file = path.join(dir, name);
|
|
1621
|
+
return new Promise((resolve, reject) => {
|
|
1622
|
+
const out = fs.createWriteStream(file);
|
|
1623
|
+
let got = 0;
|
|
1624
|
+
const fail = (err) => { out.destroy(); fs.rmSync(dir, { recursive: true, force: true }); reject(err); };
|
|
1625
|
+
req.on('data', (c) => {
|
|
1626
|
+
got += c.length;
|
|
1627
|
+
if (got > MAX_UPLOAD) { req.destroy(); fail(new Error('That file is over 25 MB.')); }
|
|
1628
|
+
});
|
|
1629
|
+
req.on('error', fail);
|
|
1630
|
+
out.on('error', fail);
|
|
1631
|
+
out.on('finish', () => resolve(attachments([id])[0]));
|
|
1632
|
+
req.pipe(out);
|
|
1633
|
+
});
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
// Ids from the browser become files on disk; anything unknown is dropped.
|
|
1637
|
+
function attachments(ids) {
|
|
1638
|
+
if (!Array.isArray(ids)) return [];
|
|
1639
|
+
const out = [];
|
|
1640
|
+
for (const id of ids.slice(0, 20)) {
|
|
1641
|
+
const dir = uploadDir(id);
|
|
1642
|
+
let name;
|
|
1643
|
+
try { [name] = fs.readdirSync(dir); } catch {}
|
|
1644
|
+
if (!name) continue;
|
|
1645
|
+
const file = path.join(dir, name);
|
|
1646
|
+
out.push({ id, name, size: fs.statSync(file).size, image: !!IMAGE_TYPES[ext(name)], audio: !!AUDIO_TYPES[ext(name)], path: file });
|
|
1647
|
+
}
|
|
1648
|
+
return out;
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
function withFiles(text, files) {
|
|
1652
|
+
text = String(text || '').trim();
|
|
1653
|
+
// A voice note reaches the agent as its transcript; the audio itself is
|
|
1654
|
+
// only there for you to replay, and claude can't open it.
|
|
1655
|
+
files = files?.filter((f) => !f.audio);
|
|
1656
|
+
if (!files?.length) return text;
|
|
1657
|
+
const list = files.map((f) => `- ${f.path}`).join('\n');
|
|
1658
|
+
return `${text}\n\nAttached ${files.length === 1 ? 'file' : 'files'} (open with the Read tool, which shows images too):\n${list}`.trim();
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
// ---------- agents speaking ----------
|
|
1662
|
+
|
|
1663
|
+
// An agent's reply, recorded in their own voice and attached to the message.
|
|
1664
|
+
// Piper takes a few seconds a line, so this never blocks the message from
|
|
1665
|
+
// appearing: the text lands now and the voice note catches up.
|
|
1666
|
+
let voiceQueue = Promise.resolve();
|
|
1667
|
+
const voiceOn = () => (state.voiceReplies === null ? piperReady(DATA).ok : !!state.voiceReplies);
|
|
1668
|
+
|
|
1669
|
+
function voiceNote(msg) {
|
|
1670
|
+
// Only what is said to you: agent-to-agent asides already have their knock.
|
|
1671
|
+
// Break-room small talk stays unspoken: it would be constant, and it isn't for you.
|
|
1672
|
+
if (!voiceOn() || msg.from === 'you' || msg.to || msg.kind === 'chill' || !String(msg.text || '').trim()) return;
|
|
1673
|
+
if (!piperReady(DATA).ok) return;
|
|
1674
|
+
voiceQueue = voiceQueue.then(async () => {
|
|
1675
|
+
// It may have scrolled out of the chat while it waited its turn.
|
|
1676
|
+
const live = state.chat.find((m) => m.id === msg.id);
|
|
1677
|
+
if (!live) return;
|
|
1678
|
+
const id = crypto.randomBytes(6).toString('hex');
|
|
1679
|
+
const dir = path.join(UPLOADS, id);
|
|
1680
|
+
const who = msg.from === 'chief' ? 'Chief' : nameOf(msg.from);
|
|
1681
|
+
const name = `${who}-${msg.id}.m4a`.replace(/[^\w.-]/g, '');
|
|
1682
|
+
try {
|
|
1683
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1684
|
+
if (!(await speak(live.text, msg.from, { dataDir: DATA, out: path.join(dir, name) }))) throw new Error('nothing worth saying');
|
|
1685
|
+
const [f] = attachments([id]);
|
|
1686
|
+
if (!f) throw new Error('the recording went missing');
|
|
1687
|
+
const { path: _, ...meta } = f;
|
|
1688
|
+
live.files = [...(live.files || []), meta];
|
|
1689
|
+
send({ t: 'voice', id: live.id, file: meta });
|
|
1690
|
+
save();
|
|
1691
|
+
} catch {
|
|
1692
|
+
fs.rm(dir, { recursive: true, force: true }, () => {});
|
|
1693
|
+
}
|
|
1694
|
+
}).catch(() => {});
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
function serveUpload(res, id, name) {
|
|
1698
|
+
const [f] = attachments([id]);
|
|
1699
|
+
if (!f || f.name !== name) return json(res, 404, { error: 'No such file.' });
|
|
1700
|
+
const type = IMAGE_TYPES[ext(f.name)] || AUDIO_TYPES[ext(f.name)];
|
|
1701
|
+
// Only plain images and audio show inline; anything else downloads, so an
|
|
1702
|
+
// uploaded page can't run on this origin.
|
|
1703
|
+
res.writeHead(200, {
|
|
1704
|
+
'content-type': type || 'application/octet-stream',
|
|
1705
|
+
'x-content-type-options': 'nosniff',
|
|
1706
|
+
...(type ? {} : { 'content-disposition': `attachment; filename*=UTF-8''${encodeURIComponent(f.name)}` }),
|
|
1707
|
+
});
|
|
1708
|
+
fs.createReadStream(f.path).pipe(res);
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
// ---------- http ----------
|
|
1712
|
+
|
|
1713
|
+
// The in-app terminal. Off unless OFFICE_TERMINAL=1; see server/terminal.js
|
|
1714
|
+
// for why its door is built the way it is.
|
|
1715
|
+
const terminals = new Terminals({ root: ROOT, dataDir: DATA, folders: listRepos });
|
|
1716
|
+
|
|
1717
|
+
// OFFICE_ALLOWED_HOSTS adds exact hostnames (comma-separated), e.g. this
|
|
1718
|
+
// machine's Tailscale name when `tailscale serve` fronts the office. Everything
|
|
1719
|
+
// else is still refused, which is what keeps rebinding sites out.
|
|
1720
|
+
const LOCAL = new Set(['localhost', '127.0.0.1', '[::1]', ...String(process.env.OFFICE_ALLOWED_HOSTS || '').split(',').map((h) => h.trim().toLowerCase()).filter(Boolean)]);
|
|
1721
|
+
function trusted(req) {
|
|
1722
|
+
// Anything that can spawn claude with full permissions must refuse
|
|
1723
|
+
// requests from other websites and from rebinding hostnames.
|
|
1724
|
+
const host = String(req.headers.host || '').replace(/:\d+$/, '');
|
|
1725
|
+
if (!LOCAL.has(host)) return false;
|
|
1726
|
+
const origin = req.headers.origin;
|
|
1727
|
+
if (!origin) return true;
|
|
1728
|
+
try { return LOCAL.has(new URL(origin).hostname); } catch { return false; }
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
function readBody(req) {
|
|
1732
|
+
return new Promise((resolve, reject) => {
|
|
1733
|
+
let size = 0;
|
|
1734
|
+
const parts = [];
|
|
1735
|
+
req.on('data', (c) => {
|
|
1736
|
+
size += c.length;
|
|
1737
|
+
if (size > 200_000) { reject(new Error('Too large.')); req.destroy(); }
|
|
1738
|
+
else parts.push(c);
|
|
1739
|
+
});
|
|
1740
|
+
req.on('end', () => {
|
|
1741
|
+
try { resolve(parts.length ? JSON.parse(Buffer.concat(parts).toString('utf8')) : {}); }
|
|
1742
|
+
catch { reject(new Error('Bad JSON.')); }
|
|
1743
|
+
});
|
|
1744
|
+
});
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
const json = (res, code, body) => {
|
|
1748
|
+
res.writeHead(code, { 'content-type': 'application/json' });
|
|
1749
|
+
res.end(JSON.stringify(body));
|
|
1750
|
+
};
|
|
1751
|
+
|
|
1752
|
+
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' };
|
|
1753
|
+
function serveStatic(req, res) {
|
|
1754
|
+
const url = decodeURIComponent(new URL(req.url, 'http://x').pathname);
|
|
1755
|
+
let file = path.join(DIST, path.normalize(url).replace(/^(\.\.[\/\\])+/, ''));
|
|
1756
|
+
if (!file.startsWith(DIST)) return json(res, 403, { error: 'No.' });
|
|
1757
|
+
if (!fs.existsSync(file) || fs.statSync(file).isDirectory()) file = path.join(DIST, 'index.html');
|
|
1758
|
+
if (!fs.existsSync(file)) {
|
|
1759
|
+
res.writeHead(404, { 'content-type': 'text/plain' });
|
|
1760
|
+
return res.end('No build yet. Use `npm run dev`, or `npm start` to build and serve.');
|
|
1761
|
+
}
|
|
1762
|
+
// The page names hashed assets, so it must be re-checked every load or a
|
|
1763
|
+
// phone keeps showing last week's build; the hashed files never change.
|
|
1764
|
+
const cache = file.includes(`${path.sep}assets${path.sep}`) ? 'public, max-age=31536000, immutable' : 'no-cache';
|
|
1765
|
+
res.writeHead(200, { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream', 'cache-control': cache });
|
|
1766
|
+
fs.createReadStream(file).pipe(res);
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
const server = http.createServer(async (req, res) => {
|
|
1770
|
+
if (!trusted(req)) return json(res, 403, { error: 'Local only.' });
|
|
1771
|
+
const url = new URL(req.url, 'http://x');
|
|
1772
|
+
const parts = url.pathname.split('/').filter(Boolean);
|
|
1773
|
+
if (parts[0] !== 'api') return serveStatic(req, res);
|
|
1774
|
+
|
|
1775
|
+
try {
|
|
1776
|
+
if (req.method === 'GET' && url.pathname === '/api/repos') return json(res, 200, { root: ROOT, repos: listRepos() });
|
|
1777
|
+
// The terminal. Both of these ran the host and origin checks above, which
|
|
1778
|
+
// is the whole reason the socket can trust a ticket that came from here.
|
|
1779
|
+
if (req.method === 'GET' && url.pathname === '/api/terminal') return json(res, 200, await terminals.status());
|
|
1780
|
+
if (req.method === 'POST' && url.pathname === '/api/terminal/ticket') {
|
|
1781
|
+
try { return json(res, 201, await terminals.ticket(await readBody(req))); }
|
|
1782
|
+
catch (err) { return json(res, err.code || 400, { error: err.message }); }
|
|
1783
|
+
}
|
|
1784
|
+
if (req.method === 'POST' && parts[1] === 'agent' && agentApi[parts[2]]) {
|
|
1785
|
+
const me = caller(req);
|
|
1786
|
+
if (!me) return json(res, 403, { error: 'Not on the clock.' });
|
|
1787
|
+
return json(res, 200, { text: agentApi[parts[2]](me, await readBody(req)) });
|
|
1788
|
+
}
|
|
1789
|
+
if (req.method === 'POST' && url.pathname === '/api/uploads') {
|
|
1790
|
+
const { path: _, ...f } = await upload(req);
|
|
1791
|
+
return json(res, 201, f);
|
|
1792
|
+
}
|
|
1793
|
+
// Which way claude signs in. Only new processes pick it up; a job
|
|
1794
|
+
// already running finishes on whatever it started with.
|
|
1795
|
+
if (url.pathname === '/api/auth' && ['GET', 'POST', 'DELETE'].includes(req.method)) {
|
|
1796
|
+
let status;
|
|
1797
|
+
try { status = req.method === 'GET' ? auth.status() : req.method === 'DELETE' ? auth.forget() : await auth.set(await readBody(req)); }
|
|
1798
|
+
catch (err) { return json(res, err.code || 400, { error: err.message }); }
|
|
1799
|
+
if (req.method !== 'GET') { send({ t: 'auth', auth: status }); refreshUsage(); }
|
|
1800
|
+
return json(res, 200, status);
|
|
1801
|
+
}
|
|
1802
|
+
// The leaderboard. Joining claims a handle with the service and starts
|
|
1803
|
+
// sharing day counters; leaving asks it to forget this office.
|
|
1804
|
+
if (url.pathname === '/api/leaderboard' && ['GET', 'POST', 'DELETE'].includes(req.method)) {
|
|
1805
|
+
let status;
|
|
1806
|
+
try { status = req.method === 'GET' ? board.status() : req.method === 'DELETE' ? await board.leave() : await board.join((await readBody(req)).handle); }
|
|
1807
|
+
catch (err) { return json(res, err.code >= 400 && err.code < 600 ? err.code : 400, { error: err.message }); }
|
|
1808
|
+
if (req.method !== 'GET') send({ t: 'leaderboard', leaderboard: status });
|
|
1809
|
+
return json(res, 200, status);
|
|
1810
|
+
}
|
|
1811
|
+
if (req.method === 'GET' && url.pathname === '/api/leaderboard/board') {
|
|
1812
|
+
try { return json(res, 200, await board.board(url.searchParams.get('period'))); }
|
|
1813
|
+
catch (err) { return json(res, err.code >= 400 && err.code < 600 ? err.code : 502, { error: err.message }); }
|
|
1814
|
+
}
|
|
1815
|
+
if (req.method === 'POST' && url.pathname === '/api/voice/replies') {
|
|
1816
|
+
const { on } = await readBody(req);
|
|
1817
|
+
state.voiceReplies = !!on;
|
|
1818
|
+
save();
|
|
1819
|
+
send({ t: 'tts', ...piperReady(DATA), replies: voiceOn() });
|
|
1820
|
+
return json(res, 200, { ok: true, replies: voiceOn() });
|
|
1821
|
+
}
|
|
1822
|
+
if (req.method === 'POST' && url.pathname === '/api/voice') {
|
|
1823
|
+
const f = await upload(req);
|
|
1824
|
+
let text = '';
|
|
1825
|
+
// The name hints only improve the spelling, so a project folder that
|
|
1826
|
+
// can't be listed must not cost you the transcript.
|
|
1827
|
+
let hint = '';
|
|
1828
|
+
try { hint = vocabulary(onFloor().map((s) => s.name), listRepos()); } catch {}
|
|
1829
|
+
try {
|
|
1830
|
+
text = await transcribe(f.path, { dataDir: DATA, prompt: hint });
|
|
1831
|
+
} catch (err) {
|
|
1832
|
+
fs.rm(path.dirname(f.path), { recursive: true, force: true }, () => {});
|
|
1833
|
+
return json(res, 503, { error: err.message });
|
|
1834
|
+
}
|
|
1835
|
+
const { path: _, ...meta } = f;
|
|
1836
|
+
return json(res, 200, { ...meta, text });
|
|
1837
|
+
}
|
|
1838
|
+
if (req.method === 'GET' && parts[1] === 'uploads' && parts.length === 4) return serveUpload(res, parts[2], decodeURIComponent(parts[3]));
|
|
1839
|
+
if (req.method === 'POST' && parts[1] === 'staff' && parts[2] && ['dismiss', 'recall'].includes(parts[3]) && !parts[4]) {
|
|
1840
|
+
const text = parts[3] === 'dismiss' ? dismiss([parts[2]]) : recall([parts[2]]);
|
|
1841
|
+
if (parts[3] === 'dismiss' && !state.dismissed.includes(parts[2])) return json(res, 400, { error: text });
|
|
1842
|
+
return json(res, 200, { text });
|
|
1843
|
+
}
|
|
1844
|
+
if (req.method === 'POST' && parts[1] === 'staff' && parts[2] && parts[3] === 'profile' && !parts[4]) {
|
|
1845
|
+
const p = updateProfile(parts[2], await readBody(req));
|
|
1846
|
+
return p ? json(res, 200, p) : json(res, 404, { error: 'No such agent.' });
|
|
1847
|
+
}
|
|
1848
|
+
if (req.method === 'POST' && url.pathname === '/api/standup') { runStandup(true); return json(res, 202, { ok: true }); }
|
|
1849
|
+
if (req.method === 'POST' && url.pathname === '/api/restart') {
|
|
1850
|
+
const { now } = await readBody(req);
|
|
1851
|
+
return json(res, 202, { text: requestRestart({ now: !!now }) });
|
|
1852
|
+
}
|
|
1853
|
+
if (req.method === 'POST' && parts[1] === 'chat' && parts[2] && parts[3] === 'react' && !parts[4]) {
|
|
1854
|
+
react(parts[2], (await readBody(req)).emoji);
|
|
1855
|
+
return json(res, 200, { ok: true });
|
|
1856
|
+
}
|
|
1857
|
+
if (req.method === 'POST' && url.pathname === '/api/chat') { chat(await readBody(req)); return json(res, 202, { ok: true }); }
|
|
1858
|
+
if (req.method === 'GET' && url.pathname === '/api/channels') return json(res, 200, { channels: state.channels });
|
|
1859
|
+
if (req.method === 'POST' && url.pathname === '/api/channels') {
|
|
1860
|
+
const { name, purpose } = await readBody(req);
|
|
1861
|
+
return json(res, 201, createChannel(name, { by: 'you', purpose }));
|
|
1862
|
+
}
|
|
1863
|
+
if (req.method === 'DELETE' && parts[1] === 'channels' && parts[2] && !parts[3]) { deleteChannel(parts[2]); return json(res, 200, { ok: true }); }
|
|
1864
|
+
// The break room: claim a conversation, post each line as it is spoken,
|
|
1865
|
+
// hand the claim back at the end. A refused claim answers with no id,
|
|
1866
|
+
// which the browser takes as "another window has this one".
|
|
1867
|
+
if (req.method === 'POST' && url.pathname === '/api/chill') {
|
|
1868
|
+
const { who = [], game = false } = await readBody(req);
|
|
1869
|
+
const pair = [...new Set(who)].filter((id) => STAFF.some((s) => s.id === id)).slice(0, 2);
|
|
1870
|
+
if (pair.length < 2) return json(res, 400, { error: 'Two of the staff, please.' });
|
|
1871
|
+
return json(res, 200, await claimBanter(pair, !!game));
|
|
1872
|
+
}
|
|
1873
|
+
if (req.method === 'POST' && parts[1] === 'chill' && parts[2] && parts[3] === 'line') {
|
|
1874
|
+
const { from, text } = await readBody(req);
|
|
1875
|
+
return json(res, 201, chillLine(parts[2], from, text));
|
|
1876
|
+
}
|
|
1877
|
+
if (req.method === 'POST' && parts[1] === 'chill' && parts[2] && parts[3] === 'ping') {
|
|
1878
|
+
if (chill?.id === parts[2]) chill.at = Date.now();
|
|
1879
|
+
return json(res, 200, { ok: !!chill && chill.id === parts[2] });
|
|
1880
|
+
}
|
|
1881
|
+
if (req.method === 'DELETE' && parts[1] === 'chill' && parts[2] && !parts[3]) { endChill(parts[2]); return json(res, 200, { ok: true }); }
|
|
1882
|
+
if (req.method === 'POST' && url.pathname === '/api/jobs') return json(res, 201, publicJob(createJob(await readBody(req)), 0));
|
|
1883
|
+
if (req.method === 'POST' && url.pathname === '/api/jobs/clear') {
|
|
1884
|
+
state.jobs = state.jobs.filter((j) => j.status === 'working' || j.status === 'queued');
|
|
1885
|
+
save();
|
|
1886
|
+
send(snapshot());
|
|
1887
|
+
return json(res, 200, { ok: true });
|
|
1888
|
+
}
|
|
1889
|
+
if (parts[1] === 'jobs' && parts[2]) {
|
|
1890
|
+
const job = state.jobs.find((j) => j.id === parts[2]);
|
|
1891
|
+
if (!job) return json(res, 404, { error: 'No such job.' });
|
|
1892
|
+
if (req.method === 'GET' && !parts[3]) return json(res, 200, publicJob(job, MAX_LOG));
|
|
1893
|
+
if (req.method === 'GET' && parts[3] === 'diff') {
|
|
1894
|
+
const cwd = repoPath(job.repo);
|
|
1895
|
+
if (!cwd) return json(res, 404, { error: 'That project folder is gone.' });
|
|
1896
|
+
return json(res, 200, { diff: await fileDiff(cwd, job.changes, url.searchParams.get('path') || '') });
|
|
1897
|
+
}
|
|
1898
|
+
if (req.method === 'POST' && parts[3] === 'stop') { stopJob(job); return json(res, 200, { ok: true }); }
|
|
1899
|
+
if (req.method === 'POST' && parts[3] === 'reply') { replyJob(job, await readBody(req)); return json(res, 200, { ok: true }); }
|
|
1900
|
+
}
|
|
1901
|
+
json(res, 404, { error: 'Not found.' });
|
|
1902
|
+
} catch (err) {
|
|
1903
|
+
json(res, 400, { error: err.message });
|
|
1904
|
+
}
|
|
1905
|
+
});
|
|
1906
|
+
|
|
1907
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
1908
|
+
// A keystroke at a time, so nothing here needs a big frame; a terminal that
|
|
1909
|
+
// could be fed a megabyte per message is just a memory hole.
|
|
1910
|
+
const termWss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024 });
|
|
1911
|
+
server.on('upgrade', (req, socket, head) => {
|
|
1912
|
+
const url = new URL(req.url, 'http://x');
|
|
1913
|
+
if (!trusted(req)) return socket.destroy();
|
|
1914
|
+
if (url.pathname === '/ws') {
|
|
1915
|
+
return wss.handleUpgrade(req, socket, head, (ws) => {
|
|
1916
|
+
clients.add(ws);
|
|
1917
|
+
ws.on('close', () => clients.delete(ws));
|
|
1918
|
+
ws.send(JSON.stringify(snapshot()));
|
|
1919
|
+
});
|
|
1920
|
+
}
|
|
1921
|
+
// No terminal, no socket: with the door shut there is nothing to hold open
|
|
1922
|
+
// while someone guesses at tickets.
|
|
1923
|
+
if (url.pathname === '/ws/terminal' && terminals.on) {
|
|
1924
|
+
return termWss.handleUpgrade(req, socket, head, (ws) => terminals.open(ws, url.searchParams.get('ticket')));
|
|
1925
|
+
}
|
|
1926
|
+
socket.destroy();
|
|
1927
|
+
});
|
|
1928
|
+
|
|
1929
|
+
server.listen(PORT, '127.0.0.1', () => {
|
|
1930
|
+
console.log(`Agent Office open on http://localhost:${PORT} (projects in ${ROOT})`);
|
|
1931
|
+
// Shell access being open is not something to find out by accident.
|
|
1932
|
+
terminals.status().then((t) => console.log(t.enabled
|
|
1933
|
+
? `Terminal ON — anything in this browser gets a shell as ${process.env.USER || 'you'}. Up to ${t.max}, ${t.idleMinutes} min idle.`
|
|
1934
|
+
: `Terminal off. ${t.reason}`));
|
|
1935
|
+
afterRestart();
|
|
1936
|
+
pump();
|
|
1937
|
+
refreshUsage();
|
|
1938
|
+
setInterval(standupDue, 60 * 1000);
|
|
1939
|
+
setTimeout(standupDue, 15 * 1000);
|
|
1940
|
+
});
|
|
1941
|
+
|
|
1942
|
+
function shutdown(code = 0) {
|
|
1943
|
+
for (const child of running.values()) child.kill('SIGTERM');
|
|
1944
|
+
terminals.killAll();
|
|
1945
|
+
clearTimeout(saveTimer);
|
|
1946
|
+
board.flush();
|
|
1947
|
+
fs.mkdirSync(DATA, { recursive: true });
|
|
1948
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify(state));
|
|
1949
|
+
process.exit(code);
|
|
1950
|
+
}
|
|
1951
|
+
process.on('SIGINT', () => shutdown());
|
|
1952
|
+
process.on('SIGTERM', () => shutdown());
|