coworking-agents 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/README.md +12 -9
- package/ROADMAP.md +6 -5
- 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/js/help.js +11 -2
- package/public/js/i18n.js +60 -2
- package/public/js/overlay.js +2 -1
- package/public/js/panel.js +1 -0
- package/public/js/player.js +1 -0
- package/public/js/replay.js +6 -3
- package/public/js/scene.js +6 -4
- package/public/js/tasks.js +308 -96
- package/public/style.css +43 -5
- package/src/collect.js +18 -1
- package/src/demo.js +3 -3
- package/src/server.js +18 -6
- package/src/tasks.js +281 -117
- package/src/update.js +22 -6
- package/src/usage.js +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0 — 2026-10-11
|
|
4
|
+
- Agent panel: tasks started by the office show running vs pending model and effort, cost and Save / More capacity / Apply recommendation; monitored sessions show the recommendation as not applied
|
|
5
|
+
- Codex models and efforts come from Codex's own model list; Claude efforts low to max
|
|
6
|
+
- Cost per real model id, unknown cost shown as unknown (never as zero), no double counting on resumed runs; budget says when it can't be enforced
|
|
7
|
+
- Ranking by AI, task and session with the spend that happened in the chosen period
|
|
8
|
+
- Clean retries, full diff since the task started (commits, staged, new files), worktree errors reported, no conclude while running, cancel waits for the process, running agents survive a server restart
|
|
9
|
+
- Roles (Backend, Frontend, Reviewer), economy mode, filtered test logs, attempt limit, new session with a summary, optional auto-apply within limits
|
|
10
|
+
- Notices keep arriving with the tab in the background and recover what was pending; Apply applies exactly what the notice proposed; private mode hides tasks and notices
|
|
11
|
+
- Releases: notes of the newer version fetched from its package, install-aware update command, "unavailable" when npm can't be reached
|
|
12
|
+
- Search across tasks, live sessions, today and this month; replay includes sessions that ended in the last hour
|
|
13
|
+
|
|
3
14
|
## 0.4.0 — 2026-10-11
|
|
4
15
|
- Tasks: create a task, pick folder, agent and profile; it runs in the background (Claude Code or Codex), in its own worktree if you want
|
|
5
16
|
- Answer questions, approve blocked tools, review the diff and test results, conclude, hand off or retry — all from the office
|
package/README.md
CHANGED
|
@@ -180,20 +180,23 @@ Agents away from their desks (kitchen, meeting room, nap corner) carry a name ta
|
|
|
180
180
|
|
|
181
181
|
## Tasks: hand work to agents from the office
|
|
182
182
|
|
|
183
|
-
Open **Tasks**
|
|
183
|
+
Open **Tasks** and describe the work, pick a folder, an agent (Claude Code, Codex or *Automatic*), a profile and optionally a role (Backend, Frontend, Reviewer). The agent runs in the background (`claude -p` / `codex exec`), in its own git worktree if you tick it. You never need the terminal:
|
|
184
184
|
|
|
185
|
-
- **Answer** its questions and **approve** tools it was not allowed to use; it resumes the same session.
|
|
186
|
-
- **Review**
|
|
187
|
-
- **Queue:** priority, "after task X" dependencies
|
|
188
|
-
- **
|
|
189
|
-
- **
|
|
190
|
-
- **
|
|
185
|
+
- **Answer** its questions and **approve** tools it was not allowed to use; it resumes the same session. **Retry**, start a **new session with a summary**, or **hand off** to the other agent with the decisions, tests and open items.
|
|
186
|
+
- **Review** everything since the task started: commits, staged and unstaged changes and new files, plus your test command's output (filtered to the failing lines and the tail).
|
|
187
|
+
- **Queue:** priority, "after task X" dependencies, how many agents run at once and how many attempts a task gets.
|
|
188
|
+
- **Model and effort:** Claude `haiku`/`sonnet`/`opus` with efforts low → max; Codex models and efforts from Codex's own model list. *Save*, *More capacity*, *Apply recommendation* or pick by hand. A change made while a step runs is shown as **pending** and marked **applied** when the next step starts.
|
|
189
|
+
- **Click the agent:** a session started by the office shows its task, running vs pending config, cost and the same controls. A session opened elsewhere is **monitored**: you see the recommendation, marked as not applied, and can turn it into a task.
|
|
190
|
+
- **Cost:** per real model, tokens in/out/cache, attempts and subagents (Claude's totals include them; Codex's stream doesn't show them). Unknown prices show as unknown, never as zero. Ranking by AI, task and session for today / 7 / 30 days. A budget pauses the task; when the cost is unknown it says the budget can't be enforced, and a call already running may go slightly over.
|
|
191
|
+
- **Recommendations:** local rules on scope, wording and attempts plus what similar finished tasks show (success with tests, cost, retries). No AI call to choose. Optional **auto-apply** up to a profile and a number of changes per task, all logged.
|
|
192
|
+
- **Warnings:** the same action repeated without progress, two failed attempts, a run costing over 3× the median finished task, budget reached or unenforceable. Notices carry the exact proposal: *Apply*, *See why*, *Not now*.
|
|
193
|
+
- **Economy mode:** short instructions on demand (read only what's needed, no long logs, no re-exploring), filtered logs, attempt and parallel limits, resume to keep the cache, or a fresh session with a summary when the context is too big.
|
|
191
194
|
|
|
192
|
-
|
|
195
|
+
Tasks live in `~/.config/coworking-agents/tasks/`. In private mode names, folders, results, diffs and notice texts stay hidden.
|
|
193
196
|
|
|
194
197
|
## Notices, releases and the office admin
|
|
195
198
|
|
|
196
|
-
The 🔔 bell collects agent requests, task updates, recommendations (*Apply*, *See why*, *Not now*), cost alerts and new versions, grouped so repeats don't pile up. **Releases** shows the installed version, the newest on npm, when it checked and
|
|
199
|
+
The 🔔 bell collects agent requests, task updates, recommendations (*Apply*, *See why*, *Not now*), cost alerts and new versions, grouped so repeats don't pile up. It keeps working with the tab in the background and recovers what was already pending when you open the office. **Releases** shows the installed version, the newest on npm (or *unavailable*), when it checked, a clickable map of every release and the notes of the newer one, with the update command for how you installed it. The office admin appears behind the top-bar counter only when there's version news: he waves the sign until you've seen it, keeps holding it until you update, then celebrates.
|
|
197
200
|
|
|
198
201
|
## MCP (read-only)
|
|
199
202
|
|
package/ROADMAP.md
CHANGED
|
@@ -25,8 +25,9 @@ Estimated cost · wait reminder · git state on the door · daily report as Mark
|
|
|
25
25
|
|
|
26
26
|
- **Seasonal decor** by date (subtle, off with a toggle).
|
|
27
27
|
|
|
28
|
-
## Tasks
|
|
29
|
-
-
|
|
30
|
-
-
|
|
31
|
-
-
|
|
32
|
-
-
|
|
28
|
+
## Tasks — next steps
|
|
29
|
+
- Mid-step permission prompts in the page (today denied tools are listed after the step; approving resumes with them allowed).
|
|
30
|
+
- Compaction of a headless Claude session (`/compact` through `-p`) once it's confirmed to work; today the option is a fresh session with a summary.
|
|
31
|
+
- Codex subagent usage (not in `codex exec --json`).
|
|
32
|
+
- CI: left out on purpose for now (GitHub Actions removed); `npm test` runs before every commit.
|
|
33
|
+
- Semantic search over history only if plain text search stops being enough; smarter automatic assignment.
|
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.5.0",
|
|
4
4
|
"description": "A pixel-art coworking office for your AI coding agents (Claude Code, Codex, …): see who's working, who needs you, and who's editing the same checkout.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"coworking-agents": "bin/coworking-agents.js"
|
package/public/js/help.js
CHANGED
|
@@ -72,7 +72,8 @@ function sceneBox() {
|
|
|
72
72
|
const x0 = Math.max(Math.round(W * .3), Math.ceil((c.right - b0.left) / DOCK_S) + 18);
|
|
73
73
|
// the office must end before the tools (and their speech clouds before the "+N" tag)
|
|
74
74
|
const tools = document.querySelector('.bar .tools'), tl = tools ? (tools.getBoundingClientRect().left - b0.left) / DOCK_S : W;
|
|
75
|
-
const
|
|
75
|
+
const reserve = typeof drawLucas === 'function' && adminState !== 'idle' ? 60 : 0; // room for the admin and his sign
|
|
76
|
+
const fit = Math.max(1, Math.min(4, Math.floor((tl - x0 - 30 - reserve) / SLOT)));
|
|
76
77
|
if (fit !== PER_OFFICE) { PER_OFFICE = fit; if (data) renderDock(); }
|
|
77
78
|
return { W, H, x0, desk: H - 7 };
|
|
78
79
|
}
|
|
@@ -120,13 +121,21 @@ function drawDock(t) {
|
|
|
120
121
|
ctx.save(); ctx.globalCompositeOperation = 'lighter';
|
|
121
122
|
for (const lx of lamps) { const g = ctx.createRadialGradient(lx + 5, desk - 6, 0, lx + 5, desk - 6, 30); g.addColorStop(0, 'rgba(255,190,90,.35)'); g.addColorStop(1, 'rgba(255,190,90,0)'); ctx.fillStyle = g; ctx.fillRect(lx - 30, desk - 40, 70, 40); }
|
|
122
123
|
ctx.restore();
|
|
124
|
+
// the office admin stands behind the counter in the monitor's place, only while there's version news (tasks.js)
|
|
125
|
+
const adminOn = typeof drawLucas === 'function' && adminState !== 'idle';
|
|
126
|
+
if (adminOn) {
|
|
127
|
+
const tl0 = document.querySelector('.bar .tools').getBoundingClientRect().left - bar.getBoundingClientRect().left;
|
|
128
|
+
const ax = Math.max(x0 + PER_OFFICE * SLOT + 44, Math.floor(tl0 / DOCK_S) - 22), ay = desk - 18;
|
|
129
|
+
drawLucas(dockCtx, ax, ay, 1, t);
|
|
130
|
+
Object.assign(adminEl.style, { left: (ax - 30) * DOCK_S + 'px', top: Math.max(0, ay - 2) * DOCK_S + 'px', width: 46 * DOCK_S + 'px', height: (desk - ay + 2) * DOCK_S + 'px' });
|
|
131
|
+
}
|
|
123
132
|
// the counter
|
|
124
133
|
r(0, desk, W, 2, '#dc9a6c'); r(0, desk + 2, W, H - desk - 2, '#8f4f3a'); r(0, H - 1, W, 1, PAL.ink);
|
|
125
134
|
for (const lx of lamps) Art.drawLamp(lx, desk - 12, true);
|
|
126
135
|
// retro monitor sign on the right
|
|
127
136
|
const tl = document.querySelector('.bar .tools').getBoundingClientRect().left - bar.getBoundingClientRect().left;
|
|
128
137
|
const mx = Math.floor(tl / DOCK_S) - 40;
|
|
129
|
-
if (mx > x0 + PER_OFFICE * SLOT + 20) {
|
|
138
|
+
if (!adminOn && mx > x0 + PER_OFFICE * SLOT + 20) {
|
|
130
139
|
r(mx - 2, desk - 26, 34, 24, PAL.ink); r(mx, desk - 24, 30, 20, '#0c1410');
|
|
131
140
|
for (let i = 0; i < 4; i++) r(mx + 3, desk - 21 + i * 4, [18, 16, 20, 14][i], 2, '#3ddc84');
|
|
132
141
|
if (f % 2) r(mx + 3 + 15, desk - 9, 3, 2, '#3ddc84');
|
package/public/js/i18n.js
CHANGED
|
@@ -136,7 +136,7 @@ const I18N = {
|
|
|
136
136
|
warn: { loop: n => `Repetiu a mesma ação (${n}) várias vezes sem progresso.`, retry: 'Já teve duas tentativas sem sucesso. Considere aumentar o esforço.', budget: 'Chegou no orçamento e foi pausada.' } },
|
|
137
137
|
notices: { tab: n => n ? `Avisos (${n})` : 'Avisos', sub: 'Pedidos dos agentes, tarefas, recomendações, orçamento e novidades do app, num lugar só.', empty: 'Nenhum aviso.', apply: 'Aplicar', why: 'Ver motivo', notNow: 'Agora não', open: 'Abrir', readAll: 'Marcar tudo como lido', times: n => `×${n}`,
|
|
138
138
|
task: (t, r) => `Tarefa “${t}”: ${r}`, update: v => `Versão ${v} disponível` },
|
|
139
|
-
ver: { tab: 'Versões', sub: 'Versão instalada, a mais nova no npm e o que mudou.', installed: 'Instalada', latest: 'No npm', checked: 'Verificado', never: 'ainda não', upToDate: 'Você está na versão mais nova.', news: 'Ver novidades', copy: 'Copiar comando', copied: 'Copiado!', later: 'Lembrar depois', npx: 'Se você usa npx:', global: 'Se instalou global:', plugin: 'No plugin do Claude Code: /plugin update coworking-agents', note: 'Copiar o comando não atualiza nada sozinho: rode, reinicie e a versão nova aparece aqui.', admin: 'Admin do escritório', celebrate: v => `Versão ${v} no ar! 🎉` },
|
|
139
|
+
ver: { tab: 'Versões', sub: 'Versão instalada, a mais nova no npm e o que mudou.', installed: 'Instalada', latest: 'No npm', checked: 'Verificado', never: 'ainda não', upToDate: 'Você está na versão mais nova.', news: 'Ver novidades', copy: 'Copiar comando', copied: 'Copiado!', later: 'Lembrar depois', npx: 'Se você usa npx:', global: 'Se instalou global:', plugin: 'No plugin do Claude Code: /plugin update coworking-agents', note: 'Copiar o comando não atualiza nada sozinho: rode, reinicie e a versão nova aparece aqui.', admin: 'Admin do escritório', map: 'Mapa das versões', unavailable: 'Não consegui consultar o npm agora; a informação está indisponível.', sayNew: v => `Saiu a ${v}! Olha o que ela traz:`, sayNewNoNotes: v => `Saiu a ${v}! Ainda não consegui buscar as novidades dela.`, sayCur: v => `Você está na ${v}. Nela eu trouxe:`, celebrate: v => `Versão ${v} no ar! 🎉` },
|
|
140
140
|
queue: { tab: n => n ? `Fila (${n})` : 'Fila', sub: 'Quem está esperando você, de quem espera há mais tempo para quem espera menos.', empty: 'Ninguém esperando você. 🎉', answer: 'Responder', reply: 'Próxima instrução' },
|
|
141
141
|
quick: ['Pode seguir.', 'Roda os testes.', 'Faz o commit e o push.', 'Explica o que você fez.', 'Continua de onde parou.', 'Revisa o código antes de seguir.'],
|
|
142
142
|
welcome: { title: a => `Bem-vindo de volta! Você ficou fora ${a}`, body: (d, a, l, need) => `Enquanto isso: ${d} terminaram, ${a} chegaram, ${l} saíram. ${need} esperando você agora.`, queue: 'Ver a fila' },
|
|
@@ -358,7 +358,7 @@ const I18N = {
|
|
|
358
358
|
warn: { loop: n => `Repeated the same action (${n}) several times without progress.`, retry: 'Two attempts failed already. Consider more effort.', budget: 'Hit the budget and was paused.' } },
|
|
359
359
|
notices: { tab: n => n ? `Notices (${n})` : 'Notices', sub: 'Agent requests, tasks, recommendations, budget and app news in one place.', empty: 'No notices.', apply: 'Apply', why: 'See why', notNow: 'Not now', open: 'Open', readAll: 'Mark all as read', times: n => `×${n}`,
|
|
360
360
|
task: (t, r) => `Task “${t}”: ${r}`, update: v => `Version ${v} available` },
|
|
361
|
-
ver: { tab: 'Releases', sub: 'Installed version, newest on npm and what changed.', installed: 'Installed', latest: 'On npm', checked: 'Checked', never: 'not yet', upToDate: 'You are on the newest version.', news: 'See what\'s new', copy: 'Copy command', copied: 'Copied!', later: 'Remind me later', npx: 'If you use npx:', global: 'If installed globally:', plugin: 'In the Claude Code plugin: /plugin update coworking-agents', note: 'Copying the command updates nothing by itself: run it, restart, and the new version shows here.', admin: 'Office admin', celebrate: v => `Version ${v} is live! 🎉` },
|
|
361
|
+
ver: { tab: 'Releases', sub: 'Installed version, newest on npm and what changed.', installed: 'Installed', latest: 'On npm', checked: 'Checked', never: 'not yet', upToDate: 'You are on the newest version.', news: 'See what\'s new', copy: 'Copy command', copied: 'Copied!', later: 'Remind me later', npx: 'If you use npx:', global: 'If installed globally:', plugin: 'In the Claude Code plugin: /plugin update coworking-agents', note: 'Copying the command updates nothing by itself: run it, restart, and the new version shows here.', admin: 'Office admin', map: 'Release map', unavailable: 'Couldn\'t reach npm right now; this information is unavailable.', sayNew: v => `${v} is out! Here's what it brings:`, sayNewNoNotes: v => `${v} is out! I couldn't fetch its notes yet.`, sayCur: v => `You're on ${v}. In it I brought:`, celebrate: v => `Version ${v} is live! 🎉` },
|
|
362
362
|
queue: { tab: n => n ? `Queue (${n})` : 'Queue', sub: 'Who is waiting on you, longest wait first.', empty: 'Nobody is waiting on you. 🎉', answer: 'Answer', reply: 'Next instruction' },
|
|
363
363
|
quick: ['Go ahead.', 'Run the tests.', 'Commit and push.', 'Explain what you did.', 'Pick up where you left off.', 'Review the code before going on.'],
|
|
364
364
|
welcome: { title: a => `Welcome back! You were away ${a}`, body: (d, a, l, need) => `Meanwhile: ${d} finished, ${a} arrived, ${l} left. ${need} waiting on you now.`, queue: 'Open the queue' },
|
|
@@ -456,6 +456,64 @@ const store = {
|
|
|
456
456
|
const qs = new URLSearchParams(location.search);
|
|
457
457
|
let lang = qs.get('lang') || store.get('lang', (navigator.language || 'pt').toLowerCase().startsWith('pt') ? 'pt' : 'en');
|
|
458
458
|
if (!Object.prototype.hasOwnProperty.call(I18N, lang)) lang = 'pt';
|
|
459
|
+
|
|
460
|
+
// tasks: controls, cost and monitoring strings (merged into the blocks above)
|
|
461
|
+
{
|
|
462
|
+
const pt = {
|
|
463
|
+
proposal: (p, m, e) => `Proposta: ${p} (${m} · ${e}).`, runningCfg: 'Em execução', currentCfg: 'Configuração', pendingCfg: 'Pendente', appliedOk: (m, e) => `Aplicado: ${m} · ${e}`,
|
|
464
|
+
partUnknown: 'parte desconhecida', saveTip: 'Um perfil abaixo: modelo e esforço mais baratos a partir da próxima etapa', moreTip: 'Um perfil acima: mais capacidade a partir da próxima etapa',
|
|
465
|
+
monitored: 'Sessão monitorada (só leitura)', monitoredWhy: 'Foi aberta fora do escritório: dá para ver tudo, mas não trocar modelo ou esforço daqui.', recFor: (p, m, e) => `Recomendado: ${p} (${m} · ${e})`, notApplied: 'não aplicado (sem controle sobre esta sessão)', asTask: 'Criar uma tarefa a partir desta',
|
|
466
|
+
managed: 'Sessão gerenciada pelo escritório', openTask: 'abrir tarefa', src: 'fonte: stream do CLI', bySession: 'Por sessão (mês)', sessionsNote: 'Todas as conversas do mês, lidas do disco.',
|
|
467
|
+
settings: 'Ajustes da fila e da economia', maxAttempts: 'Tentativas por tarefa', autoApply: 'Aplicar recomendações sozinho', autoMax: 'Até o perfil', autoPerTask: 'Mudanças automáticas por tarefa', autoNote: 'Só dentro desses limites, e cada mudança fica registrada na tarefa.',
|
|
468
|
+
recommended: 'Recomendado', role: 'Papel', roles: { backend: 'Backend', frontend: 'Frontend', reviewer: 'Revisor' }, lean: 'Modo econômico (instruções curtas: ler só o necessário, sem logs longos)', leanOn: 'econômico',
|
|
469
|
+
privateNote: 'Modo privado: nomes, pastas, resultados e diffs ficam escondidos.', kinds: { task: 'tarefa', session: 'sessão ao vivo', today: 'hoje', month: 'este mês' }, resumed: 'mesma sessão',
|
|
470
|
+
ev: { created: 'criada', start: 'início', text: 'mensagem', tool: 'ferramenta', result: 'resultado', test: 'testes', state: 'estado', warn: 'alerta', config: 'configuração', applied: 'aplicado', reply: 'resposta', approve: 'aprovado', retry: 'nova tentativa', fresh: 'nova sessão', cancelling: 'cancelando', cancelled: 'cancelada', done: 'concluída', error: 'erro', worktree: 'worktree' },
|
|
471
|
+
subagents: n => `${n} subagente(s)`, srcClaude: 'por modelo, do total do Claude Code (inclui subagentes)', srcCodex: 'por turno, dos tokens do Codex; subagentes do Codex não aparecem no stream',
|
|
472
|
+
fresh: 'Nova sessão com resumo', freshTip: 'Começa uma sessão nova levando só um resumo: decisões, testes e pendências. Gasta menos contexto, mas perde o cache da sessão antiga.',
|
|
473
|
+
since: c => `Desde o commit ${c}:`, newFiles: 'Arquivos novos', cancelling: 'Cancelando… confirma quando o processo terminar.', similar: n => `${n} tarefa(s) parecida(s) no histórico`,
|
|
474
|
+
};
|
|
475
|
+
const en = {
|
|
476
|
+
proposal: (p, m, e) => `Proposal: ${p} (${m} · ${e}).`, runningCfg: 'Running', currentCfg: 'Configuration', pendingCfg: 'Pending', appliedOk: (m, e) => `Applied: ${m} · ${e}`,
|
|
477
|
+
partUnknown: 'part unknown', saveTip: 'One profile down: cheaper model and effort from the next step', moreTip: 'One profile up: more capacity from the next step',
|
|
478
|
+
monitored: 'Monitored session (read-only)', monitoredWhy: 'It was opened outside the office: you can see everything, but not change model or effort from here.', recFor: (p, m, e) => `Recommended: ${p} (${m} · ${e})`, notApplied: 'not applied (no control over this session)', asTask: 'Create a task from this one',
|
|
479
|
+
managed: 'Session managed by the office', openTask: 'open task', src: 'source: CLI stream', bySession: 'By session (month)', sessionsNote: 'Every conversation this month, read from disk.',
|
|
480
|
+
settings: 'Queue and economy settings', maxAttempts: 'Attempts per task', autoApply: 'Apply recommendations automatically', autoMax: 'Up to profile', autoPerTask: 'Automatic changes per task', autoNote: 'Only within these limits, and every change is logged on the task.',
|
|
481
|
+
recommended: 'Recommended', role: 'Role', roles: { backend: 'Backend', frontend: 'Frontend', reviewer: 'Reviewer' }, lean: 'Economy mode (short instructions: read only what is needed, no long logs)', leanOn: 'economy',
|
|
482
|
+
privateNote: 'Private mode: names, folders, results and diffs are hidden.', kinds: { task: 'task', session: 'live session', today: 'today', month: 'this month' }, resumed: 'same session',
|
|
483
|
+
ev: { created: 'created', start: 'start', text: 'message', tool: 'tool', result: 'result', test: 'tests', state: 'state', warn: 'warning', config: 'configuration', applied: 'applied', reply: 'reply', approve: 'approved', retry: 'retry', fresh: 'new session', cancelling: 'cancelling', cancelled: 'cancelled', done: 'done', error: 'error', worktree: 'worktree' },
|
|
484
|
+
subagents: n => `${n} subagent(s)`, srcClaude: 'per model, from Claude Code\'s total (includes subagents)', srcCodex: 'per turn, from Codex tokens; Codex subagents are not in the stream',
|
|
485
|
+
fresh: 'New session with a summary', freshTip: 'Starts a new session carrying only a summary: decisions, tests and open items. Less context, but the old session\'s cache is lost.',
|
|
486
|
+
since: c => `Since commit ${c}:`, newFiles: 'New files', cancelling: 'Cancelling… confirmed when the process exits.', similar: n => `${n} similar task(s) in history`,
|
|
487
|
+
};
|
|
488
|
+
Object.assign(I18N.pt.tasks, pt); Object.assign(I18N.en.tasks, en);
|
|
489
|
+
Object.assign(I18N.pt.tasks.recWhy, { scope: 'escopo amplo (muitos arquivos ou o projeto todo)', attempts: 'já teve tentativas sem sucesso', 'history-up': 'tarefas parecidas falharam nesse perfil', 'history-down': 'tarefas parecidas deram certo num perfil mais barato' });
|
|
490
|
+
Object.assign(I18N.en.tasks.recWhy, { scope: 'wide scope (many files or the whole project)', attempts: 'previous attempts failed', 'history-up': 'similar tasks failed at this profile', 'history-down': 'similar tasks succeeded at a cheaper profile' });
|
|
491
|
+
Object.assign(I18N.pt.tasks.reasons, { nogit: 'pediu worktree, mas a pasta não é um repositório git', worktree: 'não consegui criar a worktree', detached: 'o servidor reiniciou; o agente ainda está rodando' });
|
|
492
|
+
Object.assign(I18N.en.tasks.reasons, { nogit: 'a worktree was asked but the folder is not a git repo', worktree: 'could not create the worktree', detached: 'the server restarted; the agent is still running' });
|
|
493
|
+
Object.assign(I18N.pt.tasks.warn, { high: w => `Esta execução já custou ${fmtUSD(w.usd || 0)}, mais de 3× a mediana das tarefas concluídas (${fmtUSD(w.median || 0)}).`, budgetUnknown: 'Não dá para aplicar o orçamento: o custo deste modelo é desconhecido.', budget: 'Chegou no orçamento e foi pausada. Uma chamada que já estava em andamento pode ter passado um pouco do limite.' });
|
|
494
|
+
Object.assign(I18N.en.tasks.warn, { high: w => `This run already cost ${fmtUSD(w.usd || 0)}, over 3× the median finished task (${fmtUSD(w.median || 0)}).`, budgetUnknown: 'The budget cannot be enforced: this model\'s cost is unknown.', budget: 'Hit the budget and was paused. A call already in progress may have gone slightly over.' });
|
|
495
|
+
Object.assign(I18N.pt.tasks.fail, { attempts: 'Chegou no limite de tentativas (ajuste em Ajustes).', private: 'Escondido no modo privado.', notask: 'Tarefa não encontrada.' });
|
|
496
|
+
Object.assign(I18N.en.tasks.fail, { attempts: 'Hit the attempt limit (change it in Settings).', private: 'Hidden in private mode.', notask: 'Task not found.' });
|
|
497
|
+
Object.assign(I18N.pt.notices, { hidden: 'Aviso escondido (modo privado)', privateNote: 'Modo privado: os textos dos avisos ficam escondidos.' });
|
|
498
|
+
Object.assign(I18N.en.notices, { hidden: 'Notice hidden (private mode)', privateNote: 'Private mode: notice texts are hidden.' });
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
// office objects that open the panels, and the recommendation card
|
|
503
|
+
{
|
|
504
|
+
I18N.pt.officeObj = { tasks: (q, w, b, d) => `Quadro de tarefas\n${q} na fila · ${w} trabalhando · ${b} esperam você · ${d} concluídas`, costs: (d, w) => `Monitor de custos\nHoje ${d} · 7 dias ${w} (estimativa a preço de API)`, notices: n => `Mural de avisos\n${n ? n + ' não lido(s)' : 'nada novo'}`, history: 'Arquivo de histórico\nTudo o que aconteceu no escritório', releases: (c, n) => `Quadro de versões\nInstalada ${c}${n ? ' · nova: ' + n : ''}`,
|
|
505
|
+
menu: { tasks: 'Tarefas', usage: 'Custos e limites', feed: 'Histórico', notices: 'Avisos', version: 'Atualizações' } };
|
|
506
|
+
I18N.en.officeObj = { tasks: (q, w, b, d) => `Task board\n${q} queued · ${w} working · ${b} waiting on you · ${d} done`, costs: (d, w) => `Costs screen\nToday ${d} · 7 days ${w} (estimate at API prices)`, notices: n => `Notice board\n${n ? n + ' unread' : 'nothing new'}`, history: 'History archive\nEverything that happened in the office', releases: (c, n) => `Releases board\nInstalled ${c}${n ? ' · new: ' + n : ''}`,
|
|
507
|
+
menu: { tasks: 'Tasks', usage: 'Costs and limits', feed: 'History', notices: 'Notices', version: 'Updates' } };
|
|
508
|
+
Object.assign(I18N.pt.tasks, { rcCurrent: 'Atual', rcRecommended: 'Recomendado', rcLoading: 'calculando…', rcPrivate: 'escondido no modo privado', rcMissing: 'Faltam dados', rcAlready: 'Já está na configuração recomendada.', rcExternal: 'Sessão aberta fora do escritório: dá para ver a recomendação, mas não aplicar daqui. Crie uma tarefa para ter controle.', unknownModel: 'modelo desconhecido', unknownEffort: 'esforço desconhecido',
|
|
509
|
+
missing: { desc: 'a descrição é curta demais para avaliar o escopo', history: 'ainda não há tarefas parecidas concluídas para comparar', codexModel: 'o modelo padrão do Codex não foi encontrado; o custo pode ficar desconhecido' } });
|
|
510
|
+
Object.assign(I18N.en.tasks, { rcCurrent: 'Current', rcRecommended: 'Recommended', rcLoading: 'working it out…', rcPrivate: 'hidden in private mode', rcMissing: 'Missing data', rcAlready: 'Already on the recommended setup.', rcExternal: 'Session opened outside the office: you can see the recommendation but not apply it here. Create a task to get control.', unknownModel: 'unknown model', unknownEffort: 'unknown effort',
|
|
511
|
+
missing: { desc: 'the description is too short to judge the scope', history: 'no similar finished tasks to compare yet', codexModel: 'Codex default model not found; cost may stay unknown' } });
|
|
512
|
+
Object.assign(I18N.pt.tasks.recWhy, { 'signal-loop': 'a tarefa repetiu ações sem progresso', 'signal-retry': 'falhou mais de uma vez', 'signal-high': 'esta execução está bem acima do custo normal' });
|
|
513
|
+
Object.assign(I18N.en.tasks.recWhy, { 'signal-loop': 'the task repeated actions without progress', 'signal-retry': 'it failed more than once', 'signal-high': 'this run is far above the usual cost' });
|
|
514
|
+
I18N.pt.ver.admin = 'Admin do escritório'; I18N.en.ver.admin = 'Office admin';
|
|
515
|
+
}
|
|
516
|
+
|
|
459
517
|
let T = I18N[lang];
|
|
460
518
|
// sound is on by default; alerts follow the browser permission (asked on open). Only the buttons turn them off.
|
|
461
519
|
let soundOn = store.get('sound', true);
|
package/public/js/overlay.js
CHANGED
|
@@ -89,11 +89,12 @@ function renderOverlay() {
|
|
|
89
89
|
for (const wb of windowBoxes) parts.push(`<button class="wall-switch" data-weather="1" title="${esc(weather ? T.weather.now(weather.temp) : T.weather.ask)}" style="left:${wb.x * S}px;top:${wb.y * S}px;width:${wb.w * S}px;height:${wb.h * S}px"></button>`);
|
|
90
90
|
if (usageBox) parts.push(`<button class="wall-switch usage-hit" data-usage="1" title="${esc(usageGauges().map(m => `${m.label}: ${Math.round(m.pct)}%${m.resetsAt ? ` (${T.usage.resets} ${untilText(m.resetsAt)})` : ''}`).concat([T.usage.open]).join('\n'))}" style="left:${usageBox.x * S}px;top:${usageBox.y * S}px;width:${usageBox.w * S}px;height:${usageBox.h * S}px"></button>`);
|
|
91
91
|
if (switchBox) parts.push(`<button class="wall-switch" data-switch="1" title="${esc(T.lights)}" aria-pressed="${lightsOn}" style="left:${switchBox.x * S}px;top:${switchBox.y * S}px;width:${switchBox.w * S}px;height:${switchBox.h * S}px"></button>`);
|
|
92
|
-
if (boardBox && data)
|
|
92
|
+
if (boardBox && data) parts.push(`<button class="wall-switch" data-tab="tasks" title="${esc(typeof taskBoardTip === 'function' ? taskBoardTip() : T.tasks.tab(0))}" style="left:${boardBox.x * S}px;top:${boardBox.y * S}px;width:${boardBox.w * S}px;height:${boardBox.h * S}px"></button>`);
|
|
93
93
|
const box = (b, attr, title) => b ? parts.push(`<button class="wall-switch" ${attr} title="${esc(title)}" style="left:${b.x * S}px;top:${b.y * S}px;width:${b.w * S}px;height:${b.h * S}px"></button>`) : 0;
|
|
94
94
|
{ const m = usageData && usageData.month && usageData.month.top && usageData.month.top[0], live = m && data && data.people.find(p => p.id === m.id);
|
|
95
95
|
box(aotmBox, 'data-ach="1"', m ? T.ach.aotmTip((live && live.name) || m.title || m.id.slice(0, 8), Math.round(m.activeMs / 36e5), m.tools) : T.ach.noMonth); }
|
|
96
96
|
box(trophyBox, 'data-trophyroom="1"', T.trophy.shelf);
|
|
97
|
+
if (typeof officeTips === 'function') { const o = officeTips(); box(costBox, 'data-tab="usage"', o.costs); box(noticesBox, 'data-tab="notices"', o.notices); box(historyBox, 'data-tab="feed"', o.history); box(releaseBox, 'data-tab="version"', o.releases); }
|
|
97
98
|
for (const g of gameHits) box(g, g.attr, g.title);
|
|
98
99
|
{ const working = data ? data.people.filter(p => !p.leaving && !['idle', 'asleep', 'needs_you', 'waiting'].includes(p.state)).length : 0;
|
|
99
100
|
box(moodBox, 'data-mood="1"', T.mood[!working ? 'calm' : officeMood() === 'rush' ? 'rush' : 'normal']); }
|
package/public/js/panel.js
CHANGED
|
@@ -97,6 +97,7 @@ function renderPanel() {
|
|
|
97
97
|
${canCompact(p) ? `<div class="actions"><button class="btn compact" data-compact-panel="${esc(p.id)}">⤓ ${esc(T.alerts.compact)} · ${Math.round(ctxPct * 100)}%</button></div>` : ''}
|
|
98
98
|
<div class="actions"><button class="btn" id="btn-goto" data-id="${esc(p.id)}">›_ ${esc(p.agent === 'codex' && !p.pid ? P.gotoCodex : P.goto)}</button><span class="note" id="goto-msg"></span></div>
|
|
99
99
|
${p.coarse ? `<p class="note">${esc(T.panel.coarse)}</p>` : ''}
|
|
100
|
+
${typeof taskCardHtml === 'function' ? taskCardHtml(p) : ''}
|
|
100
101
|
<section class="card now" style="--c:${col}"><small>${esc(P.doing)}</small><p><span class="ico">${KIND_ICON[p.doing ? p.doing.kind : ''] || '•'}</span>${p.doing ? `<code>${esc(p.doing.tool)}</code> ` : ''}${esc(now)}</p>${p.doing ? `<span class="note">${esc(T.for(ago(p.doing.for)))}</span>` : ''}${p.state === 'waiting' ? `<p class="note">${esc(P.waitNote)}</p>` : ''}</section>
|
|
101
102
|
${p.lastPrompt ? `<section class="card quote"><small>${esc(P.lastPrompt)}${p.lastPromptAt ? ' · ' + esc(ago(data.now - p.lastPromptAt)) : ''}</small><p>${esc(p.lastPrompt)}</p></section>` : ''}
|
|
102
103
|
${p.lastReply ? `<section class="card quote reply"><small>${esc(P.lastReply)}${p.lastReplyAt ? ' · ' + esc(ago(data.now - p.lastReplyAt)) : ''}</small><p>${esc(p.lastReply)}</p></section>` : ''}
|
package/public/js/player.js
CHANGED
|
@@ -96,6 +96,7 @@ function nearThing() {
|
|
|
96
96
|
if (near(ax, ay)) return { label: T.walk.talk(cell.p.name), run: () => (achBump('talks'), cell.p.state === 'needs_you' || cell.p.state === 'waiting' ? openTalk(cell.p.id) : showPerson(cell.p.id)) };
|
|
97
97
|
}
|
|
98
98
|
for (const g of gameHits) if (near(g.x + g.w / 2, g.y + g.h, 24)) return { label: g.title, run: () => { const m = /data-tab="(\w+)"/.exec(g.attr); if (m) openReport(m[1]); else if (/trophyroom/.test(g.attr)) setTrophyView(true); else if (/arcade/.test(g.attr)) openArcade(); } };
|
|
99
|
+
if (typeof officeTips === 'function') { const o = officeTips(); for (const [b, tab, label] of [[boardBox, 'tasks', o.tasks], [costBox, 'usage', o.costs], [noticesBox, 'notices', o.notices], [historyBox, 'feed', o.history], [releaseBox, 'version', o.releases]]) if (b && near(b.x + b.w / 2, b.y + b.h + 14, 26)) return { label: label.split('\n')[0], run: () => openReport(tab) }; }
|
|
99
100
|
if (radioBox && near(radioBox.x + 7, radioBox.y + 20, 26)) return { label: radioOn ? T.radio.on : T.radio.off, run: () => setRadio(!radioOn) };
|
|
100
101
|
if (catBox && near(cat.x + 6, cat.y + 6, 18)) return { label: T.walk.pet, run: petCat };
|
|
101
102
|
if (elevatorBox && near(elevatorBox.x + 14, elevatorBox.y + 30, 22)) return { label: T.elevator.title, run: openElevator };
|
package/public/js/replay.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// ---------------- replay: scrub the office back through the last hour ----------------
|
|
3
3
|
// Every agent's state at a past moment comes from its own timeline (measured events), so the office
|
|
4
4
|
// re-plays what really happened. Live data keeps arriving; it's just shown as of the chosen minute.
|
|
5
|
-
let replayAt = 0, replayPlay = 0, liveData = null;
|
|
5
|
+
let replayAt = 0, replayPlay = 0, liveData = null, replayEnded = [];
|
|
6
6
|
const replayEl = document.createElement('div');
|
|
7
7
|
replayEl.className = 'replay'; replayEl.hidden = true;
|
|
8
8
|
replayEl.innerHTML = '<button class="hb" data-r="play">▶</button><input type="range" min="-3600" max="0" step="15" value="0"><span class="rt"></span><button class="hb" data-r="live"></button>';
|
|
@@ -13,7 +13,10 @@ const KIND_STATE = { idle: 'idle', ask: 'needs_you' };
|
|
|
13
13
|
function applyReplay(d) {
|
|
14
14
|
if (!replayAt) return d;
|
|
15
15
|
const T0 = replayAt;
|
|
16
|
-
|
|
16
|
+
// sessions that have ended since then come back as they were (measured timelines from /api/replay)
|
|
17
|
+
const ghosts = replayEnded.filter(g => g.startedAt <= T0 && T0 < g.endedAt && !d.people.some(p => p.id === g.id))
|
|
18
|
+
.map(g => ({ state: 'idle', doing: null, files: [], editing: [], subagents: [], tools: {}, today: {}, recent: [], since: g.startedAt, ...g }));
|
|
19
|
+
return { ...d, people: [...d.people, ...ghosts].filter(p => !p.startedAt || p.startedAt <= T0).map(p => {
|
|
17
20
|
const seg = (p.timeline || []).find(g => g.from <= T0 && T0 < g.to);
|
|
18
21
|
const state = seg ? (KIND_STATE[seg.kind] || seg.kind) : 'asleep';
|
|
19
22
|
return { ...p, state, doing: null, subagents: [], since: seg ? seg.from : p.since };
|
|
@@ -31,7 +34,7 @@ function toggleReplay(on) {
|
|
|
31
34
|
if (on) achBump('replay');
|
|
32
35
|
replayEl.hidden = !on;
|
|
33
36
|
replayEl.querySelector('[data-r="live"]').textContent = T.replay.live;
|
|
34
|
-
if (!on) { clearInterval(replayPlay); replayPlay = 0; setReplay(0); } else setReplay(-1800);
|
|
37
|
+
if (!on) { clearInterval(replayPlay); replayPlay = 0; setReplay(0); } else { setReplay(-1800); fetch('api/replay').then(r => r.json()).then(list => { replayEnded = Array.isArray(list) ? list : []; if (replayAt) setReplay(Number(rRange.value)); }).catch(() => {}); }
|
|
35
38
|
}
|
|
36
39
|
rRange.addEventListener('input', () => setReplay(Number(rRange.value)));
|
|
37
40
|
replayEl.addEventListener('click', e => {
|
package/public/js/scene.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
// ---------------- scene ----------------
|
|
3
|
-
let wallGlows = [], usageBox = null, windowBoxes = [], neonBox = null;
|
|
3
|
+
let wallGlows = [], usageBox = null, windowBoxes = [], neonBox = null, releaseBox = null, historyBox = null, costBox = null, noticesBox = null;
|
|
4
4
|
// usage meters as a wall display, like a clock in the office: one gauge per limit window + today's tokens
|
|
5
5
|
function usageGauges() {
|
|
6
6
|
const out = [], L = (data && data.limits) || {};
|
|
@@ -61,22 +61,24 @@ function drawWall(t, sky) {
|
|
|
61
61
|
for (let x = 0; x < W; x += 24) r(x, 0, 1, TOP - 6, PAL.wallShade);
|
|
62
62
|
r(0, 0, W, 3, PAL.wallTrim); r(0, 3, W, 1, PAL.ink2);
|
|
63
63
|
r(0, TOP - 8, W, 2, PAL.wallShade); r(0, TOP - 6, W, 5, PAL.base); r(0, TOP - 6, W, 1, '#8f553f'); r(0, TOP - 1, W, 1, PAL.ink);
|
|
64
|
-
windowBoxes = [];
|
|
64
|
+
windowBoxes = []; releaseBox = historyBox = costBox = noticesBox = null;
|
|
65
|
+
let extra = 0; // window slots after the agent of the month become the costs screen and the notice board
|
|
65
66
|
const nWin = Math.max(1, Math.floor((CX - 30) / 74));
|
|
66
67
|
// the third window slot becomes the dev corner of the wall: neon </> and two posters
|
|
67
68
|
for (let i = 0; i < nWin; i++) {
|
|
68
69
|
const wx = 16 + i * 74;
|
|
69
70
|
if (i === 1) drawUsageBoard(wx - 6, 7, t);
|
|
70
71
|
else if (i === 2) continue; // covered by the usage screen
|
|
71
|
-
else if (i === 3 && nWin >= 5) { Art.drawNeon(wx + 9, 10, Date.now() - neonFlash < 400 ? (((Date.now() / 60) | 0) % 2 ? t : 120 * 47) : t, wallGlows); neonBox = { x: wx + 7, y: 8, w: 34, h: 18 }; Art.drawPoster(wx - 16, 14, 0); Art.drawPoster(wx + 46, 14, 2); }
|
|
72
|
+
else if (i === 3 && nWin >= 5) { Art.drawNeon(wx + 9, 10, Date.now() - neonFlash < 400 ? (((Date.now() / 60) | 0) % 2 ? t : 120 * 47) : t, wallGlows); neonBox = { x: wx + 7, y: 8, w: 34, h: 18 }; Art.drawPoster(wx - 16, 14, 0); Art.drawPoster(wx + 46, 14, 2); releaseBox = { x: wx - 17, y: 13, w: 22, h: 28 }; historyBox = { x: wx + 45, y: 13, w: 22, h: 28 }; if (typeof drawReleaseBadge === 'function') drawReleaseBadge(wx - 16, 14, t); }
|
|
72
73
|
else if (i === 4 && nWin >= 7) drawTrophyShelf(wx - 8, 9, 64, t);
|
|
73
74
|
else if (i === 5) { if (nWin >= 7) drawAgentOfMonth(wx, 10, t, wallGlows); else Art.drawPoster(wx + 14, 12, 1); }
|
|
75
|
+
else if (i >= 6 && extra < 2 && typeof drawCostScreen === 'function') { if (extra++ === 0) { drawCostScreen(wx, 9, 48, 30, t); costBox = { x: wx - 1, y: 8, w: 50, h: 32 }; } else { drawNoticeCork(wx, 9, 48, 30, t); noticesBox = { x: wx - 1, y: 8, w: 50, h: 32 }; } }
|
|
74
76
|
else { Art.drawWindow(wx, 9, 48, 30, sky, t, i); drawSkyExtras(wx, 9, 48, 30, sky, t, i, skyHour()); drawWindowView(wx, 9, 48, 30, sky, i); drawSeasonWindow(wx, 9, 48, 30, t); drawWeather(wx, 9, 48, 30, t); drawGlassDrops(wx, 9, 48, 30, t); drawHolidayWindow(wx, 9, 48, 30, t, sky); windowBoxes.push({ x: wx, y: 9, w: 48, h: 30 }); }
|
|
75
77
|
}
|
|
76
78
|
// the ON AIR sign takes the gap between the last window and the whiteboard, when there is one
|
|
77
79
|
{ const lastEnd = 16 + (nWin - 1) * 74 + 48, gap = RX - 14 - lastEnd; moodBox = null; if (gap >= 32) drawMoodSign(lastEnd + ((gap - 24) / 2 | 0), 16, t, wallGlows); }
|
|
78
80
|
boardBox = { x: RX - 14, y: 8, w: 56, h: 30 };
|
|
79
|
-
Art.drawWhiteboard(boardBox.x, boardBox.y, boardBox.w, boardBox.h, counts(), t);
|
|
81
|
+
if (typeof drawTaskBoard === 'function') drawTaskBoard(boardBox.x, boardBox.y, boardBox.w, boardBox.h, t); else Art.drawWhiteboard(boardBox.x, boardBox.y, boardBox.w, boardBox.h, counts(), t);
|
|
80
82
|
Art.drawClock(RX + 58, 20);
|
|
81
83
|
hallBox = { x: RX + 74, y: 7, w: 72, h: 32 };
|
|
82
84
|
Art.drawCork(hallBox.x, hallBox.y, hallBox.w, hallBox.h, data ? Object.values(data.credentials || {}).flatMap(c => c.topSkills || []).sort((a, b) => b.count - a.count) : []);
|