agent-office-studio 0.7.0 → 0.9.1

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 (53) hide show
  1. package/CHANGELOG.md +61 -1
  2. package/README.md +13 -1
  3. package/bin/agentoffice.js +6 -1
  4. package/dist/assets/code-Dtdckl8K.js +32 -0
  5. package/dist/assets/index-DxWppRAd.css +1 -0
  6. package/dist/assets/index-Y1a-xw5C.js +5040 -0
  7. package/dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  8. package/dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  9. package/dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  10. package/dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  11. package/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  12. package/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  13. package/dist/assets/{jetbrains-mono-cyrillic-400-normal-CIEtRijs.woff → jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff} +0 -0
  14. package/dist/assets/{jetbrains-mono-greek-400-normal-CNojz0t3.woff → jetbrains-mono-greek-400-normal-B9oWc5Lo.woff} +0 -0
  15. package/dist/assets/{jetbrains-mono-latin-400-normal-B11XCQ5g.woff → jetbrains-mono-latin-400-normal-6-qcROiO.woff} +0 -0
  16. package/dist/assets/{jetbrains-mono-latin-ext-400-normal-DHMOLxAi.woff → jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff} +0 -0
  17. package/dist/assets/{jetbrains-mono-vietnamese-400-normal-BeU3T8wf.woff → jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff} +0 -0
  18. package/dist/assets/pdf-BtCWNl9F.js +55 -0
  19. package/dist/assets/pdf-a7kwa8Fi.js +1 -0
  20. package/dist/assets/pdf.worker.min-BJd0dv5W.js +53 -0
  21. package/dist/assets/pdf.worker.min-D_0FwaWY.js +1 -0
  22. package/dist/index.html +32 -26
  23. package/dist/theme-boot.js +13 -0
  24. package/docs/desktop.md +52 -0
  25. package/docs/phone.md +51 -3
  26. package/docs/security.md +8 -3
  27. package/package.json +20 -2
  28. package/server/changes.js +13 -2
  29. package/server/chief.js +17 -0
  30. package/server/cloudflared.js +84 -0
  31. package/server/economy.js +478 -0
  32. package/server/files.js +104 -0
  33. package/server/guests.js +59 -21
  34. package/server/index.js +743 -59
  35. package/server/journeys.js +101 -0
  36. package/server/links.js +11 -4
  37. package/server/machine.js +164 -0
  38. package/server/office-mcp.js +29 -0
  39. package/server/phone.js +70 -0
  40. package/server/procstat.js +12 -6
  41. package/server/settings.js +5 -0
  42. package/server/talk.js +51 -0
  43. package/server/tunnel.js +136 -0
  44. package/server/visits.js +14 -4
  45. package/shared/shop.js +134 -0
  46. package/dist/assets/index-BtHSEs3B.js +0 -4475
  47. package/dist/assets/index-DVuFVmWF.css +0 -1
  48. package/dist/assets/inter-cyrillic-ext-wght-normal-B2xhLi22.woff2 +0 -0
  49. package/dist/assets/inter-cyrillic-wght-normal-CMZtQduZ.woff2 +0 -0
  50. package/dist/assets/inter-greek-ext-wght-normal-CGAr0uHJ.woff2 +0 -0
  51. package/dist/assets/inter-greek-wght-normal-CaVNZxsx.woff2 +0 -0
  52. package/dist/assets/inter-latin-ext-wght-normal-CFHvXkgd.woff2 +0 -0
  53. package/dist/assets/inter-latin-wght-normal-C2S99t-D.woff2 +0 -0
@@ -0,0 +1 @@
1
+ function r(e){return new Worker("/assets/pdf.worker.min-BJd0dv5W.js",{name:e==null?void 0:e.name})}export{r as default};
package/dist/index.html CHANGED
@@ -11,27 +11,14 @@
11
11
  <link rel="manifest" href="/manifest.webmanifest" />
12
12
  <link rel="icon" href="/icon.svg" type="image/svg+xml" />
13
13
  <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
14
- <script>
15
- // Before first paint, so a saved theme never flashes the default. theme.js owns it after that.
16
- (function () {
17
- try {
18
- var t = localStorage.getItem('office.theme') || 'paper';
19
- var m = localStorage.getItem('office.mode') || 'auto';
20
- var d = m === 'dark' || (m === 'auto' && matchMedia('(prefers-color-scheme: dark)').matches);
21
- var r = document.documentElement;
22
- r.dataset.theme = t;
23
- r.dataset.mode = d ? 'dark' : 'light';
24
- } catch (e) {}
25
- })();
26
- </script>
14
+ <script src="/theme-boot.js"></script>
27
15
  <title>Office</title>
