agent-office-studio 0.9.1 → 0.9.2

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.
@@ -1 +1 @@
1
- import{_ as f}from"./index-Y1a-xw5C.js";let h=null;async function v(){if(h)return h;const[a,{default:s}]=await Promise.all([f(()=>import("./pdf-BtCWNl9F.js"),[]),f(()=>import("./pdf.worker.min-D_0FwaWY.js"),[])]);return a.GlobalWorkerOptions.workerPort=new s,h=a}async function y(a,s){const g=await v(),i=await fetch(s);if(!i.ok)throw new Error((await i.json().catch(()=>({}))).error||`Could not open the PDF (${i.status}).`);const r=await g.getDocument({data:new Uint8Array(await i.arrayBuffer())}).promise;let c=!1;const d=new IntersectionObserver(e=>{for(const t of e)t.isIntersecting&&(d.unobserve(t.target),w(t.target))},{root:a,rootMargin:"600px 0px"});async function w(e){const t=await r.getPage(+e.dataset.n);if(c)return;const l=Math.min(devicePixelRatio||1,2),m=Math.min(a.clientWidth-24,980)/t.getViewport({scale:1}).width,o=t.getViewport({scale:m*l}),n=document.createElement("canvas");n.width=Math.floor(o.width),n.height=Math.floor(o.height),n.style.width=`${Math.floor(o.width/l)}px`,n.style.height=`${Math.floor(o.height/l)}px`,e.style.minHeight=n.style.height,await t.render({canvasContext:n.getContext("2d"),viewport:o,canvas:n}).promise,c||e.replaceChildren(n)}a.replaceChildren();const p=(await r.getPage(1)).getViewport({scale:1}),u=Math.round(Math.min(a.clientWidth-24,980)/p.width*p.height);for(let e=1;e<=r.numPages;e++){const t=document.createElement("div");t.className="fe-pdf-page",t.dataset.n=e,t.style.minHeight=`${u}px`,a.append(t),d.observe(t)}return{pages:r.numPages,destroy(){c=!0,d.disconnect(),r.destroy()}}}export{y as mountPdf};
1
+ import{_ as f}from"./index-qdaAZdZT.js";let h=null;async function v(){if(h)return h;const[a,{default:s}]=await Promise.all([f(()=>import("./pdf-BtCWNl9F.js"),[]),f(()=>import("./pdf.worker.min-D_0FwaWY.js"),[])]);return a.GlobalWorkerOptions.workerPort=new s,h=a}async function y(a,s){const g=await v(),i=await fetch(s);if(!i.ok)throw new Error((await i.json().catch(()=>({}))).error||`Could not open the PDF (${i.status}).`);const r=await g.getDocument({data:new Uint8Array(await i.arrayBuffer())}).promise;let c=!1;const d=new IntersectionObserver(e=>{for(const t of e)t.isIntersecting&&(d.unobserve(t.target),w(t.target))},{root:a,rootMargin:"600px 0px"});async function w(e){const t=await r.getPage(+e.dataset.n);if(c)return;const l=Math.min(devicePixelRatio||1,2),m=Math.min(a.clientWidth-24,980)/t.getViewport({scale:1}).width,o=t.getViewport({scale:m*l}),n=document.createElement("canvas");n.width=Math.floor(o.width),n.height=Math.floor(o.height),n.style.width=`${Math.floor(o.width/l)}px`,n.style.height=`${Math.floor(o.height/l)}px`,e.style.minHeight=n.style.height,await t.render({canvasContext:n.getContext("2d"),viewport:o,canvas:n}).promise,c||e.replaceChildren(n)}a.replaceChildren();const p=(await r.getPage(1)).getViewport({scale:1}),u=Math.round(Math.min(a.clientWidth-24,980)/p.width*p.height);for(let e=1;e<=r.numPages;e++){const t=document.createElement("div");t.className="fe-pdf-page",t.dataset.n=e,t.style.minHeight=`${u}px`,a.append(t),d.observe(t)}return{pages:r.numPages,destroy(){c=!0,d.disconnect(),r.destroy()}}}export{y as mountPdf};
package/dist/index.html CHANGED
@@ -13,8 +13,8 @@
13
13
  <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
