agent-office-studio 0.8.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.
- package/CHANGELOG.md +48 -0
- package/README.md +12 -0
- package/dist/assets/code-Dtdckl8K.js +32 -0
- package/dist/assets/index-DxWppRAd.css +1 -0
- package/dist/assets/index-Y1a-xw5C.js +5040 -0
- package/dist/assets/pdf-BtCWNl9F.js +55 -0
- package/dist/assets/pdf-a7kwa8Fi.js +1 -0
- package/dist/assets/pdf.worker.min-BJd0dv5W.js +53 -0
- package/dist/assets/pdf.worker.min-D_0FwaWY.js +1 -0
- package/dist/index.html +24 -27
- package/dist/theme-boot.js +13 -0
- package/docs/desktop.md +52 -0
- package/package.json +18 -2
- package/server/changes.js +13 -2
- package/server/chief.js +17 -0
- package/server/economy.js +26 -3
- package/server/files.js +104 -0
- package/server/guests.js +59 -21
- package/server/index.js +392 -53
- package/server/links.js +11 -4
- package/server/machine.js +164 -0
- package/server/office-mcp.js +10 -0
- package/server/procstat.js +12 -6
- package/server/settings.js +2 -0
- package/server/visits.js +14 -4
- package/shared/shop.js +11 -4
- package/dist/assets/index-BAvX7Imu.css +0 -1
- package/dist/assets/index-C01GpSoa.js +0 -4999
|
@@ -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-
|
|
29
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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">
|
|
@@ -106,6 +93,7 @@
|
|
|
106
93
|
<button type="button" data-tab="jobs">Jobs <span id="jobCount"></span></button>
|
|
107
94
|
<button type="button" data-tab="projects">Projects</button>
|
|
108
95
|
<button type="button" data-tab="team">Team</button>
|
|
96
|
+
<button type="button" data-tab="needs">Needs you <span id="needsCount"></span></button>
|
|
109
97
|
</nav>
|
|
110
98
|
<section class="pane" id="chatPane">
|
|
111
99
|
<div class="channels" id="channels" role="tablist" aria-label="Channels"></div>
|
|
@@ -184,6 +172,9 @@
|
|
|
184
172
|
</div>
|
|
185
173
|
<div class="projects" id="projects"></div>
|
|
186
174
|
</section>
|
|
175
|
+
<section class="pane" id="needsPane" hidden>
|
|
176
|
+
<div class="jobs needs" id="needs"></div>
|
|
177
|
+
</section>
|
|
187
178
|
<section class="pane" id="jobsPane" hidden>
|
|
188
179
|
<div class="jobs" id="jobs"></div>
|
|
189
180
|
<button class="ghost" id="clear" hidden>Clear finished</button>
|
|
@@ -195,7 +186,7 @@
|
|
|
195
186
|
<div id="labels"></div>
|
|
196
187
|
|
|
197
188
|
<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>
|
|
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>
|
|
199
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>
|
|
200
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>
|
|
201
192
|
<div class="chip away" id="away" hidden></div>
|
|
@@ -210,6 +201,9 @@
|
|
|
210
201
|
<button type="button" id="recenter" title="Recenter (R)" aria-label="Recenter">
|
|
211
202
|
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3"/></svg>
|
|
212
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>
|
|
213
207
|
<button type="button" id="standup" title="Call a standup" aria-label="Call a standup">
|
|
214
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>
|
|
215
209
|
</button>
|
|
@@ -227,20 +221,23 @@
|
|
|
227
221
|
<aside class="detail" id="detail" hidden></aside>
|
|
228
222
|
|
|
229
223
|
<nav class="mnav" id="mnav" aria-label="Sections">
|
|
230
|
-
<button type="button" data-m="office" class="on">
|
|
231
|
-
<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>
|
|
232
229
|
</button>
|
|
233
|
-
<button type="button" data-m="
|
|
234
|
-
<svg viewBox="0 0 24 24"><path d="M4
|
|
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>
|
|
235
232
|
</button>
|
|
236
|
-
<button type="button" data-m="
|
|
237
|
-
<svg viewBox="0 0 24 24"><path d="
|
|
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>
|
|
238
235
|
</button>
|
|
239
|
-
<button type="button" data-m="
|
|
240
|
-
<svg viewBox="0 0 24 24"><path d="M3
|
|
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>
|
|
241
238
|
</button>
|
|
242
|
-
<button type="button" data-m="
|
|
243
|
-
<svg viewBox="0 0 24 24"><
|
|
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>
|
|
244
241
|
</button>
|
|
245
242
|
</nav>
|
|
246
243
|
</div>
|
|
@@ -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
|
+
})();
|
package/docs/desktop.md
ADDED
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-office-studio",
|
|
3
|
-
"version": "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,7 +45,7 @@
|
|
|
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/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",
|
|
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": {
|
|
@@ -54,10 +54,26 @@
|
|
|
54
54
|
"ws": "8.22.0"
|
|
55
55
|
},
|
|
56
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",
|
|
57
70
|
"@fontsource-variable/inter": "5.3.0",
|
|
58
71
|
"@fontsource/jetbrains-mono": "5.3.0",
|
|
72
|
+
"@lezer/highlight": "1.2.5",
|
|
73
|
+
"codemirror": "6.0.2",
|
|
59
74
|
"concurrently": "10.0.6",
|
|
60
75
|
"marked": "^18.1.0",
|
|
76
|
+
"pdfjs-dist": "6.4.299",
|
|
61
77
|
"three": "0.186.1",
|
|
62
78
|
"vite": "6.4.3"
|
|
63
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
|
|
30
|
-
|
|
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.
|
package/server/economy.js
CHANGED
|
@@ -19,6 +19,8 @@ const weekStart = (ms) => { const d = Math.floor(ms / 86400000); return (d - ((d
|
|
|
19
19
|
// moment it is offered, so a wallet can never go below zero.
|
|
20
20
|
export const BET = { minStake: 100, maxStake: 5000, maxOpen: 3, maxMinutes: 60, openMs: 6 * 3600000, liveMs: 24 * 3600000, keep: 40 };
|
|
21
21
|
export const GOAL = { minTarget: 1000, maxTarget: 1e9, maxName: 40 };
|
|
22
|
+
// A handed-off job whose brief is a review, a test pass or an audit.
|
|
23
|
+
const REVIEW = /review|adversarial|audit|qa\b|test/i;
|
|
22
24
|
const day = (ms) => new Date(ms).toISOString().slice(0, 10);
|
|
23
25
|
|
|
24
26
|
export function emptyBooks() {
|
|
@@ -57,7 +59,7 @@ export class Economy {
|
|
|
57
59
|
get books() { return this.b; }
|
|
58
60
|
wallet(id) { return this.b.wallets[id] || 0; }
|
|
59
61
|
statsOf(id) {
|
|
60
|
-
return (this.b.stats[id] ||= { jobs: 0, earned: 0, byTier: {}, kudos: 0, bought: 0, streak: 0, lastDay: '' });
|
|
62
|
+
return (this.b.stats[id] ||= { jobs: 0, earned: 0, byTier: {}, kudos: 0, bought: 0, streak: 0, lastDay: '', minutes: 0, cleanKudos: 0, comebacks: 0, handed: 0, reviews: 0 });
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
record(entry) {
|
|
@@ -109,6 +111,7 @@ export class Economy {
|
|
|
109
111
|
const again = !!job.payCounted;
|
|
110
112
|
job.payCounted = true;
|
|
111
113
|
const s = this.statsOf(job.agentId);
|
|
114
|
+
if (again) { job.reopens = (job.reopens || 0) + 1; s.comebacks = (s.comebacks || 0) + 1; }
|
|
112
115
|
let boost = 0;
|
|
113
116
|
if (!again) {
|
|
114
117
|
// The day's first finish keeps or restarts the streak, and the
|
|
@@ -124,11 +127,23 @@ export class Economy {
|
|
|
124
127
|
const e = this.credit(job.agentId, q.wage * (1 + boost) + (again ? 0 : q.bonus), { kind: 'pay', jobId: job.id, title: job.title, minutes: Math.round(q.minutes * 10) / 10, tier: q.tier, mult: q.mult, ...(boost ? { streak: s.streak, boost } : {}) });
|
|
125
128
|
if (e) job.earned = (job.earned || 0) + e.gross;
|
|
126
129
|
if (again) return { pay: e, achievements: this.milestones(job.agentId) };
|
|
130
|
+
// A hand-off from someone betting on this job is not teamwork, so it
|
|
131
|
+
// earns no hand-off badges. Checked before the bets close.
|
|
132
|
+
this.sweep();
|
|
133
|
+
const fromBet = this.b.bets.some((x) => this.boundTo(x, job) && this.inBet(x, job.requestedByAgent));
|
|
127
134
|
// A bet that cannot be settled cleanly must never hold up the pay.
|
|
128
135
|
try { this.settleBets(job, q.minutes); } catch (err) { this.record({ kind: 'bet', phase: 'error', jobId: job.id, why: err.message }); }
|
|
136
|
+
// Quality counters. "Quick" is against this agent's own average, so a
|
|
137
|
+
// slower model is not penalised, and short jobs don't count.
|
|
138
|
+
const quick = s.jobs >= 5 && q.minutes >= 0.5 && q.minutes < 0.5 * ((s.minutes || 0) / s.jobs);
|
|
139
|
+
s.minutes = (s.minutes || 0) + q.minutes;
|
|
140
|
+
if (job.requestedBy && job.status === 'done' && !fromBet) {
|
|
141
|
+
s.handed = (s.handed || 0) + 1;
|
|
142
|
+
if (REVIEW.test(`${job.title || ''} ${job.prompt || ''}`)) s.reviews = (s.reviews || 0) + 1;
|
|
143
|
+
}
|
|
129
144
|
s.jobs++;
|
|
130
145
|
if (q.tier) s.byTier[q.tier] = (s.byTier[q.tier] || 0) + 1;
|
|
131
|
-
const won = this.milestones(job.agentId, q);
|
|
146
|
+
const won = this.milestones(job.agentId, { ...q, quick });
|
|
132
147
|
return { pay: e, achievements: won };
|
|
133
148
|
}
|
|
134
149
|
|
|
@@ -156,7 +171,9 @@ export class Economy {
|
|
|
156
171
|
job.kudosPaid = true;
|
|
157
172
|
this.b.kudos.push(job.id);
|
|
158
173
|
if (this.b.kudos.length > 2000) this.b.kudos.splice(0, 1000);
|
|
159
|
-
this.statsOf(job.agentId)
|
|
174
|
+
const ks = this.statsOf(job.agentId);
|
|
175
|
+
ks.kudos++;
|
|
176
|
+
if (!job.reopens) ks.cleanKudos = (ks.cleanKudos || 0) + 1;
|
|
160
177
|
const e = this.credit(job.agentId, PAY.finishBonus * modelPay(job.model), { kind: 'kudos', jobId: job.id, title: job.title });
|
|
161
178
|
return { pay: e, achievements: this.milestones(job.agentId) };
|
|
162
179
|
}
|
|
@@ -415,11 +432,17 @@ export class Economy {
|
|
|
415
432
|
return bet.state === 'live' && bet.subject === job.agentId && Number.isFinite(job.startedAt) && bet.acceptedAt <= job.startedAt;
|
|
416
433
|
}
|
|
417
434
|
|
|
435
|
+
inBet(bet, id) {
|
|
436
|
+
return !!id && (id === bet.by || id === bet.against);
|
|
437
|
+
}
|
|
438
|
+
|
|
418
439
|
// The job finished for the first time, in `minutes` of active work.
|
|
419
440
|
settleBets(job, minutes) {
|
|
420
441
|
this.sweep();
|
|
421
442
|
for (const bet of this.b.bets.filter((x) => this.boundTo(x, job))) {
|
|
422
443
|
if (!(minutes > 0)) { this.close(bet, 'void', 'no working time was recorded'); continue; }
|
|
444
|
+
// A job one side of the bet handed over is one they could have sized to win.
|
|
445
|
+
if (this.inBet(bet, job.requestedByAgent)) { this.close(bet, 'void', 'the job came from someone in the bet'); continue; }
|
|
423
446
|
const under = minutes < bet.minutes;
|
|
424
447
|
this.close(bet, under ? 'won' : 'lost', `${Math.round(minutes * 10) / 10} min on "${job.title}"`, under ? bet.by : bet.against);
|
|
425
448
|
}
|
package/server/files.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Edit a project's files from the office: list a folder, read a text file,
|
|
2
|
+
// save it back. Everything stays inside one project folder (symlinks that
|
|
3
|
+
// lead out are refused), .git is off limits, and a save only lands if the
|
|
4
|
+
// file is still as it was when it was opened, so an agent mid-job and the
|
|
5
|
+
// boss never silently overwrite each other.
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
export const MAX_BYTES = 1024 * 1024;
|
|
11
|
+
const HIDDEN = new Set(['.git', 'node_modules', '.DS_Store']);
|
|
12
|
+
|
|
13
|
+
const fail = (code, message) => Object.assign(new Error(message), { code });
|
|
14
|
+
|
|
15
|
+
// The real path of `rel` inside `root`, or an error. A file that doesn't
|
|
16
|
+
// exist yet is checked through its nearest folder that does.
|
|
17
|
+
function resolve(root, rel, { mustExist = true } = {}) {
|
|
18
|
+
if (typeof rel !== 'string' || rel.includes('\0')) throw fail(400, 'Bad path.');
|
|
19
|
+
const parts = rel.split(/[\/\\]+/).filter((p) => p && p !== '.');
|
|
20
|
+
if (parts.includes('..')) throw fail(403, 'That path leads outside the project.');
|
|
21
|
+
if (parts.some((p) => p.toLowerCase() === '.git')) throw fail(403, 'The .git folder is off limits.');
|
|
22
|
+
const realRoot = fs.realpathSync(root);
|
|
23
|
+
const abs = path.join(realRoot, ...parts);
|
|
24
|
+
let probe = abs;
|
|
25
|
+
for (;;) {
|
|
26
|
+
try { fs.lstatSync(probe); break; } catch {
|
|
27
|
+
if (mustExist && probe === abs) throw fail(404, 'No such file.');
|
|
28
|
+
const up = path.dirname(probe);
|
|
29
|
+
if (up === probe) throw fail(404, 'No such file.');
|
|
30
|
+
probe = up;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const real = fs.realpathSync(probe);
|
|
34
|
+
if (real !== realRoot && !real.startsWith(realRoot + path.sep)) throw fail(403, 'That path leads outside the project.');
|
|
35
|
+
return { abs, rel: parts.join('/'), realRoot };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function list(root, rel = '') {
|
|
39
|
+
const { abs, rel: at } = resolve(root, rel);
|
|
40
|
+
let entries;
|
|
41
|
+
try { entries = fs.readdirSync(abs, { withFileTypes: true }); } catch (err) {
|
|
42
|
+
throw fail(err.code === 'ENOTDIR' ? 400 : 404, err.code === 'ENOTDIR' ? 'That is a file, not a folder.' : 'No such folder.');
|
|
43
|
+
}
|
|
44
|
+
const out = [];
|
|
45
|
+
for (const e of entries) {
|
|
46
|
+
if (HIDDEN.has(e.name)) continue;
|
|
47
|
+
// A link is shown as what it points to, if that is still inside.
|
|
48
|
+
let st;
|
|
49
|
+
try { st = fs.statSync(path.join(abs, e.name)); resolve(root, [at, e.name].filter(Boolean).join('/')); } catch { continue; }
|
|
50
|
+
out.push({ name: e.name, dir: st.isDirectory(), size: st.isDirectory() ? 0 : st.size });
|
|
51
|
+
}
|
|
52
|
+
out.sort((a, b) => (b.dir - a.dir) || a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
|
|
53
|
+
return { path: at, entries: out };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function read(root, rel) {
|
|
57
|
+
const { abs, rel: at } = resolve(root, rel);
|
|
58
|
+
const st = fs.statSync(abs);
|
|
59
|
+
if (!st.isFile()) throw fail(400, 'That is a folder, not a file.');
|
|
60
|
+
if (st.size > MAX_BYTES) throw fail(413, `Too big to edit here (${Math.round(st.size / 1024)} KB; the limit is ${MAX_BYTES / 1024} KB).`);
|
|
61
|
+
const buf = fs.readFileSync(abs);
|
|
62
|
+
if (buf.includes(0)) throw fail(415, 'That looks like a binary file, so it can’t be edited here.');
|
|
63
|
+
return { path: at, text: buf.toString('utf8'), mtime: st.mtimeMs, size: st.size };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// A PDF to show in the viewer: its real path and size, or an error. Only files
|
|
67
|
+
// that are really PDFs are served, whatever they are called.
|
|
68
|
+
export function pdf(root, rel) {
|
|
69
|
+
const { abs } = resolve(root, rel);
|
|
70
|
+
const st = fs.statSync(abs);
|
|
71
|
+
if (!st.isFile()) throw fail(400, 'That is a folder, not a file.');
|
|
72
|
+
if (!/\.pdf$/i.test(abs)) throw fail(415, 'That is not a PDF.');
|
|
73
|
+
const fd = fs.openSync(abs, 'r');
|
|
74
|
+
const head = Buffer.alloc(5);
|
|
75
|
+
try { fs.readSync(fd, head, 0, 5, 0); } finally { fs.closeSync(fd); }
|
|
76
|
+
if (head.toString('latin1') !== '%PDF-') throw fail(415, 'That file is not a valid PDF.');
|
|
77
|
+
return { abs, size: st.size };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// `mtime` is what read() gave; null means the file must not exist yet.
|
|
81
|
+
export function write(root, rel, text, mtime) {
|
|
82
|
+
if (typeof text !== 'string') throw fail(400, 'Nothing to save.');
|
|
83
|
+
if (Buffer.byteLength(text) > MAX_BYTES) throw fail(413, `Too big to save here (the limit is ${MAX_BYTES / 1024} KB).`);
|
|
84
|
+
const { abs, rel: at } = resolve(root, rel, { mustExist: false });
|
|
85
|
+
if (!at) throw fail(400, 'Which file?');
|
|
86
|
+
let st = null;
|
|
87
|
+
try { st = fs.statSync(abs); } catch {}
|
|
88
|
+
if (st?.isDirectory()) throw fail(400, 'That is a folder.');
|
|
89
|
+
if (st && mtime !== st.mtimeMs) throw fail(409, 'This file changed on disk since you opened it. Reload it to see what changed.');
|
|
90
|
+
if (!st && mtime != null) throw fail(409, 'This file was deleted since you opened it.');
|
|
91
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
92
|
+
// Written beside it and renamed, so a crash never leaves half a file, and
|
|
93
|
+
// the file keeps its permissions.
|
|
94
|
+
const tmp = `${abs}.${process.pid}.${Date.now()}.tmp`;
|
|
95
|
+
try {
|
|
96
|
+
fs.writeFileSync(tmp, text, { mode: st ? st.mode & 0o7777 : 0o644 });
|
|
97
|
+
fs.renameSync(tmp, abs);
|
|
98
|
+
} catch (err) {
|
|
99
|
+
try { fs.unlinkSync(tmp); } catch {}
|
|
100
|
+
throw fail(500, `Could not save: ${err.message}`);
|
|
101
|
+
}
|
|
102
|
+
const now = fs.statSync(abs);
|
|
103
|
+
return { path: at, mtime: now.mtimeMs, size: now.size };
|
|
104
|
+
}
|