28
- <script type="module" crossorigin src="/assets/index-BtHSEs3B.js"></script>
29
- <link rel="stylesheet" crossorigin href="/assets/index-DVuFVmWF.css">
16
+ <script type="module" crossorigin src="/assets/index-Y1a-xw5C.js"></script>
17
+ <link rel="stylesheet" crossorigin href="/assets/index-DxWppRAd.css">
30
18
  </head>
31
19
  <body>
32
20
  <div class="app" id="app" data-m="office">
33
21
  <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>
35
22
  <header class="brand">
36
23
  <div class="title">
37
24
  <span class="title-start">
@@ -49,6 +36,9 @@
49
36
  <button type="button" class="theme-btn" id="acctBtn" aria-label="Claude access" aria-haspopup="true" aria-expanded="false" title="Claude access">
50
37
  <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>
51
38
  </button>
39
+ <button type="button" class="theme-btn" id="phoneBtn" aria-label="Open on your phone" aria-haspopup="true" aria-expanded="false" title="Open on your phone">
40
+ <svg viewBox="0 0 24 24"><rect x="7" y="3" width="10" height="18" rx="2.5"/><path d="M11 18h2"/></svg>
41
+ </button>
52
42
  <button type="button" class="theme-btn" id="setBtn" aria-label="Settings" aria-haspopup="dialog" aria-expanded="false" title="Settings">
53
43
  <svg viewBox="0 0 24 24"><path d="M4 7h9M17 7h3M4 17h3M11 17h9"/><circle cx="15" cy="7" r="2"/><circle cx="9" cy="17" r="2"/></svg>
54
44
  </button>
@@ -60,6 +50,7 @@
60
50
  <div class="theme-pop lb-pop" id="lbPop" role="dialog" aria-label="Leaderboard" hidden></div>
61
51
  <div class="theme-pop link-pop" id="linkPop" role="dialog" aria-label="Linked offices" hidden></div>
62
52
  <div class="theme-pop acct-pop" id="acctPop" role="dialog" aria-label="Claude access" hidden></div>
53
+ <div class="theme-pop phone-pop" id="phonePop" role="dialog" aria-label="Open on your phone" hidden></div>
63
54
  </div>
64
55
  <div class="level">
65
56
  <div class="xpbar"><i id="xpbar"></i></div>
@@ -102,6 +93,7 @@
102
93
  <button type="button" data-tab="jobs">Jobs <span id="jobCount"></span></button>
103
94
  <button type="button" data-tab="projects">Projects</button>
104
95
  <button type="button" data-tab="team">Team</button>
96
+ <button type="button" data-tab="needs">Needs you <span id="needsCount"></span></button>
105
97
  </nav>
106
98
  <section class="pane" id="chatPane">
107
99
  <div class="channels" id="channels" role="tablist" aria-label="Channels"></div>
@@ -111,6 +103,7 @@
111
103
  <div class="ac" id="ac" hidden></div>
112
104
  <div class="tray" id="tray" hidden></div>
113
105
  <div class="rec" id="rec" hidden><i></i><span id="recText">Recording</span><button type="button" id="recStop" class="link">Stop</button><button type="button" id="recDrop" class="link">Discard</button></div>
106
+ <div class="attn" id="attn" role="region" aria-label="Needs your attention" hidden></div>
114
107
  <textarea id="input" rows="1" placeholder="Message the office · @agent #project" aria-label="Message the office"></textarea>
115
108
  <div class="controls">
116
109
  <button type="button" class="mic" id="mic" title="Record a voice note (hold ⌥M)" aria-label="Record a voice note">
@@ -179,6 +172,9 @@
179
172
  </div>
180
173
  <div class="projects" id="projects"></div>
181
174
  </section>
175
+ <section class="pane" id="needsPane" hidden>
176
+ <div class="jobs needs" id="needs"></div>
177
+ </section>
182
178
  <section class="pane" id="jobsPane" hidden>
183
179
  <div class="jobs" id="jobs"></div>
184
180
  <button class="ghost" id="clear" hidden>Clear finished</button>
@@ -190,6 +186,9 @@
190
186
  <div id="labels"></div>
191
187
 
192
188
  <div class="hud">
