@caglarutkuguler/cubicle 0.3.1 → 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/README.md CHANGED
@@ -33,6 +33,12 @@ npx @caglarutkuguler/cubicle install-hooks # once; backs up ~/.claude/setti
33
33
  npx @caglarutkuguler/cubicle --source claude-code
34
34
  ```
35
35
 
36
+ **Both in one office** — Paperclip agents on the first rows, Claude Code sessions after them:
37
+
38
+ ```bash
39
+ npx @caglarutkuguler/cubicle --source paperclip,claude-code
40
+ ```
41
+
36
42
  **Anything else** — a JSON file or URL in the [feed format](docs/FEED.md):
37
43
 
38
44
  ```bash
@@ -56,7 +62,7 @@ node bin/cubicle.js # add --source … as above
56
62
  | --- | --- | --- |
57
63
  | `--port` | `CUBICLE_PORT` | `3200` |
58
64
  | `--host` | `CUBICLE_HOST` | `127.0.0.1` |
59
- | `--source` | `CUBICLE_SOURCE` | `paperclip` — or `claude-code`, a `.json` file, or an `http(s)://` URL |
65
+ | `--source` | `CUBICLE_SOURCE` | `paperclip` — or `claude-code`, a `.json` file, an `http(s)://` URL, or several of these comma-separated |
60
66
  | `--paperclip` | `PAPERCLIP_URL` | `http://127.0.0.1:3100` |
61
67
  | `--token-file` | `PAPERCLIP_TOKEN` / `PAPERCLIP_TOKEN_FILE` | none — API key for an authenticated Paperclip |
62
68
 
@@ -65,13 +71,14 @@ URL parameters:
65
71
  - `?company=PREFIX` opens a specific Paperclip company (for example `?company=MEG`). With several companies, a picker also appears in the header.
66
72
  - `?lang=en` or `?lang=tr` sets the language.
67
73
  - `?demo` shows fake agents.
74
+ - `?kiosk` fills the screen with the office for a TV or a second monitor: cards and footer hidden, names and bubbles scale with the screen, cursor hidden. Combined with the auto-updater, a wall display picks up new versions by itself.
68
75
 
69
76
  ## What the office shows
70
77
 
71
78
  | Status | In the office |
72
79
  | --- | --- |
73
80
  | `running` | Sits at its desk, monitor on, typing; bubble shows the task |
74
- | `waiting` | Sits at its desk with a hand up, amber screen — it needs your input or approval |
81
+ | `waiting` | Sits at its desk with a hand up, amber screen — it needs your input or approval. With Paperclip this comes from issues whose review is waiting on the board (for example pending ask-user questions); a busy agent keeps typing but its bubble flashes the issue id, and the header counts how many need you |
75
82
  | `idle` | Wanders the lounge; says "✓ done" right after finishing a run |
76
83
  | `error` | Slumped at its desk, red screen, error text on its card |
77
84
  | `paused` | "zZ" |
@@ -94,8 +101,25 @@ Use a board API key with the narrowest read-only scope Paperclip lets you create
94
101
 
95
102
  **Claude Code.** A hook script ([`bin/cubicle-hook.js`](bin/cubicle-hook.js)) runs on Claude Code's own hook events and rewrites `~/.cubicle/claude-code.json`. Tool calls put the character at its desk, permission prompts raise its hand, `Stop` sends it to the lounge. Setup and privacy notes: [`examples/claude-code/README.md`](examples/claude-code/README.md).
96
103
 
104
+ **Several at once.** `--source paperclip,claude-code,./other.json` puts every source in the same office. Each source keeps its own block of desks, so agents don't shuffle when a session starts or ends, and if one source goes down the others keep showing while the header names the one that is unreachable.
105
+
97
106
  **Feed.** Any process can write `{ "company": "…", "agents": [{ "id", "name", "role", "status", "task", "error" }] }` to a file or serve it over HTTP. Full spec with status aliases: [`docs/FEED.md`](docs/FEED.md). A sample is in [`examples/feed.json`](examples/feed.json).
98
107
 
