@caglarutkuguler/cubicle 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/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
 
@@ -95,6 +101,8 @@ Use a board API key with the narrowest read-only scope Paperclip lets you create
95
101
 
96
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).
97
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
+
98
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).
99
107
 
100
108
  ## How it compares
package/bin/cubicle.js CHANGED
@@ -43,7 +43,7 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) {
43
43
  Usage: cubicle [--port 3200] [--host 127.0.0.1] [--source paperclip] [--paperclip http://127.0.0.1:3100] [--token-file FILE]
44
44
  cubicle install-hooks [--uninstall] add/remove the Claude Code hooks in ~/.claude/settings.json
45
45
 
46
- Sources (--source / CUBICLE_SOURCE):
46
+ Sources (--source / CUBICLE_SOURCE), one or several comma-separated, e.g. paperclip,claude-code:
47
47
  paperclip Paperclip server, default (see --paperclip / PAPERCLIP_URL)
48
48
  claude-code Claude Code sessions, via the hook in examples/claude-code/
49
49
  ./agents.json Any JSON file in the Cubicle feed format (docs/FEED.md)
@@ -92,13 +92,23 @@ if (TOKEN && !/^[\x21-\x7e]+$/.test(TOKEN)) {
92
92
  process.exit(1);
93
93
  }
94
94
 
95
- // ---------- source ----------
96
- const rawSource = arg('source') || process.env.CUBICLE_SOURCE || 'paperclip';
97
- let source; // { kind: 'paperclip' } | { kind: 'file', file, label } | { kind: 'url', url, label }
98
- if (rawSource === 'paperclip') source = { kind: 'paperclip' };
99
- else if (rawSource === 'claude-code') source = { kind: 'file', file: CLAUDE_CODE_FEED, label: 'Claude Code', emptyIfMissing: true };
100
- else if (/^https?:\/\//.test(rawSource)) source = { kind: 'url', url: new URL(rawSource), label: rawSource };
101
- 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');
102
112
 
103
113
  function get(url, cb, extraHeaders = {}) {
104
114
  const client = url.protocol === 'https:' ? https : http;
@@ -122,7 +132,7 @@ function send(res, status, body, type = 'application/json') {
122
132
  res.end(body);
123
133
  }
124
134
 
125
- function serveFeed(res) {
135
+ function serveFeed(res, source) {
126
136
  if (source.kind === 'file') {
127
137
  return fs.readFile(source.file, 'utf8', (err, txt) => {
128
138
  // No hook has fired yet: that is an empty office, not an error.
@@ -145,19 +155,24 @@ const server = http.createServer((req, res) => {
145
155
  if (req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, '{"error":"read-only"}');
146
156
 
147
157
  if (url.pathname === '/config.json') {
148
- const cfg = source.kind === 'paperclip'
149
- ? { source: 'paperclip', paperclipUrl: PAPERCLIP.origin, version: VERSION, build: build() }
150
- : { source: 'feed', label: source.label, version: VERSION, build: build() };
151
- 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() }));
152
165
  }
153
166
 
154
- if (url.pathname === '/api/feed') {
155
- if (source.kind === 'paperclip') return send(res, 404, '{"error":"no feed source configured"}');
156
- 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);
157
172
  }
158
173
 
159
174
  if (url.pathname.startsWith('/api/')) {
160
- if (source.kind !== 'paperclip') return send(res, 403, '{"error":"not allowed"}');
175
+ if (!HAS_PAPERCLIP) return send(res, 403, '{"error":"not allowed"}');
161
176
  if (!ALLOWED.some((re) => re.test(url.pathname))) return send(res, 403, '{"error":"not allowed"}');
162
177
  const upstream = get(new URL(url.pathname, PAPERCLIP), (r) => {
163
178
  res.writeHead(r.statusCode, { 'content-type': 'application/json', 'cache-control': 'no-store' });
@@ -176,11 +191,11 @@ const server = http.createServer((req, res) => {
176
191
  });
177
192
 
178
193
  server.listen(PORT, HOST, () => {
179
- const reading = source.kind === 'paperclip' ? PAPERCLIP.origin : source.kind === 'file' ? source.file : source.url.href;
180
- 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(' + ')})`);
181
196
  const loopback = ['127.0.0.1', 'localhost', '::1'].includes(HOST);
182
- if (source.kind === 'paperclip' && TOKEN && !loopback) {
197
+ if (HAS_PAPERCLIP && TOKEN && !loopback) {
183
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.`);
184
199
  }
185
- 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`);
186
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.4.0",
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
@@ -145,11 +145,33 @@
145
145
  // ---------- data sources ----------
146
146
  async function getJSON(u) { const r = await fetch(u, { cache: 'no-store' }); if (!r.ok) throw new Error(r.status); return r.json(); }
147
147
 
148
- const live = {
149
- async companies() { return getJSON('/api/companies'); },
150
- async agents(id) { return getJSON(`/api/companies/${id}/agents`); },
151
- async issues(id) { return getJSON(`/api/companies/${id}/issues`); },
152
- };
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
+ }
153
175
 
154
176
  // Generic feed (docs/FEED.md): { company?, agents: [{ id, name, role?, status, task?, error? }] } or a bare array.
155
177
  const STATUS_ALIASES = {
@@ -159,30 +181,32 @@
159
181
  paused: 'paused', sleeping: 'paused', stopped: 'paused', offline: 'paused',
160
182
  waiting: 'waiting', blocked: 'waiting', needs_input: 'waiting', permission: 'waiting',
161
183
  };
162
- const feed = {
163
- label: '',
164
- async companies() { return [{ id: 'feed', name: feed.label, issuePrefix: '' }]; },
165
- async agents() {
166
- const raw = await getJSON('/api/feed');
167
- const list = Array.isArray(raw) ? raw : Array.isArray(raw?.agents) ? raw.agents : [];
168
- if (raw && raw.company) document.getElementById('co').textContent = raw.company;
169
- return list.map((a, i) => {
170
- const t = a.task;
171
- const task = !t ? null : typeof t === 'string'
172
- ? { identifier: '', title: t, url: a.taskUrl || '' }
173
- : { identifier: String(t.id ?? t.identifier ?? ''), title: t.title ?? '', url: t.url || '' };
174
- return {
175
- id: String(a.id ?? a.name ?? i), name: String(a.name ?? a.id ?? `agent ${i + 1}`),
176
- title: a.role || a.title || '', createdAt: String(a.since ?? a.createdAt ?? i).padStart(20, '0'),
177
- status: STATUS_ALIASES[String(a.status || 'idle').toLowerCase()] || 'idle',
178
- errorReason: a.error || null, task,
179
- };
180
- });
181
- },
182
- async issues() { return []; },
183
- };
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
+ }
184
208
 
185
- const demo = (() => {
209
+ function demoProvider() {
186
210
  const t0 = Date.now();
187
211
  const roster = [
188
212
  ['CEO', 'Chief Executive'], ['WebDev', 'Web Developer'], ['SEO', 'SEO & Content'],
@@ -201,23 +225,30 @@
201
225
  });
202
226
  }
203
227
  return {
204
- async companies() { return [{ id: 'demo', name: 'Demo Co.', issuePrefix: 'DEMO' }]; },
205
- async agents() { tick(); return agents.map((a) => ({ ...a })); },
206
- async issues() {
207
- 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.' };
208
234
  },
209
235
  };
210
- })();
236
+ }
211
237
 
212
- let src = live;
238
+ let providers = [];
213
239
 
214
240
  async function init() {
215
241
  const b = document.getElementById('banner');
216
242
  if (!DEMO) {
217
243
  try {
218
244
  const cfg = await getJSON('/config.json');
219
- if (cfg.source === 'feed') { src = feed; feed.label = cfg.label || 'feed'; }
220
- 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
+ });
221
252
  // Reload when the server was updated or restarted.
222
253
  if (cfg.build) setInterval(async () => {
223
254
  try { if ((await getJSON('/config.json')).build !== cfg.build) location.reload(); } catch (_) {}
@@ -230,46 +261,52 @@
230
261
  } else {
231
262
  b.innerHTML = L.demo; b.style.display = 'block';
232
263
  }
233
- if (DEMO) src = demo;
264
+ if (DEMO) providers = [demoProvider()];
234
265
  poll();
235
266
  setInterval(poll, 4000);
236
267
  requestAnimationFrame(frame);
237
268
  }
238
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
+
239
276
  async function poll() {
240
- try {
241
- if (!company) {
242
- companies = await src.companies();
243
- if (!companies.length) { statusEl.textContent = L.noCompany; return; }
244
- const want = params.get('company');
245
- company = companies.find((c) => c.issuePrefix === want || c.id === want) || companies[0];
246
- if (companies.length > 1) {
247
- companySel.hidden = false;
248
- companySel.innerHTML = companies.map((c) => `<option value="${esc(c.id)}">${esc(c.name)}</option>`).join('');
249
- companySel.value = company.id;
250
- }
251
- document.getElementById('co').textContent = company.name;
252
- }
253
- const [agents, issues] = await Promise.all([src.agents(company.id), src.issues(company.id)]);
254
- sync(agents, issues);
255
- if (src === feed) {
256
- const b = document.getElementById('banner');
257
- if (!agents.length) { b.innerHTML = feed.label === 'Claude Code' ? L.emptyClaude : L.empty; b.style.display = 'block'; }
258
- else b.style.display = 'none';
259
- }
260
- const running = agents.filter((a) => a.status === 'running').length;
261
- const needYou = [...sprites.values()].filter((x) => x.status === 'waiting' || x.ask).length;
262
- statusEl.textContent = `${L.summary(agents.length, running)}${needYou ? ' · ' + L.needYou(needYou) : ''} · ${new Date().toLocaleTimeString(lang)}`;
263
- } catch (e) {
264
- const code = Number(e && e.message);
265
- statusEl.textContent = src === feed ? L.feedUnreachable
266
- : (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;
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';
267
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(' · ');
268
306
  }
269
307
 
270
308
  companySel.addEventListener('change', () => {
271
309
  company = companies.find((c) => c.id === companySel.value);
272
- document.getElementById('co').textContent = company.name;
273
310
  for (const s of sprites.values()) s.el.remove();
274
311
  sprites.clear();
275
312
  const u = new URL(location.href); u.searchParams.set('company', company.issuePrefix); history.replaceState(null, '', u);
@@ -298,7 +335,7 @@
298
335
  }
299
336
 
300
337
  function sync(agents, issues) {
301
- 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)));
302
339
  const seen = new Set();
303
340
  sorted.forEach((a, idx) => {
304
341
  seen.add(a.id);
@@ -345,7 +382,8 @@
345
382
  function taskLink(t) {
346
383
  const label = esc(t.identifier);
347
384
  if (t.url) return `<a href="${esc(t.url)}" target="_blank" rel="noopener">${label || '↗'}</a>`;
348
- 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;
349
387
  return `<a href="${esc(paperclipUrl)}/${esc(company.issuePrefix)}/issues/${esc(t.identifier)}" target="_blank" rel="noopener">${label}</a>`;
350
388
  }
351
389