coworking-agents 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +3 -1
- package/bin/coworking-agents.js +1 -1
- package/docs/achievements.png +0 -0
- package/docs/building.png +0 -0
- package/docs/camera.png +0 -0
- package/docs/christmas.png +0 -0
- package/docs/customise.png +0 -0
- package/docs/game-room.png +0 -0
- package/docs/halloween.png +0 -0
- package/docs/house-wall.png +0 -0
- package/docs/mobile.png +0 -0
- package/docs/office-day.png +0 -0
- package/docs/office-night.png +0 -0
- package/docs/panel.png +0 -0
- package/docs/paper.png +0 -0
- package/docs/servers.png +0 -0
- package/docs/talk.png +0 -0
- package/docs/top-bar.png +0 -0
- package/docs/trophy-room.png +0 -0
- package/docs/usage.png +0 -0
- package/docs/work-site.png +0 -0
- package/package.json +1 -1
- package/public/favicon.ico +0 -0
- package/public/favicon.png +0 -0
- package/public/index.html +5 -4
- package/public/js/alerts.js +1 -0
- package/public/js/archive.js +61 -0
- package/public/js/i18n.js +16 -0
- package/public/js/panel.js +1 -1
- package/public/js/report.js +2 -0
- package/public/js/tasks.js +47 -6
- package/public/js/topbar.js +1 -1
- package/public/js/watches.js +74 -0
- package/public/style.css +30 -0
- package/public/tray.png +0 -0
- package/src/demo.js +11 -1
- package/src/menubar.js +1 -1
- package/src/progress.js +9 -2
- package/src/said.js +35 -1
- package/src/server.js +44 -6
- package/src/sources/codex.js +1 -1
- package/src/tasks.js +1 -0
- package/src/update.js +2 -2
- package/src/usage.js +4 -1
- package/public/favicon.svg +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
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
|
+
|
|
10
|
+
## 0.9.0 — 2026-10-14
|
|
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
|
|
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
|
|
13
|
+
- A new release shows up in an office left open within about 10 minutes (it checked once an hour), and the releases board, the admin's sign and the notice follow as soon as the office knows
|
|
14
|
+
|
|
3
15
|
## 0.8.0 — 2026-10-14
|
|
4
16
|
- The office wall: frames, screens and windows spread evenly between the light switch and the whiteboard (same gap between each), a quiet wallpaper pattern and a chair rail with wainscot instead of plain stripes; sunlight comes in only through real windows
|
|
5
17
|
- Everyone in the office: click the agent count under the office name for every agent with its state and the helpers working for it; an agent opens its panel, a helper opens its conversation
|
package/README.md
CHANGED
|
@@ -227,6 +227,8 @@ The office works in any browser. If you prefer an app:
|
|
|
227
227
|
- **💬 Conversation** (click an agent): the whole conversation as a chat, a search inside it, and a box to write to it once it finished its turn. Its helpers (subagents) are listed above the chat; pick one to read its own conversation. Only what you wrote and what it answered; off in private mode.
|
|
228
228
|
- **▦ Task board** (Tasks → Board): queued, running, waiting on you and done; drag a card to retry, conclude or cancel it.
|
|
229
229
|
- **When you spend** (Costs and limits): a weekday × hour map of output tokens over the last 28 days, and under each limit when it would run out at the pace of the last hour.
|
|
230
|
+
- **⏰ 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
|
+
- **🗄 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.
|
|
230
232
|
- **🎞 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.
|
|
231
233
|
- **📺 TV mode** (or `?tv=1`): full screen, only the office and big counters; Esc leaves.
|
|
232
234
|
- **Waiting on you today** in the daily report: measured time the agents waited on your answers and tasks stood blocked on you.
|
|
@@ -234,7 +236,7 @@ The office works in any browser. If you prefer an app:
|
|
|
234
236
|
|
|
235
237
|
## Phone panel (opt-in)
|
|
236
238
|
|
|
237
|
-
`npx coworking-agents --phone
|
|
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.
|
|
238
240
|
|
|
239
241
|
- **Now:** who needs you and every agent with what it is doing.
|
|
240
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).
|
package/bin/coworking-agents.js
CHANGED
|
@@ -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
|
}
|
package/docs/achievements.png
CHANGED
|
Binary file
|
package/docs/building.png
CHANGED
|
Binary file
|
package/docs/camera.png
CHANGED
|
Binary file
|
package/docs/christmas.png
CHANGED
|
Binary file
|
package/docs/customise.png
CHANGED
|
Binary file
|
package/docs/game-room.png
CHANGED
|
Binary file
|
package/docs/halloween.png
CHANGED
|
Binary file
|
package/docs/house-wall.png
CHANGED
|
Binary file
|
package/docs/mobile.png
CHANGED
|
Binary file
|
package/docs/office-day.png
CHANGED
|
Binary file
|
package/docs/office-night.png
CHANGED
|
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
|
package/docs/trophy-room.png
CHANGED
|
Binary file
|
package/docs/usage.png
CHANGED
|
Binary file
|
package/docs/work-site.png
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coworking-agents",
|
|
3
|
-
"version": "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"
|
package/public/favicon.ico
CHANGED
|
Binary file
|
package/public/favicon.png
CHANGED
|
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=
|
|
8
|
-
<link rel="icon" href="favicon.png?v=
|
|
9
|
-
<link rel="icon" href="
|
|
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">
|
|
@@ -84,6 +83,8 @@
|
|
|
84
83
|
<script src="js/tasks.js"></script>
|
|
85
84
|
<script src="js/talkdesk.js"></script>
|
|
86
85
|
<script src="js/timelapse.js"></script>
|
|
86
|
+
<script src="js/archive.js"></script>
|
|
87
|
+
<script src="js/watches.js"></script>
|
|
87
88
|
<script src="js/main.js"></script>
|
|
88
89
|
</body>
|
|
89
90
|
</html>
|
package/public/js/alerts.js
CHANGED
|
@@ -101,6 +101,7 @@ function checkAlerts() {
|
|
|
101
101
|
if (up && up.latest && !alerted.has('upd:' + up.latest)) {
|
|
102
102
|
alerted.add('upd:' + up.latest);
|
|
103
103
|
toast(`<b>${esc(A.update(up.latest))}</b>${esc(A.updateHow)} <code>npx coworking-agents@latest</code>`, 'ok sticky');
|
|
104
|
+
if (typeof loadVersion === 'function') loadVersion(); // the releases board, the admin's sign and the notice follow at once
|
|
104
105
|
}
|
|
105
106
|
firstAlertPass = false;
|
|
106
107
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// ---------------- the conversation archive ----------------
|
|
3
|
+
// Every conversation on disk, open or closed (the last 60 days): find one by name, folder or what was said,
|
|
4
|
+
// then read it as a chat, with its helpers and a search inside it. Nothing here writes anything.
|
|
5
|
+
let arcList = null, arcQ = '', arcOpen = null, arcTalk = null, arcTalkQ = '', arcSub = '', arcTimer = 0;
|
|
6
|
+
async function arcLoad() {
|
|
7
|
+
const r = await api('api/archive?q=' + encodeURIComponent(arcQ));
|
|
8
|
+
arcList = r; if (reportTab === 'archive' && !arcOpen) arcDrawList();
|
|
9
|
+
}
|
|
10
|
+
async function arcLoadTalk(toTop) {
|
|
11
|
+
const o = arcOpen; if (!o) return;
|
|
12
|
+
const r = await api(`api/archive-talk?agent=${encodeURIComponent(o.agent)}&id=${encodeURIComponent(o.id)}&q=${encodeURIComponent(arcTalkQ)}&sub=${encodeURIComponent(arcSub)}`);
|
|
13
|
+
if (arcOpen !== o) return;
|
|
14
|
+
if (r.helpers) o.helpers = r.helpers;
|
|
15
|
+
arcTalk = r; const list = document.getElementById('arc-chat'); if (list) list.innerHTML = arcChatHtml();
|
|
16
|
+
const hb = document.getElementById('arc-helpers'); if (hb) hb.innerHTML = arcHelpersHtml();
|
|
17
|
+
const box = reportEl.querySelector('.report-body'); if (box && !toTop && !arcTalkQ) box.scrollTop = box.scrollHeight;
|
|
18
|
+
}
|
|
19
|
+
const arcWhen = ms => new Date(ms).toLocaleString(lang === 'pt' ? 'pt-BR' : 'en', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
|
|
20
|
+
function arcChatHtml() {
|
|
21
|
+
const C = T.chat, A = T.archive, r = arcTalk, q = arcTalkQ.toLowerCase(), o = arcOpen, h = (o.helpers || []).find(x => x.id === arcSub);
|
|
22
|
+
if (!r) return `<p class="note">…</p>`;
|
|
23
|
+
if (!r.ok) return `<p class="note">⚠ ${esc(C.fail[r.reason] || r.reason || '?')}</p>`;
|
|
24
|
+
if (!r.items.length) return `<p class="note">${esc(q ? C.none : C.empty)}</p>`;
|
|
25
|
+
return (q ? `<p class="note">${esc(C.found(r.found))}</p>` : r.cut || r.total > r.items.length ? `<p class="note">${esc(C.cut)}</p>` : '')
|
|
26
|
+
+ r.items.map(m => `<div class="bubble ${m.role === 'user' ? 'me' : 'it'}"><p>${deskMd(deskMark(m.text, q))}</p><small>${m.role === 'user' ? esc(h ? A.mainAgent : C.you) : esc(h ? h.type : o.agent === 'codex' ? 'Codex' : 'Claude')}${m.at ? ' · ' + esc(arcWhen(m.at)) : ''}</small></div>`).join('');
|
|
27
|
+
}
|
|
28
|
+
function arcHelpersHtml() {
|
|
29
|
+
const C = T.chat, hs = (arcOpen && arcOpen.helpers) || []; if (!hs.length) return '';
|
|
30
|
+
return `<div class="phelpers"><button class="${arcSub ? '' : 'on'}" data-arc-sub="">${esc(C.main)}</button>${hs.map(h => `<button class="${h.id === arcSub ? 'on' : ''}" data-arc-sub="${esc(h.id)}" title="${esc(h.what)}">${esc(h.type)}</button>`).join('')}</div>`;
|
|
31
|
+
}
|
|
32
|
+
function arcDrawList() {
|
|
33
|
+
const A = T.archive, body = reportEl.querySelector('.report-body'), r = arcList, live = new Set(data ? data.people.map(p => p.id) : []);
|
|
34
|
+
const row = x => `<li><button class="arc-row" data-arc-open="${esc(x.agent)}|${esc(x.id)}"><i class="ailogo" data-ai="${esc(x.agent)}"></i><span class="arc-txt"><b>${esc(x.title || A.untitled)}</b><small>${x.cwd ? esc(x.cwd) + ' · ' : ''}${esc(arcWhen(x.at))}${live.has(x.id) ? ' · ● ' + esc(A.open) : ''}</small>${x.first ? `<small class="arc-first">${esc(x.first)}</small>` : ''}</span></button></li>`;
|
|
35
|
+
const focus = document.activeElement && document.activeElement.matches('[data-arc-q]');
|
|
36
|
+
body.innerHTML = tabsHtml() + `<h2>🗄 ${esc(A.title)}</h2><p class="sub">${esc(A.sub)}</p>
|
|
37
|
+
<input type="search" class="ptalk-q" data-arc-q placeholder="${esc(A.search)}" value="${esc(arcQ)}">
|
|
38
|
+
${!r ? `<p class="note">…</p>` : !r.ok ? `<p class="note">⚠ ${esc(T.chat.fail[r.reason] || r.reason)}</p>` : `
|
|
39
|
+
<h3>${esc(A.list(r.items.length, r.total))}</h3>${r.items.length ? `<ul class="arc-list">${r.items.map(row).join('')}</ul>` : `<p class="note">${esc(A.none)}</p>`}
|
|
40
|
+
${r.hits && r.hits.length ? `<h3>${esc(A.said(r.hits.length))}</h3><ul class="arc-list">${r.hits.map(x => `<li><button class="arc-row" data-arc-open="${esc(x.agent)}|${esc(x.id)}" data-arc-q-in="${esc(arcQ)}"><i class="ailogo" data-ai="${esc(x.agent)}"></i><span class="arc-txt"><small>${esc(x.role === 'user' ? T.chat.you : x.agent === 'codex' ? 'Codex' : 'Claude')} · ${esc(arcWhen(x.at))}</small><span>${deskMark(x.snippet, arcQ.toLowerCase())}</span></span></button></li>`).join('')}</ul>` : ''}`}`;
|
|
41
|
+
body.querySelectorAll('.ailogo[data-ai]').forEach(el => { const id = el.dataset.ai; el.appendChild(Art.aiIconEl(id, (AGENT[id] || {}).color || '#888')); });
|
|
42
|
+
if (focus) { const i = body.querySelector('[data-arc-q]'); i.focus(); i.setSelectionRange(i.value.length, i.value.length); }
|
|
43
|
+
}
|
|
44
|
+
function arcDrawTalk() {
|
|
45
|
+
const A = T.archive, body = reportEl.querySelector('.report-body'), o = arcOpen;
|
|
46
|
+
body.innerHTML = tabsHtml() + `<p><button class="tback" data-arc-back>${esc(A.back)}</button></p><h2>${esc(o.title || A.untitled)}</h2><p class="sub">${esc(o.agent === 'codex' ? 'Codex' : 'Claude Code')}${o.cwd ? ' · ' + esc(o.cwd) : ''}${o.at ? ' · ' + esc(arcWhen(o.at)) : ''}</p>
|
|
47
|
+
<div id="arc-helpers">${arcHelpersHtml()}</div><input type="search" class="ptalk-q" data-arc-tq placeholder="${esc(T.chat.search)}" value="${esc(arcTalkQ)}"><div id="arc-chat" class="arc-chat">${arcChatHtml()}</div>`;
|
|
48
|
+
}
|
|
49
|
+
function renderArchive() { if (arcOpen) arcDrawTalk(); else { arcDrawList(); if (!arcList) arcLoad(); } }
|
|
50
|
+
reportEl.addEventListener('click', e => {
|
|
51
|
+
if (reportTab !== 'archive') return;
|
|
52
|
+
const op = e.target.closest('[data-arc-open]');
|
|
53
|
+
if (op) { const [agent, id] = op.dataset.arcOpen.split('|'), x = (arcList && arcList.items.find(i => i.id === id)) || { agent, id, title: '' }; arcOpen = { ...x, helpers: [] }; arcTalk = null; arcSub = ''; arcTalkQ = op.dataset.arcQIn || ''; arcDrawTalk(); arcLoadTalk(!!arcTalkQ); return; }
|
|
54
|
+
if (e.target.closest('[data-arc-back]')) { arcOpen = null; arcTalk = null; arcDrawList(); return; }
|
|
55
|
+
const sb = e.target.closest('[data-arc-sub]'); if (sb) { arcSub = sb.dataset.arcSub; arcTalk = null; const c = document.getElementById('arc-chat'); if (c) c.innerHTML = '<p class="note">…</p>'; arcLoadTalk(true); }
|
|
56
|
+
});
|
|
57
|
+
reportEl.addEventListener('input', e => {
|
|
58
|
+
if (reportTab !== 'archive') return;
|
|
59
|
+
if (e.target.matches('[data-arc-q]')) { clearTimeout(arcTimer); arcTimer = setTimeout(() => { arcQ = e.target.value.trim().slice(0, 100); arcLoad(); }, 400); }
|
|
60
|
+
if (e.target.matches('[data-arc-tq]')) { clearTimeout(arcTimer); arcTimer = setTimeout(() => { arcTalkQ = e.target.value.trim().slice(0, 100); arcLoadTalk(true); }, 350); }
|
|
61
|
+
});
|
package/public/js/i18n.js
CHANGED
|
@@ -616,6 +616,22 @@ Object.assign(I18N.en, { timelapse: { title: 'Day timelapse', sub: 'A picture of
|
|
|
616
616
|
// how many agents and helpers are in the office
|
|
617
617
|
Object.assign(I18N.pt, { crew: { title: 'Todos no escritório', sub: (a, h) => `${a} agente${a === 1 ? '' : 's'} e ${h} ajudante${h === 1 ? '' : 's'} (subagentes) trabalhando agora.`, note: 'Clique num agente para abrir o painel dele, ou num ajudante para ler a conversa dele. Os ajudantes que já terminaram ficam na aba Conversa do agente.', agents: n => n === 1 ? 'agente' : 'agentes', helpers: n => n === 1 ? 'ajudante' : 'ajudantes', none: 'Nenhum ajudante (subagente) trabalhando agora.', status: n => `${n} ajudante${n === 1 ? '' : 's'}` } });
|
|
618
618
|
Object.assign(I18N.en, { crew: { title: 'Everyone in the office', sub: (a, h) => `${a} agent${a === 1 ? '' : 's'} and ${h} helper${h === 1 ? '' : 's'} (subagents) working right now.`, note: 'Click an agent to open its panel, or a helper to read its conversation. Helpers that finished stay in the agent\'s Conversation tab.', agents: n => n === 1 ? 'agent' : 'agents', helpers: n => n === 1 ? 'helper' : 'helpers', none: 'No helpers (subagents) working right now.', status: n => `${n} helper${n === 1 ? '' : 's'}` } });
|
|
619
|
+
// the conversation archive
|
|
620
|
+
Object.assign(I18N.pt.officeObj.menu, { archive: 'Conversas antigas' });
|
|
621
|
+
Object.assign(I18N.en.officeObj.menu, { archive: 'Past conversations' });
|
|
622
|
+
Object.assign(I18N.pt, { archive: { title: 'Conversas antigas', sub: 'Todas as conversas do Claude Code e do Codex guardadas no computador nos últimos 60 dias, abertas ou já fechadas. Só leitura.', search: 'Buscar pelo nome, pela pasta ou pelo que foi dito', said: n => `No que foi dito (últimos 30 dias): ${n}`, list: (n, t) => `Conversas: ${n}${t > n ? ' de ' + t : ''}`, none: 'Nenhuma conversa encontrada.', untitled: '(sem título)', open: 'aberta agora', back: '‹ Voltar', mainAgent: 'agente principal' } });
|
|
623
|
+
Object.assign(I18N.en, { archive: { title: 'Past conversations', sub: 'Every Claude Code and Codex conversation kept on this computer over the last 60 days, open or closed. Read only.', search: 'Search by name, folder or what was said', said: n => `In what was said (last 30 days): ${n}`, list: (n, t) => `Conversations: ${n}${t > n ? ' of ' + t : ''}`, none: 'No conversation found.', untitled: '(untitled)', open: 'open now', back: '‹ Back', mainAgent: 'main agent' } });
|
|
624
|
+
// "tell me when…"
|
|
625
|
+
Object.assign(I18N.pt.officeObj.menu, { watches: 'Me avise quando…' });
|
|
626
|
+
Object.assign(I18N.en.officeObj.menu, { watches: 'Tell me when…' });
|
|
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
|
+
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' } });
|
|
619
635
|
let T = I18N[lang];
|
|
620
636
|
// sound is on by default; alerts follow the browser permission (asked on open). Only the buttons turn them off.
|
|
621
637
|
let soundOn = store.get('sound', true);
|
package/public/js/panel.js
CHANGED
|
@@ -127,7 +127,7 @@ function renderPanel() {
|
|
|
127
127
|
${tools.length ? `<h3>${P.tools}</h3><ul class="bars">${tools.map(([k, n]) => `<li><code>${esc(k)}</code><span class="tbar"><i style="width:${(n / toolMax * 100).toFixed(1)}%"></i></span><span class="t">${Number(n) || 0}</span></li>`).join('')}</ul>` : ''}
|
|
128
128
|
<h3 id="certs-h">${P.certs}</h3>${certs.length ? `<div class="certs">${certs.map(c => `<div class="cert" style="--c:${certColor(c)}"><small>${T.kinds[c.kind]}</small>${esc(c.name)}${c.kind === 'badge' ? `<br><span class="note">${esc(c.desc)}</span>` : ` <span class="note">${Number(c.n) || 0}×</span>`}</div>`).join('')}</div>` : `<p class="note">${P.noCerts}</p>`}
|
|
129
129
|
<p class="note" style="margin-top:18px">${P.session}: <code>${esc(p.id)}</code></p>`;
|
|
130
|
-
if (typeof deskTabsHtml === 'function') panelBody.querySelector('header.ph').insertAdjacentHTML('afterend', deskTabsHtml());
|
|
130
|
+
if (typeof deskTabsHtml === 'function') panelBody.querySelector('header.ph').insertAdjacentHTML('afterend', deskTabsHtml() + (typeof watchBtnHtml === 'function' ? watchBtnHtml(p) : ''));
|
|
131
131
|
const face = Art.portrait(p.id, p.state === 'needs_you' ? 'open' : null);
|
|
132
132
|
const fb = panelBody.querySelector('.ph-face');
|
|
133
133
|
fb.appendChild(face);
|
package/public/js/report.js
CHANGED
|
@@ -174,6 +174,8 @@ function renderReport() {
|
|
|
174
174
|
if (reportTab === 'feed') return renderFeed();
|
|
175
175
|
if (reportTab === 'timelapse') return renderTimelapse();
|
|
176
176
|
if (reportTab === 'crew') return renderCrew();
|
|
177
|
+
if (reportTab === 'archive') return renderArchive();
|
|
178
|
+
if (reportTab === 'watches') return renderWatches();
|
|
177
179
|
if (reportTab === 'ach') return renderAchievements();
|
|
178
180
|
if (reportTab === 'paper') return renderPaper();
|
|
179
181
|
if (reportTab === 'queue') return renderQueue();
|
package/public/js/tasks.js
CHANGED
|
@@ -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
|
-
|
|
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 = `<
|
|
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
|
-
|
|
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 ----
|
|
@@ -834,7 +875,7 @@ async function loadVersion() {
|
|
|
834
875
|
if (adminPreview === 'party') adminCelebrateUntil = Date.now() + 864e5;
|
|
835
876
|
}
|
|
836
877
|
if (verData && verData.current) { if (!adminPreview) checkVersionNotice(); drawAdmin(); } if (!reportEl.hidden && reportTab === 'version') renderVersion(); }
|
|
837
|
-
setTimeout(loadVersion, 1500); setInterval(loadVersion,
|
|
878
|
+
setTimeout(loadVersion, 1500); setInterval(loadVersion, 10 * 60000);
|
|
838
879
|
function checkVersionNotice() {
|
|
839
880
|
const v = verData; if (!v.newer) return;
|
|
840
881
|
const later = Number(store.get('verLater', 0)) || 0;
|
package/public/js/topbar.js
CHANGED
|
@@ -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); });
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// ---------------- "tell me when…" ----------------
|
|
3
|
+
// Notices you pick: when an agent finishes (once), when a limit passes a level (again after it comes back down),
|
|
4
|
+
// when an agent edits a file whose path has some text. Kept with your progress; checked on every office update.
|
|
5
|
+
const watchList = () => (prog.watches = Array.isArray(prog.watches) ? prog.watches : []);
|
|
6
|
+
const watchBusy = s => !['idle', 'asleep', 'needs_you', 'waiting'].includes(s);
|
|
7
|
+
let watchSaveAt = 0;
|
|
8
|
+
function watchSave(now) { if (now || Date.now() - watchSaveAt > 5000) { watchSaveAt = Date.now(); saveProgress(); } }
|
|
9
|
+
function watchAdd(w) { watchList().push({ id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6), at: Date.now(), ...w }); watchSave(true); }
|
|
10
|
+
function watchDel(id) { prog.watches = watchList().filter(w => w.id !== id); watchSave(true); }
|
|
11
|
+
function watchFire(w, title, body, agent) {
|
|
12
|
+
toast(`<b>🔔 ${esc(title)}</b>${esc(body || '')}`, 'ok');
|
|
13
|
+
if (typeof pushNotice === 'function') pushNotice('watch:' + w.id + ':' + Date.now(), 'ok', '🔔 ' + title, body || '', agent ? { agent } : {});
|
|
14
|
+
notifyOS('🔔 ' + title, body || '');
|
|
15
|
+
}
|
|
16
|
+
const watchLabel = (ai, win) => `${(AGENT[ai] || { label: ai }).label} ${win === 'week' ? T.usage.week : T.usage.fiveHour}`;
|
|
17
|
+
function checkWatches() {
|
|
18
|
+
if (!data || !watchList().length) return;
|
|
19
|
+
const W = T.watch;
|
|
20
|
+
for (const w of [...watchList()]) {
|
|
21
|
+
if (w.kind === 'done') { // fires the first time it stops after having worked
|
|
22
|
+
const p = data.people.find(x => x.id === w.agent && !x.leaving);
|
|
23
|
+
if (!p) { if (w.seen) { watchFire(w, W.closed(w.name), '', null); watchDel(w.id); } continue; }
|
|
24
|
+
w.seen = true;
|
|
25
|
+
if (watchBusy(p.state)) { if (!w.worked) { w.worked = true; watchSave(); } continue; }
|
|
26
|
+
if (w.worked) { watchFire(w, ['needs_you', 'waiting'].includes(p.state) ? W.needs(p.name) : W.done(p.name), p.title || '', p.id); watchDel(w.id); }
|
|
27
|
+
} else if (w.kind === 'limit') {
|
|
28
|
+
const l = data.limits && data.limits[w.ai], v = l && (w.win === 'week' ? l.secondary : l.primary); if (!v) continue;
|
|
29
|
+
if (v.usedPercent >= w.pct && !w.over) { w.over = true; watchSave(true); watchFire(w, W.limit(watchLabel(w.ai, w.win), Math.round(v.usedPercent)), W.limitWhy(w.pct)); }
|
|
30
|
+
else if (v.usedPercent < w.pct - 5 && w.over) { w.over = false; watchSave(true); } // back down (a new window): armed again
|
|
31
|
+
} else if (w.kind === 'file') {
|
|
32
|
+
const k = String(w.text || '').toLowerCase(); let last = w.last || w.at;
|
|
33
|
+
for (const p of data.people) for (const f of p.files || []) {
|
|
34
|
+
if (f.at <= (w.last || w.at) || !`${f.repo}/${f.rel}`.toLowerCase().includes(k)) continue;
|
|
35
|
+
watchFire(w, W.file(p.name, f.rel), p.title || '', p.id); last = Math.max(last, f.at);
|
|
36
|
+
}
|
|
37
|
+
if (last !== (w.last || w.at)) { w.last = last; watchSave(true); }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
setInterval(checkWatches, 2000);
|
|
42
|
+
// the agent panel's button: tell me when this one finishes
|
|
43
|
+
function watchBtnHtml(p) {
|
|
44
|
+
const on = watchList().find(w => w.kind === 'done' && w.agent === p.id);
|
|
45
|
+
return `<div class="actions"><button class="btn small ${on ? '' : 'ghost'}" data-watch-done="${esc(p.id)}">${on ? '🔕 ' + esc(T.watch.cancelDone) : '🔔 ' + esc(T.watch.whenDone)}</button></div>`;
|
|
46
|
+
}
|
|
47
|
+
panelBody.addEventListener('click', e => {
|
|
48
|
+
const b = e.target.closest('[data-watch-done]'); if (!b) return;
|
|
49
|
+
const p = data && data.people.find(x => x.id === b.dataset.watchDone); if (!p) return;
|
|
50
|
+
const on = watchList().find(w => w.kind === 'done' && w.agent === p.id);
|
|
51
|
+
if (on) watchDel(on.id); else { watchAdd({ kind: 'done', agent: p.id, name: p.name }); toast(`<b>🔔 ${esc(T.watch.set)}</b>${esc(T.watch.whenDoneOf(p.name))}`, 'ok'); }
|
|
52
|
+
b.outerHTML = watchBtnHtml(p);
|
|
53
|
+
});
|
|
54
|
+
// the list of what you asked to be told, and the forms to add more
|
|
55
|
+
function renderWatches() {
|
|
56
|
+
const W = T.watch, body = reportEl.querySelector('.report-body'), live = data ? data.people.filter(p => !p.leaving) : [];
|
|
57
|
+
const text = w => w.kind === 'done' ? W.whenDoneOf(w.name) : w.kind === 'limit' ? W.whenLimit(watchLabel(w.ai, w.win), w.pct) : W.whenFile(w.text);
|
|
58
|
+
body.innerHTML = tabsHtml() + `<h2>⏰ ${esc(W.title)}</h2><p class="sub">${esc(W.sub)}</p>
|
|
59
|
+
${watchList().length ? `<ul class="list">${watchList().map(w => `<li><span class="k">🔔</span><span>${esc(text(w))}</span><button class="btn small ghost" data-watch-del="${esc(w.id)}">✕</button></li>`).join('')}</ul>` : `<p class="note">${esc(W.empty)}</p>`}
|
|
60
|
+
<h3>${esc(W.addDone)}</h3><p class="watch-form"><select data-w-agent>${live.map(p => `<option value="${esc(p.id)}">${esc(p.name)}${p.title ? ' — ' + esc(p.title.slice(0, 40)) : ''}</option>`).join('')}</select> <button class="btn small" data-watch-add="done" ${live.length ? '' : 'disabled'}>+ ${esc(W.add)}</button></p>
|
|
61
|
+
<h3>${esc(W.addLimit)}</h3><p class="watch-form"><select data-w-ai>${Object.keys(AGENT).map(id => `<option value="${esc(id)}">${esc(AGENT[id].label)}</option>`).join('')}</select> <select data-w-win><option value="5h">${esc(T.usage.fiveHour)}</option><option value="week">${esc(T.usage.week)}</option></select> <input type="number" data-w-pct min="5" max="100" value="80" style="width:70px">% <button class="btn small" data-watch-add="limit">+ ${esc(W.add)}</button></p>
|
|
62
|
+
<h3>${esc(W.addFile)}</h3><p class="watch-form"><input data-w-file placeholder="${esc(W.filePh)}"> <button class="btn small" data-watch-add="file">+ ${esc(W.add)}</button></p>
|
|
63
|
+
<p class="note">${esc(W.note)}</p>`;
|
|
64
|
+
}
|
|
65
|
+
reportEl.addEventListener('click', e => {
|
|
66
|
+
if (reportTab !== 'watches') return;
|
|
67
|
+
const d = e.target.closest('[data-watch-del]'); if (d) { watchDel(d.dataset.watchDel); return renderWatches(); }
|
|
68
|
+
const a = e.target.closest('[data-watch-add]'); if (!a) return;
|
|
69
|
+
const v = sel => (reportEl.querySelector(sel) || {}).value || '';
|
|
70
|
+
if (a.dataset.watchAdd === 'done') { const p = data.people.find(x => x.id === v('[data-w-agent]')); if (p && !watchList().some(w => w.kind === 'done' && w.agent === p.id)) watchAdd({ kind: 'done', agent: p.id, name: p.name }); }
|
|
71
|
+
if (a.dataset.watchAdd === 'limit') { const pct = Math.max(5, Math.min(100, Math.round(Number(v('[data-w-pct]')) || 80))); watchAdd({ kind: 'limit', ai: v('[data-w-ai]'), win: v('[data-w-win]') === 'week' ? 'week' : '5h', pct }); }
|
|
72
|
+
if (a.dataset.watchAdd === 'file') { const t = v('[data-w-file]').trim().slice(0, 120); if (!t) return; watchAdd({ kind: 'file', text: t }); }
|
|
73
|
+
renderWatches();
|
|
74
|
+
});
|
package/public/style.css
CHANGED
|
@@ -978,3 +978,33 @@ body.tv .app-ask { display: none; }
|
|
|
978
978
|
.crew-subs { list-style: none; margin: 6px 0 0 44px; padding: 0; display: grid; gap: 5px; }
|
|
979
979
|
.crew-subs button { width: 100%; text-align: left; padding: 6px 9px; border: 2px dashed var(--line); border-radius: 6px; background: transparent; color: var(--ink); cursor: pointer; font-size: 12px; }
|
|
980
980
|
.crew-subs small { color: var(--muted); }
|
|
981
|
+
/* the conversation archive */
|
|
982
|
+
.arc-list { list-style: none; padding: 0; margin: 6px 0 14px; display: grid; gap: 6px; }
|
|
983
|
+
.arc-row { display: flex; gap: 10px; align-items: flex-start; width: 100%; text-align: left; padding: 8px 10px; border: 2px solid var(--line); border-radius: 8px; background: var(--surface); color: var(--ink); cursor: pointer; }
|
|
984
|
+
.arc-row:hover { border-color: var(--accent); }
|
|
985
|
+
.arc-row .ailogo { flex: none; margin-top: 2px; }
|
|
986
|
+
.arc-txt { flex: 1; min-width: 0; } .arc-txt b { display: block; font-size: 13px; overflow-wrap: anywhere; } .arc-txt small { display: block; color: var(--muted); font-size: 11px; margin-top: 2px; }
|
|
987
|
+
.arc-txt .arc-first { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
988
|
+
.arc-txt span { font-size: 13px; } .arc-txt mark, .arc-chat mark { background: var(--warn); color: #120f16; border-radius: 2px; }
|
|
989
|
+
.arc-chat .bubble { max-width: 80%; margin: 0 0 10px; padding: 9px 11px; border: 2px solid var(--line); border-radius: 8px; background: color-mix(in srgb, var(--surface) 92%, var(--ink)); }
|
|
990
|
+
.arc-chat .bubble.me { margin-left: auto; background: color-mix(in srgb, var(--accent) 16%, var(--surface)); border-color: color-mix(in srgb, var(--accent) 40%, var(--line)); }
|
|
991
|
+
.arc-chat .bubble p { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; font-size: 13px; line-height: 1.45; }
|
|
992
|
+
.arc-chat .bubble code { font: 12px/1.4 ui-monospace, Menlo, monospace; background: var(--bg); border-radius: 3px; padding: 0 3px; }
|
|
993
|
+
.arc-chat .bubble small { display: block; margin-top: 5px; font: 400 9px/1.2 var(--pix); text-transform: uppercase; color: var(--muted); }
|
|
994
|
+
.arc-chat .bubble.me small { text-align: right; }
|
|
995
|
+
/* "tell me when…" */
|
|
996
|
+
.watch-form { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
|
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
|
+
.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
|
+
}
|
package/public/tray.png
ADDED
|
Binary file
|
package/src/demo.js
CHANGED
|
@@ -107,6 +107,16 @@ function demoHeat() {
|
|
|
107
107
|
const grid = Array.from({ length: 7 }, (_, d) => Array.from({ length: 24 }, (_, h) => { const work = d > 0 && d < 6 && h >= 9 && h <= 19, k = work ? (h >= 14 && h <= 16 ? 1 : 0.55) * (d === 2 ? 1.2 : 1) : (h >= 10 && h <= 17 && (d === 0 || d === 6) ? 0.12 : h >= 20 && h <= 23 ? 0.18 : 0); return { out: Math.round(k * 9e5), usd: k * 6.5 }; }));
|
|
108
108
|
return { days: 28, grid };
|
|
109
109
|
}
|
|
110
|
+
// a few closed conversations for the archive in --demo
|
|
111
|
+
function demoArchive(q) {
|
|
112
|
+
const n = Date.now(), items = [
|
|
113
|
+
{ agent: 'claude', id: 'demo-a1', title: 'Fix the login redirect loop', first: 'Users get sent back to /login after signing in', cwd: '~/code/web-app', at: n - 3 * 36e5, size: 182000 },
|
|
114
|
+
{ agent: 'codex', id: 'demo-a2', title: 'Add cursor pagination to /orders', first: '', cwd: '~/code/api', at: n - 26 * 36e5, size: 96000 },
|
|
115
|
+
{ agent: 'claude', id: 'demo-a3', title: 'Write the release notes for 2.4', first: '', cwd: '~/code/docs', at: n - 3 * 864e5, size: 41000 },
|
|
116
|
+
{ agent: 'claude', id: 'demo-a4', title: 'Explain how the checkout flow works', first: '', cwd: '~/code/web-app', at: n - 6 * 864e5, size: 230000 },
|
|
117
|
+
], k = String(q || '').toLowerCase().trim(), list = k ? items.filter(x => (x.title + ' ' + x.cwd).toLowerCase().includes(k)) : items;
|
|
118
|
+
return { ok: true, items: list, total: items.length, days: 60, hits: k.length >= 3 ? [{ agent: 'claude', id: 'demo-a1', role: 'assistant', at: n - 3 * 36e5, snippet: `…the ${k} happens because the session cookie…` }] : undefined };
|
|
119
|
+
}
|
|
110
120
|
// a short conversation for the chat view in --demo
|
|
111
121
|
function demoTalk(q) {
|
|
112
122
|
const n = Date.now(), items = [
|
|
@@ -137,4 +147,4 @@ function demoHot() {
|
|
|
137
147
|
return [['src/checkout/payment.ts', 'shop', 9, ['claude', 'codex']], ['src/api/orders.ts', 'api', 7, ['claude']], ['package.json', 'web-app', 6, ['claude', 'codex']], ['src/auth/session.ts', 'api', 4, ['codex']], ['README.md', 'docs', 3, ['claude']], ['migrations/0042_orders.sql', 'api', 2, ['claude']]]
|
|
138
148
|
.map(([rel, repo, sessions, agents], i) => ({ rel, repo, sessions, agents, last: n - i * 36e5 }));
|
|
139
149
|
}
|
|
140
|
-
module.exports = { demoHot, demoSnapshot, demoReport, demoUsage, demoTasks, demoTalk };
|
|
150
|
+
module.exports = { demoHot, demoSnapshot, demoReport, demoUsage, demoTasks, demoTalk, demoArchive };
|
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', '
|
|
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/progress.js
CHANGED
|
@@ -9,8 +9,15 @@ const path = require('path');
|
|
|
9
9
|
|
|
10
10
|
const FILE = () => path.join(os.homedir(), '.config', 'coworking-agents', 'progress.json');
|
|
11
11
|
const MAX = 128 * 1024;
|
|
12
|
-
const KEYS = { record: 'object', levels: 'object', avatars: 'object', owned: 'array', placed: 'object', spent: 'number', officeName: 'string', theme: 'string', mascot: 'string', super: 'number', style: 'object' };
|
|
12
|
+
const KEYS = { record: 'object', levels: 'object', avatars: 'object', owned: 'array', placed: 'object', spent: 'number', officeName: 'string', theme: 'string', mascot: 'string', super: 'number', style: 'object', watches: 'array' };
|
|
13
13
|
|
|
14
|
+
// "tell me when…" rules: a few known kinds with short fields only (the page decides when they fire)
|
|
15
|
+
function cleanWatches(list) {
|
|
16
|
+
const str = (x, n) => String(x == null ? '' : x).slice(0, n), num = x => (Number.isFinite(Number(x)) ? Number(x) : 0);
|
|
17
|
+
return list.filter(w => w && typeof w === 'object' && ['done', 'limit', 'file'].includes(w.kind)).slice(0, 50).map(w => ({
|
|
18
|
+
id: str(w.id, 20), kind: w.kind, at: num(w.at), agent: str(w.agent, 80), name: str(w.name, 60), ai: str(w.ai, 20), win: w.win === 'week' ? 'week' : '5h',
|
|
19
|
+
pct: Math.max(5, Math.min(100, num(w.pct) || 80)), text: str(w.text, 120), last: num(w.last), worked: !!w.worked, seen: !!w.seen, over: !!w.over }));
|
|
20
|
+
}
|
|
14
21
|
// keep only known keys of the right type; strings are short, objects are flat-ish and bounded by MAX
|
|
15
22
|
function clean(input) {
|
|
16
23
|
const out = {};
|
|
@@ -19,7 +26,7 @@ function clean(input) {
|
|
|
19
26
|
const v = input[k];
|
|
20
27
|
if (v === undefined || v === null) continue;
|
|
21
28
|
if (type === 'array' ? Array.isArray(v) : type === 'object' ? typeof v === 'object' && !Array.isArray(v) : typeof v === type) {
|
|
22
|
-
out[k] = type === 'string' ? v.slice(0, 40) : type === 'number' ? (Number.isFinite(v) ? v : 0) : type === 'array' ? v.filter(x => typeof x === 'string').slice(0, 200).map(x => x.slice(0, 40)) : v;
|
|
29
|
+
out[k] = k === 'watches' ? cleanWatches(v) : type === 'string' ? v.slice(0, 40) : type === 'number' ? (Number.isFinite(v) ? v : 0) : type === 'array' ? v.filter(x => typeof x === 'string').slice(0, 200).map(x => x.slice(0, 40)) : v;
|
|
23
30
|
}
|
|
24
31
|
}
|
|
25
32
|
return out;
|
package/src/said.js
CHANGED
|
@@ -117,4 +117,38 @@ async function conversation(agent, id, { q = '', limit = 200, sub = '' } = {}) {
|
|
|
117
117
|
return { ok: true, items: list.slice(-limit), total: c.items.length, found: list.length, cut: c.cut, helpers: sub ? undefined : helpers(agent, id) };
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
|
|
120
|
+
// ---- the archive: every conversation on disk, open or closed ----
|
|
121
|
+
// Name (the AI's title, Codex's thread name or the first request), folder and when it last moved; read from the
|
|
122
|
+
// first 64 KB of each log and remembered per file size, so listing hundreds stays quick.
|
|
123
|
+
const heads = new Map();
|
|
124
|
+
function head(f) {
|
|
125
|
+
const hit = heads.get(f.file); if (hit && hit.size === f.size) return hit;
|
|
126
|
+
const h = { size: f.size, cwd: '', first: '' };
|
|
127
|
+
try { const fd = fs.openSync(f.file, 'r'), buf = Buffer.alloc(65536), n = fs.readSync(fd, buf, 0, buf.length, 0); fs.closeSync(fd);
|
|
128
|
+
for (const line of buf.subarray(0, n).toString('utf8').split('\n')) { let j; try { j = JSON.parse(line); } catch { continue; }
|
|
129
|
+
if (!h.cwd) h.cwd = String(j.cwd || (j.payload && j.payload.cwd) || '');
|
|
130
|
+
if (!h.first) { const x = said(f.agent, j); if (x && x.role === 'user') h.first = readable(x.text).replace(/\s+/g, ' ').slice(0, 160); }
|
|
131
|
+
if (h.cwd && h.first) break; } } catch {}
|
|
132
|
+
heads.set(f.file, h); if (heads.size > 5000) heads.delete(heads.keys().next().value);
|
|
133
|
+
return h;
|
|
134
|
+
}
|
|
135
|
+
function archive({ q = '', days = 60, limit = 80 } = {}) {
|
|
136
|
+
const t = require('./usage').titles(), names = require('./sources/codex').titles(), k = String(q || '').toLowerCase().trim().slice(0, 100), home = s => String(s || '').replace(/^\/Users\/[^/]+|^\/home\/[^/]+/, '~');
|
|
137
|
+
const since = Date.now() - days * 864e5, all = [];
|
|
138
|
+
const walk = (dir, agent, depth) => { let list = []; try { list = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
139
|
+
for (const e of list) { const full = path.join(dir, e.name); if (e.isDirectory() && depth < 4 && e.name !== 'subagents') walk(full, agent, depth + 1); else if (e.isFile() && e.name.endsWith('.jsonl')) { try { const st = fs.statSync(full); if (st.mtimeMs >= since) all.push({ file: full, agent, mtime: st.mtimeMs, size: st.size }); } catch {} } } };
|
|
140
|
+
walk(CLAUDE(), 'claude', 0); walk(CODEX(), 'codex', 0);
|
|
141
|
+
all.sort((a, b) => b.mtime - a.mtime);
|
|
142
|
+
const out = [];
|
|
143
|
+
for (const f of all.slice(0, 3000)) {
|
|
144
|
+
const id = path.basename(f.file, '.jsonl').replace(/^rollout-[\d-]+T[\d-]+-/, ''), h = head(f);
|
|
145
|
+
const title = (f.agent === 'claude' ? t.get(f.file) : names.get(id)) || h.first || '';
|
|
146
|
+
if (!h.first && !title) continue; // nothing anyone wrote in it (a probe or an empty start): nothing to read
|
|
147
|
+
if (k && !(title + ' ' + h.first + ' ' + h.cwd).toLowerCase().includes(k)) continue;
|
|
148
|
+
out.push({ agent: f.agent, id, title: title.slice(0, 160), first: h.first === title ? '' : h.first, cwd: home(h.cwd), at: Math.round(f.mtime), size: f.size });
|
|
149
|
+
if (out.length >= limit) break;
|
|
150
|
+
}
|
|
151
|
+
return { ok: true, items: out, total: all.length, days };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
module.exports = { search, said, conversation, readable, helpers, archive };
|
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', '/
|
|
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') {
|
|
@@ -172,6 +172,21 @@ function start({ port = 4777, host = '127.0.0.1', privacy = false, interval = 10
|
|
|
172
172
|
return;
|
|
173
173
|
}
|
|
174
174
|
if (url.pathname === '/api/usage') return send(res, 200, 'application/json', JSON.stringify(demo ? require('./demo').demoUsage() : { ...privateUsage(usage(), privacy), pace: pace.forecast() }));
|
|
175
|
+
if (url.pathname === '/api/archive' && req.method === 'GET') { // every conversation on disk, open or closed, by name, folder or what was said (off in private mode)
|
|
176
|
+
if (privacy) return send(res, 200, 'application/json', '{"ok":false,"reason":"private"}');
|
|
177
|
+
if (demo) return send(res, 200, 'application/json', JSON.stringify(require('./demo').demoArchive(url.searchParams.get('q') || '')));
|
|
178
|
+
const q = url.searchParams.get('q') || '', said = require('./said'), list = said.archive({ q });
|
|
179
|
+
if (q.trim().length < 3) return send(res, 200, 'application/json', JSON.stringify(list));
|
|
180
|
+
once('said', () => said.search(q, { days: 30, limit: 30 })).then(hits => send(res, 200, 'application/json', JSON.stringify({ ...list, hits: Array.isArray(hits) ? hits : [] })), () => send(res, 200, 'application/json', JSON.stringify(list)));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (url.pathname === '/api/archive-talk' && req.method === 'GET') { // one conversation from the archive, by its id only
|
|
184
|
+
if (privacy) return send(res, 200, 'application/json', '{"ok":false,"reason":"private"}');
|
|
185
|
+
if (demo) return send(res, 200, 'application/json', JSON.stringify(require('./demo').demoTalk(url.searchParams.get('q') || '')));
|
|
186
|
+
const agent = url.searchParams.get('agent') === 'codex' ? 'codex' : 'claude';
|
|
187
|
+
require('./said').conversation(agent, String(url.searchParams.get('id') || ''), { q: url.searchParams.get('q') || '', sub: url.searchParams.get('sub') || '', limit: 400 }).then(r => send(res, 200, 'application/json', JSON.stringify(r)), () => send(res, 200, 'application/json', '{"ok":false,"reason":"unknown"}'));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
175
190
|
if (url.pathname === '/api/talk' && req.method === 'GET') { // one live agent's whole conversation, or what matches a search in it (off in private mode)
|
|
176
191
|
if (privacy) return send(res, 200, 'application/json', '{"ok":false,"reason":"private"}');
|
|
177
192
|
if (demo) return send(res, 200, 'application/json', JSON.stringify(require('./demo').demoTalk(url.searchParams.get('q') || '')));
|
|
@@ -318,7 +333,7 @@ function start({ port = 4777, host = '127.0.0.1', privacy = false, interval = 10
|
|
|
318
333
|
if (url.pathname === '/api/state') return send(res, 200, 'application/json', safeSnapshot());
|
|
319
334
|
if (url.pathname === '/api/ping') return send(res, 200, 'application/json', '{"ok":true}');
|
|
320
335
|
// the phone link and its QR code, only to this machine (the phone server itself never serves this)
|
|
321
|
-
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 }));
|
|
322
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 }));
|
|
323
338
|
if (url.pathname === '/api/appinstall' && req.method === 'POST') { // create or remove the app on this computer
|
|
324
339
|
const origin = req.headers.origin || '';
|
|
@@ -346,6 +361,21 @@ function start({ port = 4777, host = '127.0.0.1', privacy = false, interval = 10
|
|
|
346
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() })); });
|
|
347
362
|
return;
|
|
348
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
|
+
}
|
|
349
379
|
if (url.pathname === '/api/phone-rotate' && req.method === 'POST') { // a new phone code: every phone has to scan the QR again
|
|
350
380
|
const origin = req.headers.origin || '';
|
|
351
381
|
if (req.headers['x-coworking'] !== '1' || (origin && !LOCAL_ORIGIN.test(origin))) { res.writeHead(403); return res.end(); }
|
|
@@ -383,15 +413,23 @@ function start({ port = 4777, host = '127.0.0.1', privacy = false, interval = 10
|
|
|
383
413
|
});
|
|
384
414
|
}
|
|
385
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
|
+
}
|
|
386
427
|
return new Promise((resolve, reject) => {
|
|
387
428
|
server.once('error', reject);
|
|
388
429
|
server.listen(port, host, async () => {
|
|
389
430
|
const base = `http://${host === '0.0.0.0' ? 'localhost' : host}:${server.address().port}`; baseUrl = base;
|
|
390
431
|
if (!demo && (menubar || tasks.list().settings.menubar)) { mb.start({ base, token }); try { mb.write(snapshot({ privacy }), mbExtra()); } catch {} }
|
|
391
|
-
if (phone)
|
|
392
|
-
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; }
|
|
393
|
-
catch (e) { phoneReason = e.code === 'EADDRINUSE' ? 'port' : 'error'; }
|
|
394
|
-
}
|
|
432
|
+
if (phone || (!demo && tasks.list().settings.phone)) await startPhoneNow(); // --phone, or turned on once in the office (remembered)
|
|
395
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(); } });
|
|
396
434
|
});
|
|
397
435
|
});
|
package/src/sources/codex.js
CHANGED
|
@@ -216,4 +216,4 @@ function children(threadId, since) {
|
|
|
216
216
|
return out;
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
-
module.exports = { id: 'codex', label: 'Codex', sessions, credentials, today, limits, children };
|
|
219
|
+
module.exports = { id: 'codex', label: 'Codex', sessions, credentials, today, limits, children, titles };
|
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));
|
package/src/update.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
// "A newer version is out": asks the npm registry for the latest version, once at start, every
|
|
2
|
+
// "A newer version is out": asks the npm registry for the latest version, once at start, every 10 minutes and when the releases tab opens.
|
|
3
3
|
// Only the package name goes out (no data about you or your sessions). Off with --no-update-check
|
|
4
4
|
// or COWORKING_NO_UPDATE_CHECK=1. Started by the CLI only, so tests and the demo never touch the network.
|
|
5
5
|
|
|
@@ -29,7 +29,7 @@ async function check() {
|
|
|
29
29
|
|
|
30
30
|
function startChecks() {
|
|
31
31
|
if (timer || process.env.COWORKING_NO_UPDATE_CHECK) return Promise.resolve(null);
|
|
32
|
-
timer = setInterval(check,
|
|
32
|
+
timer = setInterval(check, 600e3); // a new release shows up within minutes in an office left open
|
|
33
33
|
timer.unref();
|
|
34
34
|
return check();
|
|
35
35
|
}
|
package/src/usage.js
CHANGED
|
@@ -157,4 +157,7 @@ function usage() {
|
|
|
157
157
|
return { scanning: state.scanning, scannedAt: state.scannedAt, byAgent: state.result, days: state.days || null, month: state.month || null, heat: state.heat || null };
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
-
|
|
160
|
+
// the AI-written titles the last scan found, per conversation log (the archive's names; empty until a scan ran)
|
|
161
|
+
function titles() { const out = new Map(); for (const [f, v] of Object.entries(loadCache())) if (v && v.res && v.res.title) out.set(f, v.res.title); return out; }
|
|
162
|
+
|
|
163
|
+
module.exports = { usage, refresh, scanFile, titles };
|
package/public/favicon.svg
DELETED
|
@@ -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>
|