14
14
  <script src="/theme-boot.js"></script>
15
15
  <title>Office</title>
16
- <script type="module" crossorigin src="/assets/index-Y1a-xw5C.js"></script>
17
- <link rel="stylesheet" crossorigin href="/assets/index-DxWppRAd.css">
16
+ <script type="module" crossorigin src="/assets/index-qdaAZdZT.js"></script>
17
+ <link rel="stylesheet" crossorigin href="/assets/index-BcqWv4NW.css">
18
18
  </head>
19
19
  <body>
20
20
  <div class="app" id="app" data-m="office">
@@ -92,13 +92,14 @@
92
92
  <button type="button" data-tab="chat" class="on">Chat</button>
93
93
  <button type="button" data-tab="jobs">Jobs <span id="jobCount"></span></button>
94
94
  <button type="button" data-tab="projects">Projects</button>
95
+ <button type="button" data-tab="apps">Apps</button>
95
96
  <button type="button" data-tab="team">Team</button>
96
97
  <button type="button" data-tab="needs">Needs you <span id="needsCount"></span></button>
97
98
  </nav>
98
99
  <section class="pane" id="chatPane">
99
- <div class="channels" id="channels" role="tablist" aria-label="Channels"></div>
100
- <div class="msgs" id="msgs"></div>
101
- <div class="typing" id="typing" hidden><span></span><span></span><span></span> Chief is reading</div>
100
+ <div class="d-fold d-top"><div class="d-fold-in"><div class="channels" id="channels" role="tablist" aria-label="Channels"></div>
101
+ </div></div> <div class="msgs" id="msgs"></div>
102
+ <div class="d-fold d-foot-fold"><div class="d-fold-in"><div class="typing" id="typing" hidden><span></span><span></span><span></span> Chief is reading</div>
102
103
  <form class="composer" id="composer">
103
104
  <div class="ac" id="ac" hidden></div>
104
105
  <div class="tray" id="tray" hidden></div>
@@ -143,20 +144,20 @@
143
144
  </select>
144
145
  <button class="primary small" type="submit">Send</button>
145
146
  </div>
146
- </form>
147
+ </form></div></div>
147
148
  </section>
148
149
  <section class="pane" id="threadPane" hidden>
149
- <header class="thread-head">
150
+ <div class="d-fold d-top"><div class="d-fold-in"><header class="thread-head">
150
151
  <button type="button" class="link" id="threadBack">← Chat</button>
151
152
  <b id="threadWhere">Thread</b>
152
- </header>
153
+ </header></div></div>
153
154
  <div class="msgs" id="threadMsgs"></div>
154
- <form class="composer" id="threadComposer">
155
+ <div class="d-fold d-foot-fold"><div class="d-fold-in"><form class="composer" id="threadComposer">
155
156
  <textarea id="threadInput" rows="1" placeholder="Reply in thread" aria-label="Reply in thread"></textarea>
156
157
  <div class="controls">
157
158
  <button class="primary small" type="submit">Reply</button>
158
159
  </div>
159
- </form>
160
+ </form></div></div>
160
161
  </section>
161
162
  <section class="pane" id="teamPane" hidden>
162
163
  <div class="viewbar" role="group" aria-label="View as">
@@ -172,6 +173,9 @@
172
173
  </div>
173
174
  <div class="projects" id="projects"></div>
174
175
  </section>
176
+ <section class="pane" id="appsPane" hidden>
177
+ <div class="home" id="home" aria-label="Apps"></div>
178
+ </section>
175
179
  <section class="pane" id="needsPane" hidden>
176
180
  <div class="jobs needs" id="needs"></div>
177
181
  </section>
@@ -233,6 +237,9 @@
233
237
  <button type="button" data-m="projects" title="Projects" aria-label="Projects">
234
238
  <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>
235
239
  </button>
240
+ <button type="button" data-m="apps" title="Apps" aria-label="Apps">
241
+ <svg viewBox="0 0 24 24"><rect x="4" y="4" width="6" height="6" rx="1.5"/><rect x="14" y="4" width="6" height="6" rx="1.5"/><rect x="4" y="14" width="6" height="6" rx="1.5"/><rect x="14" y="14" width="6" height="6" rx="1.5"/></svg><span class="lbl">Apps</span>
242
+ </button>
236
243
  <button type="button" data-m="team" title="Team" aria-label="Team">
