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.
- package/CHANGELOG.md +75 -0
- package/LICENSE +21 -0
- package/README.md +85 -2
- package/bin/agentoffice.js +98 -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-D5IzEWKU.css +32 -0
- package/dist/assets/index-DgXF8Kqx.js +4457 -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 +245 -0
- package/dist/manifest.webmanifest +16 -0
- package/dist/sw.js +39 -0
- package/docs/README.md +27 -0
- package/docs/claude-access.md +31 -0
- package/docs/configuration.md +54 -0
- package/docs/development.md +76 -0
- package/docs/getting-started.md +57 -0
- package/docs/guides/troubleshooting.md +110 -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 +67 -0
- package/docs/linked-offices.md +81 -0
- package/docs/packaging.md +49 -0
- package/docs/phone.md +32 -0
- package/docs/security.md +80 -0
- package/docs/terminal.md +46 -0
- package/docs/using-the-office.md +109 -0
- package/docs/voice.md +44 -0
- package/package.json +65 -4
- package/server/auth.js +89 -0
- package/server/banter.js +53 -0
- package/server/changes.js +74 -0
- package/server/chief.js +146 -0
- package/server/guests.js +391 -0
- package/server/index.js +2219 -0
- package/server/leaderboard.js +239 -0
- package/server/links.js +350 -0
- package/server/office-mcp.js +101 -0
- package/server/procstat.js +85 -0
- package/server/standup.js +78 -0
- package/server/supervise.js +53 -0
- package/server/terminal.js +255 -0
- package/server/tools.js +52 -0
- package/server/usage.js +40 -0
- package/server/voice.js +174 -0
- package/shared/speakable.js +50 -0
package/docs/security.md
ADDED
|
@@ -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).
|
package/docs/terminal.md
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|
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,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
|
+
}
|