agent-office-studio 0.2.0 → 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/dist/index.html CHANGED
@@ -25,12 +25,13 @@
25
25
  })();
26
26
  </script>
27
27
  <title>Agent Office</title>
28
- <script type="module" crossorigin src="/assets/index-CozPk6R9.js"></script>
29
- <link rel="stylesheet" crossorigin href="/assets/index-i7sU9SN-.css">
28
+ <script type="module" crossorigin src="/assets/index-DgXF8Kqx.js"></script>
29
+ <link rel="stylesheet" crossorigin href="/assets/index-D5IzEWKU.css">
30
30
  </head>
31
31
  <body>
32
32
  <div class="app" id="app" data-m="office">
33
33
  <aside class="side">
34
+ <div class="side-grip" id="side-grip" role="separator" aria-orientation="vertical" aria-label="Resize the side panel" tabindex="0"></div>
34
35
  <header class="brand">
35
36
  <div class="title">
36
37
  <h1>Agent Office</h1>
@@ -39,6 +40,9 @@
39
40
  <button type="button" class="theme-btn" id="lbBtn" aria-label="Leaderboard" aria-haspopup="true" aria-expanded="false" title="Leaderboard">
40
41
  <svg viewBox="0 0 24 24"><path d="M8 4h8v5a4 4 0 0 1-8 0z"/><path d="M8 6H5v1a3 3 0 0 0 3 3"/><path d="M16 6h3v1a3 3 0 0 1-3 3"/><path d="M12 13v4"/><path d="M8.5 20h7"/></svg>
41
42
  </button>
43
+ <button type="button" class="theme-btn" id="linkBtn" aria-label="Linked offices" aria-haspopup="true" aria-expanded="false" title="Linked offices">
44
+ <svg viewBox="0 0 24 24"><path d="M10 14a4.5 4.5 0 0 0 6.4 0l3-3a4.5 4.5 0 0 0-6.4-6.4l-1.2 1.2"/><path d="M14 10a4.5 4.5 0 0 0-6.4 0l-3 3a4.5 4.5 0 0 0 6.4 6.4l1.2-1.2"/></svg>
45
+ </button>
42
46
  <button type="button" class="theme-btn" id="acctBtn" aria-label="Claude access" aria-haspopup="true" aria-expanded="false" title="Claude access">
43
47
  <svg viewBox="0 0 24 24"><circle cx="8" cy="15" r="4"/><path d="M10.8 12.2 20 3"/><path d="m17 6 3 3"/><path d="m14.5 8.5 2 2"/></svg>
44
48
  </button>
@@ -48,6 +52,7 @@
48
52
  </span>
49
53
  <div class="theme-pop" id="themePop" role="dialog" aria-label="Appearance" hidden></div>
50
54
  <div class="theme-pop lb-pop" id="lbPop" role="dialog" aria-label="Leaderboard" hidden></div>
55
+ <div class="theme-pop link-pop" id="linkPop" role="dialog" aria-label="Linked offices" hidden></div>
51
56
  <div class="theme-pop acct-pop" id="acctPop" role="dialog" aria-label="Claude access" hidden></div>
52
57
  </div>
53
58
  <div class="level">
package/docs/README.md CHANGED
@@ -11,6 +11,7 @@
11
11
  - [Using the office](using-the-office.md) — the chat, channels, threads, standup, the break room
12
12
  - [Jobs](jobs.md) — clearance, which model, what changed
13
13
  - [The leaderboard](leaderboard.md) — an optional board of jobs, tokens and agent time
14
+ - [Linked offices](linked-offices.md) — see a friend's agents in your office, and invite guests to run yours
14
15
 
15
16
  **Setting it up**
16
17
  - [Claude access](claude-access.md) — Claude Code login or API key, and the plan meters
@@ -10,8 +10,9 @@ voice toggle, team profiles, theme — are made in the office and remembered.
10
10
  |---|---|---|
11
11
  | `OFFICE_ROOT` | the folder above this one; with `agentoffice`, the folder you run it from | its subfolders are the projects |
