agent-office-studio 0.0.0-stage → 0.3.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/LICENSE +21 -0
  3. package/README.md +85 -2
  4. package/bin/agentoffice.js +98 -0
  5. package/dist/apple-touch-icon.png +0 -0
  6. package/dist/assets/_commonjsHelpers-CqkleIqs.js +1 -0
  7. package/dist/assets/addon-fit-BwprjgOP.js +1 -0
  8. package/dist/assets/index-D5IzEWKU.css +32 -0
  9. package/dist/assets/index-DgXF8Kqx.js +4457 -0
  10. package/dist/assets/inter-cyrillic-ext-wght-normal-B2xhLi22.woff2 +0 -0
  11. package/dist/assets/inter-cyrillic-wght-normal-CMZtQduZ.woff2 +0 -0
  12. package/dist/assets/inter-greek-ext-wght-normal-CGAr0uHJ.woff2 +0 -0
  13. package/dist/assets/inter-greek-wght-normal-CaVNZxsx.woff2 +0 -0
  14. package/dist/assets/inter-latin-ext-wght-normal-CFHvXkgd.woff2 +0 -0
  15. package/dist/assets/inter-latin-wght-normal-C2S99t-D.woff2 +0 -0
  16. package/dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  17. package/dist/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
  18. package/dist/assets/jetbrains-mono-cyrillic-400-normal-CIEtRijs.woff +0 -0
  19. package/dist/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
  20. package/dist/assets/jetbrains-mono-greek-400-normal-CNojz0t3.woff +0 -0
  21. package/dist/assets/jetbrains-mono-latin-400-normal-B11XCQ5g.woff +0 -0
  22. package/dist/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  23. package/dist/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
  24. package/dist/assets/jetbrains-mono-latin-ext-400-normal-DHMOLxAi.woff +0 -0
  25. package/dist/assets/jetbrains-mono-vietnamese-400-normal-BeU3T8wf.woff +0 -0
  26. package/dist/assets/xterm-C7kOH91j.js +9 -0
  27. package/dist/icon-192.png +0 -0
  28. package/dist/icon-512.png +0 -0
  29. package/dist/icon.svg +9 -0
  30. package/dist/index.html +245 -0
  31. package/dist/manifest.webmanifest +16 -0
  32. package/dist/sw.js +39 -0
  33. package/docs/README.md +27 -0
  34. package/docs/claude-access.md +31 -0
  35. package/docs/configuration.md +54 -0
  36. package/docs/development.md +76 -0
  37. package/docs/getting-started.md +57 -0
  38. package/docs/guides/troubleshooting.md +110 -0
  39. package/docs/guides/updating.md +36 -0
  40. package/docs/guides/working-with-the-team.md +56 -0
  41. package/docs/guides/your-first-job.md +65 -0
  42. package/docs/how-it-works.md +80 -0
  43. package/docs/images/office.jpg +0 -0
  44. package/docs/jobs.md +54 -0
  45. package/docs/leaderboard.md +67 -0
  46. package/docs/linked-offices.md +81 -0
  47. package/docs/packaging.md +49 -0
  48. package/docs/phone.md +32 -0
  49. package/docs/security.md +80 -0
  50. package/docs/terminal.md +46 -0
  51. package/docs/using-the-office.md +109 -0
  52. package/docs/voice.md +44 -0
  53. package/package.json +65 -4
  54. package/server/auth.js +89 -0
  55. package/server/banter.js +53 -0
  56. package/server/changes.js +74 -0
  57. package/server/chief.js +146 -0
  58. package/server/guests.js +391 -0
  59. package/server/index.js +2219 -0
  60. package/server/leaderboard.js +239 -0
  61. package/server/links.js +350 -0
  62. package/server/office-mcp.js +101 -0
  63. package/server/procstat.js +85 -0
  64. package/server/standup.js +78 -0
  65. package/server/supervise.js +53 -0
  66. package/server/terminal.js +255 -0
  67. package/server/tools.js +52 -0
  68. package/server/usage.js +40 -0
  69. package/server/voice.js +174 -0
  70. package/shared/speakable.js +50 -0