189
+ <button type="button" class="chip pot" id="shopBtn" aria-haspopup="dialog" aria-expanded="false" aria-label="Shop and office pot" title="Shop and office pot" hidden><b>₦0</b><span>Shop</span></button>
190
+ <button type="button" class="chip pot shop-door" data-shop-tab="rich" aria-haspopup="dialog" aria-label="Forbes list: the richest agents" title="Forbes list: the richest agents" hidden><i aria-hidden="true">👑</i><span>Forbes list</span></button>
191
+ <button type="button" class="chip pot shop-door" data-shop-tab="looks" aria-haspopup="dialog" aria-label="Customise the office" title="Customise the office: layout, paint, floor and furniture" hidden><i aria-hidden="true">🎨</i><span>Customise</span></button>
193
192
  <div class="chip away" id="away" hidden></div>
194
193
  <div class="chip" id="stats"></div>
195
194
  <div class="chip tools">
@@ -202,6 +201,9 @@
202
201
  <button type="button" id="recenter" title="Recenter (R)" aria-label="Recenter">
203
202
  <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3"/></svg>
204
203
  </button>
204
+ <button type="button" id="arrange" aria-pressed="false" title="Arrange the office: move desks, tables and furniture" aria-label="Arrange the office">
205
+ <svg viewBox="0 0 24 24"><path d="M12 2v20M2 12h20M12 2l-3 3M12 2l3 3M12 22l-3-3M12 22l3-3M2 12l3-3M2 12l3 3M22 12l-3-3M22 12l-3 3"/></svg>
206
+ </button>
205
207
  <button type="button" id="standup" title="Call a standup" aria-label="Call a standup">
206
208
  <svg viewBox="0 0 24 24"><circle cx="12" cy="5" r="2"/><circle cx="5" cy="15" r="2"/><circle cx="19" cy="15" r="2"/><path d="M8.5 20.5a7.5 7.5 0 0 0 7 0M3.5 10.5a8.5 8.5 0 0 1 4.5-5M20.5 10.5a8.5 8.5 0 0 0-4.5-5"/></svg>
207
209
  </button>
@@ -219,23 +221,27 @@
219
221
  <aside class="detail" id="detail" hidden></aside>
220
222
 
221
223
  <nav class="mnav" id="mnav" aria-label="Sections">
222
- <button type="button" data-m="office" class="on">
223
- <svg viewBox="0 0 24 24"><path d="M3 10.5L12 4l9 6.5"/><path d="M5 9.5V20h14V9.5"/><path d="M10 20v-5h4v5"/></svg>Office
224
+ <button type="button" data-m="office" title="Office" aria-label="Office" class="on">
225
+ <svg viewBox="0 0 24 24"><path d="M3 10.5L12 4l9 6.5"/><path d="M5 9.5V20h14V9.5"/><path d="M10 20v-5h4v5"/></svg><span class="lbl">Office</span>
226
+ </button>
227
+ <button type="button" data-m="chat" title="Chat" aria-label="Chat">
228
+ <svg viewBox="0 0 24 24"><path d="M4 5h16v11H9l-5 4z"/></svg><span class="lbl">Chat</span><i class="dot" id="mChatDot" hidden></i>
224
229
  </button>
225
- <button type="button" data-m="chat">
226
- <svg viewBox="0 0 24 24"><path d="M4 5h16v11H9l-5 4z"/></svg>Chat<i class="dot" id="mChatDot" hidden></i>
230
+ <button type="button" data-m="jobs" title="Jobs" aria-label="Jobs">
231
+ <svg viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h10"/></svg><span class="lbl">Jobs</span><b id="mJobCount"></b>
227
232
  </button>
228
- <button type="button" data-m="jobs">
229
- <svg viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h10"/></svg>Jobs<b id="mJobCount"></b>
233
+ <button type="button" data-m="projects" title="Projects" aria-label="Projects">
234
+ <svg viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg><span class="lbl">Projects</span>
230
235
  </button>
231
- <button type="button" data-m="projects">
232
- <svg viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>Projects
236
+ <button type="button" data-m="team" title="Team" aria-label="Team">
237
+ <svg viewBox="0 0 24 24"><circle cx="9" cy="8" r="3.2"/><path d="M3 20a6 6 0 0 1 12 0"/><circle cx="17" cy="9" r="2.4"/><path d="M17 14.5a4.6 4.6 0 0 1 4 5.5"/></svg><span class="lbl">Team</span>
233
238
  </button>