237
244
  <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>
238
245
  </button>
@@ -0,0 +1,96 @@
1
+ # Agent Office: every feature
2
+
3
+ Compiled 2026-10-18 from the code, the docs and the changelog (as of 0.9.2).
4
+
5
+ ## 1. The office itself (3D)
6
+ - A 3D office (three.js) with desks, library, server room, research window, whiteboard, break room, pool table, reception, a guest wing.
7
+ - Agents walk to where their work happens: Read/Grep/Glob → library, Bash → server room, web/MCP → research window, plans/todos → whiteboard, edits → their desk.
8
+ - Camera: drag, right-drag or Q/E to turn, scroll to zoom, double-click to glide, WASD pan, R recenter; nearest walls drop so you can see in.
9
+ - Themes (Paper, Ocean, Forest, Sunset, Lavender), light/dark/auto.
10
+ - Lite rendering on phones (override with `?lite=0/1`).
11
+ - Build mode: pick up desks, the table, pool table or bought items, move on a snapped grid, saved per layout. Free layouts to switch between.
12
+ - Windows: Chat, Jobs, Projects, Team in draggable, resizable windows on wide screens (remembered); sidebar on phones. Dock mode with draggable job card.
13
+ - Right-click menu on the floor: tidy windows, open/close, recenter, appearance, settings.
14
+ - Header/footer fold away while scrolling lists.
15
+ - Header shows owner, level ring, usage gauges.
16
+
17
+ ## 2. Chat and the chief of staff
18
+ - Chat with `@agent` and `#project` tagging. Untagged messages go to the chief of staff.
19
+ - The chief: picks project and agent, hands follow-ups live to a running agent, resumes finished sessions, asks when unsure, can hand out several jobs from one message (one per person), reads screenshots.
20
+ - Chief picks the model (Haiku/Sonnet/Opus) to fit the work; moves a failed job up a rung.
21
+ - Attachments: paperclip, paste, drag-drop. Voice notes.
22
+ - Channels (Slack style): `#general`, `#chill-room`, your own; each message belongs to one channel; unread counts.
23
+ - Threads: job updates collect under the message that started the job.
24
+ - Slash commands: `/standup`, `/restart`, `/telegram`, "clear context".
25
+ - "Needs you" strip and pane: jobs waiting on you; dismiss one or all; answering clears it.
26
+ - Avatars drawn from each agent's look.
27
+
28
+ ## 3. Jobs
29
+ - Each job is a real `claude -p` process in a project folder, stdin open so messages reach it mid-task.
30
+ - Clearance per job: Plan only / Edit files / Full autonomy; grant more when blocked; approve a finished plan to build it.
31
+ - Model picker per job, switchable mid-job (resumes the same session).
32
+ - Auto-escalation on failure (Haiku → Sonnet → Opus) with a fresh session and summary.
33
+ - "What changed": git-tree snapshots before/after, files, lines +/-, per-file patch, Edit button per file; "Files edited" where git can't say.
34
+ - Job drawer: log, model, context size, cost and time (folded under Details).
35
+ - Context management: agents' `clear_context` with notes to self; auto-nudge past 150k tokens; the boss can say "clear context"; notes survive clears.
36
+ - Jobs survive server restarts (sessions resume).
37
+ - Machine care: limit on agents working at once (default 4), hold when memory/CPU struggles ("On hold" notice), turn-taking for heavy builds (`heavy_work`), leftover processes killed at job end, `keep_running` to keep one.
38
+ - Quality badges and XP; levels hire more agents (3 at level 1 up to 8).
39
+
40
+ ## 4. Agents and the team
41
+ - Roster of named agents with roles, personalities, looks and voices; rename them (one word); Team tab with list/icons toggle; "Meet your team" onboarding.
42
+ - Agent office tools (MCP): `office_roster`, `message_colleague` (optionally wait for reply), `hand_off`, `flag_for_boss`, `attach_files` (6 files, 25 MB each), `restart_office`, `clear_context`, `heavy_work`, `keep_running`, `add_app`, `office_shop`, `buy_item`, `bets`, `place_bet`, `take_bet`, `cancel_bet`, channel tools (`list_channels`, `create_channel`, `invite_to_channel`, `post_to_channel`).
43
+ - Hand-offs only to free colleagues, max depth two; shown as "Ada → Tunde".
44
+ - Daily standup at 9:30 on weekdays (or on demand): done / today / blockers, chief's wrap-up.
45
+ - Break room: small-talk written by a small model, eight-ball pool played shot by shot; results to `#chill-room`; can be turned off.
46
+ - Economy: agents are paid for finished work (more on lighter models), a shop for desk items, and bets between colleagues on jobs (stakes, take, cancel, voided if a party hands the job over).
47
+
48
+ ## 5. Projects and files
49
+ - Projects are the subfolders of the root folder; Projects tab (list/icons).
50
+ - Edit files in the office: folder tree and CodeMirror editor (syntax highlighting, line numbers, folding, bracket matching, find, undo, themed); Cmd/Ctrl+S saves; refuses to save if the file changed on disk.
51
+ - PDF viewer (pdf.js), served only if truly a PDF.
52
+ - Image/file attachments from agents shown in chat.
53
+
54
+ ## 6. Apps on the home screen
55
+ - Agents build self-contained HTML apps and register them with `add_app`; each gets an icon and opens in a sandboxed window (`data/apps.json`, `/api/apps`).
56
+
57
+ ## 7. Telegram
58
+ - Talk to the office from your phone through your own bot (made with @BotFather). The office long-polls, so no tunnel or webhook is needed.
59
+ - Setup: `/telegram <token>` in the office, then `/telegram pair` for a one-time code (valid 15 minutes, 5 tries), then `/pair 123456` to the bot. `/telegram off` forgets the bot; `/telegram` shows status.
60
+ - Only the paired account works; everyone else and any group chat is dropped unread. The token is kept in `data/telegram.json` (0600), not in state or chat.
61
+ - Messages go to the office as chat; replies, finished jobs and flags come back. Reply to a message to answer in its thread.
62
+ - `/grant` (reply to a blocked job) gives full autonomy; `/grant all` for every blocked job; `/auto <request>` starts a job at full autonomy.
63
+ - Photos, videos, voice notes, audio and documents come in (up to 20 MB); agent attachments go out as photos, video, audio or files (up to 50 MB).
64
+ - Long messages are clipped with a pointer to the office; sending retries on rate limits and keeps order. Break-room chatter is never sent.
65
+
66
+ ## 8. Talking and hearing
67
+ - Dictation with whisper.cpp and a different Piper voice per agent, all local; mute and chime controls; desktop notifications when a job ships.
68
+
69
+ ## 9. Linked offices
70
+ - Pair two offices with a one-time code; agents appear as visitors in a guest wing (12 hot desks, coffee bar, meeting table, up to 10 visitors).
71
+ - Invite guests to use your agents (pick which agents, projects, clearance, daily token limit), or let their agents work in your projects (read only, or edit a copy you review and apply or discard).
72
+ - Tag a host's agents from your chat (`@Ada@host`) and send yours to their project (`#repo@host`). Job titles shared only if you allow it.
73
+
74
+ ## 10. Leaderboard
75
+ - Optional board of jobs, tokens and agent time; a handle is separate from sharing; nothing shared until you join.
76
+
77
+ ## 11. Claude access and usage
78
+ - Claude Code login or API key; plan usage meters; token limit and compaction point; context limit settable in the office.
79
+
80
+ ## 12. Settings
81
+ - Settings page: owner, token limit, compaction point, chief model, standup time and weekends, job-title sharing, break-room talk, agents working at once. Saved values beat environment variables; "Use the default" reverts.
82
+ - Environment options include `OFFICE_ROOT`, `OFFICE_DATA`, `PORT`, `CLAUDE_BIN`, `STANDUP_AT`, `BANTER*`, `WHISPER_*`, `PIPER_BIN`, `FFMPEG_BIN`, `OFFICE_LEADERBOARD_URL`, `OFFICE_MAX_AGENTS`.
83
+
84
+ ## 13. Phone, desktop and install
85
+ - `agentoffice --phone` listens on the LAN and prints a QR code; or "Make a public address" starts a Cloudflare quick tunnel with a QR code (off until pressed; the key stays the only way in).
86
+ - Installable as a PWA.
87
+ - Desktop app (Tauri, macOS) with a designed DMG; starts the office on a free port.
88
+ - `npm install -g agent-office-studio`, then `agentoffice` or `agentstudio`; picks a free port; `--link` reprints the key link.
89
+
90
+ ## 14. Security
91
+ - Listens on localhost only by default; page, API and socket need a key (`data/office-key`, 0600), the key cookie holds a MAC, and a CSP limits images and forms.
92
+ - Only the office's own page may call the API; malformed URLs don't crash it.
93
+ - Guests' jobs don't get your MCP servers, settings, hooks or memory; their tokens are counted per call; unlinked guests' jobs are stopped.
94
+ - Git runs with fsmonitor and external diff drivers off; snapshots never run clean/smudge filters.
95
+ - Below Full autonomy, `attach_files` takes only files from the job's own project; job tokens stay out of the projects folder.
96
+ - Dependencies pinned and patched (ws, Vite/esbuild); CI plus npm publish on `v*` tags.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-office-studio",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "description": "A 3D office where the staff are Claude Code agents.",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -42,10 +42,11 @@
42
42
  "scripts": {
43
43
  "dev": "concurrently -k -n server,web -c blue,magenta \"node --watch server/index.js\" \"vite\"",
44
44
  "build": "vite build",
45
+ "watch": "vite build --watch",
45
46
  "start": "vite build && node server/supervise.js",
46
47
  "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
48
  "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/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
+ "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 && node scripts/apps.mjs && node scripts/telegram.mjs",
49
50
  "prepack": "vite build"
50
51
  },
