@caglarutkuguler/cubicle 0.3.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/LICENSE +21 -0
- package/README.md +150 -0
- package/bin/cubicle-hook.js +153 -0
- package/bin/cubicle.js +181 -0
- package/docs/FEED.md +59 -0
- package/examples/claude-code/README.md +38 -0
- package/examples/claude-code/settings.json +14 -0
- package/examples/feed.json +9 -0
- package/examples/systemd/cubicle-claude.service +12 -0
- package/examples/systemd/cubicle-update.service +9 -0
- package/examples/systemd/cubicle-update.sh +40 -0
- package/examples/systemd/cubicle-update.timer +10 -0
- package/examples/systemd/cubicle.service +16 -0
- package/package.json +47 -0
- package/public/index.html +512 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Çağlar Utku Güler
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# Cubicle
|
|
2
|
+
|
|
3
|
+
**A live pixel-art office for your AI agents.** Works with [Paperclip](https://github.com/paperclipai/paperclip), [Claude Code](https://claude.com/claude-code), or any tool that can write a small JSON file.
|
|
4
|
+
|
|
5
|
+
**[▶ Live demo](https://caglarutkuguler.github.io/cubicle/?lang=en)** — no install, fake agents.
|
|
6
|
+
|
|
7
|
+
Your agents get a desk. When one starts working it walks over, sits down and starts typing, with its current task floating above its head. When it needs you, it raises a hand. When it finishes it goes back to the lounge for a coffee. If it hits an error, its screen flashes red.
|
|
8
|
+
|
|
9
|
+
[](https://caglarutkuguler.github.io/cubicle/?lang=en)
|
|
10
|
+
|
|
11
|
+
- **Zero dependencies.** One small Node.js file plus one HTML page. No build step, no image assets.
|
|
12
|
+
- **Read-only by design.** Cubicle only ever makes `GET` requests, and only to a short allowlist. It cannot change anything in your agent system.
|
|
13
|
+
- **Not tied to one platform.** Paperclip and Claude Code are built in; anything else plugs in through a [tiny JSON feed](docs/FEED.md).
|
|
14
|
+
- **Runs anywhere Node runs.** `npx`, a clone, a systemd unit, or a browser tab pointed at the hosted demo.
|
|
15
|
+
- **English and Turkish UI**, picked from your browser language or with `?lang=en` / `?lang=tr`.
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
Requires Node.js 18+.
|
|
20
|
+
|
|
21
|
+
**Paperclip** (default; expects Paperclip at `http://127.0.0.1:3100`):
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npx github:caglarutkuguler/cubicle
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**Claude Code** (one character per session, driven by Claude Code's own hooks; details in [`examples/claude-code/`](examples/claude-code/)):
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npx github:caglarutkuguler/cubicle install-hooks # once; backs up ~/.claude/settings.json first
|
|
31
|
+
npx github:caglarutkuguler/cubicle --source claude-code
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
**Anything else** — a JSON file or URL in the [feed format](docs/FEED.md):
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npx github:caglarutkuguler/cubicle --source ./agents.json
|
|
38
|
+
npx github:caglarutkuguler/cubicle --source http://localhost:8080/agents
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Then open <http://127.0.0.1:3200>. Nothing running yet? Open <http://127.0.0.1:3200/?demo>, or the [hosted demo](https://caglarutkuguler.github.io/cubicle/?lang=en).
|
|
42
|
+
|
|
43
|
+
Or from a clone:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
git clone https://github.com/caglarutkuguler/cubicle.git
|
|
47
|
+
cd cubicle
|
|
48
|
+
node bin/cubicle.js # add --source … as above
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Options
|
|
52
|
+
|
|
53
|
+
| Flag | Environment variable | Default |
|
|
54
|
+
| --- | --- | --- |
|
|
55
|
+
| `--port` | `CUBICLE_PORT` | `3200` |
|
|
56
|
+
| `--host` | `CUBICLE_HOST` | `127.0.0.1` |
|
|
57
|
+
| `--source` | `CUBICLE_SOURCE` | `paperclip` — or `claude-code`, a `.json` file, or an `http(s)://` URL |
|
|
58
|
+
| `--paperclip` | `PAPERCLIP_URL` | `http://127.0.0.1:3100` |
|
|
59
|
+
| `--token-file` | `PAPERCLIP_TOKEN` / `PAPERCLIP_TOKEN_FILE` | none — API key for an authenticated Paperclip |
|
|
60
|
+
|
|
61
|
+
URL parameters:
|
|
62
|
+
|
|
63
|
+
- `?company=PREFIX` opens a specific Paperclip company (for example `?company=MEG`). With several companies, a picker also appears in the header.
|
|
64
|
+
- `?lang=en` or `?lang=tr` sets the language.
|
|
65
|
+
- `?demo` shows fake agents.
|
|
66
|
+
|
|
67
|
+
## What the office shows
|
|
68
|
+
|
|
69
|
+
| Status | In the office |
|
|
70
|
+
| --- | --- |
|
|
71
|
+
| `running` | Sits at its desk, monitor on, typing; bubble shows the task |
|
|
72
|
+
| `waiting` | Sits at its desk with a hand up, amber screen — it needs your input or approval |
|
|
73
|
+
| `idle` | Wanders the lounge; says "✓ done" right after finishing a run |
|
|
74
|
+
| `error` | Slumped at its desk, red screen, error text on its card |
|
|
75
|
+
| `paused` | "zZ" |
|
|
76
|
+
|
|
77
|
+
Each card below the office links to the agent's current task when the source provides a link (Paperclip issues do).
|
|
78
|
+
|
|
79
|
+
## Sources
|
|
80
|
+
|
|
81
|
+
**Paperclip.** Cubicle proxies three read-only endpoints (`/api/health`, `/api/companies`, `/api/companies/:id/agents|issues`) and maps Paperclip's agent status and open issues onto the office. Any adapter Paperclip supports — Claude Code, Codex, Cursor, HTTP agents — shows up, because the status comes from Paperclip itself.
|
|
82
|
+
|
|
83
|
+
The default `local_trusted` mode needs no key. For an authenticated or remote Paperclip, give Cubicle an API key; it is sent as `Authorization: Bearer …` on the forwarded requests only:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
PAPERCLIP_URL=https://paperclip.example.com PAPERCLIP_TOKEN=pcp_… npx github:caglarutkuguler/cubicle
|
|
87
|
+
# or keep it in a file only you can read
|
|
88
|
+
npx github:caglarutkuguler/cubicle --paperclip https://paperclip.example.com --token-file ~/.config/cubicle/paperclip-token
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Use a board API key with the narrowest read-only scope Paperclip lets you create; an agent key also works but only sees that agent's company. There is no `--token` flag on purpose, because command-line flags are visible in the process list. With systemd, put `PAPERCLIP_TOKEN=…` in a `chmod 600` file and point `EnvironmentFile=` at it (see `examples/systemd/cubicle.service`).
|
|
92
|
+
|
|
93
|
+
**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).
|
|
94
|
+
|
|
95
|
+
**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).
|
|
96
|
+
|
|
97
|
+
## Run it as a service (Linux / WSL)
|
|
98
|
+
|
|
99
|
+
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.
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
git clone https://github.com/caglarutkuguler/cubicle.git ~/cubicle
|
|
103
|
+
mkdir -p ~/.config/systemd/user
|
|
104
|
+
cp ~/cubicle/examples/systemd/*.service ~/cubicle/examples/systemd/*.timer ~/.config/systemd/user/
|
|
105
|
+
# edit ExecStart in cubicle*.service to point at your node binary (`command -v node`)
|
|
106
|
+
systemctl --user daemon-reload
|
|
107
|
+
systemctl --user enable --now cubicle.service cubicle-claude.service
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
**Stay up to date automatically.** `cubicle-update.timer` fast-forwards the clone every 10 minutes and restarts the running Cubicle services when something changed; open browser tabs reload themselves within a minute. It never touches a clone with local edits.
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
# link instead of copying, so updates to these two units arrive with the clone
|
|
114
|
+
systemctl --user link ~/cubicle/examples/systemd/cubicle-update.service ~/cubicle/examples/systemd/cubicle-update.timer
|
|
115
|
+
systemctl --user enable --now cubicle-update.timer
|
|
116
|
+
journalctl --user -u cubicle-update.service # what it did
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Security notes
|
|
120
|
+
|
|
121
|
+
- Cubicle binds to `127.0.0.1` by default. Keep it that way unless you put it behind your own authentication, because anyone who can reach it can see your agent names, statuses and task titles.
|
|
122
|
+
- Only `GET` and `HEAD` are accepted; everything else gets `405`. In Paperclip mode only the three endpoints above are forwarded and any other `/api/` path gets `403`. In feed mode the only upstream request is a `GET` to the configured file or URL.
|
|
123
|
+
- A Paperclip API key is added by the proxy and never sent to the browser. The browser's own cookies and `Authorization` header are never forwarded upstream. If you bind to anything other than loopback while a key is set, Cubicle prints a warning at startup: everyone who can reach the port can read what the key can read.
|
|
124
|
+
- The Claude Code hook never touches the network and stores only session id, directory name, status and a short summary of the current tool call.
|
|
125
|
+
|
|
126
|
+
## How it works
|
|
127
|
+
|
|
128
|
+
`bin/cubicle.js` serves `public/index.html` and either proxies the allowed Paperclip endpoints or serves the feed at `/api/feed`. The page polls every 4 seconds and draws everything, including characters, furniture and the day/night windows, on a 352×208 canvas with plain rectangles.
|
|
129
|
+
|
|
130
|
+
## Contributing
|
|
131
|
+
|
|
132
|
+
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).
|
|
133
|
+
|
|
134
|
+
## Credits
|
|
135
|
+
|
|
136
|
+
Inspired by the idea behind [Pixel Agents](https://github.com/pixel-agents-hq/pixel-agents) by Pablo De Lucca. Cubicle is an independent implementation and uses no code or assets from it.
|
|
137
|
+
|
|
138
|
+
Cubicle is a community project and is not affiliated with or endorsed by Paperclip Labs or Anthropic.
|
|
139
|
+
|
|
140
|
+
## License
|
|
141
|
+
|
|
142
|
+
[MIT](LICENSE)
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
### Türkçe
|
|
147
|
+
|
|
148
|
+
**Cubicle**, AI ajanlarınızı canlı bir pixel ofiste gösterir: [Paperclip](https://github.com/paperclipai/paperclip) ve Claude Code hazır gelir; başka sistemler küçük bir [JSON feed](docs/FEED.md) ile bağlanır. Çalışan ajan masasına oturup yazar ve başının üstünde görevi görünür; sizi bekleyen ajan elini kaldırır; işi biten ajan dinlenme alanına döner; hata alan ajanın ekranı kırmızı yanar.
|
|
149
|
+
|
|
150
|
+
Kurulum: `npx github:caglarutkuguler/cubicle` (Paperclip) veya `npx github:caglarutkuguler/cubicle --source claude-code` (Claude Code) çalıştırın ve <http://127.0.0.1:3200> adresini açın. Kurmadan denemek için: [canlı demo](https://caglarutkuguler.github.io/cubicle/?lang=tr). Arayüz tarayıcı diline göre Türkçe açılır; `?lang=tr` ile de seçilebilir.
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Cubicle hook for Claude Code.
|
|
3
|
+
// Claude Code runs this on hook events and pipes the event JSON to stdin.
|
|
4
|
+
// It keeps ~/.cubicle/claude-code.json up to date, one agent per session,
|
|
5
|
+
// in the Cubicle feed format (docs/FEED.md). No dependencies, no network.
|
|
6
|
+
//
|
|
7
|
+
// Wire it up with the snippet in examples/claude-code/settings.json, then run:
|
|
8
|
+
// cubicle --source claude-code
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const os = require('os');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
const DIR = path.join(os.homedir(), '.cubicle');
|
|
16
|
+
const FILE = path.join(DIR, 'claude-code.json');
|
|
17
|
+
const STALE_MS = 12 * 60 * 60 * 1000; // drop sessions with no event for 12 h
|
|
18
|
+
|
|
19
|
+
function summarize(ev) {
|
|
20
|
+
const tool = ev.tool_name || '';
|
|
21
|
+
const inp = ev.tool_input || {};
|
|
22
|
+
const short = (s, n = 48) => { s = String(s || '').replace(/\s+/g, ' ').trim(); return s.length > n ? s.slice(0, n - 1) + '…' : s; };
|
|
23
|
+
if (inp.file_path) return `${tool} ${path.basename(inp.file_path)}`;
|
|
24
|
+
if (inp.command) return `${tool}: ${short(inp.command)}`;
|
|
25
|
+
if (inp.description) return `${tool}: ${short(inp.description)}`;
|
|
26
|
+
if (inp.pattern) return `${tool} ${short(inp.pattern, 30)}`;
|
|
27
|
+
if (inp.query) return `${tool}: ${short(inp.query)}`;
|
|
28
|
+
return tool || 'working';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function apply(feed, ev) {
|
|
32
|
+
const id = ev.session_id || 'unknown';
|
|
33
|
+
const now = Date.now();
|
|
34
|
+
const agents = feed.agents.filter((a) => now - (a.updated || 0) < STALE_MS);
|
|
35
|
+
let a = agents.find((x) => x.id === id);
|
|
36
|
+
const name = path.basename(ev.cwd || '') || 'claude';
|
|
37
|
+
|
|
38
|
+
switch (ev.hook_event_name) {
|
|
39
|
+
case 'SessionEnd':
|
|
40
|
+
return { ...feed, agents: agents.filter((x) => x.id !== id) };
|
|
41
|
+
case 'SessionStart':
|
|
42
|
+
a = a || { id, since: now };
|
|
43
|
+
Object.assign(a, { name, role: 'Claude Code', status: 'idle', task: null, error: null });
|
|
44
|
+
break;
|
|
45
|
+
case 'UserPromptSubmit':
|
|
46
|
+
a = a || { id, since: now, name, role: 'Claude Code' };
|
|
47
|
+
Object.assign(a, { status: 'running', task: summarizePrompt(ev.prompt), error: null });
|
|
48
|
+
break;
|
|
49
|
+
case 'PreToolUse':
|
|
50
|
+
case 'PostToolUse':
|
|
51
|
+
case 'PreCompact':
|
|
52
|
+
a = a || { id, since: now, name, role: 'Claude Code' };
|
|
53
|
+
Object.assign(a, { status: 'running', task: ev.hook_event_name === 'PreCompact' ? 'compacting context' : summarize(ev), error: null });
|
|
54
|
+
break;
|
|
55
|
+
case 'PermissionRequest':
|
|
56
|
+
a = a || { id, since: now, name, role: 'Claude Code' };
|
|
57
|
+
Object.assign(a, { status: 'waiting', task: ev.tool_name ? `allow ${summarize(ev)}?` : 'needs your permission' });
|
|
58
|
+
break;
|
|
59
|
+
case 'Notification':
|
|
60
|
+
// Only notifications that ask the user for something raise a hand.
|
|
61
|
+
// idle_prompt (~60 s after a turn), auth_success, agent_completed, quota_* etc. are ignored.
|
|
62
|
+
if (!needsUser(ev)) return feed;
|
|
63
|
+
a = a || { id, since: now, name, role: 'Claude Code' };
|
|
64
|
+
Object.assign(a, { status: 'waiting', task: ev.message ? String(ev.message).slice(0, 80) : 'needs your input' });
|
|
65
|
+
break;
|
|
66
|
+
case 'StopFailure':
|
|
67
|
+
a = a || { id, since: now, name, role: 'Claude Code' };
|
|
68
|
+
Object.assign(a, { status: 'error', error: String(ev.error || ev.message || 'turn failed').slice(0, 120) });
|
|
69
|
+
break;
|
|
70
|
+
case 'Stop':
|
|
71
|
+
a = a || { id, since: now, name, role: 'Claude Code' };
|
|
72
|
+
Object.assign(a, { status: 'idle', task: null });
|
|
73
|
+
break;
|
|
74
|
+
default:
|
|
75
|
+
return feed; // SubagentStop etc.: nothing to draw
|
|
76
|
+
}
|
|
77
|
+
a.updated = now;
|
|
78
|
+
if (!agents.includes(a)) agents.push(a);
|
|
79
|
+
return { ...feed, agents };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const ASKS_USER = new Set(['permission_prompt', 'agent_needs_input', 'elicitation_dialog', 'elicitation_url_dialog']);
|
|
83
|
+
function needsUser(ev) {
|
|
84
|
+
if (ev.notification_type) return ASKS_USER.has(ev.notification_type);
|
|
85
|
+
// Older Claude Code versions send no type; fall back to the message text.
|
|
86
|
+
const m = String(ev.message || '');
|
|
87
|
+
return /permission|needs your|approve/i.test(m) && !/waiting for your input/i.test(m);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function summarizePrompt(p) {
|
|
91
|
+
p = String(p || '').replace(/\s+/g, ' ').trim();
|
|
92
|
+
return p ? (p.length > 60 ? p.slice(0, 59) + '…' : p) : 'thinking';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function main() {
|
|
96
|
+
let input = '';
|
|
97
|
+
process.stdin.setEncoding('utf8');
|
|
98
|
+
process.stdin.on('data', (d) => { input += d; });
|
|
99
|
+
process.stdin.on('end', () => {
|
|
100
|
+
let ev;
|
|
101
|
+
try { ev = JSON.parse(input || '{}'); } catch (_) { return; }
|
|
102
|
+
// A visualisation must never get in Claude's way: swallow every error, exit 0.
|
|
103
|
+
try {
|
|
104
|
+
let feed = { company: 'Claude Code', agents: [] };
|
|
105
|
+
try { const cur = JSON.parse(fs.readFileSync(FILE, 'utf8')); if (Array.isArray(cur.agents)) feed = cur; } catch (_) {}
|
|
106
|
+
feed = apply(feed, ev);
|
|
107
|
+
fs.mkdirSync(DIR, { recursive: true });
|
|
108
|
+
const tmp = `${FILE}.${process.pid}.tmp`;
|
|
109
|
+
fs.writeFileSync(tmp, JSON.stringify(feed));
|
|
110
|
+
fs.renameSync(tmp, FILE); // atomic on the same filesystem
|
|
111
|
+
} catch (_) {}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---------- install / uninstall into Claude Code settings ----------
|
|
116
|
+
const EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PermissionRequest', 'PostToolUse',
|
|
117
|
+
'Notification', 'PreCompact', 'Stop', 'StopFailure', 'SessionEnd'];
|
|
118
|
+
const isOurs = (h) => /cubicle-hook\.js|cubicle"? hook\b/.test(String(h && h.command));
|
|
119
|
+
|
|
120
|
+
function install({ uninstall = false, settingsFile = path.join(os.homedir(), '.claude', 'settings.json') } = {}) {
|
|
121
|
+
let settings = {};
|
|
122
|
+
let existed = false;
|
|
123
|
+
try { settings = JSON.parse(fs.readFileSync(settingsFile, 'utf8')); existed = true; }
|
|
124
|
+
catch (e) { if (e.code !== 'ENOENT') throw new Error(`cannot parse ${settingsFile}: ${e.message}`); }
|
|
125
|
+
|
|
126
|
+
const hooks = settings.hooks || {};
|
|
127
|
+
// Drop any earlier Cubicle entries so the result is the same however often this runs.
|
|
128
|
+
for (const ev of Object.keys(hooks)) {
|
|
129
|
+
hooks[ev] = (hooks[ev] || [])
|
|
130
|
+
.map((grp) => ({ ...grp, hooks: (grp.hooks || []).filter((h) => !isOurs(h)) }))
|
|
131
|
+
.filter((grp) => grp.hooks.length);
|
|
132
|
+
if (!hooks[ev].length) delete hooks[ev];
|
|
133
|
+
}
|
|
134
|
+
if (!uninstall) {
|
|
135
|
+
const command = `node "${path.resolve(__filename)}"`;
|
|
136
|
+
for (const ev of EVENTS) (hooks[ev] = hooks[ev] || []).push({ hooks: [{ type: 'command', command, timeout: 5 }] });
|
|
137
|
+
}
|
|
138
|
+
if (Object.keys(hooks).length) settings.hooks = hooks; else delete settings.hooks;
|
|
139
|
+
|
|
140
|
+
fs.mkdirSync(path.dirname(settingsFile), { recursive: true });
|
|
141
|
+
if (existed) {
|
|
142
|
+
const backup = `${settingsFile}.bak-cubicle-${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
|
143
|
+
fs.copyFileSync(settingsFile, backup);
|
|
144
|
+
console.log(`Backed up ${settingsFile} to ${backup}`);
|
|
145
|
+
}
|
|
146
|
+
fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + '\n');
|
|
147
|
+
console.log(uninstall
|
|
148
|
+
? `Removed Cubicle hooks from ${settingsFile}.`
|
|
149
|
+
: `Added Cubicle hooks for ${EVENTS.length} events to ${settingsFile}.\nStart the office with: cubicle --source claude-code (then open http://127.0.0.1:3200)\nRunning Claude Code sessions pick the hooks up after a restart.`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (require.main === module) main();
|
|
153
|
+
module.exports = { apply, summarize, main, install, EVENTS };
|
package/bin/cubicle.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Cubicle — a live pixel-art office for your AI agents.
|
|
3
|
+
// A tiny, dependency-free, read-only proxy + static page.
|
|
4
|
+
//
|
|
5
|
+
// Sources:
|
|
6
|
+
// paperclip (default) read agents/issues from a Paperclip server
|
|
7
|
+
// claude-code read the feed written by examples/claude-code/cubicle-hook.js
|
|
8
|
+
// <file.json> read a Cubicle feed from a JSON file (see docs/FEED.md)
|
|
9
|
+
// <http(s)://url> read a Cubicle feed from a URL (GET only)
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const http = require('http');
|
|
13
|
+
const https = require('https');
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
|
|
18
|
+
function arg(name) {
|
|
19
|
+
const i = process.argv.indexOf(`--${name}`);
|
|
20
|
+
return i > -1 ? process.argv[i + 1] : undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// `cubicle hook` — used from Claude Code hooks; see examples/claude-code/.
|
|
24
|
+
if (process.argv[2] === 'hook') {
|
|
25
|
+
require('./cubicle-hook.js').main();
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
// `cubicle install-hooks [--uninstall]` — add/remove the Claude Code hooks in ~/.claude/settings.json.
|
|
29
|
+
if (process.argv[2] === 'install-hooks') {
|
|
30
|
+
try { require('./cubicle-hook.js').install({ uninstall: process.argv.includes('--uninstall') }); }
|
|
31
|
+
catch (e) { console.error(e.message); process.exitCode = 1; }
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
36
|
+
console.log(`Cubicle — a live pixel-art office for your AI agents
|
|
37
|
+
|
|
38
|
+
Usage: cubicle [--port 3200] [--host 127.0.0.1] [--source paperclip] [--paperclip http://127.0.0.1:3100] [--token-file FILE]
|
|
39
|
+
cubicle install-hooks [--uninstall] add/remove the Claude Code hooks in ~/.claude/settings.json
|
|
40
|
+
|
|
41
|
+
Sources (--source / CUBICLE_SOURCE):
|
|
42
|
+
paperclip Paperclip server, default (see --paperclip / PAPERCLIP_URL)
|
|
43
|
+
claude-code Claude Code sessions, via the hook in examples/claude-code/
|
|
44
|
+
./agents.json Any JSON file in the Cubicle feed format (docs/FEED.md)
|
|
45
|
+
http://host/feed Any URL returning the Cubicle feed format
|
|
46
|
+
|
|
47
|
+
Environment variables:
|
|
48
|
+
CUBICLE_PORT Port to listen on (default 3200)
|
|
49
|
+
CUBICLE_HOST Interface to bind (default 127.0.0.1)
|
|
50
|
+
CUBICLE_SOURCE Source, as above (default paperclip)
|
|
51
|
+
PAPERCLIP_URL Paperclip server URL (default http://127.0.0.1:3100)
|
|
52
|
+
PAPERCLIP_TOKEN API key for an authenticated Paperclip, sent as "Authorization: Bearer …"
|
|
53
|
+
(or put it in a file and pass --token-file / PAPERCLIP_TOKEN_FILE).
|
|
54
|
+
There is deliberately no --token flag: flags show up in the process list.
|
|
55
|
+
`);
|
|
56
|
+
process.exit(0);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const PORT = Number(arg('port') || process.env.CUBICLE_PORT || 3200);
|
|
60
|
+
const HOST = arg('host') || process.env.CUBICLE_HOST || '127.0.0.1';
|
|
61
|
+
const PAPERCLIP = new URL(arg('paperclip') || process.env.PAPERCLIP_URL || 'http://127.0.0.1:3100');
|
|
62
|
+
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
|
|
63
|
+
const VERSION = require('../package.json').version;
|
|
64
|
+
const STARTED = Date.now();
|
|
65
|
+
// Changes when the server restarts or index.html changes on disk; open pages
|
|
66
|
+
// compare it and reload themselves, so a wall display picks up updates.
|
|
67
|
+
function build() {
|
|
68
|
+
let mtime = 0;
|
|
69
|
+
try { mtime = fs.statSync(path.join(PUBLIC_DIR, 'index.html')).mtimeMs; } catch (_) {}
|
|
70
|
+
return `${VERSION}-${STARTED}-${Math.round(mtime)}`;
|
|
71
|
+
}
|
|
72
|
+
const CLAUDE_CODE_FEED = path.join(os.homedir(), '.cubicle', 'claude-code.json');
|
|
73
|
+
|
|
74
|
+
// ---------- Paperclip credentials ----------
|
|
75
|
+
// Added by the proxy only; never sent to the browser, never taken from the browser.
|
|
76
|
+
function readToken() {
|
|
77
|
+
const file = arg('token-file') || process.env.PAPERCLIP_TOKEN_FILE;
|
|
78
|
+
if (file) {
|
|
79
|
+
try { return fs.readFileSync(file, 'utf8').trim(); }
|
|
80
|
+
catch (e) { console.error(`Cannot read token file ${file}: ${e.message}`); process.exit(1); }
|
|
81
|
+
}
|
|
82
|
+
return (process.env.PAPERCLIP_TOKEN || '').trim();
|
|
83
|
+
}
|
|
84
|
+
const TOKEN = readToken();
|
|
85
|
+
if (TOKEN && !/^[\x21-\x7e]+$/.test(TOKEN)) {
|
|
86
|
+
console.error('The Paperclip API key contains spaces or control characters; check PAPERCLIP_TOKEN / the token file.');
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
|
|
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) };
|
|
97
|
+
|
|
98
|
+
function get(url, cb, extraHeaders = {}) {
|
|
99
|
+
const client = url.protocol === 'https:' ? https : http;
|
|
100
|
+
const req = client.get(
|
|
101
|
+
{ protocol: url.protocol, hostname: url.hostname, port: url.port, path: url.pathname + url.search, headers: { accept: 'application/json', ...extraHeaders }, timeout: 10000 },
|
|
102
|
+
cb
|
|
103
|
+
);
|
|
104
|
+
req.on('timeout', () => req.destroy(new Error('timeout')));
|
|
105
|
+
return req;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Only these read-only Paperclip endpoints are ever forwarded.
|
|
109
|
+
const ALLOWED = [
|
|
110
|
+
/^\/api\/health$/,
|
|
111
|
+
/^\/api\/companies$/,
|
|
112
|
+
/^\/api\/companies\/[\w-]+\/(agents|issues)$/,
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
function send(res, status, body, type = 'application/json') {
|
|
116
|
+
res.writeHead(status, { 'content-type': type, 'cache-control': 'no-store' });
|
|
117
|
+
res.end(body);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function serveFeed(res) {
|
|
121
|
+
if (source.kind === 'file') {
|
|
122
|
+
return fs.readFile(source.file, 'utf8', (err, txt) => {
|
|
123
|
+
// No hook has fired yet: that is an empty office, not an error.
|
|
124
|
+
if (err && err.code === 'ENOENT' && source.emptyIfMissing) return send(res, 200, JSON.stringify({ company: source.label, agents: [] }));
|
|
125
|
+
if (err) return send(res, err.code === 'ENOENT' ? 404 : 500, JSON.stringify({ error: `cannot read ${source.file}` }));
|
|
126
|
+
try { JSON.parse(txt); } catch (_) { return send(res, 502, '{"error":"feed is not valid JSON"}'); }
|
|
127
|
+
send(res, 200, txt);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
const upstream = get(source.url, (r) => {
|
|
131
|
+
res.writeHead(r.statusCode, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
132
|
+
r.pipe(res);
|
|
133
|
+
});
|
|
134
|
+
upstream.on('error', () => send(res, 502, '{"error":"feed unreachable"}'));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const server = http.createServer((req, res) => {
|
|
138
|
+
const url = new URL(req.url, 'http://localhost');
|
|
139
|
+
|
|
140
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, '{"error":"read-only"}');
|
|
141
|
+
|
|
142
|
+
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));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (url.pathname === '/api/feed') {
|
|
150
|
+
if (source.kind === 'paperclip') return send(res, 404, '{"error":"no feed source configured"}');
|
|
151
|
+
return serveFeed(res);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (url.pathname.startsWith('/api/')) {
|
|
155
|
+
if (source.kind !== 'paperclip') return send(res, 403, '{"error":"not allowed"}');
|
|
156
|
+
if (!ALLOWED.some((re) => re.test(url.pathname))) return send(res, 403, '{"error":"not allowed"}');
|
|
157
|
+
const upstream = get(new URL(url.pathname, PAPERCLIP), (r) => {
|
|
158
|
+
res.writeHead(r.statusCode, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
159
|
+
r.pipe(res);
|
|
160
|
+
}, TOKEN ? { authorization: `Bearer ${TOKEN}` } : {});
|
|
161
|
+
upstream.on('error', () => send(res, 502, '{"error":"paperclip unreachable"}'));
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (url.pathname === '/' || url.pathname === '/index.html') {
|
|
166
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
167
|
+
return fs.createReadStream(path.join(PUBLIC_DIR, 'index.html')).pipe(res);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
send(res, 404, 'Not found', 'text/plain');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
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' : ''})`);
|
|
176
|
+
const loopback = ['127.0.0.1', 'localhost', '::1'].includes(HOST);
|
|
177
|
+
if (source.kind === 'paperclip' && TOKEN && !loopback) {
|
|
178
|
+
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
|
+
}
|
|
180
|
+
if (source.kind === 'paperclip') console.log(`No Paperclip yet? Try the demo: http://${HOST}:${PORT}/?demo`);
|
|
181
|
+
});
|
package/docs/FEED.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Cubicle feed format
|
|
2
|
+
|
|
3
|
+
Cubicle can show agents from any system that can produce this JSON. Point it at a file or a URL:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
cubicle --source ./agents.json
|
|
7
|
+
cubicle --source http://localhost:8080/agents
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
The file is re-read (or the URL re-fetched) every 4 seconds. Only `GET` is ever made; Cubicle never writes to the source.
|
|
11
|
+
|
|
12
|
+
## Shape
|
|
13
|
+
|
|
14
|
+
```json
|
|
15
|
+
{
|
|
16
|
+
"company": "Acme Bots",
|
|
17
|
+
"agents": [
|
|
18
|
+
{ "id": "writer", "name": "Writer", "role": "Copywriter", "status": "running", "task": { "id": "DOC-12", "title": "Draft launch post", "url": "https://example.com/DOC-12" } },
|
|
19
|
+
{ "id": "qa", "name": "QA", "status": "waiting", "task": "Approve deploy to staging?" },
|
|
20
|
+
{ "id": "scraper", "name": "Scraper", "status": "error", "error": "HTTP 429 from target site" },
|
|
21
|
+
{ "id": "intern", "name": "Intern", "status": "idle" }
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
A bare array of agents is accepted too.
|
|
27
|
+
|
|
28
|
+
| Field | Required | Notes |
|
|
29
|
+
| --- | --- | --- |
|
|
30
|
+
| `id` | yes | Stable identifier; the character keeps its look and desk while the id stays the same. Falls back to `name`. |
|
|
31
|
+
| `name` | yes | Shown above the character and on the card. |
|
|
32
|
+
| `role` | no | Shown on the card. |
|
|
33
|
+
| `status` | yes | One of `running`, `idle`, `error`, `paused`, `waiting`. Aliases: `working`/`busy`/`active` → running; `done`/`finished` → idle; `failed` → error; `sleeping`/`stopped`/`offline` → paused; `blocked`/`needs_input`/`permission` → waiting. Unknown values count as idle. |
|
|
34
|
+
| `task` | no | A string, or `{ "id", "title", "url" }`. Shown in the bubble while typing and on the card; `url` makes it a link. |
|
|
35
|
+
| `error` | no | Text shown on the card when `status` is `error`. |
|
|
36
|
+
| `since` | no | Creation timestamp (any sortable value). Decides desk order; defaults to array order. |
|
|
37
|
+
| `company` | no | Top-level; shown in the header. |
|
|
38
|
+
|
|
39
|
+
## Status → behaviour
|
|
40
|
+
|
|
41
|
+
| Status | In the office |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| `running` | At the desk, monitor on, typing; bubble shows the task |
|
|
44
|
+
| `waiting` | At the desk, hand up, amber blinking monitor; bubble "✋ needs you" |
|
|
45
|
+
| `error` | Slumped at the desk, red flashing monitor; error text on the card |
|
|
46
|
+
| `idle` | Wanders the lounge; "✓ done" right after a run |
|
|
47
|
+
| `paused` | In the lounge, "zZ" |
|
|
48
|
+
|
|
49
|
+
## Writing a feed from a script
|
|
50
|
+
|
|
51
|
+
Any language works. Write to a temp file and rename it so Cubicle never reads a half-written file:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
import json, os
|
|
55
|
+
feed = {"company": "My crew", "agents": [{"id": "a1", "name": "Alpha", "status": "running", "task": "Indexing"}]}
|
|
56
|
+
with open("agents.json.tmp", "w") as f:
|
|
57
|
+
json.dump(feed, f)
|
|
58
|
+
os.replace("agents.json.tmp", "agents.json")
|
|
59
|
+
```
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Cubicle for Claude Code
|
|
2
|
+
|
|
3
|
+
Every Claude Code session becomes one character in the office. It sits down and types while Claude is calling tools, raises a hand when Claude is waiting for your permission or input, and heads to the lounge when the turn is over.
|
|
4
|
+
|
|
5
|
+
No daemon, no sockets: Claude Code's own hooks run a small script that rewrites `~/.cubicle/claude-code.json`, and Cubicle reads that file.
|
|
6
|
+
|
|
7
|
+
## Setup
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
git clone https://github.com/caglarutkuguler/cubicle.git ~/cubicle
|
|
11
|
+
node ~/cubicle/bin/cubicle.js install-hooks # adds the hooks to ~/.claude/settings.json (backup first)
|
|
12
|
+
node ~/cubicle/bin/cubicle.js --source claude-code # then open http://127.0.0.1:3200
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`install-hooks` keeps everything else in your settings, can be run again safely, and `install-hooks --uninstall` removes only Cubicle's entries. Restart running Claude Code sessions so they load the hooks.
|
|
16
|
+
|
|
17
|
+
Prefer to edit by hand? Merge [`settings.json`](settings.json) from this folder into `~/.claude/settings.json` (user-wide) or `.claude/settings.json` (one project), replacing `/path/to/cubicle`.
|
|
18
|
+
|
|
19
|
+
To keep it running in the background next to a Paperclip office, see [`../systemd/`](../systemd/): `cubicle-claude.service` serves the Claude Code office on port 3201.
|
|
20
|
+
|
|
21
|
+
## What maps to what
|
|
22
|
+
|
|
23
|
+
| Claude Code event | In the office |
|
|
24
|
+
| --- | --- |
|
|
25
|
+
| `UserPromptSubmit`, `PreToolUse`, `PostToolUse` | Sits at the desk and types; the bubble shows the tool and file/command |
|
|
26
|
+
| `PermissionRequest`, `Notification` of type `permission_prompt` / `agent_needs_input` / `elicitation_*` | Stays at the desk with a hand up, amber screen |
|
|
27
|
+
| `Stop` | Says "✓ done" and goes to the lounge |
|
|
28
|
+
| `StopFailure` | Red screen, error on the card |
|
|
29
|
+
| `SessionEnd` | Leaves the office |
|
|
30
|
+
|
|
31
|
+
Other notifications — the "waiting for your input" reminder after a minute of idling, auth and quota messages — are ignored, so an idle session stays in the lounge.
|
|
32
|
+
|
|
33
|
+
The character is named after the working directory, so two sessions in different repos are easy to tell apart. Sessions with no events for 12 hours are dropped.
|
|
34
|
+
|
|
35
|
+
## Cost and safety
|
|
36
|
+
|
|
37
|
+
- Each hook is one short `node` process (tens of milliseconds) with a 5 s timeout. It never exits with code 2, so it can never block a tool call, and it prints nothing, so it adds nothing to Claude's context.
|
|
38
|
+
- The hook writes only: session id, directory name, status, and a short summary of the current tool call (tool name plus file name or the first ~50 characters of a command or prompt). Nothing leaves the machine.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"SessionStart": [{ "hooks": [{ "type": "command", "command": "node \"/path/to/cubicle/bin/cubicle-hook.js\"", "timeout": 5 }] }],
|
|
4
|
+
"UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "node \"/path/to/cubicle/bin/cubicle-hook.js\"", "timeout": 5 }] }],
|
|
5
|
+
"PreToolUse": [{ "hooks": [{ "type": "command", "command": "node \"/path/to/cubicle/bin/cubicle-hook.js\"", "timeout": 5 }] }],
|
|
6
|
+
"PermissionRequest": [{ "hooks": [{ "type": "command", "command": "node \"/path/to/cubicle/bin/cubicle-hook.js\"", "timeout": 5 }] }],
|
|
7
|
+
"PostToolUse": [{ "hooks": [{ "type": "command", "command": "node \"/path/to/cubicle/bin/cubicle-hook.js\"", "timeout": 5 }] }],
|
|
8
|
+
"Notification": [{ "hooks": [{ "type": "command", "command": "node \"/path/to/cubicle/bin/cubicle-hook.js\"", "timeout": 5 }] }],
|
|
9
|
+
"PreCompact": [{ "hooks": [{ "type": "command", "command": "node \"/path/to/cubicle/bin/cubicle-hook.js\"", "timeout": 5 }] }],
|
|
10
|
+
"Stop": [{ "hooks": [{ "type": "command", "command": "node \"/path/to/cubicle/bin/cubicle-hook.js\"", "timeout": 5 }] }],
|
|
11
|
+
"StopFailure": [{ "hooks": [{ "type": "command", "command": "node \"/path/to/cubicle/bin/cubicle-hook.js\"", "timeout": 5 }] }],
|
|
12
|
+
"SessionEnd": [{ "hooks": [{ "type": "command", "command": "node \"/path/to/cubicle/bin/cubicle-hook.js\"", "timeout": 5 }] }]
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"company": "Acme Bots",
|
|
3
|
+
"agents": [
|
|
4
|
+
{ "id": "writer", "name": "Writer", "role": "Copywriter", "status": "running", "task": { "id": "DOC-12", "title": "Draft launch post", "url": "https://example.com/DOC-12" } },
|
|
5
|
+
{ "id": "qa", "name": "QA", "role": "Release", "status": "waiting", "task": "Approve deploy to staging?" },
|
|
6
|
+
{ "id": "scraper", "name": "Scraper", "role": "Data", "status": "error", "error": "HTTP 429 from target site" },
|
|
7
|
+
{ "id": "intern", "name": "Intern", "status": "idle" }
|
|
8
|
+
]
|
|
9
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=Cubicle - live pixel office for Claude Code sessions
|
|
3
|
+
|
|
4
|
+
[Service]
|
|
5
|
+
# Adjust both paths: your node binary and your Cubicle clone
|
|
6
|
+
ExecStart=/usr/bin/node %h/cubicle/bin/cubicle.js --source claude-code
|
|
7
|
+
Environment=CUBICLE_PORT=3201
|
|
8
|
+
Restart=always
|
|
9
|
+
RestartSec=3
|
|
10
|
+
|
|
11
|
+
[Install]
|
|
12
|
+
WantedBy=default.target
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=Update the Cubicle clone and restart Cubicle services if it changed
|
|
3
|
+
After=network-online.target
|
|
4
|
+
|
|
5
|
+
[Service]
|
|
6
|
+
Type=oneshot
|
|
7
|
+
# Adjust the path if your clone is not in ~/cubicle
|
|
8
|
+
Environment=CUBICLE_DIR=%h/cubicle
|
|
9
|
+
ExecStart=/bin/bash %h/cubicle/examples/systemd/cubicle-update.sh
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Keep a Cubicle clone up to date and restart its running services when it changes.
|
|
3
|
+
# Run by cubicle-update.timer; safe to run by hand.
|
|
4
|
+
#
|
|
5
|
+
# - Only fast-forwards. If the clone has local edits or has diverged, it does nothing.
|
|
6
|
+
# - Restarts every active cubicle*.service (except the updater itself) after an update.
|
|
7
|
+
# Open browser tabs notice the restart and reload themselves within a minute.
|
|
8
|
+
set -euo pipefail
|
|
9
|
+
|
|
10
|
+
DIR="${CUBICLE_DIR:-$HOME/cubicle}"
|
|
11
|
+
cd "$DIR"
|
|
12
|
+
|
|
13
|
+
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
|
|
14
|
+
echo "cubicle-update: local changes in $DIR, skipping"
|
|
15
|
+
exit 0
|
|
16
|
+
fi
|
|
17
|
+
|
|
18
|
+
branch="$(git rev-parse --abbrev-ref HEAD)"
|
|
19
|
+
before="$(git rev-parse HEAD)"
|
|
20
|
+
git fetch --quiet origin "$branch"
|
|
21
|
+
if ! git merge --ff-only --quiet "origin/$branch" 2>/dev/null; then
|
|
22
|
+
echo "cubicle-update: cannot fast-forward $branch, skipping"
|
|
23
|
+
exit 0
|
|
24
|
+
fi
|
|
25
|
+
after="$(git rev-parse HEAD)"
|
|
26
|
+
[ "$before" = "$after" ] && exit 0
|
|
27
|
+
|
|
28
|
+
echo "cubicle-update: $before -> $after"
|
|
29
|
+
git --no-pager log --oneline "$before..$after"
|
|
30
|
+
|
|
31
|
+
# Unit files symlinked from this clone may have changed too.
|
|
32
|
+
systemctl --user daemon-reload
|
|
33
|
+
|
|
34
|
+
systemctl --user list-units --type=service --state=active --no-legend --plain 'cubicle*.service' \
|
|
35
|
+
| awk '{print $1}' \
|
|
36
|
+
| grep -v '^cubicle-update\.service$' \
|
|
37
|
+
| while read -r unit; do
|
|
38
|
+
echo "cubicle-update: restarting $unit"
|
|
39
|
+
systemctl --user restart "$unit"
|
|
40
|
+
done
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=Cubicle - live pixel office for Paperclip agents
|
|
3
|
+
After=paperclipai.service
|
|
4
|
+
|
|
5
|
+
[Service]
|
|
6
|
+
# Adjust both paths: your node binary and your Cubicle clone
|
|
7
|
+
ExecStart=/usr/bin/node %h/cubicle/bin/cubicle.js
|
|
8
|
+
Environment=CUBICLE_PORT=3200
|
|
9
|
+
Environment=PAPERCLIP_URL=http://127.0.0.1:3100
|
|
10
|
+
# Authenticated Paperclip: a chmod 600 file containing the line PAPERCLIP_TOKEN=pcp_...
|
|
11
|
+
# EnvironmentFile=%h/.config/cubicle/env
|
|
12
|
+
Restart=always
|
|
13
|
+
RestartSec=3
|
|
14
|
+
|
|
15
|
+
[Install]
|
|
16
|
+
WantedBy=default.target
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@caglarutkuguler/cubicle",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "A live pixel-art office for your AI agents — Paperclip, Claude Code, or any JSON feed. Zero dependencies, read-only.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"cubicle": "bin/cubicle.js"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"start": "node bin/cubicle.js",
|
|
10
|
+
"demo": "node bin/cubicle.js & sleep 1 && echo 'Open http://127.0.0.1:3200/?demo'",
|
|
11
|
+
"test": "node test/smoke.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin",
|
|
15
|
+
"public",
|
|
16
|
+
"docs/FEED.md",
|
|
17
|
+
"examples"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=18"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"ai-agents",
|
|
24
|
+
"agent-orchestration",
|
|
25
|
+
"paperclip",
|
|
26
|
+
"claude-code",
|
|
27
|
+
"codex",
|
|
28
|
+
"cursor",
|
|
29
|
+
"pixel-art",
|
|
30
|
+
"dashboard",
|
|
31
|
+
"visualization",
|
|
32
|
+
"office"
|
|
33
|
+
],
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/caglarutkuguler/cubicle.git"
|
|
37
|
+
},
|
|
38
|
+
"author": "Çağlar Utku Güler",
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"homepage": "https://caglarutkuguler.github.io/cubicle/",
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/caglarutkuguler/cubicle/issues"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Cubicle</title>
|
|
7
|
+
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%23222536'/%3E%3Crect x='3' y='3' width='10' height='6' fill='%235cc8f0'/%3E%3Crect x='2' y='11' width='12' height='2' fill='%238b5a2b'/%3E%3C/svg%3E">
|
|
8
|
+
<style>
|
|
9
|
+
:root { --bg:#171926; --panel:#222536; --line:#33374d; --text:#e9e7f2; --muted:#9a9bb3; --link:#9ec5ff; }
|
|
10
|
+
* { box-sizing: border-box; }
|
|
11
|
+
body { margin:0; background:var(--bg); color:var(--text); font:13px/1.45 system-ui, "Segoe UI", sans-serif; }
|
|
12
|
+
.wrap { max-width:1180px; margin:0 auto; padding:14px 16px 28px; }
|
|
13
|
+
header { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; margin-bottom:10px; }
|
|
14
|
+
h1 { font-size:17px; margin:0; letter-spacing:.2px; }
|
|
15
|
+
h1 small { color:var(--muted); font-weight:500; margin-left:8px; }
|
|
16
|
+
.right { display:flex; align-items:center; gap:10px; color:var(--muted); }
|
|
17
|
+
select { background:var(--panel); color:var(--text); border:1px solid var(--line); border-radius:6px; padding:3px 6px; font:inherit; }
|
|
18
|
+
#stage { position:relative; width:100%; aspect-ratio:352/208; border:2px solid var(--line); border-radius:8px; overflow:hidden; background:#2a2d3d; }
|
|
19
|
+
canvas { position:absolute; inset:0; width:100%; height:100%; image-rendering:pixelated; }
|
|
20
|
+
#labels { position:absolute; inset:0; pointer-events:none; }
|
|
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; }
|
|
23
|
+
.bub.err { background:#ffdede; color:#8a1111; }
|
|
24
|
+
.bub.ok { background:#dcffe2; color:#145c24; }
|
|
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; }
|
|
27
|
+
#banner { display:none; margin-bottom:10px; padding:8px 12px; border-radius:8px; background:#2b2f47; border:1px solid var(--line); color:var(--muted); }
|
|
28
|
+
#cards { display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr)); gap:8px; margin-top:12px; }
|
|
29
|
+
.card { background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:9px 11px; }
|
|
30
|
+
.card .top { display:flex; align-items:center; gap:8px; }
|
|
31
|
+
.sw { width:12px; height:12px; border-radius:3px; flex:none; }
|
|
32
|
+
.card b { font-size:13px; }
|
|
33
|
+
.card .role { color:var(--muted); font-size:12px; }
|
|
34
|
+
.card .st { margin-top:4px; font-size:12px; }
|
|
35
|
+
a { color:var(--link); text-decoration:none; }
|
|
36
|
+
a:hover { text-decoration:underline; }
|
|
37
|
+
.pill { display:inline-block; font-size:11px; padding:0 6px; border-radius:9px; margin-right:6px; }
|
|
38
|
+
.p-running { background:#1f5130; color:#9ff0b4; }
|
|
39
|
+
.p-idle { background:#33374d; color:#c9cbe0; }
|
|
40
|
+
.p-error { background:#5a1f25; color:#ffb3b8; }
|
|
41
|
+
.p-paused { background:#4d4221; color:#f3dc92; }
|
|
42
|
+
.p-waiting { background:#5a4a12; color:#ffe08a; }
|
|
43
|
+
footer { color:var(--muted); font-size:12px; margin-top:12px; display:flex; justify-content:space-between; gap:12px; flex-wrap:wrap; }
|
|
44
|
+
</style>
|
|
45
|
+
</head>
|
|
46
|
+
<body>
|
|
47
|
+
<div class="wrap">
|
|
48
|
+
<header>
|
|
49
|
+
<h1>Cubicle <small id="co"></small></h1>
|
|
50
|
+
<div class="right">
|
|
51
|
+
<select id="company" hidden></select>
|
|
52
|
+
<span id="status"></span>
|
|
53
|
+
</div>
|
|
54
|
+
</header>
|
|
55
|
+
<div id="banner"></div>
|
|
56
|
+
<div id="stage">
|
|
57
|
+
<canvas id="c" width="352" height="208"></canvas>
|
|
58
|
+
<div id="labels"></div>
|
|
59
|
+
</div>
|
|
60
|
+
<div id="cards"></div>
|
|
61
|
+
<footer>
|
|
62
|
+
<span id="hint"></span>
|
|
63
|
+
<span><a href="https://github.com/caglarutkuguler/cubicle" target="_blank" rel="noopener">Cubicle</a> · MIT</span>
|
|
64
|
+
</footer>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<script>
|
|
68
|
+
(() => {
|
|
69
|
+
// ---------- i18n ----------
|
|
70
|
+
const STR = {
|
|
71
|
+
en: {
|
|
72
|
+
connecting: 'Connecting…', unreachable: "Can't reach Paperclip, retrying…",
|
|
73
|
+
denied: (c) => `Paperclip refused access (${c}). Set PAPERCLIP_TOKEN for an authenticated Paperclip, or check the key.`, feedUnreachable: "Can't read the agent feed, retrying…",
|
|
74
|
+
empty: 'No agents yet.', emptyClaude: 'No Claude Code sessions yet — start <code>claude</code> in any folder and it will walk in.',
|
|
75
|
+
noCompany: 'No company yet.', summary: (n, r) => `${n} agents · ${r} working`,
|
|
76
|
+
running: 'working', idle: 'idle', error: 'error', paused: 'paused', waiting: 'needs you', noTask: 'no open task assigned',
|
|
77
|
+
done: '✓ done', errBubble: '⚠ error', waitBubble: '✋ needs you',
|
|
78
|
+
chatter: ['☕', 'coffee break', '…', 'hmm', "what's next?"],
|
|
79
|
+
hint: 'Working agents sit at their desks and type; idle agents hang out in the lounge. Refreshes every 4 s.',
|
|
80
|
+
demo: 'Demo mode — fake agents. Remove <code>?demo</code> from the URL to watch your own Paperclip company.',
|
|
81
|
+
hosted: 'This is the hosted demo with fake agents. To watch your own Paperclip company, run <code>npx github:caglarutkuguler/cubicle</code> and open <code>http://127.0.0.1:3200</code>.',
|
|
82
|
+
},
|
|
83
|
+
tr: {
|
|
84
|
+
connecting: 'Bağlanılıyor…', unreachable: "Paperclip'e ulaşılamıyor, yeniden deneniyor…",
|
|
85
|
+
denied: (c) => `Paperclip erişimi reddetti (${c}). Kimlik doğrulamalı Paperclip için PAPERCLIP_TOKEN tanımlayın veya anahtarı kontrol edin.`, feedUnreachable: 'Ajan verisi okunamıyor, yeniden deneniyor…',
|
|
86
|
+
empty: 'Henüz ajan yok.', emptyClaude: 'Henüz Claude Code oturumu yok. Herhangi bir klasörde <code>claude</code> başlatın, ofise girecek.',
|
|
87
|
+
noCompany: 'Henüz şirket yok.', summary: (n, r) => `${n} ajan · ${r} çalışıyor`,
|
|
88
|
+
running: 'çalışıyor', idle: 'boşta', error: 'hata', paused: 'duraklatıldı', waiting: 'sizi bekliyor', noTask: 'atanmış açık görev yok',
|
|
89
|
+
done: '✓ bitirdi', errBubble: '⚠ hata', waitBubble: '✋ sizi bekliyor',
|
|
90
|
+
chatter: ['☕', 'kahve molası', '…', 'hmm', 'sıradaki iş?'],
|
|
91
|
+
hint: 'Çalışan ajan masasına oturup yazar; boştaki ajan dinlenme alanında dolaşır. 4 saniyede bir yenilenir.',
|
|
92
|
+
demo: "Demo modu: sahte ajanlar. Kendi Paperclip şirketinizi izlemek için adresten <code>?demo</code> kısmını kaldırın.",
|
|
93
|
+
hosted: "Bu, sahte ajanlarla çalışan barındırılan demo. Kendi Paperclip şirketinizi izlemek için <code>npx github:caglarutkuguler/cubicle</code> çalıştırıp <code>http://127.0.0.1:3200</code> adresini açın.",
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
const params = new URLSearchParams(location.search);
|
|
97
|
+
const lang = (params.get('lang') || navigator.language || 'en').slice(0, 2).toLowerCase() === 'tr' ? 'tr' : 'en';
|
|
98
|
+
const L = STR[lang];
|
|
99
|
+
document.documentElement.lang = lang;
|
|
100
|
+
// Becomes true in init() as well when the page is served without the Cubicle
|
|
101
|
+
// proxy behind it (e.g. the hosted demo on GitHub Pages).
|
|
102
|
+
let DEMO = params.has('demo');
|
|
103
|
+
|
|
104
|
+
// ---------- setup ----------
|
|
105
|
+
const T = 16, CW = 352, CH = 208;
|
|
106
|
+
const cv = document.getElementById('c');
|
|
107
|
+
const g = cv.getContext('2d');
|
|
108
|
+
g.imageSmoothingEnabled = false;
|
|
109
|
+
const labelsEl = document.getElementById('labels');
|
|
110
|
+
const cardsEl = document.getElementById('cards');
|
|
111
|
+
const statusEl = document.getElementById('status');
|
|
112
|
+
const companySel = document.getElementById('company');
|
|
113
|
+
document.getElementById('hint').textContent = L.hint;
|
|
114
|
+
statusEl.textContent = L.connecting;
|
|
115
|
+
|
|
116
|
+
// Office layout, in tiles
|
|
117
|
+
const DESKS = [[1,3],[4.5,3],[8,3],[11.5,3],[1,7.5],[4.5,7.5],[8,7.5],[11.5,7.5],[1,10.5],[4.5,10.5],[8,10.5],[11.5,10.5]];
|
|
118
|
+
const LOUNGE = [[16,4.4],[18,4.4],[20,4.4],[16.2,10],[18.3,10.6],[20.3,10],[15.8,7.4],[21,7.4],[17,12],[19.5,12],[16.4,5.6],[19.6,5.6]];
|
|
119
|
+
const SHIRTS = ['#e05d5d','#4f9dde','#57b86b','#e0a33b','#a66de0','#3bc4c4','#e07ab8','#9aa0b4','#f07b3f','#6fcf97','#c9b458','#5f7ee0'];
|
|
120
|
+
const HAIRS = ['#2b1d14','#111111','#6b3e1f','#c9a04a','#8b4513','#3b2a20','#d9d0c1','#4a2b1a'];
|
|
121
|
+
const SKINS = ['#f1c7a3','#d9a47a','#b87b52','#f5d5b8','#c68e62'];
|
|
122
|
+
|
|
123
|
+
let paperclipUrl = '';
|
|
124
|
+
let companies = [];
|
|
125
|
+
let company = null;
|
|
126
|
+
const sprites = new Map();
|
|
127
|
+
|
|
128
|
+
const hash = (s) => { let h = 0; for (const ch of s) h = (h * 31 + ch.charCodeAt(0)) >>> 0; return h; };
|
|
129
|
+
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
|
130
|
+
|
|
131
|
+
// ---------- data sources ----------
|
|
132
|
+
async function getJSON(u) { const r = await fetch(u, { cache: 'no-store' }); if (!r.ok) throw new Error(r.status); return r.json(); }
|
|
133
|
+
|
|
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
|
+
};
|
|
139
|
+
|
|
140
|
+
// Generic feed (docs/FEED.md): { company?, agents: [{ id, name, role?, status, task?, error? }] } or a bare array.
|
|
141
|
+
const STATUS_ALIASES = {
|
|
142
|
+
running: 'running', working: 'running', busy: 'running', active: 'running',
|
|
143
|
+
idle: 'idle', done: 'idle', finished: 'idle', ready: 'idle',
|
|
144
|
+
error: 'error', failed: 'error', crashed: 'error',
|
|
145
|
+
paused: 'paused', sleeping: 'paused', stopped: 'paused', offline: 'paused',
|
|
146
|
+
waiting: 'waiting', blocked: 'waiting', needs_input: 'waiting', permission: 'waiting',
|
|
147
|
+
};
|
|
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
|
+
};
|
|
170
|
+
|
|
171
|
+
const demo = (() => {
|
|
172
|
+
const t0 = Date.now();
|
|
173
|
+
const roster = [
|
|
174
|
+
['CEO', 'Chief Executive'], ['WebDev', 'Web Developer'], ['SEO', 'SEO & Content'],
|
|
175
|
+
['Growth', 'Growth Marketer'], ['Analyst', 'Functional Analyst'], ['QA', 'QA & Refactoring'],
|
|
176
|
+
];
|
|
177
|
+
const tasks = ['Audit site health', 'Keyword research', 'Qualified leads shortlist', 'Review checkout flow', 'Refactor module', 'Write test checklist'];
|
|
178
|
+
const agents = roster.map(([name, title], i) => ({
|
|
179
|
+
id: `demo-${i}`, name, title, createdAt: `2026-01-01T00:0${i}:00Z`, status: 'idle', errorReason: null,
|
|
180
|
+
}));
|
|
181
|
+
function tick() {
|
|
182
|
+
const s = Math.floor((Date.now() - t0) / 1000);
|
|
183
|
+
agents.forEach((a, i) => {
|
|
184
|
+
const phase = Math.floor((s + i * 7) / (9 + i * 2)) % 5;
|
|
185
|
+
a.status = phase === 4 && i === 2 ? 'error' : phase === 3 && i === 3 ? 'waiting' : phase % 2 === 0 ? 'running' : 'idle';
|
|
186
|
+
a.errorReason = a.status === 'error' ? 'Example error: connection lost' : null;
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
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 }));
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
})();
|
|
197
|
+
|
|
198
|
+
let src = live;
|
|
199
|
+
|
|
200
|
+
async function init() {
|
|
201
|
+
const b = document.getElementById('banner');
|
|
202
|
+
if (!DEMO) {
|
|
203
|
+
try {
|
|
204
|
+
const cfg = await getJSON('/config.json');
|
|
205
|
+
if (cfg.source === 'feed') { src = feed; feed.label = cfg.label || 'feed'; }
|
|
206
|
+
else paperclipUrl = cfg.paperclipUrl || '';
|
|
207
|
+
// Reload when the server was updated or restarted.
|
|
208
|
+
if (cfg.build) setInterval(async () => {
|
|
209
|
+
try { if ((await getJSON('/config.json')).build !== cfg.build) location.reload(); } catch (_) {}
|
|
210
|
+
}, 60000);
|
|
211
|
+
} catch (_) {
|
|
212
|
+
// No Cubicle server behind this page: show the demo instead of an error.
|
|
213
|
+
DEMO = true;
|
|
214
|
+
b.innerHTML = L.hosted; b.style.display = 'block';
|
|
215
|
+
}
|
|
216
|
+
} else {
|
|
217
|
+
b.innerHTML = L.demo; b.style.display = 'block';
|
|
218
|
+
}
|
|
219
|
+
if (DEMO) src = demo;
|
|
220
|
+
poll();
|
|
221
|
+
setInterval(poll, 4000);
|
|
222
|
+
requestAnimationFrame(frame);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
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;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
companySel.addEventListener('change', () => {
|
|
256
|
+
company = companies.find((c) => c.id === companySel.value);
|
|
257
|
+
document.getElementById('co').textContent = company.name;
|
|
258
|
+
for (const s of sprites.values()) s.el.remove();
|
|
259
|
+
sprites.clear();
|
|
260
|
+
const u = new URL(location.href); u.searchParams.set('company', company.issuePrefix); history.replaceState(null, '', u);
|
|
261
|
+
poll();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
function currentTask(agentId, issues) {
|
|
265
|
+
const mine = issues.filter((i) => i.assigneeAgentId === agentId);
|
|
266
|
+
for (const st of ['in_progress', 'in_review', 'blocked', 'todo', 'backlog']) {
|
|
267
|
+
const f = mine.find((i) => i.status === st);
|
|
268
|
+
if (f) return f;
|
|
269
|
+
}
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function sync(agents, issues) {
|
|
274
|
+
const sorted = [...agents].sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
|
|
275
|
+
const seen = new Set();
|
|
276
|
+
sorted.forEach((a, idx) => {
|
|
277
|
+
seen.add(a.id);
|
|
278
|
+
let s = sprites.get(a.id);
|
|
279
|
+
if (!s) {
|
|
280
|
+
const h = hash(a.id);
|
|
281
|
+
const spot = LOUNGE[idx % LOUNGE.length];
|
|
282
|
+
s = {
|
|
283
|
+
id: a.id, idx, seed: (h % 1000) / 100,
|
|
284
|
+
shirt: SHIRTS[idx % SHIRTS.length], hair: HAIRS[h % HAIRS.length], skin: SKINS[(h >> 3) % SKINS.length],
|
|
285
|
+
x: spot[0] * T, y: spot[1] * T,
|
|
286
|
+
lounge: idx % LOUNGE.length, nextWander: performance.now() / 1000 + 5 + (h % 10),
|
|
287
|
+
bubble: null, bubbleClass: '', bubbleUntil: 0, prevStatus: a.status,
|
|
288
|
+
};
|
|
289
|
+
const el = document.createElement('div');
|
|
290
|
+
el.className = 'lbl';
|
|
291
|
+
el.innerHTML = '<div class="bub" style="display:none"></div><div class="name"></div>';
|
|
292
|
+
labelsEl.appendChild(el);
|
|
293
|
+
s.el = el;
|
|
294
|
+
sprites.set(a.id, s);
|
|
295
|
+
}
|
|
296
|
+
s.idx = idx;
|
|
297
|
+
s.desk = DESKS[idx % DESKS.length];
|
|
298
|
+
s.name = a.name;
|
|
299
|
+
s.role = a.title || a.role || '';
|
|
300
|
+
s.status = a.status;
|
|
301
|
+
s.error = a.errorReason || '';
|
|
302
|
+
s.task = a.task !== undefined ? a.task : currentTask(a.id, issues);
|
|
303
|
+
if (s.prevStatus === 'running' && s.status === 'idle') setBubble(s, L.done, 'ok', 6);
|
|
304
|
+
s.prevStatus = s.status;
|
|
305
|
+
s.el.querySelector('.name').textContent = s.name;
|
|
306
|
+
});
|
|
307
|
+
for (const [id, s] of sprites) if (!seen.has(id)) { s.el.remove(); sprites.delete(id); }
|
|
308
|
+
renderCards();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function setBubble(s, text, cls = '', secs = 4) {
|
|
312
|
+
s.bubble = text; s.bubbleClass = cls; s.bubbleUntil = performance.now() / 1000 + secs;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function taskLink(t) {
|
|
316
|
+
const label = esc(t.identifier);
|
|
317
|
+
if (t.url) return `<a href="${esc(t.url)}" target="_blank" rel="noopener">${label || '↗'}</a>`;
|
|
318
|
+
if (!paperclipUrl || DEMO) return label;
|
|
319
|
+
return `<a href="${esc(paperclipUrl)}/${esc(company.issuePrefix)}/issues/${esc(t.identifier)}" target="_blank" rel="noopener">${label}</a>`;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function renderCards() {
|
|
323
|
+
const list = [...sprites.values()].sort((a, b) => a.idx - b.idx);
|
|
324
|
+
cardsEl.innerHTML = list.map((s) => {
|
|
325
|
+
const t = s.task;
|
|
326
|
+
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>` : '';
|
|
328
|
+
return `<div class="card"><div class="top"><span class="sw" style="background:${s.shirt}"></span><b>${esc(s.name)}</b></div>
|
|
329
|
+
<div class="role">${esc(s.role)}</div>
|
|
330
|
+
<div class="st"><span class="pill p-${esc(s.status)}">${esc(L[s.status] || s.status)}</span>${taskHtml}</div>${err}</div>`;
|
|
331
|
+
}).join('');
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ---------- movement ----------
|
|
335
|
+
function targetFor(s, now) {
|
|
336
|
+
if (s.status === 'running' || s.status === 'error' || s.status === 'waiting') return [s.desk[0] * T + 22, s.desk[1] * T + 12, true];
|
|
337
|
+
if (now > s.nextWander) {
|
|
338
|
+
const used = new Set([...sprites.values()].filter((o) => o !== s).map((o) => o.lounge));
|
|
339
|
+
const free = LOUNGE.map((_, i) => i).filter((i) => !used.has(i));
|
|
340
|
+
if (free.length) s.lounge = free[Math.floor(Math.random() * free.length)];
|
|
341
|
+
s.nextWander = now + 9 + Math.random() * 14;
|
|
342
|
+
if (Math.random() < 0.35) setBubble(s, L.chatter[Math.floor(Math.random() * L.chatter.length)], '', 3);
|
|
343
|
+
}
|
|
344
|
+
const p = LOUNGE[s.lounge];
|
|
345
|
+
return [p[0] * T, p[1] * T, false];
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function step(s, dt, now) {
|
|
349
|
+
const [tx, ty, atDesk] = targetFor(s, now);
|
|
350
|
+
// Walk through the doorway between the work area and the lounge
|
|
351
|
+
const inWork = s.x < 14 * T, toWork = tx < 14 * T;
|
|
352
|
+
let gx = tx, gy = ty;
|
|
353
|
+
if (inWork !== toWork) {
|
|
354
|
+
const doorX = 14 * T + 8, doorY = 7 * T;
|
|
355
|
+
if (Math.abs(s.x - doorX) > 3 || Math.abs(s.y - doorY) > 3) { gx = doorX; gy = doorY; }
|
|
356
|
+
else { s.x += inWork ? 6 : -6; }
|
|
357
|
+
}
|
|
358
|
+
const dx = gx - s.x, dy = gy - s.y, d = Math.hypot(dx, dy);
|
|
359
|
+
if (d > 0.8) {
|
|
360
|
+
const m = Math.min(d, 34 * dt);
|
|
361
|
+
s.x += (dx / d) * m; s.y += (dy / d) * m;
|
|
362
|
+
s.walking = true; s.dir = Math.abs(dx) > Math.abs(dy) ? Math.sign(dx) : 0;
|
|
363
|
+
} else {
|
|
364
|
+
s.walking = false;
|
|
365
|
+
}
|
|
366
|
+
s.seated = !s.walking && atDesk && Math.hypot(tx - s.x, ty - s.y) < 1.5;
|
|
367
|
+
s.typing = s.seated && s.status === 'running';
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ---------- drawing ----------
|
|
371
|
+
const rect = (c, x, y, w, h) => { g.fillStyle = c; g.fillRect(Math.round(x), Math.round(y), w, h); };
|
|
372
|
+
|
|
373
|
+
function drawRoom(t) {
|
|
374
|
+
const hr = new Date().getHours();
|
|
375
|
+
const night = hr < 7 || hr >= 20;
|
|
376
|
+
for (let y = 2; y < 13; y++) for (let x = 0; x < 22; x++) {
|
|
377
|
+
const lounge = x >= 15;
|
|
378
|
+
rect((x + y) % 2 ? (lounge ? '#4a3d57' : '#3a3f56') : (lounge ? '#463a52' : '#363b51'), x * T, y * T, T, T);
|
|
379
|
+
}
|
|
380
|
+
rect('#5b4a6b', 16.5 * T, 6.3 * T, 4.6 * T, 3 * T);
|
|
381
|
+
rect('#4b4f6b', 0, 0, CW, 2 * T);
|
|
382
|
+
rect('#3a3d55', 0, 2 * T - 3, CW, 3);
|
|
383
|
+
for (const wx of [1.5, 5.5, 9.5, 16.5]) {
|
|
384
|
+
rect('#2c2f44', wx * T - 1, 5, 34, 18);
|
|
385
|
+
rect(night ? '#1c2a4a' : '#8fc7ff', wx * T, 6, 32, 16);
|
|
386
|
+
if (!night) { const cx = (t * 3 + wx * 11) % 44 - 8; rect('rgba(255,255,255,.85)', wx * T + cx, 11, 8, 3); rect('rgba(255,255,255,.85)', wx * T + cx + 2, 9, 4, 2); }
|
|
387
|
+
else rect('#f5f1c8', wx * T + 24, 9, 3, 3);
|
|
388
|
+
rect('#2c2f44', wx * T + 15, 6, 2, 16);
|
|
389
|
+
}
|
|
390
|
+
// wall clock
|
|
391
|
+
rect('#e9e7f2', 13 * T, 7, 12, 12); rect('#4b4f6b', 13 * T + 1, 8, 10, 10);
|
|
392
|
+
const now = new Date();
|
|
393
|
+
const hand = (a, len) => { g.beginPath(); g.moveTo(13 * T + 6, 13); g.lineTo(13 * T + 6 + Math.sin(a) * len, 13 - Math.cos(a) * len); g.stroke(); };
|
|
394
|
+
g.strokeStyle = '#e9e7f2'; g.lineWidth = 1;
|
|
395
|
+
hand(((now.getHours() % 12) + now.getMinutes() / 60) / 12 * Math.PI * 2, 3);
|
|
396
|
+
hand(now.getMinutes() / 60 * Math.PI * 2, 4);
|
|
397
|
+
// glass partition with a doorway
|
|
398
|
+
rect('rgba(160,200,255,.18)', 14 * T + 6, 2 * T, 4, 4 * T);
|
|
399
|
+
rect('rgba(160,200,255,.18)', 14 * T + 6, 8 * T, 4, 5 * T);
|
|
400
|
+
rect('#6c7090', 14 * T + 6, 2 * T, 4, 2); rect('#6c7090', 14 * T + 6, 8 * T, 4, 2);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function loungeProps(t, list) {
|
|
404
|
+
list.push({ z: 3.6 * T, f: () => {
|
|
405
|
+
rect('#6a3f8f', 15.8 * T, 2.4 * T, 5 * T, 12); rect('#7d4fa6', 15.8 * T, 2.4 * T + 12, 5 * T, 8);
|
|
406
|
+
for (let i = 0; i < 4; i++) rect('#9467bd', 16 * T + i * 19, 2.4 * T + 13, 16, 5);
|
|
407
|
+
}});
|
|
408
|
+
list.push({ z: 3.4 * T, f: () => {
|
|
409
|
+
rect('#3b3e4d', 21.1 * T, 2.2 * T, 12, 20); rect('#23252f', 21.1 * T + 2, 2.2 * T + 8, 8, 6);
|
|
410
|
+
rect(Math.floor(t * 2) % 2 ? '#ff5a5a' : '#8a2a2a', 21.1 * T + 9, 2.2 * T + 3, 2, 2);
|
|
411
|
+
}});
|
|
412
|
+
list.push({ z: 6.9 * T, f: () => {
|
|
413
|
+
rect('#8b5a2b', 17 * T, 6.9 * T, 3.6 * T, 1.6 * T); rect('#6e4520', 17 * T, 8.5 * T, 3.6 * T, 3);
|
|
414
|
+
rect('#e9e7f2', 17.4 * T, 7.2 * T, 10, 7); rect('#e9e7f2', 19.4 * T, 7.4 * T, 8, 6);
|
|
415
|
+
}});
|
|
416
|
+
for (const [px, py] of [[14.9, 2.3], [21.3, 11.6], [0.2, 12.1]]) list.push({ z: (py + 1) * T, f: () => {
|
|
417
|
+
rect('#8b5a2b', px * T + 3, py * T + 10, 8, 6);
|
|
418
|
+
rect('#3f9d55', px * T + 1, py * T, 12, 11); rect('#57b86b', px * T + 3, py * T - 3, 8, 6);
|
|
419
|
+
}});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function drawDesk(d, s, t) {
|
|
423
|
+
const x = d[0] * T, y = d[1] * T;
|
|
424
|
+
const on = s && s.typing;
|
|
425
|
+
const err = s && s.status === 'error' && s.seated;
|
|
426
|
+
const wait = s && s.status === 'waiting' && s.seated;
|
|
427
|
+
rect('#8b5a2b', x, y + 8, 34, 10); rect('#6e4520', x, y + 18, 34, 3);
|
|
428
|
+
rect('#6e4520', x + 1, y + 21, 2, 6); rect('#6e4520', x + 31, y + 21, 2, 6);
|
|
429
|
+
rect('#22252f', x + 3, y - 2, 14, 10);
|
|
430
|
+
rect(err ? (Math.floor(t * 2) % 2 ? '#ff6b6b' : '#a33') : wait ? (Math.floor(t) % 2 ? '#e0a33b' : '#b07d20') : on ? '#5cc8f0' : '#3a4050', x + 4, y - 1, 12, 7);
|
|
431
|
+
if (on) for (let i = 0; i < 3; i++) rect('rgba(255,255,255,.75)', x + 5, y + i * 2, (Math.floor(t * 9 + i * 4) % 9) + 1, 1);
|
|
432
|
+
rect('#22252f', x + 9, y + 8, 2, 1);
|
|
433
|
+
rect('#c9cbd6', x + 17, y + 10, 11, 2);
|
|
434
|
+
if (on) rect('#ffffff', x + 17 + (Math.floor(t * 12) % 10), y + 10, 1, 1);
|
|
435
|
+
rect('#f3f3f3', x + 29, y + 9, 3, 4); rect('#f3f3f3', x + 32, y + 10, 1, 2);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function drawChar(s, t) {
|
|
439
|
+
const x = Math.round(s.x), y = Math.round(s.y);
|
|
440
|
+
const bob = s.walking ? Math.floor(t * 8 + s.seed) % 2 : 0;
|
|
441
|
+
rect('rgba(0,0,0,.25)', x - 4, y - 1, 8, 2);
|
|
442
|
+
if (!s.seated) {
|
|
443
|
+
const ph = s.walking ? Math.floor(t * 8 + s.seed) % 2 : 0;
|
|
444
|
+
rect('#2b2f45', x - 3, y - 5, 2, ph ? 4 : 5);
|
|
445
|
+
rect('#2b2f45', x + 1, y - 5, 2, ph ? 5 : 4);
|
|
446
|
+
}
|
|
447
|
+
rect(s.shirt, x - 4, y - 11 - bob, 8, 6);
|
|
448
|
+
if (s.typing) {
|
|
449
|
+
const a = Math.floor(t * 10 + s.seed) % 2;
|
|
450
|
+
rect(s.skin, x - 5, y - 8 - bob - a, 2, 2);
|
|
451
|
+
rect(s.skin, x + 3, y - 8 - bob - (1 - a), 2, 2);
|
|
452
|
+
} else if (s.status === 'waiting' && s.seated) {
|
|
453
|
+
// one hand up, waving
|
|
454
|
+
const w = Math.floor(t * 3 + s.seed) % 2;
|
|
455
|
+
rect(s.skin, x - 5, y - 10 - bob, 1, 4);
|
|
456
|
+
rect(s.skin, x + 4 + w, y - 19 - bob, 2, 8);
|
|
457
|
+
} else {
|
|
458
|
+
rect(s.skin, x - 5, y - 10 - bob, 1, 4);
|
|
459
|
+
rect(s.skin, x + 4, y - 10 - bob, 1, 4);
|
|
460
|
+
}
|
|
461
|
+
const slump = s.status === 'error' && s.seated ? 1 : 0;
|
|
462
|
+
rect(s.skin, x - 3, y - 17 - bob + slump, 6, 6);
|
|
463
|
+
rect(s.hair, x - 3, y - 18 - bob + slump, 6, 2);
|
|
464
|
+
rect(s.hair, x - 3, y - 17 - bob + slump, 1, 2);
|
|
465
|
+
rect(s.hair, x + 2, y - 17 - bob + slump, 1, 2);
|
|
466
|
+
if (((t + s.seed) % 4) >= 0.14) {
|
|
467
|
+
const ox = s.dir || 0;
|
|
468
|
+
rect('#1a1a1a', x - 2 + ox, y - 14 - bob + slump, 1, 1);
|
|
469
|
+
rect('#1a1a1a', x + 1 + ox, y - 14 - bob + slump, 1, 1);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function placeLabels(now) {
|
|
474
|
+
const k = cv.clientWidth / CW;
|
|
475
|
+
for (const s of sprites.values()) {
|
|
476
|
+
s.el.style.left = (s.x * k) + 'px';
|
|
477
|
+
s.el.style.top = ((s.y - 20) * k) + 'px';
|
|
478
|
+
const b = s.el.querySelector('.bub');
|
|
479
|
+
let text = null, cls = '';
|
|
480
|
+
if (s.bubble && now < s.bubbleUntil) { text = s.bubble; cls = s.bubbleClass; }
|
|
481
|
+
else if (s.status === 'error' && s.seated) { text = L.errBubble; cls = 'err'; }
|
|
482
|
+
else if (s.status === 'waiting' && s.seated) { text = L.waitBubble; cls = 'wait'; }
|
|
483
|
+
else if (s.typing && s.task) text = '⌨ ' + (s.task.identifier || s.task.title);
|
|
484
|
+
else if (s.status === 'paused') text = 'zZ';
|
|
485
|
+
if (text) { b.textContent = text; b.className = 'bub ' + cls; b.style.display = ''; }
|
|
486
|
+
else b.style.display = 'none';
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
let last = performance.now() / 1000;
|
|
491
|
+
function frame() {
|
|
492
|
+
const now = performance.now() / 1000;
|
|
493
|
+
const dt = Math.min(0.1, now - last); last = now;
|
|
494
|
+
g.clearRect(0, 0, CW, CH);
|
|
495
|
+
drawRoom(now);
|
|
496
|
+
const list = [];
|
|
497
|
+
loungeProps(now, list);
|
|
498
|
+
const byDesk = new Map();
|
|
499
|
+
for (const s of sprites.values()) { step(s, dt, now); byDesk.set(s.desk, s); }
|
|
500
|
+
const deskCount = Math.max(4, Math.min(DESKS.length, Math.ceil(sprites.size / 4) * 4));
|
|
501
|
+
DESKS.slice(0, deskCount).forEach((d) => list.push({ z: d[1] * T + 21, f: () => drawDesk(d, byDesk.get(d), now) }));
|
|
502
|
+
for (const s of sprites.values()) list.push({ z: s.y, f: () => drawChar(s, now) });
|
|
503
|
+
list.sort((a, b) => a.z - b.z).forEach((o) => o.f());
|
|
504
|
+
placeLabels(now);
|
|
505
|
+
requestAnimationFrame(frame);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
init();
|
|
509
|
+
})();
|
|
510
|
+
</script>
|
|
511
|
+
</body>
|
|
512
|
+
</html>
|