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
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Using the office
|
|
2
|
+
|
|
3
|
+
## Talking to the office
|
|
4
|
+
|
|
5
|
+
There's no form: you chat. Type `@` to tag an agent and `#` to name a project.
|
|
6
|
+
|
|
7
|
+
- `@Ada #storefront check the open PRs` goes straight to Ada.
|
|
8
|
+
- Untagged messages go to the **chief of staff** at reception (Sonnet by
|
|
9
|
+
default), who decides who should handle them:
|
|
10
|
+
- a message that relates to a running job is handed to that agent *live*,
|
|
11
|
+
and they fold it into the work they're already doing;
|
|
12
|
+
- a follow-up to a finished job resumes that agent's session;
|
|
13
|
+
- new work goes to whoever's free, or queues for the agent you tagged;
|
|
14
|
+
- if it can't tell which project you mean, it asks.
|
|
15
|
+
- Agents post their results back into the chat.
|
|
16
|
+
|
|
17
|
+
Attach files with the paperclip, by pasting, or by dropping them on the
|
|
18
|
+
composer. A screenshot is shown to the chief itself, so "this again" over a
|
|
19
|
+
picture of an error is enough to route on: it reads the picture, picks the
|
|
20
|
+
project and the agent from what's in it, and writes the brief in words. Other
|
|
21
|
+
file types (and the audio of a voice note) it can't open, so those go by name
|
|
22
|
+
and the agent reads them with the Read tool — the path travels with the job.
|
|
23
|
+
|
|
24
|
+
The clearance and model pickers under the composer apply to new work. The
|
|
25
|
+
model picker starts on **Chief picks**, which hands the choice to the chief
|
|
26
|
+
(see [Jobs](jobs.md#sizing-the-work)); choosing a model yourself overrides it for
|
|
27
|
+
that job.
|
|
28
|
+
|
|
29
|
+
### Channels
|
|
30
|
+
|
|
31
|
+
The chat is split into channels, the way Slack is. `#general` is always
|
|
32
|
+
there; the `+` in the channel bar opens another one for a strand of work,
|
|
33
|
+
and a channel with nothing left in it can be closed again.
|
|
34
|
+
|
|
35
|
+
Every message belongs to exactly one channel. What you ask in `#design`
|
|
36
|
+
stays in `#design`: the chief answers there, the agent who picks the job up
|
|
37
|
+
reports back there, anything they hand to a colleague stays there too, and
|
|
38
|
+
the chief only reads that channel's history when deciding what you meant.
|
|
39
|
+
Threads live in their channel. A channel you aren't reading shows a count of
|
|
40
|
+
what you've missed.
|
|
41
|
+
|
|
42
|
+
### Threads
|
|
43
|
+
|
|
44
|
+
A message stays where you sent it. Type in the main chat and it stays in the
|
|
45
|
+
main chat; reply in a thread and the whole exchange stays in that thread.
|
|
46
|
+
Nothing is ever moved after you have seen it.
|
|
47
|
+
|
|
48
|
+
What threads collect is an agent's updates on a job, which hang under the
|
|
49
|
+
message that started the job as a summary line you can open. So when you
|
|
50
|
+
follow up on running work from the main chat, your message and the chief's
|
|
51
|
+
answer stay in front of you, and the answer carries a link to the thread the
|
|
52
|
+
updates are collecting in.
|
|
53
|
+
|
|
54
|
+
## Getting around
|
|
55
|
+
|
|
56
|
+
Drag to move, right-drag (or `Q` / `E`) to turn around what's in the middle
|
|
57
|
+
of the view, scroll to zoom toward the cursor, double-click to glide somewhere.
|
|
58
|
+
`W A S D` pan, `R` recenters. The walls nearest you drop down so you can always
|
|
59
|
+
see in. Chat and job details sit in their own columns and never cover the
|
|
60
|
+
office, and `⌃`` opens the terminal drawer beneath it.
|
|
61
|
+
|
|
62
|
+
## Daily standup
|
|
63
|
+
|
|
64
|
+
Every weekday at 9:30 the team gathers in a circle by the whiteboard. One at
|
|
65
|
+
a time, everyone on the floor says what they got done since the last standup,
|
|
66
|
+
what they're on today and what's blocking them, and the chief wraps up: the
|
|
67
|
+
focus, the risks and who's free. Agents who've gone home don't attend. The
|
|
68
|
+
updates come from the job history, written up by one call to the chief's
|
|
69
|
+
model; a quiet office skips that call, and if it fails the updates are built
|
|
70
|
+
from the facts alone.
|
|
71
|
+
|
|
72
|
+
Call one any time with the people button in the top right, `/standup` in the
|
|
73
|
+
chat, or just by asking ("standup time"). If the office opens after 9:30, the
|
|
74
|
+
day's standup happens then, but not after 6pm, and a day with nothing to
|
|
75
|
+
report is skipped. Jobs keep running during standup, and agents who start work
|
|
76
|
+
later in the day are told what the team said.
|
|
77
|
+
|
|
78
|
+
## The break room
|
|
79
|
+
|
|
80
|
+
Agents with nothing on drift to the break room, and every so often two of
|
|
81
|
+
them turn to each other and talk. One short call to a small model (Haiku by
|
|
82
|
+
default) writes the exchange from who they are, how they feel about each
|
|
83
|
+
other and what they have been working on, so it costs a fraction of a chat
|
|
84
|
+
message. At most one is written every two minutes; between those, and if the
|
|
85
|
+
call fails, they fall back to canned small talk. `BANTER=off` keeps it canned.
|
|
86
|
+
|
|
87
|
+
What they say lands in **#chill-room**, a channel that is always there, that
|
|
88
|
+
everyone is in, and that can't be closed. It keeps its own 60 most recent
|
|
89
|
+
lines, so small talk never pushes the day's work out of the chat history, and
|
|
90
|
+
it is never read aloud.
|
|
91
|
+
|
|
92
|
+
Two idle agents will sometimes walk over to the pool table instead and play a
|
|
93
|
+
real game of eight-ball, a shot at a time. The start and the result go to
|
|
94
|
+
#chill-room. Anyone handed work, called to standup or sent home drops what
|
|
95
|
+
they're doing, and the chat or the game ends there.
|
|
96
|
+
|
|
97
|
+
With the office open in two windows, each conversation and each game is
|
|
98
|
+
claimed from the server first, so it happens once rather than once per window.
|
|
99
|
+
|
|
100
|
+
## Levels
|
|
101
|
+
|
|
102
|
+
Shipped jobs earn XP. Each level hires one more agent, so more jobs run at
|
|
103
|
+
once (3 at level 1, up to 8).
|
|
104
|
+
|
|
105
|
+
## Appearance
|
|
106
|
+
|
|
107
|
+
The palette button at the top of the sidebar picks a theme (Paper, Ocean,
|
|
108
|
+
Forest, Sunset, Lavender) and light, dark or automatic mode. The choice is
|
|
109
|
+
kept in this browser.
|
package/docs/voice.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Talking out loud
|
|
2
|
+
|
|
3
|
+
You can say it instead of typing it, and hear the answer back. None of it
|
|
4
|
+
leaves the machine: `whisper.cpp` does the listening and the system voices do
|
|
5
|
+
the talking.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
brew install ffmpeg whisper-cpp
|
|
9
|
+
npm run voice:model # ~148 MB, ears
|
|
10
|
+
npm run voice:piper # ~700 MB, voices
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The microphone sits next to the paperclip, and `⌥M` works from anywhere.
|
|
14
|
+
Click once to start, again to stop. What you said lands **in the composer**
|
|
15
|
+
rather than going straight out, so you can fix a misheard name before anyone
|
|
16
|
+
acts on it, and the recording rides along attached so you can hear what you
|
|
17
|
+
actually said. Escape throws a recording away.
|
|
18
|
+
|
|
19
|
+
The office knows its own vocabulary, so spoken names come back spelled the way
|
|
20
|
+
the chief can route on them: "Ada, check the open PRs in Storefront" arrives as
|
|
21
|
+
*storefront*, not *store front*. Say `@` and `#` tags as plain names — the chief reads
|
|
22
|
+
them either way.
|
|
23
|
+
|
|
24
|
+
The speaker button in the top right turns on replies in kind. The chief and
|
|
25
|
+
the agents reporting back to you each record a **voice note** in their own
|
|
26
|
+
voice, attached to the message: it plays as it lands and stays there to
|
|
27
|
+
replay. Everyone sounds different, so you know who's talking without looking,
|
|
28
|
+
and standup becomes something you can listen to while doing something else.
|
|
29
|
+
|
|
30
|
+
The voices are [Piper](https://github.com/OHF-Voice/piper1-gpl), a neural
|
|
31
|
+
text-to-speech model that runs here — Apple's built-in voices are a decade
|
|
32
|
+
older and sound it. A line takes a few seconds to record, which is why agents
|
|
33
|
+
send notes rather than narrating live: the text lands immediately and the
|
|
34
|
+
voice catches up. Without Piper installed the office falls back to the system
|
|
35
|
+
voices, spoken live by the browser.
|
|
36
|
+
|
|
37
|
+
Agent-to-agent asides and break-room talk stay unspoken; they'd be constant,
|
|
38
|
+
and they aren't for you. Markdown, code blocks and file paths are stripped
|
|
39
|
+
before anything is said, because nobody wants to hear "backtick slash server
|
|
40
|
+
slash index dot j s backtick" — the bubble keeps the detail. The small speaker
|
|
41
|
+
on any message replays just that one.
|
|
42
|
+
|
|
43
|
+
Agents never hear audio themselves: `claude -p` takes text, so a voice note
|
|
44
|
+
reaches them as its transcript.
|
package/package.json
CHANGED
|
@@ -1,6 +1,66 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-office-studio",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "A 3D office where the staff are Claude Code agents.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"files": [
|
|
7
|
+
"bin",
|
|
8
|
+
"dist",
|
|
9
|
+
"server",
|
|
10
|
+
"shared",
|
|
11
|
+
"docs",
|
|
12
|
+
"CHANGELOG.md",
|
|
13
|
+
"LICENSE",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/onuohasilver/agent-office.git"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/onuohasilver/agent-office#readme",
|
|
24
|
+
"bugs": "https://github.com/onuohasilver/agent-office/issues",
|
|
25
|
+
"keywords": [
|
|
26
|
+
"claude",
|
|
27
|
+
"claude-code",
|
|
28
|
+
"agents",
|
|
29
|
+
"ai",
|
|
30
|
+
"three.js",
|
|
31
|
+
"developer-tools"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=20"
|
|
35
|
+
},
|
|
36
|
+
"type": "module",
|
|
37
|
+
"bin": {
|
|
38
|
+
"agentoffice": "bin/agentoffice.js",
|
|
39
|
+
"agentstudio": "bin/agentoffice.js"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"dev": "concurrently -k -n server,web -c blue,magenta \"node --watch server/index.js\" \"vite\"",
|
|
43
|
+
"build": "vite build",
|
|
44
|
+
"start": "vite build && node server/supervise.js",
|
|
45
|
+
"voice:model": "mkdir -p data/whisper && curl -L --progress-bar -o data/whisper/ggml-base.en.bin https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin",
|
|
46
|
+
"voice:piper": "python3 -m venv data/piper/venv && data/piper/venv/bin/pip install -q piper-tts && data/piper/venv/bin/python -m piper.download_voices --data-dir data/piper/voices en_GB-cori-high en_US-amy-medium en_GB-alan-medium en_US-kristin-medium en_US-ryan-high en_GB-alba-medium en_GB-aru-medium en_US-john-medium en_US-hfc_female-medium",
|
|
47
|
+
"test": "node scripts/smoke.mjs",
|
|
48
|
+
"prepack": "vite build"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@fontsource-variable/inter": "5.1.0",
|
|
52
|
+
"@fontsource/jetbrains-mono": "5.1.1",
|
|
53
|
+
"@xterm/addon-fit": "0.10.0",
|
|
54
|
+
"@xterm/xterm": "5.5.0",
|
|
55
|
+
"marked": "^14.1.3",
|
|
56
|
+
"three": "0.169.0",
|
|
57
|
+
"ws": "8.18.0"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"concurrently": "9.0.1",
|
|
61
|
+
"vite": "5.4.10"
|
|
62
|
+
},
|
|
63
|
+
"optionalDependencies": {
|
|
64
|
+
"node-pty": "1.0.0"
|
|
65
|
+
}
|
|
66
|
+
}
|
package/server/auth.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// How the office's claude processes sign in: the Claude Code login already on
|
|
2
|
+
// this machine, or an Anthropic API key the boss gives it. The key lives in
|
|
3
|
+
// its own file, readable only by you, and never goes to the browser: the
|
|
4
|
+
// page only ever learns which way is chosen and the key's last four.
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
const MODES = ['login', 'apikey'];
|
|
10
|
+
|
|
11
|
+
export class Auth {
|
|
12
|
+
constructor(dataDir) {
|
|
13
|
+
this.file = path.join(dataDir, 'auth.json');
|
|
14
|
+
let saved = {};
|
|
15
|
+
try { saved = JSON.parse(fs.readFileSync(this.file, 'utf8')); } catch {}
|
|
16
|
+
this.key = typeof saved.key === 'string' ? saved.key : '';
|
|
17
|
+
// Until someone chooses, a key already in the environment is the choice,
|
|
18
|
+
// which is what `claude` itself would have done with it.
|
|
19
|
+
this.mode = MODES.includes(saved.mode) ? saved.mode : process.env.ANTHROPIC_API_KEY ? 'apikey' : 'login';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The key that will be used: the one saved here, else the environment's.
|
|
23
|
+
current() { return this.key || process.env.ANTHROPIC_API_KEY || ''; }
|
|
24
|
+
|
|
25
|
+
status() {
|
|
26
|
+
const k = this.current();
|
|
27
|
+
return { mode: this.mode, keySet: !!k, keyHint: k ? k.slice(-4) : null, keyFrom: this.key ? 'office' : k ? 'env' : null };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The environment for a claude child. With the login chosen, a key lying
|
|
31
|
+
// around in the shell must not quietly take over (claude prefers a key to
|
|
32
|
+
// the login); with a key chosen, nothing else may outrank it.
|
|
33
|
+
env(base = process.env) {
|
|
34
|
+
const env = { ...base };
|
|
35
|
+
delete env.ANTHROPIC_API_KEY;
|
|
36
|
+
if (this.mode === 'apikey') {
|
|
37
|
+
delete env.ANTHROPIC_AUTH_TOKEN;
|
|
38
|
+
delete env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
39
|
+
const k = this.current();
|
|
40
|
+
if (k) env.ANTHROPIC_API_KEY = k;
|
|
41
|
+
}
|
|
42
|
+
return env;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async set({ mode, key }) {
|
|
46
|
+
if (!MODES.includes(mode)) throw Object.assign(new Error('Pick the Claude Code login or an API key.'), { code: 400 });
|
|
47
|
+
key = typeof key === 'string' ? key.trim() : '';
|
|
48
|
+
if (key) {
|
|
49
|
+
if (!/^sk-ant-[\w-]{20,}$/.test(key)) throw Object.assign(new Error("That doesn't look like an Anthropic API key (sk-ant-…)."), { code: 400 });
|
|
50
|
+
await check(key);
|
|
51
|
+
this.key = key;
|
|
52
|
+
}
|
|
53
|
+
if (mode === 'apikey' && !this.current()) throw Object.assign(new Error('Paste an API key first.'), { code: 400 });
|
|
54
|
+
this.mode = mode;
|
|
55
|
+
this.save();
|
|
56
|
+
return this.status();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
forget() {
|
|
60
|
+
this.key = '';
|
|
61
|
+
if (this.mode === 'apikey' && !this.current()) this.mode = 'login';
|
|
62
|
+
this.save();
|
|
63
|
+
return this.status();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
save() {
|
|
67
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
|
68
|
+
const tmp = `${this.file}.tmp`;
|
|
69
|
+
fs.writeFileSync(tmp, JSON.stringify({ mode: this.mode, key: this.key || undefined }), { mode: 0o600 });
|
|
70
|
+
fs.renameSync(tmp, this.file);
|
|
71
|
+
fs.chmodSync(this.file, 0o600);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// One cheap call that costs no tokens, so a typo is caught here rather than
|
|
76
|
+
// as a failed job five minutes later.
|
|
77
|
+
async function check(key) {
|
|
78
|
+
let res;
|
|
79
|
+
try {
|
|
80
|
+
res = await fetch('https://api.anthropic.com/v1/models?limit=1', {
|
|
81
|
+
headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01' },
|
|
82
|
+
signal: AbortSignal.timeout(10000),
|
|
83
|
+
});
|
|
84
|
+
} catch {
|
|
85
|
+
throw Object.assign(new Error("Couldn't reach Anthropic to check the key. Try again."), { code: 502 });
|
|
86
|
+
}
|
|
87
|
+
if (res.status === 401 || res.status === 403) throw Object.assign(new Error('Anthropic rejected that key.'), { code: 400 });
|
|
88
|
+
if (!res.ok && res.status !== 429) throw Object.assign(new Error(`Couldn't check the key (${res.status}).`), { code: 502 });
|
|
89
|
+
}
|
package/server/banter.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Break-room small talk, written by the model. Two agents idling in the break
|
|
2
|
+
// room trade a few lines; one short call writes the whole exchange from who
|
|
3
|
+
// they are, how they feel about each other, what they have been working on and
|
|
4
|
+
// what was said lately, so it costs a fraction of a chat message. If the call
|
|
5
|
+
// fails the browser falls back to its canned chats.
|
|
6
|
+
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
|
|
9
|
+
const SCHEMA = {
|
|
10
|
+
type: 'object',
|
|
11
|
+
properties: { lines: { type: 'array', items: { type: 'string' } } },
|
|
12
|
+
required: ['lines'],
|
|
13
|
+
additionalProperties: false,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const SYSTEM = `You write the small talk when two coworkers in an office of coding agents bump into each other in the break room. You get a sheet for each: role, personality, background, their story, and how they feel about colleagues. You also get what each has been working on and the last things said in the break room.
|
|
17
|
+
|
|
18
|
+
Write 4 to 6 lines, alternating speakers, starting with the first person named. Rules:
|
|
19
|
+
- Each line is one short sentence or two, under 110 characters, said out loud.
|
|
20
|
+
- Stay in character for each. Let their story, their ties to each other and their recent work colour it, but only sometimes: plenty of chats are just coffee, the weekend, a bad error message or a joke. Never force a reference in.
|
|
21
|
+
- Warm, funny, specific. A real exchange with a small turn or a punchline, not a recap of the sheets.
|
|
22
|
+
- Do not repeat or lightly reword anything in the recent lines; start somewhere new.
|
|
23
|
+
- Use real names for people. Never "he", "she", "him" or "her" about anyone.
|
|
24
|
+
- Plain words. No markdown, emoji, quotation marks around lines, speaker labels or stage directions.`;
|
|
25
|
+
|
|
26
|
+
export function writeBanter(context, { bin = 'claude', model = 'haiku', timeoutMs = 15000, env = process.env } = {}) {
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
const args = [
|
|
29
|
+
'-p', '--model', model, '--output-format', 'json', '--json-schema', JSON.stringify(SCHEMA),
|
|
30
|
+
'--tools', '', '--setting-sources', '', '--strict-mcp-config', '--no-session-persistence',
|
|
31
|
+
'--system-prompt', SYSTEM,
|
|
32
|
+
];
|
|
33
|
+
const child = spawn(bin, args, { env, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
34
|
+
let out = '', err = '';
|
|
35
|
+
child.stdout.on('data', (d) => (out += d));
|
|
36
|
+
child.stderr.on('data', (d) => (err += d));
|
|
37
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs);
|
|
38
|
+
child.on('error', (e) => { clearTimeout(timer); reject(e); });
|
|
39
|
+
child.on('close', () => {
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
try {
|
|
42
|
+
const r = JSON.parse(out);
|
|
43
|
+
const { lines } = r.structured_output || JSON.parse(r.result);
|
|
44
|
+
const clean = lines.map((l) => String(l).replace(/\s+/g, ' ').trim().slice(0, 200)).filter(Boolean).slice(0, 6);
|
|
45
|
+
if (clean.length < 2) throw new Error('Too short.');
|
|
46
|
+
resolve({ lines: clean, cost: r.total_cost_usd || 0 });
|
|
47
|
+
} catch {
|
|
48
|
+
reject(new Error(err.trim().split('\n').pop() || 'Could not write the banter.'));
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
child.stdin.end(context);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// "What changed" for a job: snapshot the working tree when the job starts and
|
|
2
|
+
// again when it ends, then diff the two. Snapshots are tree objects built from
|
|
3
|
+
// a throwaway index, so nothing in the repo's own index, stash or history is
|
|
4
|
+
// touched, and untracked files are included (ignored ones are not).
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { execFile } from 'node:child_process';
|
|
9
|
+
|
|
10
|
+
const MAX_FILES = 200;
|
|
11
|
+
const MAX_DIFF = 400 * 1024;
|
|
12
|
+
|
|
13
|
+
const git = (cwd, args, env = {}) => new Promise((resolve, reject) => {
|
|
14
|
+
execFile('git', args, { cwd, env: { ...process.env, ...env }, maxBuffer: 64 * 1024 * 1024, timeout: 60000 }, (err, out) => {
|
|
15
|
+
if (err) reject(err); else resolve(out);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// A tree sha for the working tree as it is right now, or null outside a repo.
|
|
20
|
+
export async function snapshotTree(cwd) {
|
|
21
|
+
const tmp = path.join(os.tmpdir(), `office-index-${process.pid}-${Math.random().toString(36).slice(2)}`);
|
|
22
|
+
try {
|
|
23
|
+
const real = (await git(cwd, ['rev-parse', '--path-format=absolute', '--git-path', 'index'])).trim();
|
|
24
|
+
// Seeding from the real index keeps `add -A` quick on a big tree.
|
|
25
|
+
try { fs.copyFileSync(real, tmp); } catch {}
|
|
26
|
+
const env = { GIT_INDEX_FILE: tmp };
|
|
27
|
+
await git(cwd, ['add', '-A'], env);
|
|
28
|
+
return (await git(cwd, ['write-tree'], env)).trim();
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
} finally {
|
|
32
|
+
fs.rmSync(tmp, { force: true });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const LETTER = { A: 'added', M: 'modified', D: 'deleted', T: 'modified' };
|
|
37
|
+
|
|
38
|
+
// The summary the card shows: per-file status and line counts, no diff bodies.
|
|
39
|
+
export async function summarize(cwd, base, end) {
|
|
40
|
+
if (!base || !end) return null;
|
|
41
|
+
try {
|
|
42
|
+
const [stat, num] = await Promise.all([
|
|
43
|
+
git(cwd, ['diff', '--no-renames', '--name-status', '-z', base, end]),
|
|
44
|
+
git(cwd, ['diff', '--no-renames', '--numstat', '-z', base, end]),
|
|
45
|
+
]);
|
|
46
|
+
const parts = stat.split('\0').filter(Boolean);
|
|
47
|
+
const files = new Map();
|
|
48
|
+
for (let i = 0; i + 1 < parts.length; i += 2) files.set(parts[i + 1], { path: parts[i + 1], status: LETTER[parts[i][0]] || 'modified', add: 0, del: 0, binary: false });
|
|
49
|
+
// -z numstat: "add\tdel\tpath\0"
|
|
50
|
+
for (const row of num.split('\0').filter(Boolean)) {
|
|
51
|
+
const [a, d, ...p] = row.split('\t');
|
|
52
|
+
const f = files.get(p.join('\t'));
|
|
53
|
+
if (!f) continue;
|
|
54
|
+
if (a === '-') f.binary = true; else { f.add = +a; f.del = +d; }
|
|
55
|
+
}
|
|
56
|
+
const all = [...files.values()].sort((x, y) => x.path.localeCompare(y.path));
|
|
57
|
+
return {
|
|
58
|
+
base, end, count: all.length,
|
|
59
|
+
add: all.reduce((n, f) => n + f.add, 0), del: all.reduce((n, f) => n + f.del, 0),
|
|
60
|
+
files: all.slice(0, MAX_FILES),
|
|
61
|
+
};
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The patch for one file, only when the boss asks for it.
|
|
68
|
+
export async function fileDiff(cwd, changes, file) {
|
|
69
|
+
if (!changes?.files.some((f) => f.path === file)) throw new Error('That file is not part of this job’s changes.');
|
|
70
|
+
const out = await git(cwd, ['diff', '--no-renames', '--no-color', changes.base, changes.end, '--', file]);
|
|
71
|
+
return out.length > MAX_DIFF ? `${out.slice(0, MAX_DIFF)}\n… diff cut at ${MAX_DIFF / 1024} KB` : out;
|
|
72
|
+
}
|
package/server/chief.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// The chief of staff: a fast model that reads each chat message and decides
|
|
2
|
+
// who should handle it, and on how capable a model. It never touches a
|
|
3
|
+
// project; it only routes.
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
|
|
9
|
+
// What the chief can actually look at. The API takes an image up to 5 MB once
|
|
10
|
+
// base64 has made it a third bigger, and a routing call needs a glance rather
|
|
11
|
+
// than a gallery; anything bigger or odder goes by name only, for the agent to
|
|
12
|
+
// open with the Read tool.
|
|
13
|
+
const IMAGE_TYPES = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp' };
|
|
14
|
+
const MAX_IMAGE = 3.5 * 1024 * 1024;
|
|
15
|
+
const MAX_IMAGES = 4;
|
|
16
|
+
|
|
17
|
+
// The attachments the chief will be shown, in the order they were sent.
|
|
18
|
+
export function canSee(files) {
|
|
19
|
+
return (files || [])
|
|
20
|
+
.filter((f) => IMAGE_TYPES[path.extname(f.name || '').toLowerCase()] && f.size <= MAX_IMAGE)
|
|
21
|
+
.slice(0, MAX_IMAGES);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const SCHEMA = {
|
|
25
|
+
type: 'object',
|
|
26
|
+
properties: {
|
|
27
|
+
action: { type: 'string', enum: ['followup', 'new', 'dismiss', 'recall', 'standup', 'ask', 'chat'] },
|
|
28
|
+
jobId: { type: 'string' },
|
|
29
|
+
agentId: { type: 'string' },
|
|
30
|
+
agentIds: { type: 'array', items: { type: 'string' } },
|
|
31
|
+
repo: { type: 'string' },
|
|
32
|
+
brief: { type: 'string' },
|
|
33
|
+
model: { type: 'string', enum: ['haiku', 'sonnet', 'opus'] },
|
|
34
|
+
escalate: { type: 'boolean' },
|
|
35
|
+
say: { type: 'string' },
|
|
36
|
+
},
|
|
37
|
+
required: ['action', 'say'],
|
|
38
|
+
additionalProperties: false,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const SYSTEM = `You are the chief of staff in an office of coding agents. The boss posts messages in the office chat. For each one, decide who handles it.
|
|
42
|
+
|
|
43
|
+
Actions:
|
|
44
|
+
- "followup": the message adds to, corrects, or asks about a job that already exists (working, queued or recently finished). Give its jobId. Prefer this whenever the message plausibly relates to an open job, especially the job of an agent the boss tagged. Set "escalate": true only when the boss is saying a finished job's work is wrong, incomplete, or that the agent missed the point — the second go then runs on a stronger model.
|
|
45
|
+
- "new": a new piece of work. Give "repo" (exactly one name from Projects), "brief" (the task in the boss's own words, plus any context from recent chat that the agent needs; never add requirements), "agentId" (the tagged agent's id; otherwise a free agent whose role in Staff clearly fits the work; otherwise "" for whoever is free), and "model" (see Sizing the work). Never pick a busy or dismissed agent unless the boss tagged them.
|
|
46
|
+
- "dismiss": the boss wants agents sent home (dismissed, let go for now, off the floor). Give "agentIds": the ids of exactly who they mean, worked out from Staff (e.g. "everyone who's free except Mira" means every free agent but Mira). Only free agents can go; leave out anyone working.
|
|
47
|
+
- "recall": the boss wants dismissed agents back in. Give "agentIds" (every dismissed agent if they say "everyone").
|
|
48
|
+
- "standup": the boss wants the team to hold a standup now (e.g. "standup time", "let's do standup", "gather everyone for a status update").
|
|
49
|
+
- "ask": you can't tell which project new work belongs to, or the request is too unclear to start. Ask one short question.
|
|
50
|
+
- "chat": greetings, thanks, or questions about the office itself (who is doing what). Answer from the context given. If your answer is about one particular job, give its jobId too, so the boss gets a link to where that job's updates are collecting.
|
|
51
|
+
|
|
52
|
+
Sizing the work. Most jobs do not need the most capable model, and a smaller one is faster and far cheaper. Pick the cheapest rung that can plausibly finish:
|
|
53
|
+
- "haiku": small and mechanical, where the change is already spelled out — wording and copy edits, a rename, a version bump, a log line, a tweak to one named file.
|
|
54
|
+
- "sonnet": the ordinary case, and your answer whenever you are unsure. Features, bug fixes, refactors, tests: work that means reading a few files and making a normal judgment call.
|
|
55
|
+
- "opus": genuinely hard work — a bug with no obvious cause, a design or architecture decision, security-sensitive code, a change spread across many files, or work the boss has already seen go wrong once.
|
|
56
|
+
A job that fails is retried automatically one rung up the ladder, so starting low costs little. Urgency is not difficulty: don't reach for "opus" because the boss sounds in a hurry.
|
|
57
|
+
|
|
58
|
+
A message you passed to an agent ("Passed to…", "Adding this to…" in the chat) is now theirs and in hand; don't say it is waiting.
|
|
59
|
+
|
|
60
|
+
Images. A screenshot or mockup the boss attaches is shown to you with the message, so look at it before you decide: it is usually the whole brief. Let what you see set the project, the agent and the model, and say it in plain words in "brief" — "the usage bar is drawn over the avatar in the sidebar" tells the agent far more than "see the screenshot". The file's path travels with the brief, so the agent can open it themselves. Attachments you were not shown (other file types, and voice notes, which reach you as their transcript) you cannot open: route on the boss's words and leave the reading to the agent.
|
|
61
|
+
|
|
62
|
+
"say" is one short, warm line for the chat, in plain words, e.g. "Ada's on it." or "Passing that to Tunde, it fits what they're doing." Refer to agents only by name or as "they"; never "he", "she", "him" or "her". Never claim work is done, and never call a job done while its agent is still waiting on the boss's answer; say it is waiting on them.`;
|
|
63
|
+
|
|
64
|
+
// Routing on the boss's words alone, or on the images too. Only streamed
|
|
65
|
+
// input can carry an image, and the CLI answers a stream with a stream, so
|
|
66
|
+
// that path reads one JSON object per line and keeps the last one.
|
|
67
|
+
export function route(context, { bin = 'claude', model = 'haiku', timeoutMs = 60000, images = [], env = process.env } = {}) {
|
|
68
|
+
const shown = canSee(images);
|
|
69
|
+
if (!shown.length) return ask(context, { bin, model, timeoutMs, env });
|
|
70
|
+
// An image the API won't take (too many pixels, a .png that isn't one)
|
|
71
|
+
// shouldn't cost the boss their answer, so the words go on their own.
|
|
72
|
+
return ask(context, { bin, model, timeoutMs, env, images: shown })
|
|
73
|
+
.catch(() => ask(context, { bin, model, timeoutMs, env }));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function ask(context, { bin, model, timeoutMs, env, images }) {
|
|
77
|
+
const streamed = !!images?.length;
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
const args = [
|
|
80
|
+
'-p', '--model', model,
|
|
81
|
+
'--output-format', streamed ? 'stream-json' : 'json',
|
|
82
|
+
// Streamed output is only allowed with --verbose, and it is the only
|
|
83
|
+
// output format streamed input is allowed with.
|
|
84
|
+
...(streamed ? ['--input-format', 'stream-json', '--verbose'] : []),
|
|
85
|
+
'--json-schema', JSON.stringify(SCHEMA),
|
|
86
|
+
'--tools', '', '--setting-sources', '', '--strict-mcp-config', '--no-session-persistence',
|
|
87
|
+
'--system-prompt', SYSTEM,
|
|
88
|
+
];
|
|
89
|
+
const child = spawn(bin, args, { env, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
90
|
+
let out = '', err = '';
|
|
91
|
+
child.stdout.on('data', (d) => (out += d));
|
|
92
|
+
child.stderr.on('data', (d) => (err += d));
|
|
93
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs);
|
|
94
|
+
child.on('error', (e) => { clearTimeout(timer); reject(e); });
|
|
95
|
+
child.on('close', () => {
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
try {
|
|
98
|
+
const r = streamed ? result(out) : JSON.parse(out);
|
|
99
|
+
const decision = r.structured_output || JSON.parse(r.result);
|
|
100
|
+
resolve({ ...decision, cost: r.total_cost_usd || 0 });
|
|
101
|
+
} catch {
|
|
102
|
+
reject(new Error(err.trim().split('\n').pop() || 'The chief could not decide.'));
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
child.stdin.on('error', () => {});
|
|
106
|
+
child.stdin.end(streamed ? JSON.stringify(message(context, images)) + '\n' : context);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Images first, then the question: the models read a picture better when it
|
|
111
|
+
// comes before what is being asked about it.
|
|
112
|
+
function message(context, images) {
|
|
113
|
+
const content = images.map((f) => ({
|
|
114
|
+
type: 'image',
|
|
115
|
+
source: { type: 'base64', media_type: IMAGE_TYPES[path.extname(f.name).toLowerCase()], data: fs.readFileSync(f.path).toString('base64') },
|
|
116
|
+
}));
|
|
117
|
+
content.push({ type: 'text', text: context });
|
|
118
|
+
return { type: 'user', message: { role: 'user', content } };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The decision is in the last line of the stream, the one the run ends on.
|
|
122
|
+
function result(out) {
|
|
123
|
+
for (const line of out.trim().split('\n').reverse()) {
|
|
124
|
+
try {
|
|
125
|
+
const o = JSON.parse(line);
|
|
126
|
+
if (o.type === 'result') return o;
|
|
127
|
+
} catch {}
|
|
128
|
+
}
|
|
129
|
+
throw new Error('The stream carried no result.');
|
|
130
|
+
}
|