piu-piu-cli 0.1.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/CHANGELOG.md +20 -0
- package/README.md +406 -0
- package/bin/piu-piu.mjs +428 -0
- package/dist/channel.mjs +145 -0
- package/dist/daemon.mjs +1975 -0
- package/dist/office/assets/index-77PZXJ_x.js +5529 -0
- package/dist/office/assets/index-CB3kPPFD.css +1 -0
- package/dist/office/index.html +17 -0
- package/dist/public/debug.html +81 -0
- package/docs/banner.svg +72 -0
- package/package.json +54 -0
- package/scripts/postinstall.mjs +16 -0
package/bin/piu-piu.mjs
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// piu-piu: a 3D office for your Claude Code agents.
|
|
3
|
+
// This command sets it up (hooks, channel, background service) and runs it.
|
|
4
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
5
|
+
import { accessSync, constants, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { createRequire } from 'node:module';
|
|
7
|
+
import { homedir, platform } from 'node:os';
|
|
8
|
+
import { dirname, join, resolve } from 'node:path';
|
|
9
|
+
|
|
10
|
+
const PKG = resolve(import.meta.dirname, '..');
|
|
11
|
+
const VERSION = JSON.parse(readFileSync(join(PKG, 'package.json'), 'utf8')).version;
|
|
12
|
+
const DAEMON = join(PKG, 'dist', 'daemon.mjs');
|
|
13
|
+
const CHANNEL = join(PKG, 'dist', 'channel.mjs');
|
|
14
|
+
const HOME = process.env.PIU_PIU_HOME ?? join(homedir(), '.piu-piu');
|
|
15
|
+
const PORT = Number(process.env.PIU_PIU_PORT ?? 7777);
|
|
16
|
+
const URL = `http://127.0.0.1:${PORT}`;
|
|
17
|
+
const MAC = platform() === 'darwin';
|
|
18
|
+
|
|
19
|
+
const LABEL = 'io.piupiu.daemon';
|
|
20
|
+
const LEGACY_LABEL = 'io.mazestudio.office'; // the service before the rename
|
|
21
|
+
const PLIST = join(homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`);
|
|
22
|
+
const UNIT = join(homedir(), '.config', 'systemd', 'user', 'piu-piu.service');
|
|
23
|
+
const LOG = MAC ? join(homedir(), 'Library', 'Logs', 'piu-piu.log') : join(HOME, 'piu-piu.log');
|
|
24
|
+
const SETTINGS = join(homedir(), '.claude', 'settings.json');
|
|
25
|
+
const HOOK_MARKER = 'piu-piu';
|
|
26
|
+
const LEGACY_MARKER = 'maze-office';
|
|
27
|
+
const HOOK_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Notification', 'Stop', 'SubagentStart', 'SubagentStop', 'SessionEnd'];
|
|
28
|
+
|
|
29
|
+
const c = process.stdout.isTTY ? { b: (s) => `\x1b[1m${s}\x1b[0m`, dim: (s) => `\x1b[2m${s}\x1b[0m`, ok: (s) => `\x1b[32m${s}\x1b[0m`, bad: (s) => `\x1b[31m${s}\x1b[0m`, warn: (s) => `\x1b[33m${s}\x1b[0m` } : { b: String, dim: String, ok: String, bad: String, warn: String };
|
|
30
|
+
const say = (...a) => console.log(...a);
|
|
31
|
+
const fail = (msg) => {
|
|
32
|
+
console.error(c.bad(`✗ ${msg}`));
|
|
33
|
+
process.exit(1);
|
|
34
|
+
};
|
|
35
|
+
const quiet = (cmd, args) => spawnSync(cmd, args, { stdio: 'pipe', encoding: 'utf8' });
|
|
36
|
+
|
|
37
|
+
function claudeBinary() {
|
|
38
|
+
for (const p of [join(homedir(), '.local', 'bin', 'claude'), '/opt/homebrew/bin/claude', '/usr/local/bin/claude']) if (existsSync(p)) return p;
|
|
39
|
+
const which = quiet('which', ['claude']);
|
|
40
|
+
return which.status === 0 ? which.stdout.trim() : undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function state() {
|
|
44
|
+
try {
|
|
45
|
+
const res = await fetch(`${URL}/api/state`, { signal: AbortSignal.timeout(1500) });
|
|
46
|
+
return res.ok ? await res.json() : undefined;
|
|
47
|
+
} catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function waitUp(ms = 15000) {
|
|
53
|
+
for (const end = Date.now() + ms; Date.now() < end; await new Promise((r) => setTimeout(r, 400))) if (await state()) return true;
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Restarting stops agents mid-task: refuse unless nobody is busy (or --force).
|
|
58
|
+
async function guardBusy(action) {
|
|
59
|
+
if (process.argv.includes('--force')) return;
|
|
60
|
+
const s = await state();
|
|
61
|
+
const busy = [...(s?.running ?? []), ...(s?.terminals ?? [])];
|
|
62
|
+
if (busy.length) fail(`${busy.join(', ')} ${busy.length === 1 ? 'is' : 'are'} working in the office (chat or terminal). ${action} would stop them. Try again later, or add --force.`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------- hooks
|
|
66
|
+
|
|
67
|
+
function hooks(install) {
|
|
68
|
+
if (!existsSync(SETTINGS)) {
|
|
69
|
+
mkdirSync(dirname(SETTINGS), { recursive: true });
|
|
70
|
+
writeFileSync(SETTINGS, '{}\n');
|
|
71
|
+
}
|
|
72
|
+
const settings = JSON.parse(readFileSync(SETTINGS, 'utf8'));
|
|
73
|
+
copyFileSync(SETTINGS, `${SETTINGS}.bak-piu-piu`);
|
|
74
|
+
settings.hooks ??= {};
|
|
75
|
+
const ours = (h) => [HOOK_MARKER, LEGACY_MARKER].some((m) => String(h.command ?? '').includes(`# ${m}`));
|
|
76
|
+
for (const event of new Set([...HOOK_EVENTS, ...Object.keys(settings.hooks)])) {
|
|
77
|
+
const groups = (settings.hooks[event] ?? []).map((g) => ({ ...g, hooks: (g.hooks ?? []).filter((h) => !ours(h)) })).filter((g) => g.hooks.length > 0);
|
|
78
|
+
if (install && HOOK_EVENTS.includes(event)) {
|
|
79
|
+
groups.push({
|
|
80
|
+
...(event.endsWith('ToolUse') ? { matcher: '*' } : {}),
|
|
81
|
+
hooks: [
|
|
82
|
+
{
|
|
83
|
+
type: 'command',
|
|
84
|
+
// gives up after 1s so a stopped office never slows Claude Code down;
|
|
85
|
+
// $PPID (the Claude process) lets the office match the session to its channel
|
|
86
|
+
command: `curl -s --max-time 1 -X POST -H 'Content-Type: application/json' -H "X-Claude-Pid: $PPID" --data-binary @- http://127.0.0.1:${PORT}/hooks/${event} >/dev/null 2>&1 || true # ${HOOK_MARKER}`,
|
|
87
|
+
timeout: 2,
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
if (groups.length) settings.hooks[event] = groups;
|
|
93
|
+
else delete settings.hooks[event];
|
|
94
|
+
}
|
|
95
|
+
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
|
96
|
+
writeFileSync(SETTINGS, JSON.stringify(settings, null, 2) + '\n');
|
|
97
|
+
say(c.ok('✓'), install ? `Hooks installed in ${SETTINGS}` : `Hooks removed from ${SETTINGS}`, c.dim('(backup: settings.json.bak-piu-piu)'));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const hooksInstalled = () => {
|
|
101
|
+
try {
|
|
102
|
+
return readFileSync(SETTINGS, 'utf8').includes(`# ${HOOK_MARKER}`);
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------- channel
|
|
109
|
+
|
|
110
|
+
function channel(install) {
|
|
111
|
+
const claude = claudeBinary();
|
|
112
|
+
if (!claude) fail('Claude Code is not installed (claude not found). Install it first: https://docs.claude.com/claude-code');
|
|
113
|
+
for (const name of ['piu-piu', 'maze-office']) quiet(claude, ['mcp', 'remove', '-s', 'user', name]);
|
|
114
|
+
if (!install) return say(c.ok('✓'), 'Channel removed from Claude Code');
|
|
115
|
+
const r = quiet(claude, ['mcp', 'add', '-s', 'user', 'piu-piu', '--', process.execPath, CHANNEL]);
|
|
116
|
+
if (r.status !== 0) fail(`Could not register the channel: ${r.stderr || r.stdout}`);
|
|
117
|
+
say(c.ok('✓'), 'Channel registered with Claude Code. To talk to an open terminal session from the office, start it with:');
|
|
118
|
+
say(` ${c.b('claude --dangerously-load-development-channels server:piu-piu')}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const channelInstalled = () => {
|
|
122
|
+
try {
|
|
123
|
+
return Boolean(JSON.parse(readFileSync(join(homedir(), '.claude.json'), 'utf8')).mcpServers?.['piu-piu']);
|
|
124
|
+
} catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------- service
|
|
130
|
+
|
|
131
|
+
const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<');
|
|
132
|
+
const servicePath = () => [dirname(process.execPath), join(homedir(), '.local', 'bin'), '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin'].join(':');
|
|
133
|
+
const passEnv = () => Object.entries(process.env).filter(([k]) => k.startsWith('PIU_PIU_'));
|
|
134
|
+
|
|
135
|
+
function serviceInstall() {
|
|
136
|
+
if (!existsSync(DAEMON)) fail(`The daemon isn't built (${DAEMON} is missing).`);
|
|
137
|
+
mkdirSync(HOME, { recursive: true, mode: 0o700 });
|
|
138
|
+
if (MAC) {
|
|
139
|
+
const domain = `gui/${process.getuid()}`;
|
|
140
|
+
// the pre-rename service, if this Mac still has it
|
|
141
|
+
quiet('launchctl', ['bootout', `${domain}/${LEGACY_LABEL}`]);
|
|
142
|
+
rmSync(join(homedir(), 'Library', 'LaunchAgents', `${LEGACY_LABEL}.plist`), { force: true });
|
|
143
|
+
quiet('launchctl', ['bootout', `${domain}/${LABEL}`]);
|
|
144
|
+
const env = [['PATH', servicePath()], ['HOME', homedir()], ...passEnv()].map(([k, v]) => ` <key>${esc(k)}</key><string>${esc(v)}</string>`).join('\n');
|
|
145
|
+
mkdirSync(dirname(PLIST), { recursive: true });
|
|
146
|
+
mkdirSync(dirname(LOG), { recursive: true });
|
|
147
|
+
writeFileSync(
|
|
148
|
+
PLIST,
|
|
149
|
+
`<?xml version="1.0" encoding="UTF-8"?>
|
|
150
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
151
|
+
<plist version="1.0">
|
|
152
|
+
<dict>
|
|
153
|
+
<key>Label</key><string>${LABEL}</string>
|
|
154
|
+
<key>ProgramArguments</key>
|
|
155
|
+
<array>
|
|
156
|
+
<string>${esc(process.execPath)}</string>
|
|
157
|
+
<string>${esc(DAEMON)}</string>
|
|
158
|
+
</array>
|
|
159
|
+
<key>WorkingDirectory</key><string>${esc(HOME)}</string>
|
|
160
|
+
<key>EnvironmentVariables</key>
|
|
161
|
+
<dict>
|
|
162
|
+
${env}
|
|
163
|
+
</dict>
|
|
164
|
+
<key>RunAtLoad</key><true/>
|
|
165
|
+
<key>KeepAlive</key><true/>
|
|
166
|
+
<key>ThrottleInterval</key><integer>10</integer>
|
|
167
|
+
<key>StandardOutPath</key><string>${esc(LOG)}</string>
|
|
168
|
+
<key>StandardErrorPath</key><string>${esc(LOG)}</string>
|
|
169
|
+
</dict>
|
|
170
|
+
</plist>
|
|
171
|
+
`,
|
|
172
|
+
);
|
|
173
|
+
const r = quiet('launchctl', ['bootstrap', domain, PLIST]);
|
|
174
|
+
if (r.status !== 0) fail(`launchctl could not start the service: ${r.stderr}`);
|
|
175
|
+
} else {
|
|
176
|
+
if (quiet('systemctl', ['--user', '--version']).status !== 0) fail('No systemd user session here. Run the office in the foreground instead: piu-piu run');
|
|
177
|
+
mkdirSync(dirname(UNIT), { recursive: true });
|
|
178
|
+
const env = [['PATH', servicePath()], ...passEnv()].map(([k, v]) => `Environment="${k}=${v}"`).join('\n');
|
|
179
|
+
writeFileSync(
|
|
180
|
+
UNIT,
|
|
181
|
+
`[Unit]
|
|
182
|
+
Description=piu-piu, a 3D office for your Claude Code agents
|
|
183
|
+
After=network.target
|
|
184
|
+
|
|
185
|
+
[Service]
|
|
186
|
+
ExecStart=${process.execPath} ${DAEMON}
|
|
187
|
+
WorkingDirectory=${HOME}
|
|
188
|
+
${env}
|
|
189
|
+
Restart=always
|
|
190
|
+
RestartSec=5
|
|
191
|
+
StandardOutput=append:${LOG}
|
|
192
|
+
StandardError=append:${LOG}
|
|
193
|
+
|
|
194
|
+
[Install]
|
|
195
|
+
WantedBy=default.target
|
|
196
|
+
`,
|
|
197
|
+
);
|
|
198
|
+
quiet('systemctl', ['--user', 'daemon-reload']);
|
|
199
|
+
const r = quiet('systemctl', ['--user', 'enable', '--now', 'piu-piu.service']);
|
|
200
|
+
if (r.status !== 0) fail(`systemctl could not start the service: ${r.stderr}`);
|
|
201
|
+
}
|
|
202
|
+
say(c.ok('✓'), `Service installed: piu-piu starts at login and restarts if it stops. Logs: ${LOG}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function serviceUninstall() {
|
|
206
|
+
if (MAC) {
|
|
207
|
+
quiet('launchctl', ['bootout', `gui/${process.getuid()}/${LABEL}`]);
|
|
208
|
+
rmSync(PLIST, { force: true });
|
|
209
|
+
} else {
|
|
210
|
+
quiet('systemctl', ['--user', 'disable', '--now', 'piu-piu.service']);
|
|
211
|
+
rmSync(UNIT, { force: true });
|
|
212
|
+
quiet('systemctl', ['--user', 'daemon-reload']);
|
|
213
|
+
}
|
|
214
|
+
say(c.ok('✓'), 'Service removed');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const serviceInstalled = () => existsSync(MAC ? PLIST : UNIT);
|
|
218
|
+
|
|
219
|
+
function serviceRestart() {
|
|
220
|
+
if (MAC) quiet('launchctl', ['kickstart', '-k', `gui/${process.getuid()}/${LABEL}`]);
|
|
221
|
+
else quiet('systemctl', ['--user', 'restart', 'piu-piu.service']);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ---------------------------------------------------------------- commands
|
|
225
|
+
|
|
226
|
+
function open() {
|
|
227
|
+
const r = quiet(MAC ? 'open' : 'xdg-open', [URL]);
|
|
228
|
+
if (r.status !== 0) say(`Open ${c.b(URL)} in your browser.`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function start() {
|
|
232
|
+
if (await state()) return say(c.ok('✓'), `piu-piu is running at ${c.b(URL)}`);
|
|
233
|
+
if (!serviceInstalled()) serviceInstall();
|
|
234
|
+
else if (MAC) quiet('launchctl', ['bootstrap', `gui/${process.getuid()}`, PLIST]);
|
|
235
|
+
else quiet('systemctl', ['--user', 'start', 'piu-piu.service']);
|
|
236
|
+
if (!(await waitUp())) fail(`piu-piu didn't come up. Check the logs: piu-piu logs`);
|
|
237
|
+
say(c.ok('✓'), `piu-piu is running at ${c.b(URL)}`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function stop() {
|
|
241
|
+
await guardBusy('Stopping');
|
|
242
|
+
if (MAC) quiet('launchctl', ['bootout', `gui/${process.getuid()}/${LABEL}`]);
|
|
243
|
+
else quiet('systemctl', ['--user', 'stop', 'piu-piu.service']);
|
|
244
|
+
say(c.ok('✓'), 'piu-piu stopped. Start it again with: piu-piu start');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function restart() {
|
|
248
|
+
await guardBusy('Restarting');
|
|
249
|
+
if (!serviceInstalled()) return start();
|
|
250
|
+
serviceRestart();
|
|
251
|
+
await new Promise((r) => setTimeout(r, 800));
|
|
252
|
+
if (!(await waitUp())) fail('piu-piu did not come back. Check the logs: piu-piu logs');
|
|
253
|
+
say(c.ok('✓'), `piu-piu restarted at ${c.b(URL)}`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function status() {
|
|
257
|
+
const s = await state();
|
|
258
|
+
if (!s) {
|
|
259
|
+
say(c.bad('●'), `piu-piu is not running ${c.dim(`(${URL})`)}. Start it with: piu-piu start`);
|
|
260
|
+
process.exitCode = 1;
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const sessions = s.sessions.filter((x) => !(x.background && x.source !== 'sdk'));
|
|
264
|
+
const count = (st) => sessions.filter((x) => x.status === st).length;
|
|
265
|
+
const runs = (s.subagents ?? []).filter((r) => r.status === 'working');
|
|
266
|
+
say(c.ok('●'), `piu-piu ${VERSION} is running at ${c.b(URL)}`);
|
|
267
|
+
say(` sessions ${sessions.length} open · ${count('working')} working · ${count('waiting')} waiting for you`);
|
|
268
|
+
say(` specialists ${runs.length} working${runs.length ? ` (${runs.map((r) => r.type).join(', ')})` : ''}`);
|
|
269
|
+
say(` approvals ${s.approvals.length} waiting in the inbox`);
|
|
270
|
+
say(` terminals ${s.terminals?.length ?? 0} open`);
|
|
271
|
+
say(` data ${HOME}`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function logs() {
|
|
275
|
+
if (!existsSync(LOG)) fail(`No log yet at ${LOG}`);
|
|
276
|
+
const follow = process.argv.includes('-f') || process.argv.includes('--follow');
|
|
277
|
+
spawn('tail', [follow ? '-f' : '-n', ...(follow ? [] : ['80']), LOG], { stdio: 'inherit' });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function run() {
|
|
281
|
+
if (await state()) fail(`Something is already running at ${URL} (the background service?). Stop it first: piu-piu stop`);
|
|
282
|
+
await import(DAEMON);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function doctor() {
|
|
286
|
+
let ok = true;
|
|
287
|
+
const line = (good, label, hint) => {
|
|
288
|
+
if (!good) ok = false;
|
|
289
|
+
say(good ? c.ok('✓') : c.bad('✗'), label, good || !hint ? '' : c.dim(`→ ${hint}`));
|
|
290
|
+
};
|
|
291
|
+
const [major] = process.versions.node.split('.').map(Number);
|
|
292
|
+
line(major >= 24, `Node.js ${process.versions.node}`, 'piu-piu needs Node.js 24 or newer');
|
|
293
|
+
const claude = claudeBinary();
|
|
294
|
+
const version = claude ? quiet(claude, ['--version']).stdout.trim() : '';
|
|
295
|
+
line(Boolean(claude), claude ? `Claude Code ${version} (${claude})` : 'Claude Code', 'install Claude Code and log in');
|
|
296
|
+
line(existsSync(join(PKG, 'dist', 'office', 'index.html')) && existsSync(DAEMON), 'Office and daemon built', 'reinstall: npm i -g piu-piu-cli');
|
|
297
|
+
let pty = false;
|
|
298
|
+
try {
|
|
299
|
+
const require = createRequire(import.meta.url);
|
|
300
|
+
const prebuilds = join(dirname(require.resolve('node-pty/package.json')), 'prebuilds');
|
|
301
|
+
pty = !existsSync(prebuilds) || readdirSync(prebuilds).every((a) => !existsSync(join(prebuilds, a, 'spawn-helper')) || (accessSync(join(prebuilds, a, 'spawn-helper'), constants.X_OK), true));
|
|
302
|
+
} catch {
|
|
303
|
+
pty = false;
|
|
304
|
+
}
|
|
305
|
+
line(pty, 'Terminal support (node-pty)', MAC ? 'npm i -g piu-piu-cli' : 'install build-essential and python3, then: npm i -g piu-piu-cli --allow-scripts=node-pty');
|
|
306
|
+
line(hooksInstalled(), 'Hooks in ~/.claude/settings.json', 'piu-piu hooks install');
|
|
307
|
+
line(channelInstalled(), 'Channel registered with Claude Code', 'piu-piu channel install');
|
|
308
|
+
line(serviceInstalled(), 'Background service', 'piu-piu start');
|
|
309
|
+
const s = await state();
|
|
310
|
+
line(Boolean(s), `Office answering at ${URL}`, 'piu-piu start, then piu-piu logs if it fails');
|
|
311
|
+
if (!MAC) line(quiet('which', ['lsof']).status === 0, 'lsof (verifies hook and channel connections)', 'apt install lsof');
|
|
312
|
+
say(ok ? c.ok('\nAll good.') : c.warn('\nSome things need attention (see →).'));
|
|
313
|
+
if (!ok) process.exitCode = 1;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function setup() {
|
|
317
|
+
say(c.b(`piu-piu ${VERSION} setup\n`));
|
|
318
|
+
if (!claudeBinary()) fail('Claude Code is not installed (claude not found). Install it first: https://docs.claude.com/claude-code');
|
|
319
|
+
hooks(true);
|
|
320
|
+
channel(true);
|
|
321
|
+
await restartOrStart();
|
|
322
|
+
open();
|
|
323
|
+
say(`\nThe office is at ${c.b(URL)}. Every Claude Code session on this machine shows up there.`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// setup after an upgrade: restart so the new version runs (if nobody is busy)
|
|
327
|
+
async function restartOrStart() {
|
|
328
|
+
const s = await state();
|
|
329
|
+
if (s && [...(s.running ?? []), ...(s.terminals ?? [])].length) {
|
|
330
|
+
return say(c.warn('!'), 'piu-piu is running but agents are busy, so it was not restarted. Run piu-piu restart later to switch to this version.');
|
|
331
|
+
}
|
|
332
|
+
if (!serviceInstalled() || !s) {
|
|
333
|
+
serviceInstall();
|
|
334
|
+
if (!(await waitUp())) fail('piu-piu did not come up. Check the logs: piu-piu logs');
|
|
335
|
+
return say(c.ok('✓'), `piu-piu is running at ${c.b(URL)}`);
|
|
336
|
+
}
|
|
337
|
+
serviceInstall(); // rewrites the service for this install and restarts it
|
|
338
|
+
if (!(await waitUp())) fail('piu-piu did not come up. Check the logs: piu-piu logs');
|
|
339
|
+
say(c.ok('✓'), `piu-piu is running at ${c.b(URL)}`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function uninstall() {
|
|
343
|
+
await guardBusy('Uninstalling');
|
|
344
|
+
serviceUninstall();
|
|
345
|
+
hooks(false);
|
|
346
|
+
channel(false);
|
|
347
|
+
say(`\nRemoved the service, hooks and channel. Your data stays in ${HOME} (delete it to start over).`);
|
|
348
|
+
say(`Remove the command itself with: ${c.b('npm rm -g piu-piu-cli')}`);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const HELP = `${c.b('piu-piu')} ${VERSION}: a 3D office for your Claude Code agents
|
|
352
|
+
|
|
353
|
+
${c.b('Usage')}
|
|
354
|
+
piu-piu setup install hooks, channel and background service, then open the office
|
|
355
|
+
piu-piu open open the office in your browser (${URL})
|
|
356
|
+
piu-piu status what's running right now
|
|
357
|
+
piu-piu start | stop | restart the background service (stop/restart refuse while agents work; --force)
|
|
358
|
+
piu-piu run run in the foreground instead of the service
|
|
359
|
+
piu-piu logs [-f] show (or follow) the service log
|
|
360
|
+
piu-piu hooks install|uninstall Claude Code hooks for real-time status
|
|
361
|
+
piu-piu channel install|uninstall talk to open terminal sessions from the office
|
|
362
|
+
piu-piu service install|uninstall start at login
|
|
363
|
+
piu-piu doctor check the setup
|
|
364
|
+
piu-piu uninstall remove service, hooks and channel (keeps your data)
|
|
365
|
+
|
|
366
|
+
${c.b('Environment')}
|
|
367
|
+
PIU_PIU_PORT (7777) PIU_PIU_HOST (127.0.0.1) PIU_PIU_HOME (~/.piu-piu)
|
|
368
|
+
`;
|
|
369
|
+
|
|
370
|
+
if (process.argv.slice(2).some((a) => a === '--version' || a === '-v')) {
|
|
371
|
+
say(VERSION);
|
|
372
|
+
process.exit(0);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const [cmd, sub] = process.argv.slice(2).filter((a) => !a.startsWith('-') || a === '-f');
|
|
376
|
+
const subcommand = (name, on, off) => {
|
|
377
|
+
if (sub === 'install') return on();
|
|
378
|
+
if (sub === 'uninstall' || sub === 'remove') return off();
|
|
379
|
+
fail(`Usage: piu-piu ${name} install|uninstall`);
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
switch (cmd) {
|
|
383
|
+
case 'setup':
|
|
384
|
+
await setup();
|
|
385
|
+
break;
|
|
386
|
+
case 'open':
|
|
387
|
+
open();
|
|
388
|
+
break;
|
|
389
|
+
case 'status':
|
|
390
|
+
await status();
|
|
391
|
+
break;
|
|
392
|
+
case 'start':
|
|
393
|
+
await start();
|
|
394
|
+
break;
|
|
395
|
+
case 'stop':
|
|
396
|
+
await stop();
|
|
397
|
+
break;
|
|
398
|
+
case 'restart':
|
|
399
|
+
await restart();
|
|
400
|
+
break;
|
|
401
|
+
case 'run':
|
|
402
|
+
await run();
|
|
403
|
+
break;
|
|
404
|
+
case 'logs':
|
|
405
|
+
logs();
|
|
406
|
+
break;
|
|
407
|
+
case 'hooks':
|
|
408
|
+
subcommand('hooks', () => hooks(true), () => hooks(false));
|
|
409
|
+
break;
|
|
410
|
+
case 'channel':
|
|
411
|
+
subcommand('channel', () => channel(true), () => channel(false));
|
|
412
|
+
break;
|
|
413
|
+
case 'service':
|
|
414
|
+
subcommand('service', serviceInstall, serviceUninstall);
|
|
415
|
+
break;
|
|
416
|
+
case 'doctor':
|
|
417
|
+
await doctor();
|
|
418
|
+
break;
|
|
419
|
+
case 'uninstall':
|
|
420
|
+
await uninstall();
|
|
421
|
+
break;
|
|
422
|
+
case undefined:
|
|
423
|
+
case 'help':
|
|
424
|
+
say(HELP);
|
|
425
|
+
break;
|
|
426
|
+
default:
|
|
427
|
+
fail(`Unknown command "${cmd}". Run piu-piu help.`);
|
|
428
|
+
}
|
package/dist/channel.mjs
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// piu-piu channel: a Claude Code channel (MCP server over stdio) that links
|
|
3
|
+
// one terminal session to the office. Messages you type in the office chat for
|
|
4
|
+
// this session arrive here from the daemon and are pushed into the session;
|
|
5
|
+
// Claude answers with the `reply` tool, which goes back to the office panel.
|
|
6
|
+
// Permission prompts are relayed to the office too, so you can approve there.
|
|
7
|
+
//
|
|
8
|
+
// Claude Code starts this process for every session (it's a user-level MCP
|
|
9
|
+
// server), but it only does anything when the session was started with
|
|
10
|
+
// claude --dangerously-load-development-channels server:piu-piu
|
|
11
|
+
// Otherwise Claude Code would silently drop the events, so it stays quiet.
|
|
12
|
+
import { execFileSync } from 'node:child_process';
|
|
13
|
+
import { readFileSync } from 'node:fs';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
17
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
18
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
19
|
+
import { z } from 'zod';
|
|
20
|
+
|
|
21
|
+
const DAEMON = process.env.PIU_PIU_URL ?? process.env.MAZE_OFFICE_URL ?? 'ws://127.0.0.1:7777/channel';
|
|
22
|
+
// Written by the daemon (mode 600); proves this process runs as the same user.
|
|
23
|
+
const TOKEN_PATH = join(process.env.PIU_PIU_HOME ?? join(homedir(), '.piu-piu'), 'channel-token');
|
|
24
|
+
const token = () => {
|
|
25
|
+
try {
|
|
26
|
+
return readFileSync(TOKEN_PATH, 'utf8').trim();
|
|
27
|
+
} catch {
|
|
28
|
+
return '';
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
const claudePid = process.ppid;
|
|
32
|
+
|
|
33
|
+
// Was this session started with our channel enabled? Read the parent's arguments.
|
|
34
|
+
function channelEnabled() {
|
|
35
|
+
try {
|
|
36
|
+
const args = execFileSync('ps', ['-o', 'args=', '-p', String(claudePid)], { encoding: 'utf8' });
|
|
37
|
+
return /--(dangerously-load-development-)?channels\b[^]*\bserver:(piu-piu|maze-office)\b/.test(args);
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const enabled = channelEnabled();
|
|
44
|
+
|
|
45
|
+
const mcp = new Server(
|
|
46
|
+
{ name: 'piu-piu', version: '0.1.0' },
|
|
47
|
+
{
|
|
48
|
+
capabilities: {
|
|
49
|
+
experimental: enabled ? { 'claude/channel': {}, 'claude/channel/permission': {} } : {},
|
|
50
|
+
tools: {},
|
|
51
|
+
},
|
|
52
|
+
instructions: enabled
|
|
53
|
+
? 'The user can also talk to you from the piu-piu office. Their messages arrive as <channel source="piu-piu" chat_id="...">. ' +
|
|
54
|
+
'Treat them exactly like messages typed in the terminal. Always answer them with the reply tool, passing the chat_id from the tag, ' +
|
|
55
|
+
'so the answer shows up in the dashboard; keep replies short and plain.'
|
|
56
|
+
: undefined,
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// --- connection to the office daemon -----------------------------------------
|
|
61
|
+
let ws;
|
|
62
|
+
let retry = 0;
|
|
63
|
+
const outbox = [];
|
|
64
|
+
function sendToDaemon(msg) {
|
|
65
|
+
const data = JSON.stringify(msg);
|
|
66
|
+
if (ws?.readyState === WebSocket.OPEN) ws.send(data);
|
|
67
|
+
else outbox.push(data);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function connect() {
|
|
71
|
+
ws = new WebSocket(DAEMON);
|
|
72
|
+
ws.onopen = () => {
|
|
73
|
+
retry = 0;
|
|
74
|
+
ws.send(JSON.stringify({ type: 'hello', pid: claudePid, cwd: process.cwd(), token: token() }));
|
|
75
|
+
while (outbox.length) ws.send(outbox.shift());
|
|
76
|
+
};
|
|
77
|
+
ws.onmessage = async (event) => {
|
|
78
|
+
let msg;
|
|
79
|
+
try {
|
|
80
|
+
msg = JSON.parse(String(event.data));
|
|
81
|
+
} catch {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (msg.type === 'message' && typeof msg.text === 'string') {
|
|
85
|
+
await mcp.notification({
|
|
86
|
+
method: 'notifications/claude/channel',
|
|
87
|
+
params: { content: msg.text, meta: { chat_id: String(msg.chatId), sender: 'office' } },
|
|
88
|
+
});
|
|
89
|
+
} else if (msg.type === 'verdict' && /^[a-km-z]{5}$/.test(msg.requestId)) {
|
|
90
|
+
await mcp.notification({
|
|
91
|
+
method: 'notifications/claude/channel/permission',
|
|
92
|
+
params: { request_id: msg.requestId, behavior: msg.behavior === 'allow' ? 'allow' : 'deny' },
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
ws.onclose = () => setTimeout(connect, Math.min(30_000, 1000 * 2 ** retry++));
|
|
97
|
+
ws.onerror = () => {}; // onclose handles reconnection
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// --- reply tool --------------------------------------------------------------
|
|
101
|
+
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
102
|
+
tools: enabled
|
|
103
|
+
? [
|
|
104
|
+
{
|
|
105
|
+
name: 'reply',
|
|
106
|
+
description: 'Answer a message that came from the piu-piu office. Pass the chat_id from the <channel> tag.',
|
|
107
|
+
inputSchema: {
|
|
108
|
+
type: 'object',
|
|
109
|
+
properties: {
|
|
110
|
+
chat_id: { type: 'string', description: 'chat_id from the incoming <channel> tag' },
|
|
111
|
+
text: { type: 'string', description: 'Your reply, in plain text or simple Markdown' },
|
|
112
|
+
},
|
|
113
|
+
required: ['chat_id', 'text'],
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
]
|
|
117
|
+
: [],
|
|
118
|
+
}));
|
|
119
|
+
|
|
120
|
+
mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
121
|
+
if (req.params.name !== 'reply' || !enabled) throw new Error(`unknown tool: ${req.params.name}`);
|
|
122
|
+
const { chat_id, text } = req.params.arguments ?? {};
|
|
123
|
+
sendToDaemon({ type: 'reply', chatId: String(chat_id), text: String(text ?? '') });
|
|
124
|
+
return { content: [{ type: 'text', text: 'Delivered to the piu-piu office chat.' }] };
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// --- permission relay ----------------------------------------------------------
|
|
128
|
+
mcp.setNotificationHandler(
|
|
129
|
+
z.object({
|
|
130
|
+
method: z.literal('notifications/claude/channel/permission_request'),
|
|
131
|
+
params: z.object({ request_id: z.string(), tool_name: z.string(), description: z.string(), input_preview: z.string() }),
|
|
132
|
+
}),
|
|
133
|
+
async ({ params }) => {
|
|
134
|
+
sendToDaemon({
|
|
135
|
+
type: 'permission',
|
|
136
|
+
requestId: params.request_id,
|
|
137
|
+
tool: params.tool_name,
|
|
138
|
+
description: params.description,
|
|
139
|
+
preview: params.input_preview,
|
|
140
|
+
});
|
|
141
|
+
},
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
await mcp.connect(new StdioServerTransport());
|
|
145
|
+
if (enabled) connect();
|