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.
@@ -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, '&amp;').replace(/</g, '&lt;');
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
+ }
@@ -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();