pi-tau-mux 1.0.9

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 ADDED
@@ -0,0 +1,91 @@
1
+ # pi-tau-mux
2
+
3
+ **A lightweight client extension that connects Pi to [pi-tau-mux-server](https://github.com/dwainm/pi-tau-mux-server).**
4
+
5
+ This extension turns your Pi session into a client that registers with the standalone Tau mux server, enabling real-time mirroring in the browser across multiple Pi instances.
6
+
7
+ ## Architecture
8
+
9
+ ```
10
+ ┌─────────────┐ ┌──────────────────────┐ ┌─────────────┐
11
+ │ Pi TUI │ │ pi-tau-mux-server │ │ Browser │
12
+ │ (terminal) │ WebSocket /pi │ (standalone daemon) │ WebSocket /ws │ (Tau UI) │
13
+ │ │◄───────────────────►│ │◄──────────────────►│ │
14
+ └─────────────┘ │ Aggregates all │ └─────────────┘
15
+ │ Pi instances │
16
+ ┌─────────────┐ │ │ ┌─────────────┐
17
+ │ Pi TUI │ │ Serves web UI │ │ Phone │
18
+ │ (another) │◄───────────────────►│ Scans sessions │◄──────────────────►│ (QR scan) │
19
+ └─────────────┘ └──────────────────────┘ └─────────────┘
20
+ ```
21
+
22
+ **This extension** = lightweight client (registers, forwards events, unregisters)
23
+ **[pi-tau-mux-server](https://github.com/dwainm/pi-tau-mux-server)** = standalone server (web UI, session browser, Tailscale support)
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pi install git:github.com/dwainm/pi-tau-mux
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ 1. **Start the mux server** (one-time or auto-start):
34
+ ```bash
35
+ pi-tau-mux-server
36
+ ```
37
+ Or let the extension prompt you when Pi starts.
38
+
39
+ 2. **Start Pi normally** — the extension auto-connects to the mux server
40
+
41
+ 3. **Open the web UI** at the URL shown (e.g., `http://localhost:3001` or your Tailscale URL)
42
+
43
+ ## Commands
44
+
45
+ | Command | Description |
46
+ |---------|-------------|
47
+ | `/tauconnect` | Connect to the mux server |
48
+ | `/taudisconnect` | Disconnect from the mux server |
49
+
50
+ ## Environment Variables
51
+
52
+ | Variable | Default | Description |
53
+ |----------|---------|-------------|
54
+ | `TAU_HOST` | `localhost` | Mux server host |
55
+ | `TAU_PORT` | `3001` | Mux server port |
56
+ | `TAU_AUTO_CONNECT` | `1` | Set to `0` to disable auto-connect |
57
+
58
+ ## Features
59
+
60
+ ### Session Mirroring
61
+ - Real-time streaming of messages, tool calls, and thinking blocks
62
+ - Multiple Pi instances can connect to the same mux server
63
+ - Browser shows all active sessions across projects
64
+
65
+ ### Tailscale Support
66
+ The mux server auto-detects Tailscale and uses your Tailscale IP or MagicDNS hostname. Scan the QR code from any device on your tailnet.
67
+
68
+ ### Session Browser
69
+ View history from any past Pi session, grouped by project. Active sessions show a "LIVE" indicator.
70
+
71
+ ## Related
72
+
73
+ - **[pi-tau-mux-server](https://github.com/dwainm/pi-tau-mux-server)** — The standalone server (install globally)
74
+ - **[pi-coding-agent](https://github.com/mariozechner/pi-coding-agent)** — The Pi coding agent
75
+ - **[Tau (original)](https://github.com/deflating/tau)** — This is a fork with tmux awareness and mux architecture
76
+
77
+ ## Why the Split?
78
+
79
+ The original Tau ran an HTTP server inside each Pi process. This worked for single instances but caused issues with:
80
+ - Multiple Pi instances (port conflicts)
81
+ - Remote access (needed each port forwarded)
82
+ - Resource usage (server per Pi process)
83
+
84
+ The mux architecture solves these:
85
+ - **One server** for all Pi instances
86
+ - **One port** to forward or expose via Tailscale
87
+ - **Session aggregation** across all running Pi instances
88
+
89
+ ## License
90
+
91
+ MIT
@@ -0,0 +1,373 @@
1
+ /**
2
+ * iMessage Bridge Extension
3
+ *
4
+ * Connects Pi to iMessage via BlueBubbles REST API.
5
+ * Polls for incoming messages, injects them as user messages,
6
+ * and sends assistant responses back via iMessage.
7
+ *
8
+ * Config via environment variables:
9
+ * BB_PASSWORD - BlueBubbles server password (default: Zawsx@12)
10
+ * BB_URL - BlueBubbles server URL (default: http://localhost:1234)
11
+ * BB_PHONE - Phone number to bridge (default: +61435599858)
12
+ * BB_POLL_INTERVAL - Poll interval in ms (default: 2000)
13
+ */
14
+
15
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
16
+ import * as fs from "node:fs";
17
+ import * as path from "node:path";
18
+ import * as http from "node:http";
19
+ import * as https from "node:https";
20
+
21
+ const BB_PASSWORD = process.env.BB_PASSWORD || "Zawsx@12";
22
+ const BB_URL = process.env.BB_URL || "http://localhost:1234";
23
+ const BB_PHONE = process.env.BB_PHONE || "+61435599858";
24
+ const BB_POLL_INTERVAL = parseInt(process.env.BB_POLL_INTERVAL || "2000");
25
+ const CHAT_GUID = `iMessage;-;${BB_PHONE}`;
26
+ const ATTACHMENTS_DIR = path.join(process.env.HOME || "~", "claude-memory/imessage/attachments");
27
+
28
+ export default function (pi: ExtensionAPI) {
29
+ let pollTimer: ReturnType<typeof setInterval> | null = null;
30
+ let lastMessageTime = 0;
31
+ let waitingForReply = false; // true when we've injected an iMessage and are waiting for the turn to end
32
+ let latestCtx: ExtensionContext | null = null;
33
+ let enabled = false;
34
+
35
+ fs.mkdirSync(ATTACHMENTS_DIR, { recursive: true });
36
+
37
+ // ═══════════════════════════════════════
38
+ // HTTP helpers
39
+ // ═══════════════════════════════════════
40
+
41
+ function request(method: string, urlPath: string, body?: any): Promise<any> {
42
+ return new Promise((resolve, reject) => {
43
+ const url = new URL(urlPath, BB_URL);
44
+ url.searchParams.set("password", BB_PASSWORD);
45
+
46
+ const mod = url.protocol === "https:" ? https : http;
47
+ const payload = body ? JSON.stringify(body) : undefined;
48
+
49
+ const req = mod.request(url, {
50
+ method,
51
+ headers: payload ? { "Content-Type": "application/json" } : {},
52
+ }, (res) => {
53
+ let data = "";
54
+ res.on("data", (chunk: Buffer) => data += chunk);
55
+ res.on("end", () => {
56
+ try { resolve(JSON.parse(data)); }
57
+ catch { resolve(data); }
58
+ });
59
+ });
60
+
61
+ req.on("error", reject);
62
+ if (payload) req.write(payload);
63
+ req.end();
64
+ });
65
+ }
66
+
67
+ function downloadAttachment(guid: string, mime: string): Promise<{ path: string; isAudio: boolean } | null> {
68
+ return new Promise((resolve) => {
69
+ const url = new URL(`/api/v1/attachment/${guid}/download`, BB_URL);
70
+ url.searchParams.set("password", BB_PASSWORD);
71
+
72
+ const mod = url.protocol === "https:" ? https : http;
73
+ mod.get(url, (res) => {
74
+ const contentType = res.headers["content-type"] || mime;
75
+ const extMap: Record<string, string> = {
76
+ "image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif",
77
+ "image/webp": ".webp", "image/heic": ".heic",
78
+ "video/mp4": ".mp4", "audio/mpeg": ".mp3",
79
+ "audio/mp4": ".m4a", "audio/x-m4a": ".m4a",
80
+ "audio/aac": ".aac", "audio/caf": ".caf",
81
+ "application/pdf": ".pdf",
82
+ };
83
+ const isAudio = (contentType || "").startsWith("audio/");
84
+ const ext = extMap[contentType || ""] || "";
85
+ const filename = `${guid.replace(/\//g, "_")}${ext}`;
86
+ const filepath = path.join(ATTACHMENTS_DIR, filename);
87
+
88
+ const chunks: Buffer[] = [];
89
+ res.on("data", (chunk: Buffer) => chunks.push(chunk));
90
+ res.on("end", () => {
91
+ fs.writeFileSync(filepath, Buffer.concat(chunks));
92
+ log(`Downloaded attachment: ${filepath}`);
93
+ resolve({ path: filepath, isAudio });
94
+ });
95
+ res.on("error", () => resolve(null));
96
+ }).on("error", () => resolve(null));
97
+ });
98
+ }
99
+
100
+ // ═══════════════════════════════════════
101
+ // Logging
102
+ // ═══════════════════════════════════════
103
+
104
+ function log(msg: string) {
105
+ console.log(`[iMessage] ${msg}`);
106
+ }
107
+
108
+ // ═══════════════════════════════════════
109
+ // Send iMessage via BlueBubbles
110
+ // ═══════════════════════════════════════
111
+
112
+ async function sendIMessage(text: string) {
113
+ try {
114
+ await request("POST", "/api/v1/message/text", {
115
+ chatGuid: CHAT_GUID,
116
+ message: text,
117
+ });
118
+ log(`Sent reply (${text.length} chars)`);
119
+ } catch (err: any) {
120
+ log(`Failed to send: ${err.message}`);
121
+ }
122
+ }
123
+
124
+ async function sendTypingIndicator() {
125
+ try {
126
+ await request("POST", `/api/v1/chat/${CHAT_GUID}/typing`);
127
+ } catch {}
128
+ }
129
+
130
+ // ═══════════════════════════════════════
131
+ // Poll for new messages
132
+ // ═══════════════════════════════════════
133
+
134
+ async function getLatestMessageTime(): Promise<number> {
135
+ try {
136
+ const res = await request("POST", "/api/v1/message/query", {
137
+ limit: 1, sort: "DESC",
138
+ });
139
+ const msgs = res?.data || [];
140
+ return msgs.length > 0 ? msgs[0].dateCreated || 0 : 0;
141
+ } catch (err: any) {
142
+ log(`Error getting latest message time: ${err.message}`);
143
+ return 0;
144
+ }
145
+ }
146
+
147
+ async function pollMessages() {
148
+ if (!enabled || !latestCtx) return;
149
+
150
+ try {
151
+ const res = await request("POST", "/api/v1/message/query", {
152
+ limit: 20,
153
+ sort: "DESC",
154
+ after: lastMessageTime,
155
+ with: ["chat", "handle", "attachment"],
156
+ });
157
+
158
+ const messages = (res?.data || []).reverse();
159
+
160
+ for (const msg of messages) {
161
+ const msgTime = msg.dateCreated || 0;
162
+ if (msgTime <= lastMessageTime) continue;
163
+
164
+ lastMessageTime = Math.max(lastMessageTime, msgTime);
165
+
166
+ // Skip our own messages
167
+ if (msg.isFromMe) continue;
168
+
169
+ // Check it's from Matt
170
+ const handle = msg.handle || {};
171
+ const address = handle.address || "";
172
+ const fromMatt = BB_PHONE && address.includes(BB_PHONE.replace("+", ""));
173
+ if (!fromMatt) {
174
+ const chats = msg.chats || [];
175
+ const chatMatch = chats.some((c: any) => (c.chatIdentifier || "").includes(BB_PHONE));
176
+ if (!chatMatch) continue;
177
+ }
178
+
179
+ // Process the message
180
+ await processMessage(msg);
181
+ }
182
+ } catch (err: any) {
183
+ log(`Poll error: ${err.message}`);
184
+ }
185
+ }
186
+
187
+ async function processMessage(msg: any) {
188
+ let text = msg.text || "";
189
+ const attachments = msg.attachments || [];
190
+
191
+ const attachmentPaths: string[] = [];
192
+ let voiceNotePath: string | null = null;
193
+
194
+ for (const att of attachments) {
195
+ const guid = att.guid || "";
196
+ const mime = att.mimeType || "";
197
+ if (!guid) continue;
198
+ const result = await downloadAttachment(guid, mime);
199
+ if (result) {
200
+ attachmentPaths.push(result.path);
201
+ if (result.isAudio) voiceNotePath = result.path;
202
+ }
203
+ }
204
+
205
+ // Build the message content
206
+ let fullMessage = text;
207
+ if (voiceNotePath) {
208
+ fullMessage = `[Voice note from Matt at ${voiceNotePath} — transcribe and respond]`;
209
+ if (text) fullMessage += ` (caption: ${text})`;
210
+ } else if (attachmentPaths.length > 0) {
211
+ fullMessage += ` [Attachments: ${attachmentPaths.join(", ")}]`;
212
+ }
213
+
214
+ if (!fullMessage.trim()) return;
215
+
216
+ log(`From Matt: ${fullMessage.substring(0, 100)}...`);
217
+
218
+ // Send typing indicator
219
+ await sendTypingIndicator();
220
+
221
+ // Inject as a real user message
222
+ waitingForReply = true;
223
+
224
+ // Build content array with images if applicable
225
+ const imageExts = [".jpg", ".jpeg", ".png", ".gif", ".webp"];
226
+ const imageAttachments = attachmentPaths.filter(p =>
227
+ imageExts.some(ext => p.toLowerCase().endsWith(ext))
228
+ );
229
+
230
+ if (imageAttachments.length > 0 && !voiceNotePath) {
231
+ const content: any[] = [];
232
+ if (text) content.push({ type: "text", text: `[iMessage from Matt] ${text}` });
233
+ for (const imgPath of imageAttachments) {
234
+ try {
235
+ const imgData = fs.readFileSync(imgPath);
236
+ const ext = path.extname(imgPath).toLowerCase().replace(".", "");
237
+ const mediaType = ext === "jpg" ? "image/jpeg" : `image/${ext}`;
238
+ content.push({
239
+ type: "image",
240
+ source: { type: "base64", mediaType, data: imgData.toString("base64") },
241
+ });
242
+ } catch {}
243
+ }
244
+ pi.sendUserMessage(content, { deliverAs: "followUp" });
245
+ } else {
246
+ pi.sendUserMessage(`[iMessage from Matt] ${fullMessage}`, { deliverAs: "followUp" });
247
+ }
248
+ }
249
+
250
+ // ═══════════════════════════════════════
251
+ // Capture responses and send back
252
+ // ═══════════════════════════════════════
253
+
254
+ pi.on("turn_end", async (event, ctx) => {
255
+ if (!enabled || !waitingForReply) return;
256
+ waitingForReply = false;
257
+
258
+ // Extract the assistant's text response from the turn
259
+ const message = event.message;
260
+ if (!message) return;
261
+
262
+ // Get text content from the message
263
+ let responseText = "";
264
+ if (typeof message.content === "string") {
265
+ responseText = message.content;
266
+ } else if (Array.isArray(message.content)) {
267
+ responseText = message.content
268
+ .filter((b: any) => b.type === "text")
269
+ .map((b: any) => b.text)
270
+ .join("\n");
271
+ }
272
+
273
+ if (!responseText.trim()) return;
274
+
275
+ // Strip markdown for iMessage (keep it readable)
276
+ const cleanText = responseText
277
+ .replace(/```[\s\S]*?```/g, "[code block]") // collapse code blocks
278
+ .replace(/`([^`]+)`/g, "$1") // inline code → plain
279
+ .replace(/\*\*([^*]+)\*\*/g, "$1") // bold → plain
280
+ .replace(/\*([^*]+)\*/g, "$1") // italic → plain
281
+ .replace(/^#{1,6}\s+/gm, "") // strip headers
282
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // links → text
283
+ .trim();
284
+
285
+ // iMessage has a practical limit — split long messages
286
+ const MAX_LEN = 4000;
287
+ if (cleanText.length <= MAX_LEN) {
288
+ await sendIMessage(cleanText);
289
+ } else {
290
+ // Split on paragraph boundaries
291
+ const paragraphs = cleanText.split(/\n\n+/);
292
+ let chunk = "";
293
+ for (const para of paragraphs) {
294
+ if (chunk.length + para.length + 2 > MAX_LEN) {
295
+ if (chunk) await sendIMessage(chunk.trim());
296
+ chunk = para;
297
+ } else {
298
+ chunk += (chunk ? "\n\n" : "") + para;
299
+ }
300
+ }
301
+ if (chunk) await sendIMessage(chunk.trim());
302
+ }
303
+ });
304
+
305
+ // ═══════════════════════════════════════
306
+ // Lifecycle
307
+ // ═══════════════════════════════════════
308
+
309
+ pi.on("session_start", async (_event, ctx) => {
310
+ latestCtx = ctx;
311
+
312
+ // Check if BlueBubbles is reachable
313
+ try {
314
+ const res = await request("GET", "/api/v1/ping");
315
+ if (res?.message === "pong") {
316
+ enabled = true;
317
+ log("Connected to BlueBubbles");
318
+ ctx.ui.setStatus("imessage", "📱 iMessage bridge active");
319
+
320
+ // Start from current latest message
321
+ lastMessageTime = await getLatestMessageTime();
322
+ log(`Starting poll from message time: ${lastMessageTime}`);
323
+
324
+ // Start polling
325
+ if (pollTimer) clearInterval(pollTimer);
326
+ pollTimer = setInterval(pollMessages, BB_POLL_INTERVAL);
327
+ } else {
328
+ log("BlueBubbles not responding — bridge disabled");
329
+ }
330
+ } catch (err: any) {
331
+ log(`BlueBubbles unreachable (${err.message}) — bridge disabled`);
332
+ }
333
+ });
334
+
335
+ pi.on("session_shutdown", async () => {
336
+ if (pollTimer) {
337
+ clearInterval(pollTimer);
338
+ pollTimer = null;
339
+ }
340
+ enabled = false;
341
+ log("Bridge shut down");
342
+ });
343
+
344
+ // ═══════════════════════════════════════
345
+ // Commands
346
+ // ═══════════════════════════════════════
347
+
348
+ pi.registerCommand("imessage", {
349
+ description: "Send an iMessage to Matt",
350
+ args: [{ name: "message", description: "Message text", required: true }],
351
+ execute: async (args, ctx) => {
352
+ if (!enabled) {
353
+ ctx.ui.notify("iMessage bridge not connected", "error");
354
+ return;
355
+ }
356
+ const text = args.join(" ");
357
+ await sendIMessage(text);
358
+ ctx.ui.notify(`Sent iMessage: ${text.substring(0, 50)}...`, "info");
359
+ },
360
+ });
361
+
362
+ pi.registerCommand("imessage-status", {
363
+ description: "Check iMessage bridge status",
364
+ execute: async (_args, ctx) => {
365
+ ctx.ui.notify(
366
+ enabled
367
+ ? `iMessage bridge active. Polling every ${BB_POLL_INTERVAL / 1000}s. Last message time: ${lastMessageTime}`
368
+ : "iMessage bridge disabled (BlueBubbles unreachable)",
369
+ "info"
370
+ );
371
+ },
372
+ });
373
+ }