12
12
  | `OFFICE_DATA` | `data/` here; with `agentoffice`, `~/.agent-office` | where the office keeps its state |
13
- | `PORT` | `4317` | |
13
+ | `PORT` | `4317` | with `agentoffice`, a free port is chosen if this is taken (or `--port <n>`); one you set is used as given |
14
14
  | `OFFICE_ALLOWED_HOSTS` | none | extra hostnames to accept, comma-separated; see [On your phone](phone.md) |
15
+ | `OFFICE_SHARE_JOB_TITLES` | unset | `1` lets linked offices see your agents' job titles, not just that they're busy |
15
16
 
16
17
  ## Claude
17
18
 
@@ -46,7 +47,7 @@ voice toggle, team profiles, theme — are made in the office and remembered.
46
47
 
47
48
  | Env | Default | |
48
49
  |---|---|---|
49
- | `OFFICE_LEADERBOARD_URL` | unset | address of a leaderboard service; see [The leaderboard](leaderboard.md) |
50
+ | `OFFICE_LEADERBOARD_URL` | the hosted board | address of a different leaderboard service; see [The leaderboard](leaderboard.md) |
50
51
  | `OFFICE_TERMINAL` | unset | `1` opens the in-app terminal |
51
52
  | `OFFICE_TERMINAL_SHELL` | your `$SHELL` | |
52
53
  | `OFFICE_TERMINAL_MAX` | `4` | sessions at once |
@@ -18,7 +18,10 @@ npm install
18
18
  npm start # builds, then serves on http://localhost:4317
19
19
  ```
20
20
 
21
- Open <http://localhost:4317>.
21
+ Open the link it prints, `Agent Office open on http://localhost:4317/?key=…`.
22
+ The key keeps other accounts on the machine out; your browser remembers it, so
23
+ after that <http://localhost:4317> is enough. Lost the link? `agentoffice --link`,
24
+ or from a clone, the key is in `data/office-key`.
22
25
 
23
26
  ## Where your projects are
24
27
 
@@ -5,8 +5,9 @@ Find the message you're seeing, or the symptom.
5
5
  ## The office won't start
6
6
 
7
7
  **`Error: listen EADDRINUSE: address already in use 127.0.0.1:4317`**
8
- Something is already on that port — usually another office. Find it with
9
- `lsof -i :4317`, or start this one elsewhere: `PORT=4400 npm start`.
8
+ Something is already on that port — usually another office. `agentoffice`
9
+ picks a free port on its own; to choose, use `agentoffice --port 4400`. With
10
+ `npm start`, find the other one with `lsof -i :4317` or set `PORT=4400`.
10
11
 
11
12
  **The page says "No build yet."**
12
13
  The server is running but `dist/` is empty. Run `npm run build`, or use
@@ -18,6 +19,16 @@ remote desktops and VMs), the 3D view can't draw.
18
19
 
19
20
  ## Jobs don't run
20
21
 