234
- <button type="button" data-m="team">
235
- <svg viewBox="0 0 24 24"><circle cx="9" cy="8" r="3.2"/><path d="M3 20a6 6 0 0 1 12 0"/><circle cx="17" cy="9" r="2.4"/><path d="M17 14.5a4.6 4.6 0 0 1 4 5.5"/></svg>Team
239
+ <button type="button" data-m="needs" title="Needs you" aria-label="Needs you">
240
+ <svg viewBox="0 0 24 24"><path d="M6 17V11a6 6 0 0 1 12 0v6l1.5 2h-15z"/><path d="M10 21h4"/></svg><span class="lbl">Needs you</span><b id="mNeedsCount"></b>
236
241
  </button>
237
242
  </nav>
238
243
  </div>
239
244
  <dialog class="settings" id="settings" aria-label="Settings"></dialog>
245
+ <dialog class="settings shop" id="shop" aria-label="Shop"></dialog>
240
246
  </body>
241
247
  </html>
@@ -0,0 +1,13 @@
1
+ // Before first paint, so a saved theme never flashes the default. theme.js owns it after that.
2
+ // A file rather than inline, so the page's CSP can be script-src 'self'.
3
+ (function () {
4
+ try {
5
+ var t = localStorage.getItem('office.theme') || 'paper';
6
+ var m = localStorage.getItem('office.mode') || 'auto';
7
+ var d = m === 'dark' || (m === 'auto' && matchMedia('(prefers-color-scheme: dark)').matches);
8
+ var r = document.documentElement;
9
+ r.dataset.theme = t;
10
+ r.dataset.mode = d ? 'dark' : 'light';
11
+ r.dataset.nav = localStorage.getItem('office.nav') === 'sidebar' ? 'sidebar' : 'dock';
12
+ } catch (e) {}
13
+ })();
@@ -0,0 +1,52 @@
1
+ # Desktop app (Tauri)
2
+
3
+ The desktop app is a thin Tauri shell around the same office server and web UI you get from `agentoffice`. The shell lives in `desktop/`; this page covers how the web side behaves inside it.
4
+
5
+ ## How the shell starts the office
6
+
7
+ 1. Binds `127.0.0.1:0` to get a free port `N`.
8
+ 2. Runs `$SHELL -lc 'exec node <Resources>/app/bin/agentoffice.js --port N'`. The login shell means a Finder-launched app still finds `node` and `claude` on `PATH`.
9
+ 3. Sets `OFFICE_ROOT` (the folder you pick on first run, remembered), leaves `OFFICE_DATA` at `~/.agent-office`, and sets `OFFICE_DESKTOP=1`.
10
+ 4. Waits until `N` accepts connections, reads `~/.agent-office/office-key`, and points the window at `http://127.0.0.1:N/?key=…`.
11
+ 5. On quit, sends `SIGTERM` to the server and waits, so it flushes its state.
12
+
13
+ The server needs no desktop-specific behaviour: the phone QR code only prints with `--phone`, which the shell never passes.
14
+
15
+ ## External links
16
+
17
+ The webview drops `target="_blank"` links and `window.open`. `web/src/desktop.js` detects `window.__TAURI__` and sends those URLs to the opener plugin (`window.__TAURI__.opener.openUrl`). It handles:
18
+
19
+ - links to other sites (`https:`, `http:`, `mailto:`),
20
+ - `window.open(url)`.
21
+
22
+ It also adds a `desktop` class to `<html>` so CSS can adapt. In a normal browser the module does nothing.
23
+
24
+ Links to the office itself are left alone: the system browser doesn't have the webview's HttpOnly key cookie and would get a 401, and the key is stripped from the page URL so it can't be appended.
25
+
26
+ The shell must enable the opener plugin with a capability that allows the localhost page to call it.
27
+
28
+ ## Building it
29
+
30
+ ```
31
+ cd desktop && npm install
32
+ npm run dev # runs the repo; needs a built dist/
33
+ npm run build # stages from HEAD, writes src-tauri/target/release/bundle/{macos,dmg}
34
+ ```
35
+
36
+ Rust comes via rustup. The build log goes to `~/.agent-office/desktop.log`.
37
+
38
+ ## Opening a downloaded build
39
+
40
+ The dmg is signed ad hoc (`signingIdentity: "-"` in `tauri.conf.json`) and is **not notarized**: that needs an Apple *Developer ID Application* certificate and an Apple notarization login, which the project doesn't have. So on another Mac, Gatekeeper blocks the first launch of a downloaded copy.
41
+
42
+ Before the ad-hoc signing was added, the bundle carried only the linker's signature on the executable, with no sealed resources. Gatekeeper treats that as a broken signature and says "Agent Office is damaged and can't be opened". Now the signature is valid, and the message is the milder "can't be opened because Apple cannot check it for malicious software".
43
+
44
+ For users, any one of:
45
+
46
+ - Terminal: `xattr -cr "/Applications/Agent Office.app"`, then open it (drag it to Applications first). This strips the download quarantine flag.
47
+ - Right-click the app, choose Open, then Open again.
48
+ - macOS 15 and later: try to open it once, then System Settings, Privacy & Security, Open Anyway.
49
+
50
+ To remove the warning properly: enrol in the Apple Developer Program, create a Developer ID Application certificate, then build with `APPLE_SIGNING_IDENTITY="Developer ID Application: …"` and either `APPLE_ID`, `APPLE_PASSWORD` (app-specific) and `APPLE_TEAM_ID`, or `APPLE_API_KEY`, `APPLE_API_ISSUER` and `APPLE_API_KEY_PATH`. Tauri then signs, notarizes and staples the dmg. Those variables take precedence over the ad-hoc identity.
51
+
52
+ The dmg is arm64 only (Apple Silicon).
package/docs/phone.md CHANGED
@@ -8,10 +8,58 @@ for an office in its own window.
8
8
  The app shell is cached so it opens when the connection drops; live office