51
52
  "dependencies": {
package/server/apps.js ADDED
@@ -0,0 +1,146 @@
1
+ // The office's apps: what the home screen shows. Each app is one manifest
2
+ // entry in data/apps.json. The boss asks for an app, an agent builds it and
3
+ // registers it with the add_app tool (or POST /api/apps), and it gets an icon.
4
+ //
5
+ // `kind` says how an app opens. KINDS checks what each kind needs; the web
6
+ // side (web/src/apps.js) has the matching opener. To add a kind, add it in
7
+ // both places.
8
+ //
9
+ // A manifest entry:
10
+ // id slug, unique; made from the name if not given
11
+ // name what the icon says
12
+ // icon an emoji (or a few characters)
13
+ // color the icon's tile, #rgb or #rrggbb (optional)
14
+ // description one line, shown on hover (optional)
15
+ // kind 'page': a self-contained HTML file in a project, run sandboxed
16
+ // repo, path for 'page': the project folder and the file inside it
17
+ // w, h the window's starting size (optional)
18
+ // addedBy, addedAt
19
+
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+ import * as files from './files.js';
23
+
24
+ const fail = (code, msg) => Object.assign(new Error(msg), { code });
25
+ const MAX_APPS = 200;
26
+ const DATA_MAX = 1e6;
27
+ const ID_RE = /^[a-z0-9][a-z0-9-]{0,39}$/;
28
+
29
+ export const slug = (s) => String(s || '').toLowerCase().normalize('NFKD').replace(/[^\w\s-]/g, '').trim().replace(/[\s_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
30
+
31
+ const text = (v, label, max, need = false) => {
32
+ if (v == null || v === '') { if (need) throw fail(400, `An app needs a ${label}.`); return ''; }
33
+ if (typeof v !== 'string') throw fail(400, `The ${label} must be text.`);
34
+ v = v.trim().replace(/\s+/g, ' ');
35
+ if (need && !v) throw fail(400, `An app needs a ${label}.`);
36
+ if (v.length > max) throw fail(400, `The ${label} is limited to ${max} characters.`);
37
+ return v;
38
+ };
39
+
40
+ // What each kind needs, checked when an app is added. `repoPath` turns a
41
+ // project name into its folder, or null.
42
+ export const KINDS = {
43
+ page: {
44
+ clean(a, { repoPath }) {
45
+ const repo = text(a.repo, 'project folder', 200, true);
46
+ const dir = repoPath(repo);
47
+ if (!dir) throw fail(400, `There is no project folder called "${repo}".`);
48
+ const rel = text(a.path, 'file path', 500, true).replace(/^\/+/, '');
49
+ files.page(dir, rel); // throws if it is missing, not HTML or too big
50
+ return { repo, path: rel };
51
+ },
52
+ },
53
+ };
54
+
55
+ export class Apps {
56
+ constructor(dataDir, { repoPath, onChange = () => {} }) {
57
+ this.file = path.join(dataDir, 'apps.json');
58
+ this.repoPath = repoPath;
59
+ this.onChange = onChange;
60
+ this.apps = [];
61
+ try {
62
+ const saved = JSON.parse(fs.readFileSync(this.file, 'utf8'));
63
+ if (Array.isArray(saved.apps)) this.apps = saved.apps.filter((a) => a && ID_RE.test(a.id) && KINDS[a.kind]);
64
+ } catch {}
65
+ }
66
+
67
+ list() { return this.apps.map((a) => ({ ...a })); }
68
+
69
+ get(id) { return this.apps.find((a) => a.id === id) || null; }
70
+
71
+ // Adds an app, or replaces the one with the same id (so re-registering an
72
+ // app after a rebuild updates it). Returns the stored entry.
73
+ add(input, by = 'boss') {
74
+ if (!input || typeof input !== 'object') throw fail(400, 'Nothing to add.');
75
+ const name = text(input.name, 'name', 40, true);
76
+ const kind = text(input.kind || 'page', 'kind', 20);
77
+ if (!KINDS[kind]) throw fail(400, `Unknown kind of app "${kind}". Known: ${Object.keys(KINDS).join(', ')}.`);
78
+ const id = input.id ? text(input.id, 'id', 40) : slug(name);
79
+ if (!ID_RE.test(id)) throw fail(400, 'The id must be lowercase letters, numbers and dashes.');
80
+ const icon = text(input.icon, 'icon', 8) || name.slice(0, 1).toUpperCase();
81
+ const color = text(input.color, 'colour', 7);
82
+ if (color && !/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(color)) throw fail(400, 'The colour must be a hex colour like #1d4ed8.');
83
+ for (const v of [input.w, input.h]) if (v != null && !(Number.isInteger(v) && v >= 300 && v <= 4000)) throw fail(400, 'Window sizes are whole pixels from 300 to 4000.');
84
+ const description = text(input.description, 'description', 200);
85
+ const was = this.get(id);
86
+ if (!was && this.apps.length >= MAX_APPS) throw fail(400, `The home screen holds ${MAX_APPS} apps; remove one first.`);
87
+ const app = {
88
+ id, name, icon, kind,
89
+ ...(color && { color }),
90
+ ...(description && { description }),
91
+ ...KINDS[kind].clean(input, { repoPath: this.repoPath }),
92
+ ...(input.w && { w: input.w }), ...(input.h && { h: input.h }),
93
+ addedBy: was?.addedBy || by, addedAt: was?.addedAt || Date.now(),
94
+ ...(was && { updatedAt: Date.now() }),
95
+ };
96
+ this.apps = was ? this.apps.map((a) => (a.id === id ? app : a)) : [...this.apps, app];
97
+ this.save();
98
+ return app;
99
+ }
100
+
101
+ remove(id) {
102
+ if (!this.get(id)) throw fail(404, 'No app with that id.');
103
+ this.apps = this.apps.filter((a) => a.id !== id);
104
+ this.save();
105
+ }
106
+
107
+ // The order the icons sit in. Ids not given keep their place at the end.
108
+ reorder(ids) {
109
+ if (!Array.isArray(ids)) throw fail(400, 'Give the app ids in order.');
110
+ const rank = new Map(ids.map((id, i) => [id, i]));
111
+ this.apps = [...this.apps].sort((a, b) => (rank.get(a.id) ?? 1e9) - (rank.get(b.id) ?? 1e9));
112
+ this.save();
113
+ }
114
+
115
+ // What an app keeps for itself (its page has no storage of its own): one
116
+ // JSON value per app in app-data/<id>.json, so every window onto this
117
+ // office (a browser tab, the desktop app on whatever port) sees the same.
118
+ dataFile(id) {
119
+ if (!ID_RE.test(id || '')) throw fail(400, 'No app with that id.');
120
+ return path.join(path.dirname(this.file), 'app-data', `${id}.json`);
121
+ }
122
+
123
+ readData(id) {
124
+ try { return JSON.parse(fs.readFileSync(this.dataFile(id), 'utf8')); } catch (err) {
125
+ if (err.code === 400) throw err;
126
+ return null;
127
+ }
128
+ }
129
+
130
+ writeData(id, value) {
131
+ const file = this.dataFile(id);
132
+ const text = JSON.stringify(value ?? null);
133
+ if (text.length > DATA_MAX) throw fail(413, 'That is more than an app can keep (1 MB).');
134
+ fs.mkdirSync(path.dirname(file), { recursive: true });
135
+ fs.writeFileSync(file + '.tmp', text);
136
+ fs.renameSync(file + '.tmp', file);
137
+ }
138
+
139
+ save() {
140
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
141
+ const tmp = this.file + '.tmp';
142
+ fs.writeFileSync(tmp, JSON.stringify({ apps: this.apps }, null, 2));
143
+ fs.renameSync(tmp, this.file);
144
+ this.onChange(this.list());
145
+ }
146
+ }
package/server/chief.js CHANGED
@@ -24,7 +24,7 @@ export function canSee(files) {
24
24
  const SCHEMA = {
25
25
  type: 'object',
26
26
  properties: {
27
- action: { type: 'string', enum: ['followup', 'new', 'dismiss', 'recall', 'clear', 'standup', 'ask', 'chat'] },
27
+ action: { type: 'string', enum: ['followup', 'new', 'dismiss', 'recall', 'clear', 'standup', 'restart', 'takeover', 'ask', 'chat'] },
28
28
  jobId: { type: 'string' },
29
29
  agentId: { type: 'string' },
30
30
  agentIds: { type: 'array', items: { type: 'string' } },
@@ -63,6 +63,8 @@ Actions:
63
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.
64
64
  - "recall": the boss wants dismissed agents back in. Give "agentIds" (every dismissed agent if they say "everyone").
65
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.
66
+ - "restart": the boss says an agent is stuck, frozen or not responding and wants it nudged, unstuck or restarted. Give the jobId of that agent's working job. Do it yourself: a stuck session can't read messages, so never send it a followup.
67
+ - "takeover": the boss wants someone else to help with, take over or finish a working agent's job (often because it is stuck). Give its jobId, and "agentId" if they named who should take it (otherwise "" for whoever is free). Never pick the agent who was on it.
66
68
  - "standup": the boss wants the team to hold a standup now (e.g. "standup time", "let's do standup", "gather everyone for a status update").
67
69
  - "ask": you can't tell which project new work belongs to, or the request is too unclear to start. Ask one short question.
68
70
  - "chat": greetings, thanks, or questions about the office itself (who is doing what). Answer from the context given. If your answer is about one particular job, give its jobId too, so the boss gets a link to where that job's updates are collecting.
package/server/files.js CHANGED
@@ -77,6 +77,17 @@ export function pdf(root, rel) {
77
77
  return { abs, size: st.size };
78
78
  }
79
79
 
80
+ // An HTML page to show in the preview: its real path and size, or an error.
81
+ export const MAX_PAGE = 8 * 1024 * 1024;
82
+ export function page(root, rel) {
83
+ const { abs } = resolve(root, rel);
84
+ const st = fs.statSync(abs);
85
+ if (!st.isFile()) throw fail(400, 'That is a folder, not a file.');
86
+ if (!/\.html?$/i.test(abs)) throw fail(415, 'That is not an HTML page.');
87
+ if (st.size > MAX_PAGE) throw fail(413, `Too big to preview (${Math.round(st.size / 1048576)} MB; the limit is ${MAX_PAGE / 1048576} MB).`);
88
+ return { abs, size: st.size };
89
+ }
90
+
80
91
  // `mtime` is what read() gave; null means the file must not exist yet.
81
92
  export function write(root, rel, text, mtime) {
82
93
  if (typeof text !== 'string') throw fail(400, 'Nothing to save.');