coworking-agents 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.0 — 2026-10-14
4
+ - Fix: the test suite no longer stops a running office started at login (it turned the login item off through the real launchd label)
5
+ - Three new rooms on the office floor, under the game room: the library, the lab and the waiting room. The agents stay at their desks; walk into a room (arrows/WASD or a click) and you see who is reading or browsing, running commands, or waiting on you right now. A number on the door says how many; Enter next to someone opens them (the waiting room opens the question)
6
+ - Walk inside the rooftop, the trophy room and the server room too; the elevator door in the corner takes you back
7
+
3
8
  ## 0.9.1 — 2026-10-14
4
9
  - ☰ → Settings → *Open with the computer*: the start-at-login switch right in the menu, with its green dot (it also stays in Personalise)
5
10
  - The browser tab shows the new app icon (the agent at its desk) instead of the old face; the menu bar keeps the small face, which reads best at that size
package/README.md CHANGED
@@ -212,13 +212,16 @@ The 🔔 bell collects agent requests, task updates, recommendations (*Apply*, *
212
212
  The office works in any browser. If you prefer an app:
213
213
 
214
214
  - **Create the app** (Personalise, or `npx coworking-agents install-app`): a *Coworking Agents* app made on your computer — free, no store, no signing — in Applications (macOS), the Start Menu and Desktop (Windows) or the applications menu (Linux). Opening it starts the office if needed and shows it in an app window. `uninstall-app` removes it.
215
- - **On the phone:** with `--phone`, add the phone panel to the home screen (Safari → Share → Add to Home Screen; Chrome → Add to Home screen). It opens full screen on the same Wi-Fi; the code is kept, and *Change the phone code* cuts every phone off.
215
+ - **On the phone:** turn the phone on in ☰ → 📱 Phone (or start with `--phone`) and add it to the home screen (Safari → Share → Add to Home Screen; Chrome → Add to Home screen). Its address never changes: the computer's own name on the network (`yourmac.local`, so a new Wi-Fi address doesn't matter) and port 4778, with a kept code, so the icon added once keeps working after restarts and updates. *Change the phone code* cuts every phone off.
216
216
  - **Install as an app** (Personalise, or Chrome/Edge's install icon): its own window, no tabs, an icon in the Dock or taskbar. Nothing is cached; it is the same live office.
217
217
  - **`npx coworking-agents --app`** opens it in an app window of Chrome, Edge, Brave or Chromium without installing anything.
218
- - **Start with the computer:** `npx coworking-agents autostart on` (or the switch in Personalise) starts it hidden at login with the tray icon; `autostart off` undoes it. It uses each system's own login entry (macOS LaunchAgent, Windows Startup folder, Linux XDG autostart) with the program and its arguments, never a shell line.
218
+ - **Start with the computer:** `npx coworking-agents autostart on` (or ☰ → Settings → *Open with the computer*) starts it hidden at login with the tray icon; `autostart off` undoes it. It uses each system's own login entry (macOS LaunchAgent, Windows Startup folder, Linux XDG autostart) with the program and its arguments, never a shell line.
219
219
 
220
220
  ## More panels
221
221
 
222
+ The ☰ menu groups everything in three columns: **See** (tasks, costs, notices, history, repositories, past conversations, timelapse, releases), **Do** (new session, message several, tell me when…, phone, TV mode, search, replay, photo, decorate) and **Settings** (system notices, sounds, focus, walking, radio, open with the computer, language, personalise).
223
+
224
+
222
225
  - **📦 Repositories:** who is in each repo now, what is not committed, to push or behind, worktrees, open tasks, PRs and today's activity.
223
226
  - **📣 Message several agents:** one request to the agents you pick, typed into terminal sessions that are waiting and continued in the background for VS Code and app conversations; busy ones are left out.
224
227
  - **Office events and mood:** confetti for tests passing, a PR opened or a week under the cost goal; rain when tests or a task fail; the room turns grey-blue when many agents are stuck on you and warm when everything flows.
@@ -230,6 +233,7 @@ The office works in any browser. If you prefer an app:
230
233
  - **⏰ Tell me when…:** a notice when an agent finishes (also a button in its panel), when a limit passes a level you pick, or when any agent edits a file you name.
231
234
  - **🗄 Past conversations:** every Claude Code and Codex conversation from the last 60 days, open or closed, found by name, folder or what was said, and read as a chat with its helpers. Read only.
232
235
  - **🎞 Day timelapse:** a picture of the office every 5 minutes (no names), turned into a ~20 s GIF in your browser. The pictures stay in this browser for 3 days.
236
+ - **👥 Everyone in the office:** the count under the office name (`6 agents +2 🤖` while helpers work); click it for every agent with its state and the helpers working for it.
233
237
  - **📺 TV mode** (or `?tv=1`): full screen, only the office and big counters; Esc leaves.
234
238
  - **Waiting on you today** in the daily report: measured time the agents waited on your answers and tasks stood blocked on you.
235
239
  - **Sounds and do not disturb** (Personalise): each sound on or off, and a daily quiet window that mutes sounds and system notifications.
@@ -271,6 +275,8 @@ Click the elevator (or press Enter next to it) to ride between floors, up to the
271
275
 
272
276
  ![The server room](docs/servers.png)
273
277
 
278
+
279
+ **More rooms on the floor:** under the game room are the **library**, the **lab** and the **waiting room**. The agents stay at their desks; walk into a room and you see who is reading or browsing, running commands or tests, or waiting on you right now (a number on the door says how many). Enter next to someone opens them. You can also walk inside the rooftop, the trophy room and the server room.
274
280
  ## Achievements, coins and the shop
275
281
 
276
282
  - **86 achievements**, each with five tiers from bronze to legend, measured from your history (hours, actions, streaks, nights, Friday evenings, weeks and months with work, tokens, cache, models, cost), the live office (parallel sessions, subagents, giant contexts, commits and pushes, repos and branches, quick answers, clean desks) and what you do in the office (walking, talking, the radio, the shop, customising). A few are secret.
Binary file
package/docs/building.png CHANGED
Binary file
package/docs/camera.png CHANGED
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/docs/mobile.png CHANGED
Binary file
Binary file
Binary file
package/docs/panel.png CHANGED
Binary file
package/docs/paper.png CHANGED
Binary file
package/docs/servers.png CHANGED
Binary file
package/docs/talk.png CHANGED
Binary file
package/docs/top-bar.png CHANGED
Binary file
Binary file
package/docs/usage.png CHANGED
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coworking-agents",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "description": "A pixel-art coworking office for your AI coding agents (Claude Code, Codex, …): see who's working, who needs you, and who's editing the same checkout.",
5
5
  "bin": {
6
6
  "coworking-agents": "bin/coworking-agents.js"
package/public/index.html CHANGED
@@ -85,6 +85,7 @@
85
85
  <script src="js/timelapse.js"></script>
86
86
  <script src="js/archive.js"></script>
87
87
  <script src="js/watches.js"></script>
88
+ <script src="js/places.js"></script>
88
89
  <script src="js/main.js"></script>
89
90
  </body>
90
91
  </html>
@@ -10,7 +10,7 @@ let ctx = mainCtx; // switched to the thumbnail canvas while drawing another flo
10
10
  Art.setCtx(ctx);
11
11
  const overlay = document.getElementById('overlay');
12
12
  let S = 3, W = 480, H = 300, CX = 320, RX = 330;
13
- let game = null, data = null, selected = null, layout = [], desks = [], rooms = [], floors = [], floor = 0, spots = null, hallBox = null, boardBox = null, wing = null, floorStates = [], building = false;
13
+ let game = null, places = [], data = null, selected = null, layout = [], desks = [], rooms = [], floors = [], floor = 0, spots = null, hallBox = null, boardBox = null, wing = null, floorStates = [], building = false;
14
14
 
15
15
  function rawRoomKey(p) {
16
16
  if (p.repo && p.repo.name) return p.repo.name;
@@ -131,15 +131,19 @@ function buildFloor(i) {
131
131
  const game = { x: 8, y: gy, w: gw, h: GH, doorY: gy + GH / 2,
132
132
  doors: [{ side: 'right', x: 8 + gw, y: gy + GH / 2 }, { side: 'top', x: 8 + Math.round(gw * .2), y: gy }, { side: 'top', x: 8 + Math.round(gw * .62), y: gy }, { side: 'bottom', x: 8 + Math.round(gw * .75), y: gy + GH }] };
133
133
  y += GH + SHELF_GAP + 4;
134
+ // under the game room: the library, the lab and the waiting room, side by side, doors on their top wall to the hallway
135
+ const PH = 118, pw = Math.floor((CX - 20 - 16) / 3), py = y + 6;
136
+ const places = ['library', 'lab', 'waiting'].map((name, k) => { const x = 8 + k * (pw + 8); return { name, x, y: py, w: pw, h: PH, doors: [{ side: 'top', x: x + Math.round(pw / 2), y: py }] }; });
137
+ y += PH + SHELF_GAP + 10;
134
138
  const occupied = ds.filter(d => d.p);
135
139
  const lay = occupied.map(d => Object.assign({}, d));
136
140
  lay.forEach((c, k) => { occupied[k].cell = c; });
137
- return { rooms: rs, desks: ds, layout: lay, bottom: y, H: 0, game };
141
+ return { rooms: rs, desks: ds, layout: lay, bottom: y, H: 0, game, places };
138
142
  }
139
143
 
140
144
  function useFloor(i) {
141
145
  const f = floorStates[i];
142
- rooms = f.rooms; desks = f.desks; layout = f.layout; H = f.H; game = f.game;
146
+ rooms = f.rooms; desks = f.desks; layout = f.layout; H = f.H; game = f.game; places = f.places || [];
143
147
  }
144
148
 
145
149
  function fitScale() {
package/public/js/i18n.js CHANGED
@@ -124,7 +124,7 @@ const I18N = {
124
124
  surprise: { hint: 'Uma surpresa! Clique para pegar', cake: 'Bolo surpresa na copa!', pigeon: 'Um pombo na janela!', ticket: 'Um bilhete dourado no chão!' },
125
125
  scoreboard: { title: 'Placar da semana entre as IAs', hours: 'horas', actions: 'ações', tokens: 'tokens' },
126
126
  share: { bestDay: 'Melhor dia', topAi: 'Mais usada', cost: 'Custo estimado', ready: 'Seu cartão da semana está pronto', readyWhy: 'Horas, ações, melhor dia, a IA mais usada e o custo da semana, numa imagem para compartilhar (Jornal → Cartão da semana).', card: 'Cartão da semana', visit: 'Foto para visita', week: 'Minha semana no escritório', hours: 'horas de agentes trabalhando', actions: 'ações', trophies: 'níveis de conquista · lendas', saved: 'Cartão salvo', visitSaved: 'Foto para visita salva', visitNote: 'Sem nomes, branches nem o nome do escritório.' },
127
- elevator: { title: 'Elevador', sub: 'Escolha o andar: terraço, escritório, sala de troféus ou servidores.', terrace: '🌇 Terraço', trophy: '🏆 Sala de troféus', servers: '🖧 Sala de servidores', office: '🏢 Escritório', floor: n => `🏢 Andar ${n}` },
127
+ elevator: { title: 'Elevador', sub: 'Escolha o andar: terraço, escritório, troféus ou servidores. Dentro das salas você anda com as setas.', terrace: '🌇 Terraço', trophy: '🏆 Sala de troféus', servers: '🖧 Sala de servidores', office: '🏢 Escritório', floor: n => `🏢 Andar ${n}` },
128
128
  tasks: { tab: n => n ? `Tarefas (${n})` : 'Tarefas', sub: 'Crie uma tarefa e um agente trabalha nela em segundo plano. Você responde, aprova e revisa por aqui, sem abrir o terminal. Só as tarefas criadas aqui podem ser controladas; as sessões abertas por fora seguem só monitoradas.',
129
129
  newT: 'Nova tarefa', title: 'Título (opcional)', desc: 'O que fazer', folder: 'Pasta', agent: 'Agente', auto: 'Automático', profile: 'Perfil', prio: 'Prioridade', prios: ['', 'Baixa', 'Normal', 'Alta'],
130
130
  budget: 'Orçamento (US$, 0 = sem limite)', test: 'Comando de teste (opcional)', wt: 'Worktree separada (isola as alterações)', perm: 'Permissões', perms: { acceptEdits: 'Pode editar arquivos', plan: 'Só planejar', default: 'Pedir para tudo' },
@@ -349,7 +349,7 @@ const I18N = {
349
349
  surprise: { hint: 'A surprise! Click to grab it', cake: 'Surprise cake in the kitchen!', pigeon: 'A pigeon at the window!', ticket: 'A golden ticket on the floor!' },
350
350
  scoreboard: { title: "This week's AI scoreboard", hours: 'hours', actions: 'actions', tokens: 'tokens' },
351
351
  share: { bestDay: 'Best day', topAi: 'Used most', cost: 'Estimated cost', ready: 'Your weekly card is ready', readyWhy: "Hours, actions, best day, the AI used most and the week's cost, in one image to share (Newspaper → Weekly card).", card: 'Weekly card', visit: 'Visitor photo', week: 'My week at the office', hours: 'hours of agents at work', actions: 'actions', trophies: 'achievement tiers · legends', saved: 'Card saved', visitSaved: 'Visitor photo saved', visitNote: 'No names, branches or office name.' },
352
- elevator: { title: 'Elevator', sub: 'Pick a floor: rooftop, office, trophy room or servers.', terrace: '🌇 Rooftop', trophy: '🏆 Trophy room', servers: '🖧 Server room', office: '🏢 Office', floor: n => `🏢 Floor ${n}` },
352
+ elevator: { title: 'Elevator', sub: 'Pick a floor: rooftop, office, trophies or servers. Inside the rooms you walk with the arrows.', terrace: '🌇 Rooftop', trophy: '🏆 Trophy room', servers: '🖧 Server room', office: '🏢 Office', floor: n => `🏢 Floor ${n}` },
353
353
  tasks: { tab: n => n ? `Tasks (${n})` : 'Tasks', sub: 'Create a task and an agent works on it in the background. You answer, approve and review here, no terminal needed. Only tasks created here can be controlled; sessions opened elsewhere stay monitor-only.',
354
354
  newT: 'New task', title: 'Title (optional)', desc: 'What to do', folder: 'Folder', agent: 'Agent', auto: 'Automatic', profile: 'Profile', prio: 'Priority', prios: ['', 'Low', 'Normal', 'High'],
355
355
  budget: 'Budget (USD, 0 = no limit)', test: 'Test command (optional)', wt: 'Separate worktree (isolates the changes)', perm: 'Permissions', perms: { acceptEdits: 'May edit files', plan: 'Plan only', default: 'Ask for everything' },
@@ -632,6 +632,9 @@ Object.assign(I18N.en, { phoneOn: { how: 'See the agents and tasks and answer fr
632
632
  // the ☰ menu's groups
633
633
  Object.assign(I18N.pt, { menuGroups: { see: 'Ver', do: 'Fazer', prefs: 'Ajustes', autostart: 'Abrir com o computador' } });
634
634
  Object.assign(I18N.en, { menuGroups: { see: 'See', do: 'Do', prefs: 'Settings', autostart: 'Open with the computer' } });
635
+ // the library, the lab and the meeting room
636
+ Object.assign(I18N.pt, { places: { waiting: 'Sala de espera', no_library: 'Ninguém lendo agora', no_lab: 'Nenhum comando rodando', no_waiting: 'Ninguém esperando você', library: 'Biblioteca', lab: 'Laboratório', meeting: 'Sala de reunião', back: 'Voltar ao escritório', noReaders: 'NINGUEM LENDO AGORA', noRunners: 'NENHUM COMANDO RODANDO', noMeeting: 'NENHUMA REUNIAO AGORA', pipeline: 'PIPELINE', plan: 'PLANO', helpersNow: 'AJUDANTES AGORA', meetCount: (a, h) => `${a} AGENTE${a === 1 ? '' : 'S'} · ${h} AJUDANTE${h === 1 ? '' : 'S'}` } });
637
+ Object.assign(I18N.en, { places: { waiting: 'Waiting room', no_library: 'Nobody reading now', no_lab: 'No commands running', no_waiting: 'Nobody waiting on you', library: 'Library', lab: 'Lab', meeting: 'Meeting room', back: 'Back to the office', noReaders: 'NOBODY READING NOW', noRunners: 'NO COMMANDS RUNNING', noMeeting: 'NO MEETING NOW', pipeline: 'PIPELINE', plan: 'PLAN', helpersNow: 'HELPERS NOW', meetCount: (a, h) => `${a} AGENT${a === 1 ? '' : 'S'} · ${h} HELPER${h === 1 ? '' : 'S'}` } });
635
638
  let T = I18N[lang];
636
639
  // sound is on by default; alerts follow the browser permission (asked on open). Only the buttons turn them off.
637
640
  let soundOn = store.get('sound', true);
package/public/js/main.js CHANGED
@@ -24,10 +24,10 @@ function loop(t) {
24
24
  if (document.hidden) return; // tab not visible: draw nothing (browsers also slow rAF, this makes it zero) // schedule first: an error in one frame must not stop the animation
25
25
  if (data && spots) {
26
26
  const f = (t / 70) | 0; // ~14 frames per second, a pixel-art pace
27
- if (f !== lastFrame || (playerOn && (me.moving || me.path.length || keysDown.size))) {
27
+ if (f !== lastFrame || (playerOn && (me.moving || me.path.length || keysDown.size || (trophyView && (vme.moving || vme.path.length))))) {
28
28
  const dt = lastT ? Math.min(.25, (t - lastT) / 1000) : 0;
29
29
  lastT = t; lastFrame = f;
30
- if (trophyView) (viewName === 'terrace' ? drawTerrace : viewName === 'servers' ? drawServers : drawTrophyRoom)(t); else if (building) { if (f % 3 === 0) drawThumbs(t, dt * 3); } else drawScene(t, dt);
30
+ if (trophyView) { roomUpdate(dt); ({ terrace: drawTerrace, servers: drawServers }[viewName] || drawTrophyRoom)(t); } else if (building) { if (f % 3 === 0) drawThumbs(t, dt * 3); } else drawScene(t, dt);
31
31
  }
32
32
  }
33
33
  }
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+ // ---------------- the library, the lab and the waiting room on the office floor; walking inside the elevator rooms ----------------
3
+ // The agents stay at their desks. Walk into one of the three rooms under the game room and it shows who is doing that
4
+ // right now (measured from their state): reading or browsing (library), running commands (lab), waiting on you
5
+ // (waiting room). Enter next to someone opens them. The rooftop, trophy and server rooms can be walked in too.
6
+
7
+ // --- walking in a room: its floor, where you arrive, the door back ---
8
+ const ROOM_WALK = {
9
+ terrace: { x: 20, y: 186, w: 600, h: 192, door: { x: 26, y: 222 } },
10
+ servers: { x: 20, y: 200, w: 600, h: 178, door: { x: 26, y: 236 } },
11
+ trophy: { x: 20, y: 290, w: 600, h: 88, door: { x: 26, y: 326 } },
12
+ };
13
+ const vme = { x: 0, y: 0, path: [], flip: false, moving: false, room: '' };
14
+ let vTag = null, vHint = null, vLastHint = '';
15
+ const roomWalk = () => (trophyView && playerOn && ROOM_WALK[viewName]) || null;
16
+ function roomUpdate(dt) {
17
+ const F = roomWalk(); if (!F) { if (vTag) vTag.hidden = vHint.hidden = true; return; }
18
+ if (vme.room !== viewName) Object.assign(vme, { x: F.door.x + 22, y: F.door.y - 6, path: [], room: viewName }); // in through the door
19
+ let dx = 0, dy = 0; const k = c => keysDown.has(c);
20
+ if (k('ArrowLeft') || k('a')) dx--; if (k('ArrowRight') || k('d')) dx++; if (k('ArrowUp') || k('w')) dy--; if (k('ArrowDown') || k('s')) dy++;
21
+ vme.moving = false;
22
+ const clamp = p => ({ x: Math.max(F.x, Math.min(F.x + F.w - 16, p.x)), y: Math.max(F.y, Math.min(F.y + F.h - 24, p.y)) });
23
+ if (dx || dy) {
24
+ vme.path = []; const len = Math.hypot(dx, dy), step = PLAYER_SPEED * (k('Shift') ? 2 : 1) * dt;
25
+ Object.assign(vme, clamp({ x: vme.x + dx / len * step, y: vme.y + dy / len * step })); vme.moving = true; if (dx) vme.flip = dx < 0;
26
+ } else if (vme.path.length) {
27
+ const w = vme.path[0], ddx = w.x - vme.x, ddy = w.y - vme.y, d = Math.hypot(ddx, ddy), step = PLAYER_SPEED * 1.4 * dt;
28
+ if (d <= step) { vme.x = w.x; vme.y = w.y; vme.path.shift(); } else { vme.x += ddx / d * step; vme.y += ddy / d * step; if (Math.abs(ddx) > .5) vme.flip = ddx < 0; }
29
+ vme.moving = true;
30
+ }
31
+ if (vme.moving) { const rc = cv.getBoundingClientRect(), py = rc.top + vme.y * S; if (py > innerHeight - 80) scrollBy({ top: py - (innerHeight - 160) }); else if (py < 130) scrollBy({ top: py - 190 }); } // the page follows you
32
+ if (!vTag) { vTag = document.createElement('span'); vHint = document.createElement('span'); vTag.className = 'me-tag'; vHint.className = 'me-hint'; overlay.append(vTag, vHint); }
33
+ vTag.hidden = false; vTag.textContent = T.walk.you; vTag.style.left = (vme.x + 8) * S + 'px'; vTag.style.top = (vme.y - 4) * S + 'px';
34
+ const n = roomNear(), label = n ? n.label : '';
35
+ vHint.hidden = !n; if (label !== vLastHint) { vLastHint = label; vHint.innerHTML = `<b>Enter ↵</b> ${esc(label)}`; }
36
+ if (n) { vHint.style.left = (vme.x + 8) * S + 'px'; vHint.style.top = (vme.y + 30) * S + 'px'; }
37
+ }
38
+ // what Enter does where you stand: talk to the agent next to you, or take the door back
39
+ function roomNear() {
40
+ const F = roomWalk(); if (!F) return null;
41
+ const c = { x: vme.x + 8, y: vme.y + 12 };
42
+ let best = null;
43
+ for (const h of viewHits) if (h.id) { const d = Math.hypot(h.x + h.w / 2 - c.x, h.y + h.h / 2 - c.y); if (d < 30 && (!best || d < best.d)) best = { d, h }; }
44
+ if (best) { const p = data.people.find(x => x.id === best.h.id); if (p) return { label: T.walk.talk(best.h.who || p.name), run: () => { setView(null); showPerson(p.id); } }; }
45
+ if (Math.hypot(F.door.x + 8 - c.x, F.door.y - c.y) < 26) return { label: T.places.back, run: () => setView(null) };
46
+ return null;
47
+ }
48
+ // you, drawn last in the room, and the elevator door you came in through
49
+ function drawRoomWalker(t) {
50
+ const F = roomWalk(); if (!F) return;
51
+ const d = F.door; r(d.x - 4, d.y - 30, 26, 32, PAL.ink); r(d.x - 2, d.y - 28, 10, 28, '#9aa3b0'); r(d.x + 9, d.y - 28, 10, 28, '#9aa3b0'); r(d.x + 8, d.y - 28, 1, 28, '#5a6270');
52
+ r(d.x + 2, d.y - 37, 14, 8, '#1b1d2e'); r(d.x + 5, d.y - 35, 3, 1, '#feae34'); r(d.x + 6, d.y - 36, 1, 1, '#feae34'); r(d.x + 10, d.y - 33, 3, 1, '#feae34'); r(d.x + 11, d.y - 32, 1, 1, '#feae34'); // the lift's ▲▼
53
+ ctx.fillStyle = 'rgba(255,216,77,.35)'; ctx.fillRect(vme.x + 1, vme.y + 21, 14, 3);
54
+ Art.drawStanding(vme.x, vme.y, youLook(), t, vme.moving); if (typeof drawOutfit === 'function') drawOutfit(vme.x, vme.y - 2);
55
+ }
56
+ document.addEventListener('keydown', e => {
57
+ if (!roomWalk() || !reportEl.hidden) return;
58
+ const tag = (e.target.tagName || '').toLowerCase(); if (tag === 'input' || tag === 'textarea') return;
59
+ const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
60
+ if (key === 'Shift') keysDown.add('Shift');
61
+ if (key.startsWith('Arrow') || (!e.metaKey && !e.ctrlKey && !e.altKey && key.length === 1 && 'wasd'.includes(key))) { keysDown.add(key); e.preventDefault(); e.stopImmediatePropagation(); }
62
+ if (e.key === 'Enter') { const n = roomNear(); if (n) { e.preventDefault(); n.run(); } }
63
+ }, true);
64
+ overlay.addEventListener('click', e => {
65
+ const F = roomWalk(); if (!F || e.target.closest('button,.tag')) return;
66
+ const rc = overlay.getBoundingClientRect(), x = (e.clientX - rc.left) / S - 8, y = (e.clientY - rc.top) / S - 14;
67
+ vme.path = [{ x: Math.max(F.x, Math.min(F.x + F.w - 16, x)), y: Math.max(F.y, Math.min(F.y + F.h - 24, y)) }];
68
+ });
69
+
70
+ // --- drawing helpers shared by the three rooms ---
71
+ const plain = x => String(x || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toUpperCase(); // the pixel font has no accents
72
+ const roomName = (x, y, txt) => { const s = plain(txt).slice(0, 12), w = s.length * 4 + 4; r(x + 8 - w / 2, y, w, 7, '#000000aa'); pixText(x + 10 - w / 2, y + 1, s, '#f4ecd8'); };
73
+ const roomSign = (x, y, txt, bg, fg) => { txt = plain(txt); const w = txt.length * 4 + 12; r(x - w / 2 - 2, y - 2, w + 4, 13, PAL.ink); r(x - w / 2, y, w, 9, bg); pixText(x - w / 2 + 6, y + 2, txt, fg); };
74
+ const roomEmpty = (Wt, Ht, txt) => { txt = plain(txt); const w = txt.length * 4 + 16; r(Wt / 2 - w / 2, Ht - 46, w, 14, '#00000066'); pixText(Wt / 2 - w / 2 + 8, Ht - 42, txt, '#f4ecd8'); };
75
+ const doingOf = p => (p.doing ? `${p.doing.tool || ''} ${p.doing.what || ''}`.trim() : '');
76
+
77
+
78
+ // ---- the three rooms on the office floor ----
79
+ const PLACE_STATES = { library: ['read', 'web'], lab: ['terminal', 'mcp'], waiting: ['needs_you', 'waiting'] };
80
+ let placeHits = [];
81
+ const placeOf = name => places.find(P => P.name === name);
82
+ function drawPlaceRooms(t, movers) {
83
+ placeHits = [];
84
+ for (const P of places) {
85
+ const { x, y, w, h } = P, d = P.doors[0], L = T.places;
86
+ // floor, walls with the door gap on top, a sign
87
+ const floorC = P.name === 'library' ? ['#8a5a3c', '#93613f'] : P.name === 'lab' ? ['#9aa3b0', '#a7b0bd'] : ['#5a4a6e', '#64547a'];
88
+ r(x, y, w, h, floorC[0]); for (let yy = y; yy < y + h; yy += 8) for (let xx = x + ((yy - y) / 8 % 2) * 8; xx < x + w; xx += 16) r(xx, yy, 8, 8, floorC[1]);
89
+ r(x - 2, y - 2, w + 4, 3, PAL.ink); r(x - 2, y, 2, h + 2, PAL.ink); r(x + w, y, 2, h + 2, PAL.ink); r(x - 2, y + h, w + 4, 2, PAL.ink);
90
+ r(d.x - 9, y - 2, 18, 3, floorC[1]); r(d.x - 9, y + 1, 18, 3, '#00000033'); // the open door
91
+ if (P.name === 'library') { // shelves along the back wall, two reading tables with green lamps
92
+ for (let i = 0; i < Math.floor((w - 12) / 34); i++) { const sx = x + 6 + i * 34; if (Math.abs(sx + 15 - d.x) < 22) continue; r(sx, y + 6, 30, 26, '#5a3a28'); for (let sh = 0; sh < 2; sh++) for (let k = 0; k < 6; k++) { const hh = hash(`pl${P.x}${i}${sh}${k}`); r(sx + 2 + k * 5, y + 8 + sh * 12 + (hh % 3), 4, 10 - (hh % 3), ['#a8434f', '#3b5dc9', '#63c74d', '#feae34', '#7a5cff', '#2ce8f5'][hh % 6]); } }
93
+ for (const tx of [x + w * .18, x + w * .58]) { r(tx, y + 70, w * .26, 12, '#6b4a33'); r(tx, y + 70, w * .26, 2, '#8a6a4a'); r(tx + w * .13 - 1, y + 62, 2, 8, '#c9a34a'); r(tx + w * .13 - 5, y + 59, 10, 4, '#2f8f4e'); }
94
+ } else if (P.name === 'lab') { // a CI screen on the back wall, two benches of monitors
95
+ r(x + w / 2 - 40, y + 6, 80, 24, PAL.ink); r(x + w / 2 - 38, y + 8, 76, 20, '#0c1410'); pixText(x + w / 2 - 34, y + 11, 'CI', '#62ff7a');
96
+ ['B', 'T', 'L'].forEach((c, i) => { const on = ((t / 600 + i) | 0) % 3 !== 2; r(x + w / 2 - 20 + i * 18, y + 18, 14, 7, on ? '#1f7a48' : '#1b2a22'); });
97
+ for (const by of [y + 58, y + 92]) { r(x + 10, by, w - 20, 8, '#e8ecf2'); r(x + 10, by, w - 20, 2, '#ffffff'); for (let k = 0; k < 3; k++) { const mx = x + 22 + k * (w - 44) / 3; r(mx, by - 10, 16, 10, PAL.ink); r(mx + 1, by - 9, 14, 8, '#0c1410'); r(mx + 3, by - 7, 4 + hash('lm' + k + by + ((t / 400) | 0)) % 9, 1, '#62ff7a'); } }
98
+ } else { // the waiting room: two phone booths with a red light, a bench
99
+ for (const bx of [x + 10, x + w - 34]) { r(bx, y + 8, 24, 44, '#3a2a4a'); r(bx + 2, y + 12, 20, 38, '#8fc7ff55'); r(bx + 8, y + 6, 8, 3, ((t / 400) | 0) % 2 ? '#ff4d57' : '#5a0f16'); }
100
+ r(x + w * .25, y + 88, w * .5, 6, '#6b4a33'); r(x + w * .25, y + 94, 3, 8, '#4a3324'); r(x + w * .75 - 3, y + 94, 3, 8, '#4a3324');
101
+ }
102
+ const label = plain(L[P.name]), lw = label.length * 4 + 8; r(x + 6, y + h - 12, lw, 9, '#00000088'); pixText(x + 10, y + h - 10, label, '#f4ecd8');
103
+ // inside, you see who is doing this right now (the agents themselves stay at their desks)
104
+ const here = playerOn && me.ready && me.x + 8 > x && me.x + 8 < x + w && me.y + 12 > y && me.y + 12 < y + h;
105
+ const who = (data ? data.people : []).filter(p => !p.leaving && PLACE_STATES[P.name].includes(p.state));
106
+ if (!here) { if (who.length) { const n = String(who.length); r(x + w - 18, y + h - 13, 14, 11, PAL.ink); r(x + w - 17, y + h - 12, 12, 9, P.name === 'waiting' ? '#e43b44' : '#1b1622'); pixText(x + w - 14 - (n.length - 1) * 2, y + h - 10, n, '#ffffff'); } continue; }
107
+ const cols = Math.max(1, Math.floor((w - 20) / 34)), seats = [];
108
+ for (let i = 0; i < who.length && seats.length < cols * 2; i++) seats.push({ x: x + 14 + (i % cols) * 34, y: y + (i < cols ? 44 : 78) - 4 });
109
+ who.slice(0, seats.length).forEach((p, i) => {
110
+ const st = seats[i], lk = look(p.id);
111
+ movers.push({ y: st.y, draw: () => { Art.drawFront(st.x, st.y, lk, t, { legs: Art.LEGS_SIT, legsKey: 'sit' }); const nm = plain(p.name).slice(0, 8), nw = nm.length * 4 + 4; r(st.x + 8 - nw / 2, st.y + 26, nw, 7, '#000000aa'); pixText(st.x + 10 - nw / 2, st.y + 27, nm, '#f4ecd8'); } });
112
+ placeHits.push({ x: st.x, y: st.y, id: p.id, name: p.name, need: P.name === 'waiting' });
113
+ });
114
+ if (!who.length) { const e = plain(L['no_' + P.name]), ew = e.length * 4 + 8; r(x + w / 2 - ew / 2, y + h / 2 - 5, ew, 10, '#00000088'); pixText(x + w / 2 - ew / 2 + 4, y + h / 2 - 3, e, '#f4ecd8'); }
115
+ }
116
+ }
117
+ // a click on someone you see in one of these rooms opens them
118
+ overlay.addEventListener('click', e => {
119
+ if (trophyView || !placeHits.length) return;
120
+ const rc = overlay.getBoundingClientRect(), x = (e.clientX - rc.left) / S, y = (e.clientY - rc.top) / S;
121
+ const h = placeHits.find(h => x >= h.x && x <= h.x + 16 && y >= h.y - 2 && y <= h.y + 26); if (!h) return;
122
+ e.stopImmediatePropagation(); h.need && typeof openTalk === 'function' ? openTalk(h.id) : showPerson(h.id);
123
+ }, true);
@@ -11,6 +11,7 @@ const PLAYER_SPEED = 70;
11
11
  function solidRooms() {
12
12
  const out = rooms.map(R => ({ x: R.x, y: R.y, w: R.w, h: R.h, doors: [{ x: R.doorX + 9, y: R.y + R.h, side: 'bottom' }], gap: 9 }));
13
13
  if (game) out.push({ x: game.x, y: game.y, w: game.w, h: game.h, doors: game.doors, gap: 9 });
14
+ for (const P of places) out.push({ x: P.x, y: P.y, w: P.w, h: P.h, doors: P.doors, gap: 9 });
14
15
  if (napBox) out.push({ x: RX - 4, y: wing.nap, w: RW + 8, h: 80, doors: [{ x: RX - 4, y: wing.nap + 37, side: 'left' }], gap: 9 });
15
16
  return out;
16
17
  }
@@ -97,6 +98,7 @@ function nearThing() {
97
98
  }
98
99
  for (const g of gameHits) if (near(g.x + g.w / 2, g.y + g.h, 24)) return { label: g.title, run: () => { const m = /data-tab="(\w+)"/.exec(g.attr); if (m) openReport(m[1]); else if (/trophyroom/.test(g.attr)) setTrophyView(true); else if (/arcade/.test(g.attr)) openArcade(); } };
99
100
  if (typeof officeTips === 'function') { const o = officeTips(); for (const [b, tab, label] of [[boardBox, 'tasks', o.tasks], [costBox, 'usage', o.costs], [noticesBox, 'notices', o.notices], [historyBox, 'feed', o.history], [releaseBox, 'version', o.releases]]) if (b && near(b.x + b.w / 2, b.y + b.h + 14, 26)) return { label: label.split('\n')[0], run: () => openReport(tab) }; }
101
+ for (const h of (typeof placeHits !== 'undefined' ? placeHits : [])) if (near(h.x + 8, h.y + 12, 22)) return { label: T.walk.talk(h.name), run: () => (h.need ? openTalk(h.id) : showPerson(h.id)) };
100
102
  if (radioBox && near(radioBox.x + 7, radioBox.y + 20, 26)) return { label: radioOn ? T.radio.on : T.radio.off, run: () => setRadio(!radioOn) };
101
103
  if (catBox && near(cat.x + 6, cat.y + 6, 18)) return { label: T.walk.pet, run: petCat };
102
104
  if (elevatorBox && near(elevatorBox.x + 14, elevatorBox.y + 30, 22)) return { label: T.elevator.title, run: openElevator };
@@ -264,7 +264,7 @@ function drawTrophyRoom(t) {
264
264
  r(cx - 50, Ht - 34, 100, 15, PAL.ink); r(cx - 49, Ht - 33, 98, 13, '#d9a441'); r(cx - 49, Ht - 33, 98, 1, '#ffd84d'); pixText(cx - 23, Ht - 29, 'HALL OF FAME', '#3b2418');
265
265
  const got = st.reduce((x, a) => x + a.tier, 0), all = st.reduce((x, a) => x + a.max, 0);
266
266
  pixText(cx - 70, Ht - 12, `${got}/${all}`, '#8a6420'); { const l2 = `${legends.length} LEGEND`; pixText(cx + 70 - l2.length * 4, Ht - 12, l2, '#a3283a'); }
267
- trophyHits = hits;
267
+ trophyHits = hits; if (typeof drawRoomWalker === 'function') drawRoomWalker(t);
268
268
  }
269
269
  // clicking a trophy: a card with the trophy large, its tier and how far the next one is
270
270
  const trophyCard = document.createElement('div');
@@ -448,6 +448,7 @@ function drawScene(t, dt) {
448
448
  movers.push({ y: cat.y, draw: () => drawMascot(cat.x, cat.y, cat.mode, t, cat.flip) });
449
449
  movers.push(...extraWalkers(t));
450
450
  drawPets(t, movers);
451
+ if (typeof drawPlaceRooms === 'function') drawPlaceRooms(t, movers); // the library, the lab and the waiting room (who you see depends on where you stand)
451
452
  drawPlayer(t, movers);
452
453
  movers.push({ y: robot.y, draw: () => drawRobot(t) });
453
454
  movers.sort((a, b) => a.y - b.y).forEach(m => m.draw());
@@ -71,7 +71,7 @@ function drawTerrace(t) {
71
71
  });
72
72
  if (!relaxing.length) { r(Wt / 2 - 66, Ht - 40, 132, 14, '#00000055'); pixText(Wt / 2 - 58, Ht - 36, lang === 'pt' ? 'NINGUEM DE FOLGA' : 'NOBODY ON BREAK', '#f4ecd8'); }
73
73
  if (!day) { ctx.fillStyle = 'rgba(10,12,40,.25)'; ctx.fillRect(0, HZ, Wt, Ht - HZ); } // evening light on the deck
74
- viewHits = hits;
74
+ viewHits = hits; if (typeof drawRoomWalker === 'function') drawRoomWalker(t);
75
75
  }
76
76
 
77
77
  // ---- the server room: two rows of glass racks, a NOC wall with live numbers, cold aisle, cable trays ----
@@ -118,7 +118,7 @@ function drawServers(t) {
118
118
  r(14, 160, 8, 18, '#e43b44'); r(15, 156, 6, 4, '#2a2a30'); r(16, 164, 4, 6, '#ffffff');
119
119
  for (let x = 0; x < Wt; x += 16) r(x, Ht - 6, 8, 6, '#ffd84d'), r(x + 8, Ht - 6, 8, 6, '#1b1622');
120
120
  if (!people.length) pixText(Wt / 2 - 30, 200, 'NO SESSIONS', '#c0cbdc');
121
- viewHits = hits;
121
+ viewHits = hits; if (typeof drawRoomWalker === 'function') drawRoomWalker(t);
122
122
  }
123
123
 
124
124
  // ---- the elevator panel: floors, the terrace, the server room, the trophy room ----
@@ -132,6 +132,7 @@ function openElevator() {
132
132
  ${btn('data-go="trophy"', E.trophy, viewName === 'trophy')}
133
133
  ${floors.map((f, i) => btn(`data-go-floor="${i}"`, floors.length > 1 ? E.floor(i + 1) : E.office, !trophyView && floor === i)).reverse().join('')}
134
134
  ${btn('data-go="servers"', E.servers, viewName === 'servers')}
135
+
135
136
  </div></div></div>`;
136
137
  liftEl.hidden = false; achBump('lifts');
137
138
  }
package/src/autostart.js CHANGED
@@ -59,7 +59,8 @@ function enable(mode) {
59
59
  }
60
60
  function disable() {
61
61
  const e = entry(command('npx'));
62
- if (process.platform === 'darwin') try { execFileSync('launchctl', ['bootout', `gui/${process.getuid()}/${LABEL}`], { stdio: 'ignore' }); } catch {}
62
+ // stop the running one too (the tests skip this: the label is the real one, and they must never stop your office)
63
+ if (process.platform === 'darwin' && !process.env.COWORKING_TEST) try { execFileSync('launchctl', ['bootout', `gui/${process.getuid()}/${LABEL}`], { stdio: 'ignore' }); } catch {}
63
64
  try { fs.unlinkSync(e.file); } catch {}
64
65
  return { ok: true };
65
66
  }