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