@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 +9 -1
- package/bin/cubicle.js +36 -21
- package/examples/systemd/cubicle.service +2 -0
- package/package.json +1 -1
- package/public/index.html +105 -67
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,
|
|
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
|
-
// ----------
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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 (
|
|
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 =
|
|
180
|
-
console.log(`Cubicle is open at http://${HOST}:${PORT} (reading ${reading
|
|
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 (
|
|
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 (
|
|
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
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
205
|
-
async
|
|
206
|
-
|
|
207
|
-
|
|
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
|
|
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
|
-
|
|
220
|
-
|
|
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)
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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
|
|