agent-office-studio 0.7.0 → 0.8.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 (38) hide show
  1. package/CHANGELOG.md +13 -1
  2. package/README.md +1 -1
  3. package/bin/agentoffice.js +6 -1
  4. package/dist/assets/index-BAvX7Imu.css +1 -0
  5. package/dist/assets/index-C01GpSoa.js +4999 -0
  6. package/dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  7. package/dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  8. package/dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  9. package/dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  10. package/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  11. package/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  12. package/dist/assets/{jetbrains-mono-cyrillic-400-normal-CIEtRijs.woff → jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff} +0 -0
  13. package/dist/assets/{jetbrains-mono-greek-400-normal-CNojz0t3.woff → jetbrains-mono-greek-400-normal-B9oWc5Lo.woff} +0 -0
  14. package/dist/assets/{jetbrains-mono-latin-400-normal-B11XCQ5g.woff → jetbrains-mono-latin-400-normal-6-qcROiO.woff} +0 -0
  15. package/dist/assets/{jetbrains-mono-latin-ext-400-normal-DHMOLxAi.woff → jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff} +0 -0
  16. package/dist/assets/{jetbrains-mono-vietnamese-400-normal-BeU3T8wf.woff → jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff} +0 -0
  17. package/dist/index.html +11 -2
  18. package/docs/phone.md +51 -3
  19. package/docs/security.md +8 -3
  20. package/package.json +4 -2
  21. package/server/cloudflared.js +84 -0
  22. package/server/economy.js +455 -0
  23. package/server/index.js +361 -16
  24. package/server/journeys.js +101 -0
  25. package/server/office-mcp.js +19 -0
  26. package/server/phone.js +70 -0
  27. package/server/settings.js +3 -0
  28. package/server/talk.js +51 -0
  29. package/server/tunnel.js +136 -0
  30. package/shared/shop.js +127 -0
  31. package/dist/assets/index-BtHSEs3B.js +0 -4475
  32. package/dist/assets/index-DVuFVmWF.css +0 -1
  33. package/dist/assets/inter-cyrillic-ext-wght-normal-B2xhLi22.woff2 +0 -0
  34. package/dist/assets/inter-cyrillic-wght-normal-CMZtQduZ.woff2 +0 -0
  35. package/dist/assets/inter-greek-ext-wght-normal-CGAr0uHJ.woff2 +0 -0
  36. package/dist/assets/inter-greek-wght-normal-CaVNZxsx.woff2 +0 -0
  37. package/dist/assets/inter-latin-ext-wght-normal-CFHvXkgd.woff2 +0 -0
  38. package/dist/assets/inter-latin-wght-normal-C2S99t-D.woff2 +0 -0
package/dist/index.html CHANGED
@@ -25,8 +25,8 @@
25
25
  })();
26
26
  </script>
27
27
  <title>Office</title>
28
- <script type="module" crossorigin src="/assets/index-BtHSEs3B.js"></script>
29
- <link rel="stylesheet" crossorigin href="/assets/index-DVuFVmWF.css">
28
+ <script type="module" crossorigin src="/assets/index-C01GpSoa.js"></script>
29
+ <link rel="stylesheet" crossorigin href="/assets/index-BAvX7Imu.css">
30
30
  </head>
31
31
  <body>
32
32
  <div class="app" id="app" data-m="office">
@@ -49,6 +49,9 @@
49
49
  <button type="button" class="theme-btn" id="acctBtn" aria-label="Claude access" aria-haspopup="true" aria-expanded="false" title="Claude access">
50
50
  <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
51
  </button>
52
+ <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">
53
+ <svg viewBox="0 0 24 24"><rect x="7" y="3" width="10" height="18" rx="2.5"/><path d="M11 18h2"/></svg>
54
+ </button>
52
55
  <button type="button" class="theme-btn" id="setBtn" aria-label="Settings" aria-haspopup="dialog" aria-expanded="false" title="Settings">
53
56
  <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
57
  </button>
@@ -60,6 +63,7 @@
60
63
  <div class="theme-pop lb-pop" id="lbPop" role="dialog" aria-label="Leaderboard" hidden></div>
61
64
  <div class="theme-pop link-pop" id="linkPop" role="dialog" aria-label="Linked offices" hidden></div>
62
65
  <div class="theme-pop acct-pop" id="acctPop" role="dialog" aria-label="Claude access" hidden></div>
66
+ <div class="theme-pop phone-pop" id="phonePop" role="dialog" aria-label="Open on your phone" hidden></div>
63
67
  </div>
64
68
  <div class="level">
65
69
  <div class="xpbar"><i id="xpbar"></i></div>
@@ -111,6 +115,7 @@
111
115
  <div class="ac" id="ac" hidden></div>
112
116
  <div class="tray" id="tray" hidden></div>
113
117
  <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>
118
+ <div class="attn" id="attn" role="region" aria-label="Needs your attention" hidden></div>
114
119
  <textarea id="input" rows="1" placeholder="Message the office · @agent #project" aria-label="Message the office"></textarea>
115
120
  <div class="controls">
116
121
  <button type="button" class="mic" id="mic" title="Record a voice note (hold ⌥M)" aria-label="Record a voice note">
@@ -190,6 +195,9 @@
190
195
  <div id="labels"></div>
191
196
 
192
197
  <div class="hud">
198
+ <button type="button" class="chip pot" id="shopBtn" aria-haspopup="dialog" aria-expanded="false" title="Shop and office pot" hidden><b>₦0</b><span>Shop</span></button>
199
+ <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>
200
+ <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
201
  <div class="chip away" id="away" hidden></div>
194
202
  <div class="chip" id="stats"></div>
195
203
  <div class="chip tools">
@@ -237,5 +245,6 @@
237
245
  </nav>
238
246
  </div>
239
247
  <dialog class="settings" id="settings" aria-label="Settings"></dialog>
248
+ <dialog class="settings shop" id="shop" aria-label="Shop"></dialog>
240
249
  </body>
241
250
  </html>
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.8.0",
4
4
  "description": "A 3D office where the staff are Claude Code agents.",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -45,10 +45,12 @@
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/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/wagers-server.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": {
@@ -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
+ }