108
+ ## How it compares
109
+
110
+ Cubicle is one of several pixel offices for AI agents, all inspired by [Pixel Agents](https://github.com/pixel-agents-hq/pixel-agents). They optimise for different things:
111
+
112
+ | | Cubicle | [Pixel Agents](https://github.com/pixel-agents-hq/pixel-agents) | [Agent Pixels](https://github.com/gcampton/Agent-Pixels) | [agents-in-the-office](https://github.com/gukosowa/agents-in-the-office) |
113
+ | --- | --- | --- | --- | --- |
114
+ | Works with | Paperclip, Claude Code, any JSON feed | Claude Code | Paperclip | Claude Code, Gemini CLI |
115
+ | Runs as | Standalone page (`npx`, systemd, kiosk) | VS Code extension or `npx` browser app | Paperclip plugin | Standalone app |
116
+ | Setup | One command, nothing to build | Marketplace install; build from source to hack on it | Build against the Paperclip plugin SDK | See its README |
117
+ | Access to your agents | Read-only by design (GET allowlist) | Watches local Claude Code sessions | Inside Paperclip | Watches local sessions |
118
+ | Shows "needs you" | Yes: permission prompts, board questions | Yes: waiting for approval | Not listed | Yes: approval alerts |
119
+ | Office editor, art | Fixed room, programmer art | Layout editor, furniture and character assets, pets | 80+ characters, multi-room views | Tile map editor, sound packs |
120
+
121
+ **Pick Cubicle** if you want something you can trust to run next to production agents (it can only read), that starts in seconds with no build, or that shows Paperclip and Claude Code in one place. **Pick another one** if you want to design your office, richer art, or tighter editor integration.
122
+
99
123
  ## Run it as a service (Linux / WSL)
100
124
 
101
125
  systemd user units are in [`examples/systemd/`](examples/systemd/): `cubicle.service` (Paperclip, port 3200), `cubicle-claude.service` (Claude Code, port 3201), and an optional updater.
@@ -131,7 +155,7 @@ journalctl --user -u cubicle-update.service # what it did
131
155
 
132
156
  ## Contributing
133
157
 
134
- Issues and pull requests are welcome. Ideas: adapters for other agent runtimes (write a feed, send a PR with an example), a kiosk mode for wall displays, meeting-room animations when agents hand work to each other, per-agent sprite customisation, replay of a recorded day, and multi-source offices (Paperclip and Claude Code on one floor).
158
+ Issues and pull requests are welcome; see [CONTRIBUTING.md](CONTRIBUTING.md) for the few ground rules (zero dependencies, read-only). Look for issues labelled [good first issue](https://github.com/caglarutkuguler/cubicle/labels/good%20first%20issue). Ideas: adapters for other agent runtimes (write a feed, send a PR with an example), a kiosk mode for wall displays, meeting-room animations when agents hand work to each other, per-agent sprite customisation, replay of a recorded day, and multi-source offices (Paperclip and Claude Code on one floor).
135
159
 
136
160
  ## Releasing
137
161
 
package/bin/cubicle.js CHANGED
@@ -32,13 +32,18 @@ if (process.argv[2] === 'install-hooks') {
32
32
  return;
33
33
  }
34
34
 
35
+ if (process.argv.includes('--version') || process.argv.includes('-v')) {
36
+ console.log(require('../package.json').version);
37
+ process.exit(0);
38
+ }
39
+
35
40
  if (process.argv.includes('--help') || process.argv.includes('-h')) {
36
41
  console.log(`Cubicle — a live pixel-art office for your AI agents
37
42
 
38
43
  Usage: cubicle [--port 3200] [--host 127.0.0.1] [--source paperclip] [--paperclip http://127.0.0.1:3100] [--token-file FILE]
39
44
  cubicle install-hooks [--uninstall] add/remove the Claude Code hooks in ~/.claude/settings.json
40
45
 
41
- Sources (--source / CUBICLE_SOURCE):
46
+ Sources (--source / CUBICLE_SOURCE), one or several comma-separated, e.g. paperclip,claude-code:
42
47
  paperclip Paperclip server, default (see --paperclip / PAPERCLIP_URL)
43
48
  claude-code Claude Code sessions, via the hook in examples/claude-code/
44
49
  ./agents.json Any JSON file in the Cubicle feed format (docs/FEED.md)
@@ -87,13 +92,23 @@ if (TOKEN && !/^[\x21-\x7e]+$/.test(TOKEN)) {
87
92
  process.exit(1);
88
93
  }
89
94
 
90
- // ---------- source ----------
91
- const rawSource = arg('source') || process.env.CUBICLE_SOURCE || 'paperclip';
92
- let source; // { kind: 'paperclip' } | { kind: 'file', file, label } | { kind: 'url', url, label }
93
- if (rawSource === 'paperclip') source = { kind: 'paperclip' };
94
- else if (rawSource === 'claude-code') source = { kind: 'file', file: CLAUDE_CODE_FEED, label: 'Claude Code', emptyIfMissing: true };
95
- else if (/^https?:\/\//.test(rawSource)) source = { kind: 'url', url: new URL(rawSource), label: rawSource };
96
- else source = { kind: 'file', file: path.resolve(rawSource), label: path.basename(rawSource) };
95
+ // ---------- sources ----------
96
+ // --source takes one source or several, comma-separated: "paperclip,claude-code" shows both
97
+ // in one office. At most one Paperclip; any number of feeds (served at /api/feed/<n>).
98
+ function parseSource(raw) {
99
+ if (raw === 'paperclip') return { kind: 'paperclip' };
100
+ if (raw === 'claude-code') return { kind: 'file', file: CLAUDE_CODE_FEED, label: 'Claude Code', emptyIfMissing: true };
101
+ if (/^https?:\/\//.test(raw)) return { kind: 'url', url: new URL(raw), label: raw };
102
+ return { kind: 'file', file: path.resolve(raw), label: path.basename(raw) };
103
+ }
104
+ const SOURCES = (arg('source') || process.env.CUBICLE_SOURCE || 'paperclip')
105
+ .split(',').map((x) => x.trim()).filter(Boolean).map(parseSource);
106
+ if (SOURCES.filter((x) => x.kind === 'paperclip').length > 1) {
107
+ console.error('Only one Paperclip source is supported.');
108
+ process.exit(1);
109
+ }
110
+ const HAS_PAPERCLIP = SOURCES.some((x) => x.kind === 'paperclip');
111
+ const FEEDS = SOURCES.filter((x) => x.kind !== 'paperclip');
97
112
 
98
113
  function get(url, cb, extraHeaders = {}) {
99
114
  const client = url.protocol === 'https:' ? https : http;
@@ -117,7 +132,7 @@ function send(res, status, body, type = 'application/json') {
117
132
  res.end(body);
118
133
  }
119
134
 
120
- function serveFeed(res) {
135
+ function serveFeed(res, source) {
121
136
  if (source.kind === 'file') {
122
137
  return fs.readFile(source.file, 'utf8', (err, txt) => {
123
138
  // No hook has fired yet: that is an empty office, not an error.
@@ -140,19 +155,24 @@ const server = http.createServer((req, res) => {
140
155
  if (req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, '{"error":"read-only"}');
141
156
 
142
157
  if (url.pathname === '/config.json') {
143
- const cfg = source.kind === 'paperclip'
144
- ? { source: 'paperclip', paperclipUrl: PAPERCLIP.origin, version: VERSION, build: build() }
145
- : { source: 'feed', label: source.label, version: VERSION, build: build() };
146
- return send(res, 200, JSON.stringify(cfg));
158
+ let n = 0;
159
+ const sources = SOURCES.map((x) => x.kind === 'paperclip'
160
+ ? { kind: 'paperclip', paperclipUrl: PAPERCLIP.origin }
161
+ : { kind: 'feed', label: x.label, path: `/api/feed/${n++}` });
162
+ // `source`/`label`/`paperclipUrl` keep single-source pages from older versions working.
163
+ const first = sources[0];
164
+ return send(res, 200, JSON.stringify({ sources, source: first.kind, label: first.label, paperclipUrl: HAS_PAPERCLIP ? PAPERCLIP.origin : undefined, version: VERSION, build: build() }));
147
165
  }
148
166
 
149
- if (url.pathname === '/api/feed') {
150
- if (source.kind === 'paperclip') return send(res, 404, '{"error":"no feed source configured"}');
151
- return serveFeed(res);
167
+ const feedMatch = url.pathname.match(/^\/api\/feed(?:\/(\d+))?$/);
168
+ if (feedMatch) {
169
+ const feed = FEEDS[Number(feedMatch[1] || 0)];
170
+ if (!feed) return send(res, 404, '{"error":"no such feed source"}');
171
+ return serveFeed(res, feed);
152
172
  }
153
173
 
154
174
  if (url.pathname.startsWith('/api/')) {
155
- if (source.kind !== 'paperclip') return send(res, 403, '{"error":"not allowed"}');
175
+ if (!HAS_PAPERCLIP) return send(res, 403, '{"error":"not allowed"}');
156
176
  if (!ALLOWED.some((re) => re.test(url.pathname))) return send(res, 403, '{"error":"not allowed"}');
157
177
  const upstream = get(new URL(url.pathname, PAPERCLIP), (r) => {
158
178
  res.writeHead(r.statusCode, { 'content-type': 'application/json', 'cache-control': 'no-store' });
@@ -171,11 +191,11 @@ const server = http.createServer((req, res) => {
171
191
  });
172
192
 
173
193
  server.listen(PORT, HOST, () => {
174
- const reading = source.kind === 'paperclip' ? PAPERCLIP.origin : source.kind === 'file' ? source.file : source.url.href;
175
- console.log(`Cubicle is open at http://${HOST}:${PORT} (reading ${reading}${source.kind === 'paperclip' && TOKEN ? ' with an API key' : ''})`);
194
+ const reading = SOURCES.map((x) => x.kind === 'paperclip' ? PAPERCLIP.origin + (TOKEN ? ' (with an API key)' : '') : x.kind === 'file' ? x.file : x.url.href);
195
+ console.log(`Cubicle is open at http://${HOST}:${PORT} (reading ${reading.join(' + ')})`);
176
196
  const loopback = ['127.0.0.1', 'localhost', '::1'].includes(HOST);
177
- if (source.kind === 'paperclip' && TOKEN && !loopback) {
197
+ if (HAS_PAPERCLIP && TOKEN && !loopback) {
178
198
  console.warn(`Warning: bound to ${HOST} with a Paperclip API key. Anyone who can reach this port can read what that key can read.`);
179
199
  }
180
- if (source.kind === 'paperclip') console.log(`No Paperclip yet? Try the demo: http://${HOST}:${PORT}/?demo`);
200
+ if (HAS_PAPERCLIP) console.log(`No Paperclip yet? Try the demo: http://${HOST}:${PORT}/?demo`);
181
201
  });
@@ -5,6 +5,8 @@ After=paperclipai.service
5
5
  [Service]
6
6
  # Adjust both paths: your node binary and your Cubicle clone
7
7
  ExecStart=/usr/bin/node %h/cubicle/bin/cubicle.js
8
+ # Paperclip and Claude Code in one office instead:
9
+ # ExecStart=/usr/bin/node %h/cubicle/bin/cubicle.js --source paperclip,claude-code
8
10
  Environment=CUBICLE_PORT=3200
9
11
  Environment=PAPERCLIP_URL=http://127.0.0.1:3100
10
12
  # Authenticated Paperclip: a chmod 600 file containing the line PAPERCLIP_TOKEN=pcp_...
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caglarutkuguler/cubicle",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "description": "A live pixel-art office for your AI agents — Paperclip, Claude Code, or any JSON feed. Zero dependencies, read-only.",
5
5
  "bin": {
6
6
  "cubicle": "bin/cubicle.js"
package/public/index.html CHANGED
@@ -17,13 +17,21 @@
17
17
  select { background:var(--panel); color:var(--text); border:1px solid var(--line); border-radius:6px; padding:3px 6px; font:inherit; }
18
18
  #stage { position:relative; width:100%; aspect-ratio:352/208; border:2px solid var(--line); border-radius:8px; overflow:hidden; background:#2a2d3d; }
19
19
  canvas { position:absolute; inset:0; width:100%; height:100%; image-rendering:pixelated; }
20
- #labels { position:absolute; inset:0; pointer-events:none; }
20
+ #labels { position:absolute; inset:0; pointer-events:none; font-size:11px; }
21
21
  .lbl { position:absolute; transform:translate(-50%,-100%); display:flex; flex-direction:column; align-items:center; white-space:nowrap; }
22
- .bub { background:#fff; color:#1d1f2b; border-radius:9px; padding:2px 7px; font-size:11px; font-weight:600; margin-bottom:3px; box-shadow:0 1px 0 rgba(0,0,0,.3); max-width:210px; overflow:hidden; text-overflow:ellipsis; }
22
+ .bub { background:#fff; color:#1d1f2b; border-radius:.82em; padding:.18em .64em; font-size:1em; font-weight:600; margin-bottom:.27em; box-shadow:0 1px 0 rgba(0,0,0,.3); max-width:210px; overflow:hidden; text-overflow:ellipsis; }
23
23
  .bub.err { background:#ffdede; color:#8a1111; }
24
24
  .bub.ok { background:#dcffe2; color:#145c24; }
25
25
  .bub.wait { background:#fff1c9; color:#6b4d00; }
26
- .name { background:rgba(15,16,26,.78); color:#fff; font-size:11px; font-weight:600; padding:1px 6px; border-radius:5px; }
26
+ .name { background:rgba(15,16,26,.78); color:#fff; font-size:1em; font-weight:600; padding:.09em .55em; border-radius:.45em; }
27
+ /* ?kiosk: the office fills the screen, for a TV or a second monitor */
28
+ body.kiosk { overflow:hidden; cursor:none; }
29
+ body.kiosk .wrap { max-width:none; padding:0; height:100vh; display:flex; align-items:center; justify-content:center; }
30
+ body.kiosk header { position:fixed; top:0; left:0; right:0; z-index:2; margin:0; padding:1vh 1.4vw; background:linear-gradient(rgba(10,11,18,.8), rgba(10,11,18,0)); }
31
+ body.kiosk h1 { font-size:clamp(16px, 1.5vw, 34px); }
32
+ body.kiosk h1 small, body.kiosk #status { font-size:clamp(12px, 1.05vw, 24px); }
33
+ body.kiosk #stage { width:min(100vw, calc(100vh * 352 / 208)); border:0; border-radius:0; }
34
+ body.kiosk #cards, body.kiosk footer, body.kiosk #banner, body.kiosk #company { display:none !important; }
27
35
  #banner { display:none; margin-bottom:10px; padding:8px 12px; border-radius:8px; background:#2b2f47; border:1px solid var(--line); color:var(--muted); }
28
36
  #cards { display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr)); gap:8px; margin-top:12px; }
29
37
  .card { background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:9px 11px; }
@@ -75,6 +83,7 @@
75
83
  noCompany: 'No company yet.', summary: (n, r) => `${n} agents · ${r} working`,
76
84
  running: 'working', idle: 'idle', error: 'error', paused: 'paused', waiting: 'needs you', noTask: 'no open task assigned',
77
85
  done: '✓ done', errBubble: '⚠ error', waitBubble: '✋ needs you',
86
+ asks: 'waiting for your answer on', needYou: (n) => `${n} need${n === 1 ? 's' : ''} you`,
78
87
  chatter: ['☕', 'coffee break', '…', 'hmm', "what's next?"],
79
88
  hint: 'Working agents sit at their desks and type; idle agents hang out in the lounge. Refreshes every 4 s.',
80
89
  demo: 'Demo mode — fake agents. Remove <code>?demo</code> from the URL to watch your own Paperclip company.',
@@ -87,6 +96,7 @@
87
96
  noCompany: 'Henüz şirket yok.', summary: (n, r) => `${n} ajan · ${r} çalışıyor`,
88
97
  running: 'çalışıyor', idle: 'boşta', error: 'hata', paused: 'duraklatıldı', waiting: 'sizi bekliyor', noTask: 'atanmış açık görev yok',
89
98
  done: '✓ bitirdi', errBubble: '⚠ hata', waitBubble: '✋ sizi bekliyor',
99
+ asks: 'cevabınızı bekliyor:', needYou: (n) => `${n} sizi bekliyor`,
90
100
  chatter: ['☕', 'kahve molası', '…', 'hmm', 'sıradaki iş?'],
91
101
  hint: 'Çalışan ajan masasına oturup yazar; boştaki ajan dinlenme alanında dolaşır. 4 saniyede bir yenilenir.',
92
102
  demo: "Demo modu: sahte ajanlar. Kendi Paperclip şirketinizi izlemek için adresten <code>?demo</code> kısmını kaldırın.",
@@ -94,12 +104,16 @@
94
104
  },
95
105
  };
96
106
  const params = new URLSearchParams(location.search);
97
- const lang = (params.get('lang') || navigator.language || 'en').slice(0, 2).toLowerCase() === 'tr' ? 'tr' : 'en';
107
+ // Any language with a block in STR is picked from ?lang=xx or the browser language; English otherwise.
108
+ const wantLang = (params.get('lang') || navigator.language || 'en').slice(0, 2).toLowerCase();
109
+ const lang = STR[wantLang] ? wantLang : 'en';
98
110
  const L = STR[lang];
99
111
  document.documentElement.lang = lang;
100
112
  // Becomes true in init() as well when the page is served without the Cubicle
101
113
  // proxy behind it (e.g. the hosted demo on GitHub Pages).
102
114
  let DEMO = params.has('demo');
115
+ const KIOSK = params.has('kiosk');
116
+ if (KIOSK) document.body.classList.add('kiosk');
103
117
 
104
118
  // ---------- setup ----------
105
119
  const T = 16, CW = 352, CH = 208;
@@ -131,11 +145,33 @@
131
145
  // ---------- data sources ----------
132
146
  async function getJSON(u) { const r = await fetch(u, { cache: 'no-store' }); if (!r.ok) throw new Error(r.status); return r.json(); }
133
147
 
134
- const live = {
135
- async companies() { return getJSON('/api/companies'); },
136
- async agents(id) { return getJSON(`/api/companies/${id}/agents`); },
137
- async issues(id) { return getJSON(`/api/companies/${id}/issues`); },
138
- };
148
+ // Each provider loads { agents, issues, label } from one source. Several providers can
149
+ // share one office (--source paperclip,claude-code); feed agent ids are namespaced so they
150
+ // never collide with Paperclip ids.
151
+ function paperclipProvider() {
152
+ const p = {
153
+ kind: 'paperclip', label: 'Paperclip',
154
+ async load() {
155
+ if (!company) {
156
+ companies = await getJSON('/api/companies');
157
+ if (!companies.length) return { agents: [], issues: [], label: '', note: L.noCompany };
158
+ const want = params.get('company');
159
+ company = companies.find((c) => c.issuePrefix === want || c.id === want) || companies[0];
160
+ if (companies.length > 1) {
161
+ companySel.hidden = false;
162
+ companySel.innerHTML = companies.map((c) => `<option value="${esc(c.id)}">${esc(c.name)}</option>`).join('');
163
+ companySel.value = company.id;
164
+ }
165
+ }
166
+ const [agents, issues] = await Promise.all([
167
+ getJSON(`/api/companies/${company.id}/agents`), getJSON(`/api/companies/${company.id}/issues`),
168
+ ]);
169
+ p.label = company.name;
170
+ return { agents, issues, label: company.name };
171
+ },
172
+ };
173
+ return p;
174
+ }
139
175
 
140
176
  // Generic feed (docs/FEED.md): { company?, agents: [{ id, name, role?, status, task?, error? }] } or a bare array.
141
177
  const STATUS_ALIASES = {
@@ -145,30 +181,32 @@
145
181
  paused: 'paused', sleeping: 'paused', stopped: 'paused', offline: 'paused',
146
182
  waiting: 'waiting', blocked: 'waiting', needs_input: 'waiting', permission: 'waiting',
147
183
  };
148
- const feed = {
149
- label: '',
150
- async companies() { return [{ id: 'feed', name: feed.label, issuePrefix: '' }]; },
151
- async agents() {
152
- const raw = await getJSON('/api/feed');
153
- const list = Array.isArray(raw) ? raw : Array.isArray(raw?.agents) ? raw.agents : [];
154
- if (raw && raw.company) document.getElementById('co').textContent = raw.company;
155
- return list.map((a, i) => {
156
- const t = a.task;
157
- const task = !t ? null : typeof t === 'string'
158
- ? { identifier: '', title: t, url: a.taskUrl || '' }
159
- : { identifier: String(t.id ?? t.identifier ?? ''), title: t.title ?? '', url: t.url || '' };
160
- return {
161
- id: String(a.id ?? a.name ?? i), name: String(a.name ?? a.id ?? `agent ${i + 1}`),
162
- title: a.role || a.title || '', createdAt: String(a.since ?? a.createdAt ?? i).padStart(20, '0'),
163
- status: STATUS_ALIASES[String(a.status || 'idle').toLowerCase()] || 'idle',
164
- errorReason: a.error || null, task,
165
- };
166
- });
167
- },
168
- async issues() { return []; },
169
- };
184
+ function feedProvider(path, label) {
185
+ const p = {
186
+ kind: 'feed', label, claude: label === 'Claude Code',
187
+ async load() {
188
+ const raw = await getJSON(path);
189
+ const list = Array.isArray(raw) ? raw : Array.isArray(raw?.agents) ? raw.agents : [];
190
+ if (raw && raw.company) p.label = String(raw.company);
191
+ const agents = list.map((a, i) => {
192
+ const t = a.task;
193
+ const task = !t ? null : typeof t === 'string'
194
+ ? { identifier: '', title: t, url: a.taskUrl || '' }
195
+ : { identifier: String(t.id ?? t.identifier ?? ''), title: t.title ?? '', url: t.url || '' };
196
+ return {
197
+ id: `${path}#${a.id ?? a.name ?? i}`, name: String(a.name ?? a.id ?? `agent ${i + 1}`),
198
+ title: a.role || a.title || '', createdAt: String(a.since ?? a.createdAt ?? i).padStart(20, '0'),
199
+ status: STATUS_ALIASES[String(a.status || 'idle').toLowerCase()] || 'idle',
200
+ errorReason: a.error || null, task,
201
+ };
202
+ });
203
+ return { agents, issues: [], label: p.label };
204
+ },
205
+ };
206
+ return p;
207
+ }
170
208
 
171
- const demo = (() => {
209
+ function demoProvider() {
172
210
  const t0 = Date.now();
173
211
  const roster = [
174
212
  ['CEO', 'Chief Executive'], ['WebDev', 'Web Developer'], ['SEO', 'SEO & Content'],
@@ -187,23 +225,30 @@
187
225
  });
188
226
  }
189
227
  return {
190
- async companies() { return [{ id: 'demo', name: 'Demo Co.', issuePrefix: 'DEMO' }]; },
191
- async agents() { tick(); return agents.map((a) => ({ ...a })); },
192
- async issues() {
193
- return agents.map((a, i) => ({ identifier: `DEMO-${i + 1}`, title: tasks[i], status: 'in_progress', assigneeAgentId: a.id }));
228
+ kind: 'demo', label: 'Demo Co.',
229
+ async load() {
230
+ tick();
231
+ if (!company) company = { id: 'demo', name: 'Demo Co.', issuePrefix: 'DEMO' };
232
+ const issues = agents.map((a, i) => ({ identifier: `DEMO-${i + 1}`, title: tasks[i], status: 'in_progress', assigneeAgentId: a.id }));
233
+ return { agents: agents.map((a) => ({ ...a })), issues, label: 'Demo Co.' };
194
234
  },
195
235
  };
196
- })();
236
+ }
197
237
 
198
- let src = live;
238
+ let providers = [];
199
239
 
200
240
  async function init() {
201
241
  const b = document.getElementById('banner');
202
242
  if (!DEMO) {
203
243
  try {
204
244
  const cfg = await getJSON('/config.json');
205
- if (cfg.source === 'feed') { src = feed; feed.label = cfg.label || 'feed'; }
206
- else paperclipUrl = cfg.paperclipUrl || '';
245
+ const list = cfg.sources || [cfg.source === 'feed'
246
+ ? { kind: 'feed', label: cfg.label, path: '/api/feed' }
247
+ : { kind: 'paperclip', paperclipUrl: cfg.paperclipUrl }];
248
+ providers = list.map((x) => {
249
+ if (x.kind === 'paperclip') { paperclipUrl = x.paperclipUrl || ''; return paperclipProvider(); }
250
+ return feedProvider(x.path || '/api/feed', x.label || 'feed');
251
+ });
207
252
  // Reload when the server was updated or restarted.
208
253
  if (cfg.build) setInterval(async () => {
209
254
  try { if ((await getJSON('/config.json')).build !== cfg.build) location.reload(); } catch (_) {}
@@ -216,45 +261,52 @@
216
261
  } else {
217
262
  b.innerHTML = L.demo; b.style.display = 'block';
218
263
  }
219
- if (DEMO) src = demo;
264
+ if (DEMO) providers = [demoProvider()];
220
265
  poll();
221
266
  setInterval(poll, 4000);
222
267
  requestAnimationFrame(frame);
223
268
  }
224
269
 
270
+ function failureText(p, e) {
271
+ const code = Number(e && e.message);
272
+ if (p.kind === 'feed') return L.feedUnreachable;
273
+ return (code === 401 || code === 403) ? L.denied(code) : L.unreachable;
274
+ }
275
+
225
276
  async function poll() {
226
- try {
227
- if (!company) {
228
- companies = await src.companies();
229
- if (!companies.length) { statusEl.textContent = L.noCompany; return; }
230
- const want = params.get('company');
231
- company = companies.find((c) => c.issuePrefix === want || c.id === want) || companies[0];
232
- if (companies.length > 1) {
233
- companySel.hidden = false;
234
- companySel.innerHTML = companies.map((c) => `<option value="${esc(c.id)}">${esc(c.name)}</option>`).join('');
235
- companySel.value = company.id;
236
- }
237
- document.getElementById('co').textContent = company.name;
238
- }
239
- const [agents, issues] = await Promise.all([src.agents(company.id), src.issues(company.id)]);
240
- sync(agents, issues);
241
- if (src === feed) {
242
- const b = document.getElementById('banner');
243
- if (!agents.length) { b.innerHTML = feed.label === 'Claude Code' ? L.emptyClaude : L.empty; b.style.display = 'block'; }
244
- else b.style.display = 'none';
245
- }
246
- const running = agents.filter((a) => a.status === 'running').length;
247
- statusEl.textContent = `${L.summary(agents.length, running)} · ${new Date().toLocaleTimeString(lang)}`;
248
- } catch (e) {
249
- const code = Number(e && e.message);
250
- statusEl.textContent = src === feed ? L.feedUnreachable
251
- : (code === 401 || code === 403) ? L.denied(code) : L.unreachable;
277
+ const results = await Promise.allSettled(providers.map((p) => p.load()));
278
+ const agents = [], issues = [], labels = [], failed = [], notes = [];
279
+ results.forEach((r, i) => {
280
+ if (r.status === 'rejected') { failed.push(i); return; }
281
+ r.value.agents.forEach((a) => { a._src = i; });
282
+ agents.push(...r.value.agents);
283
+ issues.push(...r.value.issues);
284
+ if (r.value.label) labels.push(r.value.label);
285
+ if (r.value.note) notes.push(r.value.note);
286
+ });
287
+ if (failed.length === providers.length) {
288
+ statusEl.textContent = failureText(providers[0], results[0].reason);
289
+ return;
252
290
  }
291
+ document.getElementById('co').textContent = labels.join(' · ');
292
+ sync(agents, issues);
293
+ if (!DEMO) {
294
+ const b = document.getElementById('banner');
295
+ if (!agents.length && providers.some((p) => p.kind === 'feed')) {
296
+ b.innerHTML = providers.some((p) => p.claude) ? L.emptyClaude : L.empty; b.style.display = 'block';
297
+ } else b.style.display = 'none';
298
+ }
299
+ const running = agents.filter((a) => a.status === 'running').length;
300
+ const needYou = [...sprites.values()].filter((x) => x.status === 'waiting' || x.ask).length;
301
+ const down = failed.map((i) => `⚠ ${providers[i].label}: ${failureText(providers[i], results[i].reason)}`);
302
+ statusEl.textContent = [
303
+ notes.length && !agents.length ? notes.join(' ') : L.summary(agents.length, running),
304
+ needYou ? L.needYou(needYou) : '', ...down, new Date().toLocaleTimeString(lang),
305
+ ].filter(Boolean).join(' · ');
253
306
  }
254
307
 
255
308
  companySel.addEventListener('change', () => {
256
309
  company = companies.find((c) => c.id === companySel.value);
257
- document.getElementById('co').textContent = company.name;
258
310
  for (const s of sprites.values()) s.el.remove();
259
311
  sprites.clear();
260
312
  const u = new URL(location.href); u.searchParams.set('company', company.issuePrefix); history.replaceState(null, '', u);
@@ -270,8 +322,20 @@
270
322
  return null;
271
323
  }
272
324
 
325
+ // Paperclip: an open issue whose review is waiting on the board (a pending question or
326
+ // interaction for a human) means the agent needs you, even though Paperclip reports it idle.
327
+ function askingBoard(agentId, issues) {
328
+ for (const i of issues) {
329
+ if (i.assigneeAgentId !== agentId || ['done', 'cancelled'].includes(i.status)) continue;
330
+ const paths = (i.reviewAttention && i.reviewAttention.paths) || [];
331
+ const p = paths.find((x) => /board|user|human/i.test(String(x && x.responder || '')));
332
+ if (p) return { issue: i, reason: String(p.label || '') };
333
+ }
334
+ return null;
335
+ }
336
+
273
337
  function sync(agents, issues) {
274
- const sorted = [...agents].sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
338
+ const sorted = [...agents].sort((a, b) => ((a._src || 0) - (b._src || 0)) || String(a.createdAt).localeCompare(String(b.createdAt)));
275
339
  const seen = new Set();
276
340
  sorted.forEach((a, idx) => {
277
341
  seen.add(a.id);
@@ -300,6 +364,9 @@
300
364
  s.status = a.status;
301
365
  s.error = a.errorReason || '';
302
366
  s.task = a.task !== undefined ? a.task : currentTask(a.id, issues);
367
+ s.waitReason = '';
368
+ s.ask = a.task === undefined && a.status !== 'error' ? askingBoard(a.id, issues) : null;
369
+ if (s.ask && a.status === 'idle') { s.status = 'waiting'; s.task = s.ask.issue; s.waitReason = s.ask.reason; }
303
370
  if (s.prevStatus === 'running' && s.status === 'idle') setBubble(s, L.done, 'ok', 6);
304
371
  s.prevStatus = s.status;
305
372
  s.el.querySelector('.name').textContent = s.name;
@@ -315,7 +382,8 @@
315
382
  function taskLink(t) {
316
383
  const label = esc(t.identifier);
317
384
  if (t.url) return `<a href="${esc(t.url)}" target="_blank" rel="noopener">${label || '↗'}</a>`;
318
- if (!paperclipUrl || DEMO) return label;
385
+ // Only Paperclip issues (which carry assigneeAgentId) link into Paperclip.
386
+ if (!paperclipUrl || DEMO || !company || !('assigneeAgentId' in t)) return label;
319
387
  return `<a href="${esc(paperclipUrl)}/${esc(company.issuePrefix)}/issues/${esc(t.identifier)}" target="_blank" rel="noopener">${label}</a>`;
320
388
  }
321
389
 
@@ -324,7 +392,9 @@
324
392
  cardsEl.innerHTML = list.map((s) => {
325
393
  const t = s.task;
326
394
  const taskHtml = t ? `${taskLink(t)} ${esc(t.title)}` : `<span style="color:var(--muted)">${L.noTask}</span>`;
327
- const err = s.status === 'error' && s.error ? `<div class="st" style="color:#ffb3b8">${esc(s.error)}</div>` : '';
395
+ const err = s.status === 'error' && s.error ? `<div class="st" style="color:#ffb3b8">${esc(s.error)}</div>`
396
+ : s.status === 'waiting' && s.waitReason ? `<div class="st" style="color:#ffe08a">${esc(s.waitReason)}</div>`
397
+ : s.ask ? `<div class="st" style="color:#ffe08a">✋ ${L.asks} ${taskLink(s.ask.issue)} ${esc(s.ask.issue.title)}</div>` : '';
328
398
  return `<div class="card"><div class="top"><span class="sw" style="background:${s.shirt}"></span><b>${esc(s.name)}</b></div>
329
399
  <div class="role">${esc(s.role)}</div>
330
400
  <div class="st"><span class="pill p-${esc(s.status)}">${esc(L[s.status] || s.status)}</span>${taskHtml}</div>${err}</div>`;
@@ -472,14 +542,20 @@
472
542
 
473
543
  function placeLabels(now) {
474
544
  const k = cv.clientWidth / CW;
545
+ // Keep bubbles narrower than the gap between neighbouring desks so they never overlap.
546
+ const bubMax = Math.max(80, Math.floor(3.3 * T * k - 6)) + 'px';
547
+ // In kiosk mode names and bubbles grow with the screen (11px at the default ~3x scale).
548
+ if (KIOSK) labelsEl.style.fontSize = Math.max(11, Math.round(11 * k / 3)) + 'px';
475
549
  for (const s of sprites.values()) {
476
550
  s.el.style.left = (s.x * k) + 'px';
477
551
  s.el.style.top = ((s.y - 20) * k) + 'px';
478
552
  const b = s.el.querySelector('.bub');
553
+ b.style.maxWidth = bubMax;
479
554
  let text = null, cls = '';
480
555
  if (s.bubble && now < s.bubbleUntil) { text = s.bubble; cls = s.bubbleClass; }
481
556
  else if (s.status === 'error' && s.seated) { text = L.errBubble; cls = 'err'; }
482
557
  else if (s.status === 'waiting' && s.seated) { text = L.waitBubble; cls = 'wait'; }
558
+ else if (s.typing && s.ask && Math.floor(now / 3) % 2) { text = '✋ ' + (s.ask.issue.identifier || L.waiting); cls = 'wait'; }
483
559
  else if (s.typing && s.task) text = '⌨ ' + (s.task.identifier || s.task.title);
484
560
  else if (s.status === 'paused') text = 'zZ';
485
561
  if (text) { b.textContent = text; b.className = 'bub ' + cls; b.style.display = ''; }