22
+ **Every message gets "I couldn't route that one".**
23
+ The chief is a short `claude -p` call, and it failed. The words after the colon
24
+ say why: an error from `claude` itself (signed out, out of credit, a model your
25
+ plan can't use), a timeout, or `claude` exiting without answering. To see the
26
+ same thing yourself, run this in the terminal you start the office from:
27
+ `claude -p --model sonnet "hi"`. It should answer within a few seconds. Fix
28
+ whatever it complains about, then send the message again. Versions before
29
+ 0.3.0 only ever said "The chief could not decide"; update to see the reason.
30
+ `/standup` and `/restart` don't go through the chief.
31
+
21
32
  **A job fails with "Could not start claude: spawn claude ENOENT".**
22
33
  Claude Code isn't on the `PATH` the office was started with. Check
23
34
  `which claude` in the same terminal. If it lives somewhere unusual, point the
@@ -1,7 +1,8 @@
1
1
  # The leaderboard
2
2
 
3
3
  An optional board where offices compare how much they got done. It is off
4
- until you join, and it needs a leaderboard service somebody hosts (see
4
+ until you join. Every office uses the same hosted service, https://agent-office-leaderboard.agent-office-leaderboard.workers.dev,
5
+ so there is nothing to set up (the service's code is in
5
6
  [`leaderboard/`](../leaderboard/README.md)).
6
7
 
7
8
  ## What is ranked
@@ -49,17 +50,18 @@ those day counters. Boards list handles and totals.
49
50
  ## Leaving
50
51
 
51
52
  "Leave and delete my data" asks the service to delete your handle and counters,
52
- then forgets the secret here. If the service can't be reached, nothing changes
53
+ then forgets the secret here. If you have [linked offices](linked-offices.md), only your counters are deleted: the handle stays, because the links need it. If the service can't be reached, nothing changes
53
54
  and you can try again. Your local `data/activity.json` stays; delete it if you
54
55
  want that gone too.
55
56
 
56
- ## Setting it up
57
+ ## Using a different service
57
58
 
58
- Set the address of your service and restart the office:
59
+ The hosted address is built in. To use a board you host yourself instead, set
60
+ its address and restart the office:
59
61
 
60
62
  ```
61
63
  OFFICE_LEADERBOARD_URL=https://your-service.example npm start
62
64
  ```
63
65
 
64
- It must be `https` (plain `http` works only for `localhost`). Without it the
65
- panel says no leaderboard is set up and nothing is ever sent.
66
+ It must be `https` (plain `http` works only for `localhost`). Either way,
67
+ nothing is sent until you join.
@@ -0,0 +1,81 @@
1
+ # Linked offices
2
+
3
+ Two Agent Offices on different machines can link with a code. Linked offices see
4
+ each other's agents walk into their 3D office as visitors. An office can also
5
+ invite a **guest**, who can then start jobs with the host's agents, within limits
6
+ the host sets.
7
+
8
+ Linking is off until you make or enter a code. It uses the same hosted service
9
+ as [the leaderboard](leaderboard.md), so there is nothing to set up.
10
+
11
+ ## Linking two offices
12
+
13
+ 1. One of you opens the chain button in the sidebar header and picks **Invite
14
+ an office**. If your office has no handle yet, it asks for one first. A
15
+ handle doesn't put you on the leaderboard.
16
+ 2. Send the code (it looks like `K7QX-M2PD`) to the other person however you
17
+ like. It works once and expires after 24 hours.
18
+ 3. They open the chain button, pick **Enter a code**, and paste it in. Case,
19
+ dashes and spaces don't matter.
20
+
21
+ Either of you can **Unlink** from the same panel at any time.
22
+
23
+ ## What a linked office sees
24
+
25
+ Your agents' names, roles, looks, what they are doing (working, idle, on a
26
+ break, in a meeting, gone home). That's all: no job titles, projects, files,
27
+ logs, prompts, chat or cost. To let linked offices see the titles of the jobs
28
+ your agents are on, start the office with `OFFICE_SHARE_JOB_TITLES=1`.
29
+
30
+ Their agents walk in at the door, wear a visitor tint, and carry the office's
31
+ handle on their nameplate (`Kai · @friend`). They don't use your desks. When
32
+ they're working they stand at the hot desks, and on a break they join the pool
33
+ table or the coffee. Up to six visitors are shown at once; the rest are counted
34
+ on a sign by the door.
35
+
36
+ ## Inviting a guest
37
+
38
+ A guest can start jobs with your agents, in your projects, on your Claude
39
+ account. Pick **Invite a guest** and choose:
40
+
41
+ - **Projects:** at least one. The guest can't see or touch any other project.
42
+ - **Clearance cap:** *Plan only*, or *Edit files* (the default). *Full autonomy*
43
+ has to be ticked on its own. It lets the guest run any command on this
44
+ machine.
45
+ - **Tokens per day:** 2,000,000 by default. Once a guest has used their tokens,
46
+ their running jobs stop and new ones are refused until midnight UTC.
47
+
48
+ A guest can start up to three jobs at a time, reply to them, stop them, and see
49
+ their logs and changes. They can't see your other jobs, chat or channels, and
50
+ they can't use the terminal, restart the office, or change staff or settings.
51
+ Their jobs show `from @handle` in your job list, and you can stop any of them.
52
+ Agents working for a guest can't message or hand work to your other agents.
53
+ They also don't get your MCP servers or claude.ai connectors (mail, Slack,
54
+ calendar and so on), your user-level Claude Code settings and hooks, your
55
+ Claude Code memory, or your attachments.
56
+ Unlinking a guest stops their running jobs straight away.
57
+
58
+ Even at *Edit files*, a guest can change files that run later, such as
59
+ `package.json` scripts, build files or git hooks. Only invite someone you would
60
+ trust with those projects. *Plan only* is the safe choice.
61
+
62
+ ## Visiting as a guest
63
+
64
+ When an office has made you its guest, its row in the Links panel has **Visit
65
+ @host**. It opens the host's office, showing their agents and your jobs there.
66
+ A bar at the top shows whose office you're in and your limits, and **Back to my
67
+ office** takes you home. Only the projects and clearances you're allowed are
68
+ offered.
69
+
70
+ ## What the service keeps
71
+
72
+ Your handle, a hash of your office's secret, which offices you're linked to, and
73
+ open invites (as hashes of the codes). Presence and messages pass through
74
+ without being stored. Guest limits and token counts stay on the host's machine,
75
+ in `data/links.json` and `data/guests.json`.
76
+
77
+ ## Using a different service
78
+
79
+ `OFFICE_LEADERBOARD_URL` points linking at another service too, as described in
80
+ [The leaderboard](leaderboard.md#using-a-different-service). Offices can only
81
+ link if they use the same service.
package/docs/phone.md CHANGED
@@ -22,6 +22,10 @@ OFFICE_ALLOWED_HOSTS=my-mac.tail1234.ts.net npm start
22
22
  `OFFICE_ALLOWED_HOSTS` takes exact hostnames, comma-separated. Every other
23
23
  host and origin is still refused.
24
24
 
25
+ On the phone, open the tailnet address once with the office key on the end,
26
+ `https://my-mac.tail1234.ts.net/?key=…` (the key is in the link the office
27
+ printed, or `agentoffice --link`). The phone keeps it after that.
28
+
25
29
  **Read this first.** Whoever can open that address can run Claude in your
26
30
  projects with full autonomy, and — if `OFFICE_TERMINAL=1` — get a shell on
27
31
  this machine. Only expose it on a tailnet that is just you. Never put it on
package/docs/security.md CHANGED
@@ -8,7 +8,14 @@ hand out a shell. It is built to be used by one person, on their own machine.
8
8
  - The server listens on `127.0.0.1` only.
9
9
  - Every request's `Host` and `Origin` must be local (or a name in
10
10
  `OFFICE_ALLOWED_HOSTS`), which keeps other websites and DNS-rebinding