@@ -0,0 +1,80 @@
1
+ # Security
2
+
3
+ The office starts Claude in your projects, can give it full autonomy, and can
4
+ hand out a shell. It is built to be used by one person, on their own machine.
5
+
6
+ ## Who can reach it
7
+
8
+ - The server listens on `127.0.0.1` only.
9
+ - Every request's `Host` and `Origin` must be local (or a name in
10
+ `OFFICE_ALLOWED_HOSTS`), which keeps other websites and DNS-rebinding
11
+ attacks out of it. A page on another localhost port counts as another
12
+ website.
13
+ - The office has a key, made on first start and kept in `data/office-key` with
14
+ mode `0600`. Other accounts on the machine can reach `127.0.0.1` too, so the
15
+ page, the API and the socket all need the key. The address the office prints
16
+ when it starts carries the key once; your browser keeps it in a cookie
17
+ (HttpOnly, SameSite=Strict). `agentoffice --link` prints that address again.
18
+ Delete `data/office-key` and restart to sign every browser out.
19
+ - Agents call back into the office (the roster, messages, hand-offs) with a
20
+ per-job token that dies with the job.
21
+
22
+ Don't put it behind a public URL. If you reach it from your phone, do it over
23
+ a private network that is only you — see [On your phone](phone.md).
24
+
25
+ ## What jobs may do
26
+
27
+ Each job runs with the clearance you picked — *Plan only*, *Edit files* or
28
+ *Full autonomy* — enforced by Claude Code's own permission modes, and leaves a
29
+ full log in its drawer. Full autonomy means what it says: the agent can run any
30
+ command you could.
31
+
32
+ ## Claude credentials
33
+
34
+ - With the **Claude Code login**, the office never sees your credentials; the
35
+ `claude` it starts reads them. The plan meters read the login's access token
36
+ to ask for usage, and nothing else.
37
+ - With an **API key**, the key is checked with Anthropic, stored in
38
+ `data/auth.json` with mode `0600`, passed only to the `claude` processes the
39
+ office starts, and never sent to the browser, which sees the last four
40
+ characters.
41
+ - `data/` is git-ignored.
42
+
43
+ ## The leaderboard
44
+
45
+ Off until you join, and then the only thing besides Claude that the office
46
+ talks to. It sends a handle and three counters per day (jobs, tokens, agent
47
+ time) to the hosted leaderboard service (or the one named by
48
+ `OFFICE_LEADERBOARD_URL`), signed with a random
49
+ secret kept in `data/leaderboard.json` (mode `0600`). Never prompts, projects,
50
+ files, chat or cost. Details in [The leaderboard](leaderboard.md).
51
+
52
+ ## Linked offices and guests
53
+
54
+ Off until you make or enter a code. The office only ever connects out to the
55
+ service, so nothing listens for other offices. A link shares your agents' names,
56
+ roles, looks and states (working, on a break and so on), and nothing else. Job
57
+ titles stay home, since a title is the first line of a prompt;
58
+ `OFFICE_SHARE_JOB_TITLES=1` shares them. Everything a linked office
59
+ sends is checked and trimmed before your browser sees it.
60
+
61
+ A **guest** runs jobs on your machine, on your Claude account. Everything is
62
+ enforced on your side and the guest's office is never trusted:
63
+
64
+ - only the projects you picked;
65
+ - at or below the clearance cap you set;
66
+ - within the tokens per day you set;
67
+ - no terminal, settings, chat, or other people's jobs;
68
+ - their agents can't reach your other agents;
69
+ - their jobs don't get your MCP servers or claude.ai connectors, your
70
+ user-level Claude Code settings and hooks, or your Claude Code memory.
71
+
72
+ Even *Edit files* can change scripts or hooks that run later, so invite only
73
+ people you would trust with those projects. Details in
74
+ [Linked offices](linked-offices.md).
75
+
76
+ ## The terminal
77
+
78
+ Off unless `OFFICE_TERMINAL=1`. A session needs a single-use ticket from the
79
+ origin-checked API, no agent can open one, and keystrokes are never logged. It
80
+ is not a sandbox. Details in [The terminal](terminal.md).
@@ -0,0 +1,46 @@
1
+ # The terminal
2
+
3
+ There is a real shell in the drawer under the office: `⌃``, or the terminal
4
+ button in the top right. Tabs across the top, `+` opens another, and the picker
5
+ beside it chooses which project folder the next one starts in. Drag the top
6
+ edge to resize — the office makes room rather than hiding behind it. It is a
7
+ proper pty, so `vim`, `top`, `git add -p`, colours and your own prompt all work
8
+ the way they do in a terminal app.
9
+
10
+ It is off until you ask for it:
11
+
12
+ ```bash
13
+ OFFICE_TERMINAL=1 npm start
14
+ ```
15
+
16
+ That switch exists because shell access is the one door in the building that
17
+ doesn't care about clearance. A job runs with the mode you picked and leaves a
18
+ log; a shell is you, with everything you can do, and no record of what it did.
19
+ So:
20
+
21
+ - **Off by default.** No flag, no terminal — the socket won't even open.
22
+ - **This machine's browser only.** The server already refuses any request whose
23
+ host or origin isn't local. On top of that, a session needs a single-use
24
+ ticket that expires in ten seconds, and tickets are only handed out on that
25
+ origin-checked route — the socket accepts nothing else.
26
+ - **Not for the staff.** No agent can open one. There's no office tool and no
27
+ `/api/agent` route that reaches it, and a shell never inherits the job
28
+ credentials the office gives its own children.
29
+ - **Nothing is kept.** Not in `data/state.json`, not in the chat, not on the
30
+ main socket. Scrollback lives in the tab and dies with it. `data/terminal.log`
31
+ records when sessions opened and closed and in which folder — never
32
+ keystrokes, because a log of what you typed into a shell is a file full of
33
+ passwords.
34
+ - **It tidies up.** Closing a tab hangs up the shell, a session with nothing
35
+ happening closes itself after 30 minutes, and there's a cap on how many can
36
+ be open at once.
37
+
38
+ What it is *not* is a sandbox. The shell runs as you and `cd ..` walks out of
39
+ whichever folder it started in; picking the folder is a convenience, not a
40
+ boundary. Leave it off on any machine where "whoever has the browser" isn't
41
+ just you.
42
+
43
+ The terminal needs [`node-pty`](https://github.com/microsoft/node-pty), which
44
+ builds natively — it's an optional dependency, so `npm install` on a machine
45
+ without build tools still gets you the whole office, just without this. On
46
+ macOS: `xcode-select --install`, then `npm install`.
@@ -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,67 @@
1
1
  {
2
2
  "name": "agent-office-studio",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.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
+ "!docs/proposals",
13
+ "CHANGELOG.md",
14
+ "LICENSE",
15
+ "README.md"
16
+ ],
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/onuohasilver/agent-office.git"
23
+ },
24
+ "homepage": "https://github.com/onuohasilver/agent-office#readme",
25
+ "bugs": "https://github.com/onuohasilver/agent-office/issues",
26
+ "keywords": [
27
+ "claude",
28
+ "claude-code",
29
+ "agents",
30
+ "ai",
31
+ "three.js",
32
+ "developer-tools"
33
+ ],
34
+ "engines": {
35
+ "node": ">=20"
36
+ },
37
+ "type": "module",
38
+ "bin": {
39
+ "agentoffice": "bin/agentoffice.js",
40
+ "agentstudio": "bin/agentoffice.js"
41
+ },
42
+ "scripts": {
43
+ "dev": "concurrently -k -n server,web -c blue,magenta \"node --watch server/index.js\" \"vite\"",
44
+ "build": "vite build",
45
+ "start": "vite build && node server/supervise.js",
46
+ "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",
47
+ "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",
48
+ "test": "node scripts/smoke.mjs && node scripts/guests.mjs",
49
+ "prepack": "vite build"
50
+ },
51
+ "dependencies": {
52
+ "@fontsource-variable/inter": "5.1.0",
53
+ "@fontsource/jetbrains-mono": "5.1.1",
54
+ "@xterm/addon-fit": "0.10.0",
55
+ "@xterm/xterm": "5.5.0",
56
+ "marked": "^14.1.3",
57
+ "three": "0.169.0",
58
+ "ws": "8.18.0"
59
+ },
60
+ "devDependencies": {
61
+ "concurrently": "9.0.1",
62
+ "vite": "5.4.10"
63
+ },
64
+ "optionalDependencies": {
65
+ "node-pty": "1.0.0"
66
+ }
67
+ }
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
+ }
@@ -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,74 @@
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
+ // A project's own config can name commands git runs (fsmonitor, diff
15
+ // drivers); none of them should run just because a job ended.
16
+ execFile('git', ['-c', 'core.fsmonitor=false', ...args], { cwd, env: { ...process.env, ...env }, maxBuffer: 64 * 1024 * 1024, timeout: 60000 }, (err, out) => {
17
+ if (err) reject(err); else resolve(out);
18
+ });
19
+ });
20
+
21
+ // A tree sha for the working tree as it is right now, or null outside a repo.
22
+ export async function snapshotTree(cwd) {
23
+ const tmp = path.join(os.tmpdir(), `office-index-${process.pid}-${Math.random().toString(36).slice(2)}`);
24
+ try {
25
+ const real = (await git(cwd, ['rev-parse', '--path-format=absolute', '--git-path', 'index'])).trim();
26
+ // Seeding from the real index keeps `add -A` quick on a big tree.
27
+ try { fs.copyFileSync(real, tmp); } catch {}
28
+ const env = { GIT_INDEX_FILE: tmp };
29
+ await git(cwd, ['add', '-A'], env);
30
+ return (await git(cwd, ['write-tree'], env)).trim();
31
+ } catch {
32
+ return null;
33
+ } finally {
34
+ fs.rmSync(tmp, { force: true });
35
+ }
36
+ }
37
+
38
+ const LETTER = { A: 'added', M: 'modified', D: 'deleted', T: 'modified' };
39
+
40
+ // The summary the card shows: per-file status and line counts, no diff bodies.
41
+ export async function summarize(cwd, base, end) {
42
+ if (!base || !end) return null;
43
+ try {
44
+ const [stat, num] = await Promise.all([
45
+ git(cwd, ['diff', '--no-renames', '--name-status', '-z', base, end]),
46
+ git(cwd, ['diff', '--no-renames', '--numstat', '-z', base, end]),
47
+ ]);
48
+ const parts = stat.split('\0').filter(Boolean);
49
+ const files = new Map();
50
+ 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 });
51
+ // -z numstat: "add\tdel\tpath\0"
52
+ for (const row of num.split('\0').filter(Boolean)) {
53
+ const [a, d, ...p] = row.split('\t');
54
+ const f = files.get(p.join('\t'));
55
+ if (!f) continue;
56
+ if (a === '-') f.binary = true; else { f.add = +a; f.del = +d; }
57
+ }
58
+ const all = [...files.values()].sort((x, y) => x.path.localeCompare(y.path));
59
+ return {
60
+ base, end, count: all.length,
61
+ add: all.reduce((n, f) => n + f.add, 0), del: all.reduce((n, f) => n + f.del, 0),
62
+ files: all.slice(0, MAX_FILES),
63
+ };
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ // The patch for one file, only when the boss asks for it.
70
+ export async function fileDiff(cwd, changes, file) {
71
+ if (!changes?.files.some((f) => f.path === file)) throw new Error('That file is not part of this job’s changes.');
72
+ const out = await git(cwd, ['diff', '--no-renames', '--no-color', '--no-ext-diff', '--no-textconv', changes.base, changes.end, '--', file]);
73
+ return out.length > MAX_DIFF ? `${out.slice(0, MAX_DIFF)}\n… diff cut at ${MAX_DIFF / 1024} KB` : out;
74
+ }