coworking-agents 0.9.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 CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.1 — 2026-10-14
4
+ - ☰ → Settings → *Open with the computer*: the start-at-login switch right in the menu, with its green dot (it also stays in Personalise)
5
+ - 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
6
+ - Phone: always the same address (the computer's name and port 4778, whatever port the office got), so the home-screen icon added once keeps working
7
+ - Phone: turn it on from the office (☰ → 📱 → *Turn the phone on*) and it stays on, starting with the office every time with the same QR (the app and autostart included); *Turn the phone off* ends it. `--phone` still works
8
+ - ☰ menu: three groups (See, Do, Settings) side by side, every item with a pixel icon in the same style, and it scrolls instead of running off the screen
9
+
3
10
  ## 0.9.0 — 2026-10-14
4
11
  - Tell me when… (☰ → ⏰, or the agent's panel): a notice when an agent finishes (once), when a limit passes a level you pick (again after it resets), or when any agent edits a file whose path has some text; in the office, the bell and as a system notification. Kept with your progress
5
12
  - Past conversations (☰ → 🗄): every Claude Code and Codex conversation on this computer from the last 60 days, open or closed, found by name, folder or what was said (last 30 days), and read as a chat with its helpers and a search inside it. Read only; off in private mode
package/README.md CHANGED
@@ -236,7 +236,7 @@ The office works in any browser. If you prefer an app:
236
236
 
237
237
  ## Phone panel (opt-in)
238
238
 
239
- `npx coworking-agents --phone` also serves a small app for your phone on the same Wi-Fi, in the office's pixel look, updated live as things change. Scan the QR code printed in the terminal or shown in ☰ → 📱 Phone, then add it to the home screen.
239
+ Turn the phone on in ☰ → 📱 Phone (it stays on and starts with the office every time), or start with `npx coworking-agents --phone`: a small app for your phone on the same Wi-Fi, in the office's pixel look, updated live as things change. Scan the QR code printed in the terminal or shown in ☰ → 📱 Phone, then add it to the home screen.
240
240
 
241
241
  - **Now:** who needs you and every agent with what it is doing.
242
242
  - **Tap an agent** to see it in full: what it asks or does right now, your last request, its last answer, what it did, the files it touched, its helpers, model, context and cost. The **Conversation** tab shows the whole conversation as a chat, with a search inside it (only what you wrote and what it answered, never tool output). When it finished its turn you can **write to it**; the text is typed into its terminal, like the office does (not into the Claude app or VS Code).
@@ -117,7 +117,7 @@ async function reuse() {
117
117
  } catch {}
118
118
  }
119
119
  console.log(`coworking-agents → ${hq.url}${privacy ? ' (private mode)' : ''}\nCtrl+C to close.`);
120
- if (phone) {
120
+ if (phone || hq.phone) { // --phone, or the phone turned on in the office (remembered)
121
121
  if (hq.phone) console.log(`\nPhone (same Wi-Fi, trusted networks only — the LAN link is not encrypted) → ${hq.phone.url}\n${require('../src/qr').toTerminal(require('../src/qr').encode(hq.phone.url))}\n`);
122
122
  else console.log(`\nPhone panel not started: ${hq.phoneReason === 'nolan' ? 'no local network address found' : hq.phoneReason}`);
123
123
  }
Binary file
package/docs/building.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/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.0",
3
+ "version": "0.9.1",
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"
Binary file
Binary file
package/public/index.html CHANGED
@@ -4,10 +4,9 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>coworking-agents</title>
7
- <link rel="icon" href="favicon.ico?v=4" sizes="any">
8
- <link rel="icon" href="favicon.png?v=4" type="image/png" sizes="64x64">
9
- <link rel="icon" href="favicon.svg?v=4" type="image/svg+xml">
10
- <link rel="apple-touch-icon" href="apple-touch-icon.png?v=4">
7
+ <link rel="icon" href="favicon.ico?v=5" sizes="32x32">
8
+ <link rel="icon" href="favicon.png?v=5" type="image/png" sizes="64x64">
9
+ <link rel="apple-touch-icon" href="apple-touch-icon.png?v=5">
11
10
  <link rel="manifest" href="manifest.webmanifest">
12
11
  <meta name="theme-color" content="#17151a">
13
12
  <link rel="stylesheet" href="style.css">
package/public/js/i18n.js CHANGED
@@ -626,6 +626,12 @@ Object.assign(I18N.pt.officeObj.menu, { watches: 'Me avise quando…' });
626
626
  Object.assign(I18N.en.officeObj.menu, { watches: 'Tell me when…' });
627
627
  Object.assign(I18N.pt, { watch: { title: 'Me avise quando…', sub: 'Avisos que você escolhe. Chegam aqui, no sino e como notificação do sistema.', empty: 'Nenhum aviso ainda.', add: 'Adicionar', addDone: 'Quando um agente terminar', addLimit: 'Quando um limite passar de', addFile: 'Quando algum agente mexer num arquivo', filePh: 'parte do caminho, ex.: checkout.ts ou src/auth', note: 'Funcionam enquanto o escritório estiver aberto. O de agente vale uma vez; o de limite volta a valer quando o limite renova; o de arquivo avisa a cada edição nova.', whenDone: 'Me avise quando terminar', cancelDone: 'Cancelar o aviso', set: 'Aviso criado', whenDoneOf: n => `Quando ${n} terminar`, whenLimit: (l, p) => `Quando ${l} passar de ${p}%`, whenFile: t => `Quando algum agente mexer em “${t}”`, done: n => `${n} terminou`, needs: n => `${n} parou e precisa de você`, closed: n => `${n} fechou`, limit: (l, p) => `${l} chegou a ${p}%`, limitWhy: p => `Você pediu aviso a partir de ${p}%.`, file: (n, f) => `${n} mexeu em ${f}` } });
628
628
  Object.assign(I18N.en, { watch: { title: 'Tell me when…', sub: 'Notices you choose. They show here, in the bell and as system notifications.', empty: 'No notices yet.', add: 'Add', addDone: 'When an agent finishes', addLimit: 'When a limit passes', addFile: 'When any agent touches a file', filePh: 'part of the path, e.g. checkout.ts or src/auth', note: 'They work while the office is open. The agent one fires once; the limit one again after the limit resets; the file one on every new edit.', whenDone: 'Tell me when it finishes', cancelDone: 'Cancel the notice', set: 'Notice set', whenDoneOf: n => `When ${n} finishes`, whenLimit: (l, p) => `When ${l} passes ${p}%`, whenFile: t => `When any agent touches “${t}”`, done: n => `${n} finished`, needs: n => `${n} stopped and needs you`, closed: n => `${n} closed`, limit: (l, p) => `${l} reached ${p}%`, limitWhy: p => `You asked to be told from ${p}%.`, file: (n, f) => `${n} edited ${f}` } });
629
+ // the phone panel turned on from the office
630
+ Object.assign(I18N.pt, { phoneOn: { how: 'Veja os agentes, as tarefas e responda pelo celular, no mesmo Wi-Fi. Ao ligar aqui, ele volta a ligar sozinho toda vez que o escritório abrir.', on: 'Ligar o celular', off: 'Desligar o celular', cli: 'Ou abra o escritório com', kept: 'Fica ligado: abre junto com o escritório nas próximas vezes, com o mesmo QR.', fail: { nolan: 'não achei uma rede Wi-Fi neste computador', port: 'a porta do celular está ocupada', error: 'não deu para ligar', demo: 'modo demonstração' } } });
631
+ Object.assign(I18N.en, { phoneOn: { how: 'See the agents and tasks and answer from your phone, on the same Wi-Fi. Turned on here, it comes back on by itself every time the office opens.', on: 'Turn the phone on', off: 'Turn the phone off', cli: 'Or start the office with', kept: 'It stays on: it opens with the office next time, with the same QR.', fail: { nolan: 'no Wi-Fi network found on this computer', port: 'the phone port is taken', error: 'could not turn it on', demo: 'demo mode' } } });
632
+ // the ☰ menu's groups
633
+ Object.assign(I18N.pt, { menuGroups: { see: 'Ver', do: 'Fazer', prefs: 'Ajustes', autostart: 'Abrir com o computador' } });
634
+ Object.assign(I18N.en, { menuGroups: { see: 'See', do: 'Do', prefs: 'Settings', autostart: 'Open with the computer' } });
629
635
  let T = I18N[lang];
630
636
  // sound is on by default; alerts follow the browser permission (asked on open). Only the buttons turn them off.
631
637
  let soundOn = store.get('sound', true);
@@ -622,11 +622,43 @@ function drawReleaseBadge(x, y, t) { // the rocket poster gets a star when a new
622
622
  // the same panels in the ☰ menu
623
623
  {
624
624
  const menu = document.getElementById('tools-menu');
625
- if (menu) for (const [tab, icon] of [['tv', '📺'], ['phone', '📱'], ['broadcast', '📣'], ['repos', '📦'], ['tasks', '📋'], ['usage', '💲'], ['watches', '⏰'], ['archive', '🗄'], ['timelapse', '🎞'], ['feed', '🗂'], ['notices', '🔔'], ['version', '🚀']]) {
625
+ // each panel gets a 16 px pixel icon in the same style as the menu's other buttons (rectangles x, y, w, h)
626
+ const R = rects => rects.map(([x, y, w, h]) => `M${x} ${y}h${w}v${h}h-${w}z`).join('');
627
+ const ICON = {
628
+ tasks: R([[5, 1, 6, 3], [3, 3, 10, 1], [3, 3, 1, 12], [12, 3, 1, 12], [3, 14, 10, 1], [5, 7, 6, 1], [5, 10, 6, 1]]),
629
+ usage: R([[7, 1, 2, 14], [4, 3, 8, 2], [4, 5, 2, 2], [4, 7, 8, 2], [10, 9, 2, 2], [4, 11, 8, 2]]),
630
+ notices: 'M7 1h2v1h2v1h1v5h1v2h1v2H2v-2h1V8h1V3h1V2h2zM6 13h4v1H9v1H7v-1H6z',
631
+ feed: R([[2, 3, 2, 2], [6, 3, 8, 2], [2, 7, 2, 2], [6, 7, 8, 2], [2, 11, 2, 2], [6, 11, 8, 2]]),
632
+ repos: R([[1, 2, 14, 3], [2, 5, 1, 9], [13, 5, 1, 9], [2, 13, 12, 1], [6, 7, 4, 1]]),
633
+ archive: R([[3, 1, 10, 1], [3, 1, 1, 14], [12, 1, 1, 14], [3, 14, 10, 1], [3, 7, 10, 1], [7, 4, 2, 1], [7, 10, 2, 1]]),
634
+ timelapse: R([[1, 3, 14, 1], [1, 12, 14, 1], [1, 3, 1, 10], [14, 3, 1, 10], [6, 6, 2, 4], [8, 7, 2, 2]]),
635
+ version: R([[7, 1, 2, 2], [6, 3, 4, 7], [4, 8, 2, 3], [10, 8, 2, 3], [7, 11, 2, 3]]),
636
+ broadcast: R([[2, 6, 2, 4], [4, 5, 2, 6], [6, 4, 2, 8], [8, 2, 2, 12], [11, 7, 3, 2]]),
637
+ watches: R([[2, 1, 3, 2], [11, 1, 3, 2], [4, 3, 8, 1], [3, 4, 1, 8], [12, 4, 1, 8], [4, 12, 8, 1], [7, 6, 1, 4], [8, 9, 2, 1], [3, 13, 2, 2], [11, 13, 2, 2]]),
638
+ phone: R([[4, 1, 8, 1], [4, 1, 1, 14], [11, 1, 1, 14], [4, 14, 8, 1], [6, 12, 4, 1]]),
639
+ tv: R([[5, 1, 1, 3], [10, 1, 1, 3], [1, 4, 14, 1], [1, 12, 14, 1], [1, 4, 1, 9], [14, 4, 1, 9], [6, 13, 4, 2]]),
640
+ };
641
+ if (menu) for (const tab of ['tv', 'phone', 'broadcast', 'repos', 'tasks', 'usage', 'watches', 'archive', 'timelapse', 'feed', 'notices', 'version']) {
626
642
  const b = document.createElement('button'); b.className = 'icon-btn menu-panel'; b.dataset.menuTab = tab;
627
- b.innerHTML = `<span aria-hidden="true">${icon}</span><span class="ml"></span>`; menu.prepend(b);
643
+ b.innerHTML = `<svg viewBox="0 0 16 16" shape-rendering="crispEdges" aria-hidden="true"><path fill="currentColor" d="${ICON[tab]}"/></svg><span class="ml"></span>`; menu.prepend(b);
628
644
  b.addEventListener('click', () => { if (typeof setMenu === 'function') setMenu(false); if (tab === 'tv') return setTv(true); openReport(tab); });
629
645
  }
646
+ // three groups instead of one long list: what you look at, what you do, how the office behaves
647
+ if (menu) {
648
+ const groups = [['see', ['[data-menu-tab=tasks]', '[data-menu-tab=usage]', '[data-menu-tab=notices]', '[data-menu-tab=feed]', '[data-menu-tab=repos]', '[data-menu-tab=archive]', '[data-menu-tab=timelapse]', '[data-menu-tab=version]']],
649
+ ['do', ['#btn-launch', '[data-menu-tab=broadcast]', '[data-menu-tab=watches]', '[data-menu-tab=phone]', '[data-menu-tab=tv]', '#btn-search', '#btn-replay', '#btn-photo', '#btn-deco']],
650
+ ['prefs', ['#btn-notify', '#btn-sound', '#btn-focus', '#btn-walk', '#btn-radio', '#btn-autostart', '#btn-lang', '#btn-office']]];
651
+ // start with the computer, right in the menu (it also stays in Personalise): a switch with the same green dot
652
+ const as = document.createElement('button'); as.id = 'btn-autostart'; as.className = 'icon-btn'; as.setAttribute('aria-pressed', 'false');
653
+ as.innerHTML = `<svg viewBox="0 0 16 16" shape-rendering="crispEdges" aria-hidden="true"><path fill="currentColor" d="${R([[7, 1, 2, 6], [4, 3, 2, 1], [10, 3, 2, 1], [3, 4, 1, 1], [12, 4, 1, 1], [2, 5, 1, 6], [13, 5, 1, 6], [3, 11, 1, 1], [12, 11, 1, 1], [4, 12, 2, 1], [10, 12, 2, 1], [6, 13, 4, 1]])}"/></svg><span class="ml"></span>`;
654
+ menu.appendChild(as);
655
+ const syncAs = () => fetch('api/autostart').then(r => r.json()).then(d => { as.setAttribute('aria-pressed', d.on ? 'true' : 'false'); as.disabled = !!d.demo; }).catch(() => {});
656
+ as.addEventListener('click', async () => { const on = as.getAttribute('aria-pressed') !== 'true'; as.disabled = true; const r = await api('api/autostart', { on }); as.disabled = false; as.setAttribute('aria-pressed', r.on ? 'true' : 'false'); toast(`<b>${esc(T.menuGroups.autostart)}</b>${esc(r.ok ? (r.on ? T.appx.asOn : T.appx.asOff) : '⚠')}`, r.ok ? 'ok' : 'warn'); if (typeof syncAutostart === 'function') syncAutostart(); });
657
+ syncAs(); document.getElementById('btn-menu').addEventListener('click', syncAs);
658
+ const secs = groups.map(([k, sels]) => { const sec = document.createElement('div'); sec.className = 'menu-sec'; sec.innerHTML = `<div class="menu-h" data-menu-group="${k}"></div>`; for (const q of sels) { const el = menu.querySelector(':scope > ' + q); if (el) sec.appendChild(el); } return sec; });
659
+ for (const el of [...menu.querySelectorAll(':scope > .icon-btn')]) secs[1].appendChild(el); // anything not listed still shows
660
+ secs.forEach(sec => menu.appendChild(sec));
661
+ }
630
662
  }
631
663
  // repositories at a glance: who is in each one now, what is not committed or pushed, task branches and PRs, today's activity
632
664
  // one message to several agents: typed into the ones waiting in a terminal, sent in the background to the others
@@ -730,10 +762,11 @@ document.addEventListener('click', async e => {
730
762
  async function renderPhone() {
731
763
  const P = T.phone, body = reportEl.querySelector('.report-body');
732
764
  const d = await api('api/phone'); if (reportTab !== 'phone' || reportEl.hidden) return;
733
- if (!d || !d.on) { body.innerHTML = tabsHtml() + `<h3>📱 ${esc(P.title)}</h3><p>${esc(P.how)}</p><p><code>npx coworking-agents --phone</code></p><p class="note">${esc(P.warn)}</p>`; return; }
765
+ const F = T.phoneOn;
766
+ if (!d || !d.on) { body.innerHTML = tabsHtml() + `<h3>📱 ${esc(P.title)}</h3><p>${esc(F.how)}</p><p><button class="btn" data-phone-on="1">📱 ${esc(F.on)}</button> <small class="note" data-phone-msg>${d && d.reason && !['off', 'starting'].includes(d.reason) ? esc(F.fail[d.reason] || d.reason) : ''}</small></p><p class="note">${esc(F.cli)} <code>npx coworking-agents --phone</code></p><p class="note">${esc(P.warn)}</p>`; return; }
734
767
  body.innerHTML = tabsHtml() + `<h3>📱 ${esc(P.title)}</h3><p>${esc(P.scan)}</p><canvas class="qr" width="10" height="10"></canvas><p><code class="qr-url">${esc(d.url)}</code></p>${d.ipUrl && d.ipUrl !== d.url ? `<p class="note">${esc(T.phone.ipAlt)}</p><p><code class="qr-url">${esc(d.ipUrl)}</code></p>` : ''}<p class="note">${esc(P.warn)}</p>`;
735
768
  drawQr(body.querySelector('canvas.qr'), d.qr);
736
- body.insertAdjacentHTML('beforeend', `<h3>📲 ${esc(T.phone.homeTitle)}</h3><p class="note">${esc(T.phone.homeIos)}</p><p class="note">${esc(T.phone.homeAndroid)}</p><p class="note">${esc(T.phone.homeNote)}</p><p><button class="btn small ghost" data-phonerotate>🔄 ${esc(T.phone.rotate)}</button> <small class="note" data-phonerotate-msg></small></p>`);
769
+ body.insertAdjacentHTML('beforeend', `<h3>📲 ${esc(T.phone.homeTitle)}</h3><p class="note">${esc(T.phone.homeIos)}</p><p class="note">${esc(T.phone.homeAndroid)}</p><p class="note">${esc(T.phone.homeNote)}</p><p><button class="btn small ghost" data-phonerotate>🔄 ${esc(T.phone.rotate)}</button> <button class="btn small ghost" data-phone-on="0">⏻ ${esc(F.off)}</button> <small class="note" data-phonerotate-msg></small></p><p class="note">${esc(F.kept)}</p>`);
737
770
  const V = T.visit; visitUntil = d.visit ? d.visit.until : 0;
738
771
  body.insertAdjacentHTML('beforeend', `<h3>👀 ${esc(V.title)}</h3><p class="note">${esc(V.sub)}</p>${d.visit
739
772
  ? `<p class="ok">${esc(V.live(new Date(d.visit.until).toLocaleTimeString(lang === 'pt' ? 'pt-BR' : 'en', { hour: '2-digit', minute: '2-digit' })))}</p><canvas class="qr qr-visit" width="10" height="10"></canvas><p><code class="qr-url">${esc(d.visit.url)}</code></p><p><button class="btn small danger" data-visit="stop">✕ ${esc(V.stop)}</button></p>`
@@ -745,6 +778,14 @@ function drawQr(c, qr) {
745
778
  c.width = c.height = (n + q * 2) * s; g.fillStyle = '#fff'; g.fillRect(0, 0, c.width, c.height); g.fillStyle = '#000';
746
779
  qr.forEach((row, r) => [...row].forEach((x, k) => { if (x === '1') g.fillRect((k + q) * s, (r + q) * s, s, s); }));
747
780
  }
781
+ // turn the phone panel on or off right here (remembered: it comes back on with the office)
782
+ document.addEventListener('click', async e => {
783
+ const b = e.target.closest('[data-phone-on]'); if (!b) return;
784
+ b.disabled = true;
785
+ const r = await api('api/phone-toggle', { on: b.dataset.phoneOn === '1' });
786
+ if (!r.ok) { b.disabled = false; const m = document.querySelector('[data-phone-msg]'); if (m) m.textContent = T.phoneOn.fail[r.reason] || r.reason || '?'; return; }
787
+ phoneOn = b.dataset.phoneOn === '1'; renderPhone();
788
+ });
748
789
  document.addEventListener('click', async e => {
749
790
  const b = e.target.closest('[data-phonerotate]'); if (!b || !confirm(T.phone.rotateSure)) return;
750
791
  b.disabled = true; const r = await fetch('api/phone-rotate', { method: 'POST', headers: { 'X-Coworking': '1' } }).then(x => x.json()).catch(() => ({ ok: false }));
@@ -791,7 +832,7 @@ function setTv(on) {
791
832
  document.addEventListener('keydown', e => { if (e.key === 'Escape' && document.body.classList.contains('tv')) setTv(false); });
792
833
  document.addEventListener('fullscreenchange', () => { if (!document.fullscreenElement && document.body.classList.contains('tv')) setTv(false); });
793
834
  if (new URLSearchParams(location.search).get('tv') === '1') setTimeout(() => setTv(true), 500);
794
- function labelMenuPanels() { const O = T.officeObj; document.querySelectorAll('[data-menu-tab]').forEach(b => { const l = O.menu[b.dataset.menuTab]; b.querySelector('.ml').textContent = l; b.title = l; }); }
835
+ function labelMenuPanels() { const O = T.officeObj; document.querySelectorAll('[data-menu-tab]').forEach(b => { const l = O.menu[b.dataset.menuTab]; b.querySelector('.ml').textContent = l; b.title = l; }); document.querySelectorAll('[data-menu-group]').forEach(h => { h.textContent = T.menuGroups[h.dataset.menuGroup]; }); const as = document.querySelector('#btn-autostart .ml'); if (as) { as.textContent = T.menuGroups.autostart; as.parentNode.title = T.appx.autostart; } }
795
836
  setInterval(labelMenuPanels, 2000); setTimeout(labelMenuPanels, 300);
796
837
 
797
838
  // ---- the recommendation card: current, recommended and pending config, the reason, Apply; refreshed as the task moves ----
@@ -213,7 +213,7 @@ const menuEl = document.getElementById('tools-menu'), menuBtn = document.getElem
213
213
  document.body.appendChild(menuEl); // out of the bar: its overflow would clip the menu in some browsers
214
214
  function setMenu(open) {
215
215
  menuEl.hidden = !open; menuBtn.setAttribute('aria-expanded', open);
216
- if (open) { const b = menuBtn.getBoundingClientRect(); menuEl.style.top = (b.bottom + 8) + 'px'; menuEl.style.right = Math.max(8, innerWidth - b.right) + 'px'; }
216
+ if (open) { const b = menuBtn.getBoundingClientRect(); menuEl.style.top = (b.bottom + 8) + 'px'; menuEl.style.right = Math.max(8, innerWidth - b.right) + 'px'; menuEl.style.maxHeight = Math.max(160, innerHeight - b.bottom - 20) + 'px'; } // never taller than the window: it scrolls
217
217
  }
218
218
  menuBtn.onclick = e => { e.stopPropagation(); setMenu(menuEl.hidden); };
219
219
  menuEl.addEventListener('click', e => { if (e.target.closest('#btn-search,#btn-replay')) setMenu(false); });
package/public/style.css CHANGED
@@ -996,3 +996,15 @@ body.tv .app-ask { display: none; }
996
996
  .watch-form { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
997
997
  .watch-form select, .watch-form input { font: 13px/1.3 system-ui, -apple-system, sans-serif; padding: 6px 8px; border: 2px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--ink); }
998
998
  .watch-form input[data-w-file] { min-width: 280px; }
999
+ /* the ☰ menu: two columns on a wide window, a little tighter, and it scrolls instead of running off the screen */
1000
+ .tools-menu:not([hidden]) { overflow-y: auto; overscroll-behavior: contain; }
1001
+ .tools-menu .icon-btn { height: 30px; }
1002
+ /* three groups side by side on a wide window, stacked on a narrow one */
1003
+ .tools-menu:not([hidden]) { padding: 10px; gap: 0; }
1004
+ .tools-menu .menu-sec { display: flex; flex-direction: column; gap: 1px; min-width: 210px; }
1005
+ .tools-menu .menu-sec + .menu-sec { border-top: 2px dashed #332f37; margin-top: 8px; padding-top: 8px; }
1006
+ .tools-menu .menu-h { font: 700 10px/1 var(--pix); letter-spacing: .08em; text-transform: uppercase; color: #6f6577; padding: 4px 10px 8px; }
1007
+ @media (min-width: 900px) {
1008
+ .tools-menu:not([hidden]) { display: grid; grid-template-columns: repeat(3, minmax(200px, 1fr)); column-gap: 10px; }
1009
+ .tools-menu .menu-sec + .menu-sec { border-top: 0; margin-top: 0; padding-top: 0; border-left: 2px dashed #332f37; padding-left: 10px; }
1010
+ }
Binary file
package/src/menubar.js CHANGED
@@ -11,7 +11,7 @@ const { spawn, spawnSync } = require('child_process');
11
11
 
12
12
  const DIR = () => path.join(os.homedir(), '.config', 'coworking-agents');
13
13
  const FILE = () => path.join(DIR(), 'menubar.json');
14
- const ICON = path.join(__dirname, '..', 'public', 'favicon.png');
14
+ const ICON = path.join(__dirname, '..', 'public', 'tray.png'); // the small face reads best at menu bar size
15
15
  let child = null, kind = null, base = '', token = '', lastBody = '', lastTip = '', seen = null, notes = [];
16
16
 
17
17
  const has = cmd => { try { return spawnSync('which', [cmd], { stdio: 'ignore' }).status === 0; } catch { return false; } };
package/src/server.js CHANGED
@@ -126,7 +126,7 @@ function start({ port = 4777, host = '127.0.0.1', privacy = false, interval = 10
126
126
  return res.end();
127
127
  }
128
128
  // icons are public: browsers fetch them without cookies and cache the 401 otherwise
129
- const PUBLIC_ICONS = new Set(['/favicon.ico', '/favicon.png', '/favicon.svg', '/apple-touch-icon.png', '/logo.svg', '/manifest.webmanifest', '/icon-192.png', '/icon-512.png']); // no data in any of them
129
+ const PUBLIC_ICONS = new Set(['/favicon.ico', '/favicon.png', '/apple-touch-icon.png', '/logo.svg', '/manifest.webmanifest', '/icon-192.png', '/icon-512.png']); // no data in any of them
130
130
  if (!PUBLIC_ICONS.has(url.pathname) && !sameSecret(cookie(req, 'cw'), token)) return send(res, 401, 'text/html; charset=utf-8', LOCKED);
131
131
 
132
132
  if (url.pathname === '/events') {
@@ -333,7 +333,7 @@ function start({ port = 4777, host = '127.0.0.1', privacy = false, interval = 10
333
333
  if (url.pathname === '/api/state') return send(res, 200, 'application/json', safeSnapshot());
334
334
  if (url.pathname === '/api/ping') return send(res, 200, 'application/json', '{"ok":true}');
335
335
  // the phone link and its QR code, only to this machine (the phone server itself never serves this)
336
- if (url.pathname === '/api/phone') return send(res, 200, 'application/json', JSON.stringify(phoneInfo ? { on: true, url: phoneInfo.url, ipUrl: phoneInfo.ipUrl, qr: require('./qr').encode(phoneInfo.url).map(r => r.map(x => (x ? 1 : 0)).join('')), visit: visitNow && visitNow.until > Date.now() ? { ...visitNow, qr: require('./qr').encode(visitNow.url).map(r => r.map(x => (x ? 1 : 0)).join('')) } : null } : { on: false, reason: phoneReason }));
336
+ if (url.pathname === '/api/phone') return send(res, 200, 'application/json', JSON.stringify(phoneInfo ? { on: true, url: phoneInfo.url, ipUrl: phoneInfo.ipUrl, qr: require('./qr').encode(phoneInfo.url).map(r => r.map(x => (x ? 1 : 0)).join('')), visit: visitNow && visitNow.until > Date.now() ? { ...visitNow, qr: require('./qr').encode(visitNow.url).map(r => r.map(x => (x ? 1 : 0)).join('')) } : null } : { on: false, reason: phoneReason, always: !!tasks.list().settings.phone }));
337
337
  if (url.pathname === '/api/appinstall' && req.method === 'GET') return send(res, 200, 'application/json', JSON.stringify({ on: require('./appinstall').installed(), platform: process.platform, demo }));
338
338
  if (url.pathname === '/api/appinstall' && req.method === 'POST') { // create or remove the app on this computer
339
339
  const origin = req.headers.origin || '';
@@ -361,6 +361,21 @@ function start({ port = 4777, host = '127.0.0.1', privacy = false, interval = 10
361
361
  req.on('end', () => { let m = {}; try { m = JSON.parse(body); } catch {} if (demo) return send(res, 200, 'application/json', '{"ok":false,"reason":"demo"}'); const on = !!m.on; tasks.op({ op: 'settings', menubar: on }); const r = on ? mb.start({ base: baseUrl, token }) : (mb.stop(), { ok: true }); if (on) mb.write(snapshot({ privacy }), mbExtra()); send(res, 200, 'application/json', JSON.stringify({ ...r, on: mb.running() })); });
362
362
  return;
363
363
  }
364
+ if (url.pathname === '/api/phone-toggle' && req.method === 'POST') { // turn the phone panel on or off now, remembered for the next start
365
+ const origin = req.headers.origin || '';
366
+ if (req.headers['x-coworking'] !== '1' || (origin && !LOCAL_ORIGIN.test(origin))) { res.writeHead(403); return res.end(); }
367
+ let body = '';
368
+ req.on('data', c => { body += c; if (body.length > 1e3) req.destroy(); });
369
+ req.on('end', async () => {
370
+ let m = {}; try { m = JSON.parse(body); } catch {}
371
+ if (demo) return send(res, 200, 'application/json', '{"ok":false,"reason":"demo"}');
372
+ const on = !!m.on; tasks.op({ op: 'settings', phone: on });
373
+ if (on) return send(res, 200, 'application/json', JSON.stringify(await startPhoneNow()));
374
+ if (phoneInfo) { phoneInfo.close(); phoneInfo = null; } phoneReason = 'off'; visitNow = null;
375
+ send(res, 200, 'application/json', '{"ok":true}');
376
+ });
377
+ return;
378
+ }
364
379
  if (url.pathname === '/api/phone-rotate' && req.method === 'POST') { // a new phone code: every phone has to scan the QR again
365
380
  const origin = req.headers.origin || '';
366
381
  if (req.headers['x-coworking'] !== '1' || (origin && !LOCAL_ORIGIN.test(origin))) { res.writeHead(403); return res.end(); }
@@ -398,15 +413,23 @@ function start({ port = 4777, host = '127.0.0.1', privacy = false, interval = 10
398
413
  });
399
414
  }
400
415
 
416
+ // opt-in: a separate, restricted server on the LAN address (see src/phone.js); its code is kept like the office's, and its
417
+ // port is always the same (not the office's + 1), so a home-screen icon keeps working whatever port the office got
418
+ const PHONE_PORT = Number(process.env.COWORKING_PHONE_PORT) || 4778;
419
+ async function startPhoneNow() {
420
+ if (phoneInfo) return { ok: true };
421
+ try {
422
+ const r = await require('./phone').startPhone({ port: port ? PHONE_PORT : 0, privacy, demo, snapshot, tasks, sendText, people: () => real().people, token: keepToken ? phoneToken : '', onToken: onPhoneToken, extra: phoneUsage });
423
+ if (r.ok) { phoneInfo = r; phoneReason = ''; const t = r.url.split('t=')[1]; if (keepToken && onPhoneToken && t !== phoneToken) { phoneToken = t; onPhoneToken(t); } } else phoneReason = r.reason;
424
+ return r.ok ? { ok: true } : { ok: false, reason: r.reason };
425
+ } catch (e) { phoneReason = e.code === 'EADDRINUSE' ? 'port' : 'error'; return { ok: false, reason: phoneReason }; }
426
+ }
401
427
  return new Promise((resolve, reject) => {
402
428
  server.once('error', reject);
403
429
  server.listen(port, host, async () => {
404
430
  const base = `http://${host === '0.0.0.0' ? 'localhost' : host}:${server.address().port}`; baseUrl = base;
405
431
  if (!demo && (menubar || tasks.list().settings.menubar)) { mb.start({ base, token }); try { mb.write(snapshot({ privacy }), mbExtra()); } catch {} }
406
- if (phone) { // opt-in: a separate, restricted server on the LAN address (see src/phone.js)
407
- try { const r = await require('./phone').startPhone({ port: port ? server.address().port + 1 : 0, privacy, demo, snapshot, tasks, sendText, people: () => real().people, token: keepToken ? phoneToken : '', onToken: onPhoneToken, extra: phoneUsage }); if (r.ok) phoneInfo = r; else phoneReason = r.reason; }
408
- catch (e) { phoneReason = e.code === 'EADDRINUSE' ? 'port' : 'error'; }
409
- }
432
+ if (phone || (!demo && tasks.list().settings.phone)) await startPhoneNow(); // --phone, or turned on once in the office (remembered)
410
433
  resolve({ server, token, base, url: `${base}/?t=${token}`, phone: phoneInfo, phoneReason, close: () => { clearInterval(timer); clearInterval(mbTimer); clearInterval(paceTimer); mb.stop(); for (const c of clients) c.destroy(); server.close(); if (phoneInfo) phoneInfo.close(); } });
411
434
  });
412
435
  });
package/src/tasks.js CHANGED
@@ -202,6 +202,7 @@ function op(m = {}) {
202
202
  if (m.autoReview !== undefined) settings.autoReview = !!m.autoReview;
203
203
  if (m.monthGoal !== undefined) settings.monthGoal = Math.max(0, Math.min(1e6, Number(m.monthGoal) || 0));
204
204
  if (m.menubar !== undefined) settings.menubar = !!m.menubar;
205
+ if (m.phone !== undefined) settings.phone = !!m.phone;
205
206
  if (['app', 'browser', ''].includes(m.appChoice)) settings.appChoice = m.appChoice;
206
207
  if (m.quiet && typeof m.quiet === 'object') settings.quiet = { on: !!m.quiet.on, from: /^([01]\d|2[0-3]):[0-5]\d$/.test(m.quiet.from) ? m.quiet.from : '22:00', to: /^([01]\d|2[0-3]):[0-5]\d$/.test(m.quiet.to) ? m.quiet.to : '08:00' };
207
208
  if (m.dayBudget !== undefined) settings.dayBudget = Math.max(0, Math.min(1e5, Number(m.dayBudget) || 0));
@@ -1 +0,0 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" shape-rendering="crispEdges"><rect x="2" y="0" width="12" height="1" fill="#1b1622"/><rect x="1" y="1" width="1" height="1" fill="#1b1622"/><rect x="2" y="1" width="12" height="1" fill="#f4dfc6"/><rect x="14" y="1" width="1" height="1" fill="#1b1622"/><rect x="0" y="2" width="1" height="1" fill="#1b1622"/><rect x="1" y="2" width="2" height="1" fill="#f4dfc6"/><rect x="3" y="2" width="10" height="1" fill="#1b1622"/><rect x="13" y="2" width="2" height="1" fill="#f4dfc6"/><rect x="15" y="2" width="1" height="1" fill="#1b1622"/><rect x="0" y="3" width="1" height="1" fill="#1b1622"/><rect x="1" y="3" width="1" height="1" fill="#f4dfc6"/><rect x="2" y="3" width="1" height="1" fill="#1b1622"/><rect x="3" y="3" width="10" height="1" fill="#2b2336"/><rect x="13" y="3" width="1" height="1" fill="#1b1622"/><rect x="14" y="3" width="1" height="1" fill="#f4dfc6"/><rect x="15" y="3" width="1" height="1" fill="#1b1622"/><rect x="0" y="4" width="1" height="1" fill="#1b1622"/><rect x="1" y="4" width="1" height="1" fill="#f4dfc6"/><rect x="2" y="4" width="1" height="1" fill="#1b1622"/><rect x="3" y="4" width="3" height="1" fill="#2b2336"/><rect x="6" y="4" width="1" height="1" fill="#2ce8f5"/><rect x="7" y="4" width="2" height="1" fill="#2b2336"/><rect x="9" y="4" width="1" height="1" fill="#ff5fd2"/><rect x="10" y="4" width="3" height="1" fill="#2b2336"/><rect x="13" y="4" width="1" height="1" fill="#1b1622"/><rect x="14" y="4" width="1" height="1" fill="#f4dfc6"/><rect x="15" y="4" width="1" height="1" fill="#1b1622"/><rect x="0" y="5" width="1" height="1" fill="#1b1622"/><rect x="1" y="5" width="1" height="1" fill="#f4dfc6"/><rect x="2" y="5" width="1" height="1" fill="#1b1622"/><rect x="3" y="5" width="2" height="1" fill="#2b2336"/><rect x="5" y="5" width="1" height="1" fill="#2ce8f5"/><rect x="6" y="5" width="4" height="1" fill="#2b2336"/><rect x="10" y="5" width="1" height="1" fill="#ff5fd2"/><rect x="11" y="5" width="2" height="1" fill="#2b2336"/><rect x="13" y="5" width="1" height="1" fill="#1b1622"/><rect x="14" y="5" width="1" height="1" fill="#f4dfc6"/><rect x="15" y="5" width="1" height="1" fill="#1b1622"/><rect x="0" y="6" width="1" height="1" fill="#1b1622"/><rect x="1" y="6" width="1" height="1" fill="#f4dfc6"/><rect x="2" y="6" width="1" height="1" fill="#1b1622"/><rect x="3" y="6" width="1" height="1" fill="#2b2336"/><rect x="4" y="6" width="1" height="1" fill="#2ce8f5"/><rect x="5" y="6" width="6" height="1" fill="#2b2336"/><rect x="11" y="6" width="1" height="1" fill="#ff5fd2"/><rect x="12" y="6" width="1" height="1" fill="#2b2336"/><rect x="13" y="6" width="1" height="1" fill="#1b1622"/><rect x="14" y="6" width="1" height="1" fill="#f4dfc6"/><rect x="15" y="6" width="1" height="1" fill="#1b1622"/><rect x="0" y="7" width="1" height="1" fill="#1b1622"/><rect x="1" y="7" width="1" height="1" fill="#f4dfc6"/><rect x="2" y="7" width="1" height="1" fill="#1b1622"/><rect x="3" y="7" width="2" height="1" fill="#2b2336"/><rect x="5" y="7" width="1" height="1" fill="#2ce8f5"/><rect x="6" y="7" width="4" height="1" fill="#2b2336"/><rect x="10" y="7" width="1" height="1" fill="#ff5fd2"/><rect x="11" y="7" width="2" height="1" fill="#2b2336"/><rect x="13" y="7" width="1" height="1" fill="#1b1622"/><rect x="14" y="7" width="1" height="1" fill="#f4dfc6"/><rect x="15" y="7" width="1" height="1" fill="#1b1622"/><rect x="0" y="8" width="1" height="1" fill="#1b1622"/><rect x="1" y="8" width="1" height="1" fill="#f4dfc6"/><rect x="2" y="8" width="1" height="1" fill="#1b1622"/><rect x="3" y="8" width="3" height="1" fill="#2b2336"/><rect x="6" y="8" width="1" height="1" fill="#2ce8f5"/><rect x="7" y="8" width="2" height="1" fill="#2b2336"/><rect x="9" y="8" width="1" height="1" fill="#ff5fd2"/><rect x="10" y="8" width="3" height="1" fill="#2b2336"/><rect x="13" y="8" width="1" height="1" fill="#1b1622"/><rect x="14" y="8" width="1" height="1" fill="#f4dfc6"/><rect x="15" y="8" width="1" height="1" fill="#1b1622"/><rect x="0" y="9" width="1" height="1" fill="#1b1622"/><rect x="1" y="9" width="1" height="1" fill="#f4dfc6"/><rect x="2" y="9" width="1" height="1" fill="#1b1622"/><rect x="3" y="9" width="10" height="1" fill="#2b2336"/><rect x="13" y="9" width="1" height="1" fill="#1b1622"/><rect x="14" y="9" width="1" height="1" fill="#f4dfc6"/><rect x="15" y="9" width="1" height="1" fill="#1b1622"/><rect x="0" y="10" width="1" height="1" fill="#1b1622"/><rect x="1" y="10" width="2" height="1" fill="#f4dfc6"/><rect x="3" y="10" width="10" height="1" fill="#1b1622"/><rect x="13" y="10" width="2" height="1" fill="#f4dfc6"/><rect x="15" y="10" width="1" height="1" fill="#1b1622"/><rect x="1" y="11" width="1" height="1" fill="#1b1622"/><rect x="2" y="11" width="12" height="1" fill="#f4dfc6"/><rect x="14" y="11" width="1" height="1" fill="#1b1622"/><rect x="2" y="12" width="2" height="1" fill="#1b1622"/><rect x="4" y="12" width="2" height="1" fill="#f4dfc6"/><rect x="6" y="12" width="8" height="1" fill="#1b1622"/><rect x="3" y="13" width="1" height="1" fill="#1b1622"/><rect x="4" y="13" width="1" height="1" fill="#f4dfc6"/><rect x="5" y="13" width="1" height="1" fill="#1b1622"/><rect x="3" y="14" width="2" height="1" fill="#1b1622"/></svg>