11
- attacks out of it.
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.
12
19
  - Agents call back into the office (the roster, messages, hand-offs) with a
13
20
  per-job token that dies with the job.
14
21
 
@@ -37,10 +44,35 @@ command you could.
37
44
 
38
45
  Off until you join, and then the only thing besides Claude that the office
39
46
  talks to. It sends a handle and three counters per day (jobs, tokens, agent
40
- time) to the service named by `OFFICE_LEADERBOARD_URL`, signed with a random
47
+ time) to the hosted leaderboard service (or the one named by
48
+ `OFFICE_LEADERBOARD_URL`), signed with a random
41
49
  secret kept in `data/leaderboard.json` (mode `0600`). Never prompts, projects,
42
50
  files, chat or cost. Details in [The leaderboard](leaderboard.md).
43
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
+
44
76
  ## The terminal
45
77
 
46
78
  Off unless `OFFICE_TERMINAL=1`. A session needs a single-use ticket from the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-office-studio",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "A 3D office where the staff are Claude Code agents.",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -9,6 +9,7 @@
9
9
  "server",
10
10
  "shared",
11
11
  "docs",
12
+ "!docs/proposals",
12
13
  "CHANGELOG.md",
13
14
  "LICENSE",
14
15
  "README.md"
@@ -44,7 +45,7 @@
44
45
  "start": "vite build && node server/supervise.js",