9
9
  state never is, because a stale office is worse than none.
10
10
 
11
- ## Reaching your machine
11
+ ## From anywhere: a public address
12
12
 
13
- The server only listens on `127.0.0.1`, so a phone can't reach it directly.
14
- The supported way is [Tailscale](https://tailscale.com): put the office behind
13
+ Press the phone button at the top of the side panel, then **Make a public
14
+ address**. The office starts a [Cloudflare quick tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/)
15
+ (no account), and shows a QR code for a random `https://….trycloudflare.com`
16
+ address with the office key on the end. Scan it from any network, mobile data
17
+ included. Nothing on your machine starts listening, and the tunnel goes out
18
+ from your machine, so no router settings change.
19
+
20
+ There is nothing to install. The office uses `cloudflared` if it is on your
21
+ PATH, and otherwise downloads a pinned release (about 20–55 MB, once) from
22
+ Cloudflare's GitHub into the data folder, checked against its SHA-256 before it
23
+ runs. macOS, Linux (x64, arm64, arm) and Windows (x64, x86). Any
24
+ `~/.cloudflared/config.yml` you have for your own tunnels is left alone and
25
+ ignored.
26
+
27
+ The QR code only appears once the office has reached itself through the new
28
+ address from the internet, which usually takes about a minute: a phone that
29
+ looks up a brand-new name too early can remember it as missing for a while.
30
+ If the address never works (a network that blocks Cloudflare's tunnel ports,
31
+ for instance), the button says so instead of handing you a blank page.
32
+
33
+ It is off until you press the button, closes when you press it again or the
34
+ office stops, and the address is different every time. The key is what protects
35
+ it: 64 random hex digits, over https. Treat the link like a password. Whoever
36
+ has it can run Claude in your projects with the clearance you give it. To sign
37
+ every browser out, delete `data/office-key` and restart.
38
+
39
+ ## Scan a QR code (same Wi-Fi)
40
+
41
+ The quickest way, for a phone on the same Wi-Fi as this machine:
42
+
43
+ ```bash
44
+ agentoffice --phone
45
+ ```
46
+
47
+ That also listens on your local network, and prints a QR code in the terminal
48
+ after the usual link. Scan it with the phone's camera and the office opens, key
49
+ included. The same code is in the office itself, under the phone button at the
50
+ top of the side panel. `OFFICE_LAN=1` does what `--phone` does.
51
+
52
+ Only this machine's own private addresses (`192.168.x.x`, `10.x.x.x`,
53
+ `172.16-31.x.x`) are accepted as the address, and the page still needs the key.
54
+ But it is plain `http`, and the link carries the key, so anyone who can watch
55
+ that network's traffic could copy it and run Claude in your projects. Use it at
56
+ home, never on a café, hotel or office network; elsewhere use Tailscale below.
57
+ With `OFFICE_ALLOWED_HOSTS` set, the QR points at that name instead.
58
+
59
+ ## Reaching your machine from anywhere
60
+
61
+ By default the server only listens on `127.0.0.1`, so a phone can't reach it
62
+ directly. Away from home, the supported way is [Tailscale](https://tailscale.com): put the office behind
15
63
  `tailscale serve` on this machine, then tell the office to accept that name.
16
64
 
17
65
  ```bash
package/docs/security.md CHANGED
@@ -5,7 +5,9 @@ hand out a shell. It is built to be used by one person, on their own machine.
5
5
 
6
6
  ## Who can reach it
7
7
 
8
- - The server listens on `127.0.0.1` only.
8
+ - The server listens on `127.0.0.1` only, unless you start it with `--phone`
9
+ (`OFFICE_LAN=1`), which also listens on your local network for a phone to
10
+ scan. See [On your phone](phone.md).
9
11
  - Every request's `Host` and `Origin` must be local (or a name in
10
12
  `OFFICE_ALLOWED_HOSTS`), which keeps other websites and DNS-rebinding
11
13
  attacks out of it. A page on another localhost port counts as another
@@ -19,8 +21,11 @@ hand out a shell. It is built to be used by one person, on their own machine.
19
21
  - Agents call back into the office (the roster, messages, hand-offs) with a
20
22
  per-job token that dies with the job.
21
23
 
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
+ The office never opens itself to the internet. The public address on the phone
25
+ button is yours to turn on and off; while it is on, the key in the link is the
26
+ only thing between the internet and your projects, so keep it private and turn
27
+ it off when you're done. Don't put the office behind any other public URL. See
28
+ [On your phone](phone.md).
24
29
 
25
30
  ## What jobs may do
26
31
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-office-studio",
3
- "version": "0.7.0",
3
+ "version": "0.9.1",
4
4
  "description": "A 3D office where the staff are Claude Code agents.",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -45,17 +45,35 @@
45
45
  "start": "vite build && node server/supervise.js",
46
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
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 && node scripts/remote.mjs && node scripts/lent-home.mjs && node scripts/visits.mjs",
48
+ "test": "node scripts/economy.mjs && node scripts/wagers.mjs && node scripts/smoke.mjs && node scripts/machine.mjs && node scripts/talk.mjs && node scripts/guests.mjs && node scripts/remote.mjs && node scripts/lent-home.mjs && node scripts/visits.mjs && node scripts/phone.mjs && node scripts/desktop-gate.mjs && node scripts/hardening-gate.mjs && node scripts/guest-gate.mjs && node scripts/wagers-server.mjs && node scripts/chief-gate.mjs && node scripts/files.mjs",
49
49
  "prepack": "vite build"
50
50
  },
51
51
  "dependencies": {
52
+ "@dagrejs/dagre": "^1.1.8",
53
+ "qrcode-generator": "2.0.4",
52
54
  "ws": "8.22.0"
53
55
  },
54
56
  "devDependencies": {
57
+ "@codemirror/autocomplete": "6.20.3",
58
+ "@codemirror/commands": "6.11.1",
59
+ "@codemirror/lang-css": "6.3.1",
60
+ "@codemirror/lang-html": "6.4.12",
61
+ "@codemirror/lang-javascript": "6.2.5",
62
+ "@codemirror/lang-json": "6.0.2",
63
+ "@codemirror/lang-markdown": "6.5.2",
64
+ "@codemirror/lang-python": "6.2.1",
65
+ "@codemirror/lang-yaml": "6.1.3",
66
+ "@codemirror/language": "6.13.1",
67
+ "@codemirror/search": "6.7.2",
68
+ "@codemirror/state": "6.7.6",
69
+ "@codemirror/view": "6.43.14",
55
70
  "@fontsource-variable/inter": "5.3.0",
56
71
  "@fontsource/jetbrains-mono": "5.3.0",
72
+ "@lezer/highlight": "1.2.5",
73
+ "codemirror": "6.0.2",
57
74
  "concurrently": "10.0.6",
58
75
  "marked": "^18.1.0",
76
+ "pdfjs-dist": "6.4.299",
59
77
  "three": "0.186.1",
60
78
  "vite": "6.4.3"
61
79
  }
package/server/changes.js CHANGED
@@ -18,6 +18,16 @@ const git = (cwd, args, env = {}) => new Promise((resolve, reject) => {
18
18
  });
19
19
  });
20
20
 
21
+ // Clean and smudge filters are commands a project's config names, and
22
+ // `add -A`, `write-tree` or a checkout would run them. These blank every one
23
+ // configured, for one call: git then passes the content through as it is.
24
+ export async function noFilters(cwd) {
25
+ let out = '';
26
+ try { out = await git(cwd, ['config', '--name-only', '--get-regexp', '^filter\\.']); } catch { return []; }
27
+ const names = new Set(out.split('\n').map((k) => k.match(/^filter\.(.+)\.[^.]+$/)?.[1]).filter(Boolean));
28
+ return [...names].flatMap((n) => [...['clean', 'smudge', 'process'].map((k) => `filter.${n}.${k}=`), `filter.${n}.required=false`].flatMap((c) => ['-c', c]));
29
+ }
30
+
21
31
  // A tree sha for the working tree as it is right now, or null outside a repo.
22
32
  export async function snapshotTree(cwd) {
23
33
  const tmp = path.join(os.tmpdir(), `office-index-${process.pid}-${Math.random().toString(36).slice(2)}`);
@@ -26,8 +36,9 @@ export async function snapshotTree(cwd) {
26
36
  // Seeding from the real index keeps `add -A` quick on a big tree.
27
37
  try { fs.copyFileSync(real, tmp); } catch {}
28
38
  const env = { GIT_INDEX_FILE: tmp };
29
- await git(cwd, ['add', '-A'], env);
30
- return (await git(cwd, ['write-tree'], env)).trim();
39
+ const nf = await noFilters(cwd);
40
+ await git(cwd, [...nf, 'add', '-A'], env);
41
+ return (await git(cwd, [...nf, 'write-tree'], env)).trim();
31
42
  } catch {
32
43
  return null;
33
44
  } finally {
package/server/chief.js CHANGED
@@ -32,6 +32,22 @@ const SCHEMA = {
32
32
  brief: { type: 'string' },
33
33
  model: { type: 'string', enum: ['haiku', 'sonnet', 'opus'] },
34
34
  escalate: { type: 'boolean' },
35
+ // More than one piece of new work from a single message, one per job.
36
+ jobs: {
37
+ type: 'array',
38
+ items: {
39
+ type: 'object',
40
+ properties: {
41
+ agentId: { type: 'string' },
42
+ repo: { type: 'string' },
43
+ brief: { type: 'string' },
44
+ model: { type: 'string', enum: ['haiku', 'sonnet', 'opus'] },
45
+ say: { type: 'string' },
46
+ },
47
+ required: ['repo', 'brief'],
48
+ additionalProperties: false,
49
+ },
50
+ },
35
51
  say: { type: 'string' },
36
52
  },
37
53
  required: ['action', 'say'],
@@ -43,6 +59,7 @@ const SYSTEM = `You are the chief of staff in an office of coding agents. The bo
43
59
  Actions:
44
60
  - "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
61
  - "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.
62
+ You can hand out several jobs from one message. When the boss tags more than one agent for work, or asks for separate pieces of work, give "jobs" instead: one entry per job, each with its own "repo", "brief", "agentId", "model" and a short "say" line for that job. Every tagged agent gets a job: the same brief for each when the boss wants them all on it (e.g. two agents doing a review pass), or a part each when the work splits naturally. Never tell the boss you can only hand out one job at a time.
46
63
  - "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
64
  - "recall": the boss wants dismissed agents back in. Give "agentIds" (every dismissed agent if they say "everyone").
48
65
  - "clear": the boss wants agents to clear their context (start fresh, forget the conversation so far). Do it yourself; never hand it out as a job. Give "agentIds" for the agents they name, or leave it empty when they mean everyone.
@@ -0,0 +1,84 @@
1
+ // Where the office gets `cloudflared` for the phone's public address, so that
2
+ // nobody has to install it: the one on the PATH if there is one, otherwise a
3
+ // pinned release downloaded once from Cloudflare's GitHub into the data
4
+ // folder and checked against the checksum below before it is ever run.
5
+
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import crypto from 'node:crypto';
9
+ import { spawnSync } from 'node:child_process';
10
+
11
+ export const VERSION = '2026.10.0';
12
+ export const BASE = `https://github.com/cloudflare/cloudflared/releases/download/${VERSION}`;
13
+ // Checked against the downloads themselves. For macOS the release notes give
14
+ // the binary inside the .tgz (`inner`); `sha256` is always the file fetched,
15
+ // and matches GitHub's own digest for it.
16
+ export const ASSETS = {
17
+ 'darwin-arm64': { name: 'cloudflared-darwin-arm64.tgz', sha256: 'a2f79ff7b9420aa537d74af239f376da170bbabeb529aec416002adac6a72e70', inner: '72edfd3eea463aef4d5cb89e2e209cecb048cc756c2b01915de2e0ad7cb39830' },
18
+ 'darwin-x64': { name: 'cloudflared-darwin-amd64.tgz', sha256: '903845b81828c8cb3c5d13d816a2de71c06a3da5785469df8eb0e1b736d92f9f', inner: '0560c9ab7281ac3f746055323623ed23bc0405b6dab9400474020cba33a978da' },
19
+ 'linux-x64': { name: 'cloudflared-linux-amd64', sha256: 'd33ff2d14475178d2012c2c56beba87389ac5ded27649519f198a7d3134a99db' },
20
+ 'linux-arm64': { name: 'cloudflared-linux-arm64', sha256: 'e6422b9d4f72d3194bc5a38676f13667c06666523217b842a877d72a80b5ac08' },
21
+ 'linux-arm': { name: 'cloudflared-linux-arm', sha256: '1dbe8e4ec17e74bb7f49cf91db6a4903bd0f9fe41984556c7503e40b765fd099' },
22
+ 'win32-x64': { name: 'cloudflared-windows-amd64.exe', sha256: '86aee4017b26625cee8484c113558f48effa4cd47f7aa05fcf425604e5d2b23c' },
23
+ 'win32-ia32': { name: 'cloudflared-windows-386.exe', sha256: '0630a8779e9823a1a3b091698b8e71874e0f7b205559219f52fdd301466b5546' },
24
+ };
25
+
26
+ const exe = (name) => (process.platform === 'win32' ? `${name}.exe` : name);
27
+
28
+ function onPath() {
29
+ for (const dir of String(process.env.PATH || '').split(path.delimiter)) {
30
+ if (!dir) continue;
31
+ const f = path.join(dir, exe('cloudflared'));
32
+ try { fs.accessSync(f, fs.constants.X_OK); if (fs.statSync(f).isFile()) return f; } catch {}
33
+ }
34
+ return null;
35
+ }
36
+
37
+ const sha = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
38
+
39
+ // Fetch url to dest, refusing it unless its SHA-256 is sha256. A .tgz is
40
+ // unpacked and its cloudflared kept, which must match inner. Nothing
41
+ // half-written is left at dest.
42
+ export async function download({ url, sha256, inner, dest, tgz = false }) {
43
+ const res = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(10 * 60 * 1000) });
44
+ if (!res.ok) throw new Error(`Couldn’t download cloudflared (${res.status}).`);
45
+ const buf = Buffer.from(await res.arrayBuffer());
46
+ if (sha(buf) !== sha256) throw new Error('The cloudflared download didn’t match its checksum, so it wasn’t used.');
47
+ const dir = path.dirname(dest);
48
+ fs.mkdirSync(dir, { recursive: true });
49
+ const work = fs.mkdtempSync(path.join(dir, '.dl-'));
50
+ try {
51
+ let bin;
52
+ if (tgz) {
53
+ const archive = path.join(work, 'cf.tgz');
54
+ fs.writeFileSync(archive, buf);
55
+ const r = spawnSync('tar', ['-xzf', archive, '-C', work], { stdio: 'ignore' });
56
+ bin = path.join(work, 'cloudflared');
57
+ if (r.status !== 0 || !fs.existsSync(bin)) throw new Error('Couldn’t unpack cloudflared.');
58
+ if (sha(fs.readFileSync(bin)) !== inner) throw new Error('The cloudflared download didn’t match its checksum, so it wasn’t used.');
59
+ } else {
60
+ bin = path.join(work, 'bin');
61
+ fs.writeFileSync(bin, buf);
62
+ }
63
+ fs.chmodSync(bin, 0o755);
64
+ fs.renameSync(bin, dest);
65
+ } finally {
66
+ fs.rmSync(work, { recursive: true, force: true });
67
+ }
68
+ return dest;
69
+ }
70
+
71
+ let pending = null;
72
+ // The cloudflared to run. onStep('download') says a download is starting.
73
+ export function findCloudflared({ dataDir, onStep = () => {} }) {
74
+ if (process.env.OFFICE_CLOUDFLARED) return Promise.resolve(process.env.OFFICE_CLOUDFLARED);
75
+ const found = onPath();
76
+ if (found) return Promise.resolve(found);
77
+ const dest = path.join(dataDir, 'bin', exe(`cloudflared-${VERSION}`));
78
+ if (fs.existsSync(dest)) return Promise.resolve(dest);
79
+ const asset = ASSETS[`${process.platform}-${process.arch}`];
80
+ if (!asset) return Promise.reject(new Error(`There’s no cloudflared for ${process.platform}-${process.arch} to download. Install it yourself and try again.`));
81
+ onStep('download');
82
+ pending ||= download({ url: `${BASE}/${asset.name}`, sha256: asset.sha256, inner: asset.inner, dest, tgz: asset.name.endsWith('.tgz') }).finally(() => { pending = null; });
83
+ return pending;
84
+ }