45
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",
46
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",
47
- "test": "node scripts/smoke.mjs",
48
+ "test": "node scripts/smoke.mjs && node scripts/guests.mjs",
48
49
  "prepack": "vite build"
49
50
  },
50
51
  "dependencies": {
package/server/changes.js CHANGED
@@ -11,7 +11,9 @@ const MAX_FILES = 200;
11
11
  const MAX_DIFF = 400 * 1024;
12
12
 
13
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) => {
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) => {
15
17
  if (err) reject(err); else resolve(out);
16
18
  });
17
19
  });
@@ -67,6 +69,6 @@ export async function summarize(cwd, base, end) {
67
69
  // The patch for one file, only when the boss asks for it.
68
70
  export async function fileDiff(cwd, changes, file) {
69
71
  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]);
72
+ const out = await git(cwd, ['diff', '--no-renames', '--no-color', '--no-ext-diff', '--no-textconv', changes.base, changes.end, '--', file]);
71
73
  return out.length > MAX_DIFF ? `${out.slice(0, MAX_DIFF)}\n… diff cut at ${MAX_DIFF / 1024} KB` : out;
72
74
  }
package/server/chief.js CHANGED
@@ -90,7 +90,8 @@ function ask(context, { bin, model, timeoutMs, env, images }) {
90
90
  let out = '', err = '';
91
91
  child.stdout.on('data', (d) => (out += d));
92
92
  child.stderr.on('data', (d) => (err += d));
93
- const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs);
93
+ let timedOut = false;
94
+ const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, timeoutMs);
94
95
  child.on('error', (e) => { clearTimeout(timer); reject(e); });
95
96
  child.on('close', () => {
96
97
  clearTimeout(timer);
@@ -99,7 +100,7 @@ function ask(context, { bin, model, timeoutMs, env, images }) {
99
100
  const decision = r.structured_output || JSON.parse(r.result);
100
101
  resolve({ ...decision, cost: r.total_cost_usd || 0 });
101
102
  } catch {
102
- reject(new Error(err.trim().split('\n').pop() || 'The chief could not decide.'));
103
+ reject(new Error(whyNot(out, err, streamed, timedOut, timeoutMs)));
103
104
  }
104
105
  });
105
106
  child.stdin.on('error', () => {});
@@ -119,6 +120,21 @@ function message(context, images) {
119
120
  }
120
121
 
121
122
  // The decision is in the last line of the stream, the one the run ends on.
123
+ // Why a routing call gave no decision, in words the boss can act on. claude
124
+ // reports most failures (not signed in, out of credit, a model the account
125
+ // can't use) inside its JSON on stdout with nothing on stderr, so read that
126
+ // before falling back.
127
+ function whyNot(out, err, streamed, timedOut, timeoutMs) {
128
+ if (timedOut) return `The chief took longer than ${Math.round(timeoutMs / 1000)} seconds to answer. Check that \`claude -p "hi"\` answers in a terminal.`;
129
+ let r = null;
130
+ try { r = streamed ? result(out) : JSON.parse(out); } catch {}
131
+ const said = String(r?.is_error ? r.result || r.subtype || '' : '').trim();
132
+ if (said) return `claude said: ${said.split('\n')[0].slice(0, 300)}`;
133
+ const last = err.trim().split('\n').pop();
134
+ if (last) return last.slice(0, 300);
135
+ return out.trim() ? 'The chief could not decide.' : 'claude exited without answering. Check that `claude -p "hi"` answers in a terminal.';
136
+ }
137
+
122
138
  function result(out) {
123
139
  for (const line of out.trim().split('\n').reverse()) {
124
140
  try {