clawty 0.0.1

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/src/cli.ts ADDED
@@ -0,0 +1,1959 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * iMessage <-> Claude Code bridge.
4
+ *
5
+ * Polls chat.db for new iMessages, sends them to Claude Code via
6
+ * `claude -p` with streaming JSON output, and sends responses back via iMessage.
7
+ *
8
+ * Shows full visibility: thinking, tool calls, MCP calls, costs.
9
+ * Uses --session-id / --resume to maintain a persistent conversation.
10
+ * Supports slash commands via iMessage (e.g. /new, /compact, /status).
11
+ */
12
+
13
+ import { existsSync } from "fs";
14
+ import { resolve } from "path";
15
+ import { verifySendPermission, sendIMessage } from "./imessage/sender.ts";
16
+ import { verifyClaudeInstalled } from "./claude/runner.ts";
17
+ import { IMessageDatabase } from "./imessage/database.ts";
18
+ import type { IMessage } from "./imessage/types.ts";
19
+
20
+ // --- Styles ---
21
+
22
+ const RESET = "\x1b[0m";
23
+ const BOLD = "\x1b[1m";
24
+ const DIM = "\x1b[2m";
25
+ const ITALIC = "\x1b[3m";
26
+ const RED = "\x1b[31m";
27
+ const GREEN = "\x1b[32m";
28
+ const YELLOW = "\x1b[33m";
29
+ const MAGENTA = "\x1b[35m";
30
+ const CYAN = "\x1b[36m";
31
+ const BRIGHT_MAGENTA = "\x1b[95m";
32
+ const BRIGHT_CYAN = "\x1b[96m";
33
+ const WHITE = "\x1b[37m";
34
+ const BG_BRIGHT_MAGENTA = "\x1b[105m";
35
+ const BG_RESET = "\x1b[49m";
36
+ const UNDERLINE = "\x1b[4m";
37
+ const NO_UNDERLINE = "\x1b[24m";
38
+ const NO_BOLD = "\x1b[22m";
39
+ const NO_ITALIC = "\x1b[23m";
40
+ const NO_DIM = "\x1b[22m"; // same SGR as no-bold; resets "intensity"
41
+
42
+ // --- Streaming Markdown → ANSI renderer ---
43
+
44
+ /**
45
+ * Converts markdown to ANSI escape codes in a streaming-friendly way.
46
+ * Handles bold, italic, inline code, fenced code blocks, headers, lists,
47
+ * horizontal rules, and links — similar to how Claude Code renders output.
48
+ */
49
+ class MarkdownRenderer {
50
+ private pending = ""; // un-emitted chars that might start a token
51
+ private bold = false;
52
+ private italic = false;
53
+ private inCode = false; // inline `code`
54
+ private inCodeBlock = false;
55
+ private codeBlockTicks = 0; // number of backticks that opened the block
56
+ private atLineStart = true;
57
+ private lineContent = ""; // content on the current line (for HR detection)
58
+
59
+ /** Feed a chunk of text, get back ANSI-formatted output. */
60
+ push(chunk: string): string {
61
+ const input = this.pending + chunk;
62
+ this.pending = "";
63
+ let out = "";
64
+ let i = 0;
65
+
66
+ while (i < input.length) {
67
+ // --- Fenced code blocks (``` or ~~~) ---
68
+ if (this.atLineStart && !this.inCode && !this.inCodeBlock) {
69
+ const fence = this.matchFence(input, i);
70
+ if (fence > 0) {
71
+ // opening fence
72
+ this.inCodeBlock = true;
73
+ this.codeBlockTicks = fence;
74
+ // skip to end of line (language tag)
75
+ const nl = input.indexOf("\n", i + fence);
76
+ if (nl === -1) { this.pending = input.slice(i); break; }
77
+ out += `${DIM}`;
78
+ i = nl + 1;
79
+ this.atLineStart = true;
80
+ this.lineContent = "";
81
+ continue;
82
+ }
83
+ }
84
+
85
+ if (this.inCodeBlock) {
86
+ // check for closing fence
87
+ if (this.atLineStart) {
88
+ const fence = this.matchFence(input, i);
89
+ if (fence >= this.codeBlockTicks) {
90
+ this.inCodeBlock = false;
91
+ out += RESET;
92
+ // skip to end of fence line
93
+ const nl = input.indexOf("\n", i + fence);
94
+ if (nl === -1) { i = input.length; } else { i = nl + 1; }
95
+ this.atLineStart = true;
96
+ this.lineContent = "";
97
+ continue;
98
+ }
99
+ }
100
+ // inside code block — pass through as dim text
101
+ if (input[i] === "\n") {
102
+ out += "\n";
103
+ this.atLineStart = true;
104
+ this.lineContent = "";
105
+ } else {
106
+ out += input[i];
107
+ this.atLineStart = false;
108
+ this.lineContent += input[i];
109
+ }
110
+ i++;
111
+ continue;
112
+ }
113
+
114
+ // --- Inline code ---
115
+ if (input[i] === "`" && !this.inCodeBlock) {
116
+ this.inCode = !this.inCode;
117
+ out += this.inCode ? CYAN : RESET;
118
+ // restore active styles after closing code
119
+ if (!this.inCode) {
120
+ if (this.bold) out += BOLD;
121
+ if (this.italic) out += ITALIC;
122
+ }
123
+ i++;
124
+ this.atLineStart = false;
125
+ continue;
126
+ }
127
+
128
+ // Inside inline code — pass through literally
129
+ if (this.inCode) {
130
+ if (input[i] === "\n") {
131
+ out += "\n";
132
+ this.atLineStart = true;
133
+ this.lineContent = "";
134
+ } else {
135
+ out += input[i];
136
+ this.atLineStart = false;
137
+ }
138
+ i++;
139
+ continue;
140
+ }
141
+
142
+ // --- Headers at line start: # ... ---
143
+ if (this.atLineStart && input[i] === "#") {
144
+ let level = 0;
145
+ let j = i;
146
+ while (j < input.length && input[j] === "#") { level++; j++; }
147
+ if (j >= input.length) { this.pending = input.slice(i); break; }
148
+ if (input[j] === " ") {
149
+ j++; // skip space after #
150
+ const nl = input.indexOf("\n", j);
151
+ if (nl === -1) { this.pending = input.slice(i); break; }
152
+ out += `${BOLD}${input.slice(j, nl)}${RESET}`;
153
+ if (this.bold) out += BOLD;
154
+ if (this.italic) out += ITALIC;
155
+ out += "\n";
156
+ i = nl + 1;
157
+ this.atLineStart = true;
158
+ this.lineContent = "";
159
+ continue;
160
+ }
161
+ }
162
+
163
+ // --- Horizontal rule (---, ***, ___) at line start ---
164
+ if (this.atLineStart && (input[i] === "-" || input[i] === "*" || input[i] === "_")) {
165
+ const ruleChar = input[i]!;
166
+ let j = i;
167
+ let count = 0;
168
+ let isRule = true;
169
+ while (j < input.length && input[j] !== "\n") {
170
+ if (input[j] === ruleChar) count++;
171
+ else if (input[j] !== " ") { isRule = false; break; }
172
+ j++;
173
+ }
174
+ if (j >= input.length && count < 3) { this.pending = input.slice(i); break; }
175
+ if (isRule && count >= 3 && (j < input.length || count >= 3)) {
176
+ const cols = process.stdout.columns || 80;
177
+ const ruleWidth = Math.min(cols - 20, 40); // leave room for indent
178
+ out += `${DIM}${"─".repeat(ruleWidth)}${RESET}`;
179
+ if (this.bold) out += BOLD;
180
+ if (this.italic) out += ITALIC;
181
+ if (j < input.length) { out += "\n"; i = j + 1; }
182
+ else i = j;
183
+ this.atLineStart = true;
184
+ this.lineContent = "";
185
+ continue;
186
+ }
187
+ }
188
+
189
+ // --- List items at line start: - item, * item, N. item ---
190
+ if (this.atLineStart && input[i] === "-" && i + 1 < input.length && input[i + 1] === " ") {
191
+ out += `${DIM}•${RESET} `;
192
+ if (this.bold) out += BOLD;
193
+ if (this.italic) out += ITALIC;
194
+ i += 2;
195
+ this.atLineStart = false;
196
+ this.lineContent = "• ";
197
+ continue;
198
+ }
199
+ if (this.atLineStart && input[i] === "*" && i + 1 < input.length && input[i + 1] === " ") {
200
+ // Only treat as list item if not potentially bold (**)
201
+ if (i + 1 < input.length && input[i + 1] === " ") {
202
+ out += `${DIM}•${RESET} `;
203
+ if (this.bold) out += BOLD;
204
+ if (this.italic) out += ITALIC;
205
+ i += 2;
206
+ this.atLineStart = false;
207
+ this.lineContent = "• ";
208
+ continue;
209
+ }
210
+ }
211
+ if (this.atLineStart && input[i]! >= "0" && input[i]! <= "9") {
212
+ let j = i;
213
+ while (j < input.length && input[j]! >= "0" && input[j]! <= "9") j++;
214
+ if (j < input.length && input[j] === "." && j + 1 < input.length && input[j + 1] === " ") {
215
+ const num = input.slice(i, j);
216
+ out += `${DIM}${num}.${RESET} `;
217
+ if (this.bold) out += BOLD;
218
+ if (this.italic) out += ITALIC;
219
+ i = j + 2;
220
+ this.atLineStart = false;
221
+ this.lineContent = `${num}. `;
222
+ continue;
223
+ }
224
+ }
225
+
226
+ // --- Bold **text** ---
227
+ if (input[i] === "*" && i + 1 < input.length && input[i + 1] === "*") {
228
+ this.bold = !this.bold;
229
+ out += this.bold ? BOLD : NO_BOLD;
230
+ // re-apply italic if active (NO_BOLD resets intensity)
231
+ if (!this.bold && this.italic) out += ITALIC;
232
+ i += 2;
233
+ this.atLineStart = false;
234
+ continue;
235
+ }
236
+
237
+ // --- Italic *text* ---
238
+ if (input[i] === "*") {
239
+ // might be start of ** — buffer if at end of input
240
+ if (i === input.length - 1) { this.pending = "*"; break; }
241
+ this.italic = !this.italic;
242
+ out += this.italic ? ITALIC : NO_ITALIC;
243
+ i++;
244
+ this.atLineStart = false;
245
+ continue;
246
+ }
247
+
248
+ // --- Links [text](url) ---
249
+ if (input[i] === "[") {
250
+ const closeBracket = input.indexOf("]", i + 1);
251
+ if (closeBracket === -1) {
252
+ if (input.length - i < 200) { this.pending = input.slice(i); break; }
253
+ // too far — treat as literal
254
+ } else if (closeBracket + 1 < input.length && input[closeBracket + 1] === "(") {
255
+ const closeParen = input.indexOf(")", closeBracket + 2);
256
+ if (closeParen === -1) {
257
+ if (input.length - i < 500) { this.pending = input.slice(i); break; }
258
+ } else {
259
+ const linkText = input.slice(i + 1, closeBracket);
260
+ const url = input.slice(closeBracket + 2, closeParen);
261
+ out += `${UNDERLINE}${linkText}${NO_UNDERLINE}${DIM} (${url})${RESET}`;
262
+ if (this.bold) out += BOLD;
263
+ if (this.italic) out += ITALIC;
264
+ i = closeParen + 1;
265
+ this.atLineStart = false;
266
+ continue;
267
+ }
268
+ } else {
269
+ // just a bracket, not a link
270
+ }
271
+ // fall through to literal output
272
+ }
273
+
274
+ // --- Newlines ---
275
+ if (input[i] === "\n") {
276
+ out += "\n";
277
+ this.atLineStart = true;
278
+ this.lineContent = "";
279
+ i++;
280
+ continue;
281
+ }
282
+
283
+ // --- Literal character ---
284
+ out += input[i];
285
+ this.atLineStart = false;
286
+ this.lineContent += input[i];
287
+ i++;
288
+ }
289
+
290
+ return out;
291
+ }
292
+
293
+ /** Flush any buffered pending chars and close open styles. */
294
+ flush(): string {
295
+ let out = this.pending;
296
+ this.pending = "";
297
+ if (this.bold || this.italic || this.inCode || this.inCodeBlock) {
298
+ out += RESET;
299
+ }
300
+ this.bold = false;
301
+ this.italic = false;
302
+ this.inCode = false;
303
+ this.inCodeBlock = false;
304
+ this.atLineStart = true;
305
+ this.lineContent = "";
306
+ return out;
307
+ }
308
+
309
+ /** Check if a ``` or ~~~ fence starts at position i. Returns fence length or 0. */
310
+ private matchFence(input: string, i: number): number {
311
+ const ch = input[i];
312
+ if (ch !== "`" && ch !== "~") return 0;
313
+ let count = 0;
314
+ let j = i;
315
+ while (j < input.length && input[j] === ch) { count++; j++; }
316
+ return count >= 3 ? count : 0;
317
+ }
318
+ }
319
+
320
+ // --- Args ---
321
+
322
+ interface CliArgs {
323
+ contact: string;
324
+ dir: string;
325
+ model: string;
326
+ interval: number;
327
+ permissionMode: string;
328
+ }
329
+
330
+ function printUsage(): void {
331
+ console.log(`
332
+ ${BRIGHT_MAGENTA}\u2726${RESET} ${BOLD}iMessage ${BRIGHT_MAGENTA}\u2194${RESET}${BOLD} Claude Code${RESET}
333
+ ${DIM}Text Claude Code from your phone via iMessage${RESET}
334
+
335
+ ${BOLD}Usage${RESET} ${DIM}imessage-claude [options]${RESET}
336
+
337
+ ${BOLD}Options${RESET}
338
+ ${BRIGHT_MAGENTA}--contact, -c${RESET} ${DIM}<phone|email>${RESET} Contact to bridge with ${DIM}(required)${RESET}
339
+ ${BRIGHT_MAGENTA}--dir, -d${RESET} ${DIM}<path>${RESET} Working directory for Claude Code
340
+ ${BRIGHT_MAGENTA}--model, -m${RESET} ${DIM}<model>${RESET} Claude model ${DIM}(e.g. sonnet, opus, haiku)${RESET}
341
+ ${BRIGHT_MAGENTA}--interval, -i${RESET} ${DIM}<ms>${RESET} Poll interval in ms ${DIM}(default: 2000)${RESET}
342
+ ${BRIGHT_MAGENTA}--permission-mode${RESET} ${DIM}<mode>${RESET} Permission mode ${DIM}(default: bypassPermissions)${RESET}
343
+ ${BRIGHT_MAGENTA}--help, -h${RESET} Show this help
344
+ `);
345
+ }
346
+
347
+ function parseArgs(args: string[]): CliArgs {
348
+ let contact = "";
349
+ let dir = process.cwd();
350
+ let model = "";
351
+ let interval = 2000;
352
+ let permissionMode = "bypassPermissions";
353
+
354
+ for (let i = 0; i < args.length; i++) {
355
+ const arg = args[i]!;
356
+ switch (arg) {
357
+ case "--contact":
358
+ case "-c":
359
+ contact = args[++i] ?? "";
360
+ break;
361
+ case "--dir":
362
+ case "-d":
363
+ dir = args[++i] ?? process.cwd();
364
+ break;
365
+ case "--model":
366
+ case "-m":
367
+ model = args[++i] ?? "";
368
+ break;
369
+ case "--interval":
370
+ case "-i":
371
+ interval = parseInt(args[++i] ?? "2000", 10) || 2000;
372
+ break;
373
+ case "--permission-mode":
374
+ permissionMode = args[++i] ?? "bypassPermissions";
375
+ break;
376
+ case "--help":
377
+ case "-h":
378
+ printUsage();
379
+ process.exit(0);
380
+ }
381
+ }
382
+
383
+ return { contact, dir, model, interval, permissionMode };
384
+ }
385
+
386
+ // --- Session state (mutable, shared across the polling loop) ---
387
+
388
+ interface SessionState {
389
+ sessionId: string;
390
+ isFirstMessage: boolean;
391
+ model: string;
392
+ workingDir: string;
393
+ messageCount: number;
394
+ totalCostUsd: number;
395
+ totalDurationMs: number;
396
+ startedAt: Date;
397
+ compactSummary: string;
398
+ }
399
+
400
+ // --- System prompt ---
401
+
402
+ function buildSystemPrompt(contact: string, compactSummary?: string): string {
403
+ const parts = [
404
+ "You are being contacted via iMessage.",
405
+ `The user is texting you from their phone (${contact}).`,
406
+ "Keep responses concise and mobile-friendly when possible.",
407
+ "You have full access to the working directory.",
408
+ ];
409
+ if (compactSummary) {
410
+ parts.push(
411
+ `\n\nContext from previous conversation (compacted):\n${compactSummary}`,
412
+ );
413
+ }
414
+ return parts.join(" ");
415
+ }
416
+
417
+ // --- Banner ---
418
+
419
+ function printBanner(args: CliArgs, workingDir: string): void {
420
+ console.log("");
421
+ console.log(
422
+ ` ${BRIGHT_MAGENTA}\u2726${RESET} ${BOLD}iMessage ${BRIGHT_MAGENTA}\u2194${RESET}${BOLD} Claude Code${RESET}`,
423
+ );
424
+ console.log(
425
+ ` ${DIM}Text Claude from your phone \u2022 Type below or send via iMessage${RESET}`,
426
+ );
427
+ console.log("");
428
+ console.log(
429
+ ` ${GREEN}\u2713${RESET} Contact ${BOLD}${args.contact}${RESET}`,
430
+ );
431
+ console.log(
432
+ ` ${GREEN}\u2713${RESET} Directory ${DIM}${workingDir}${RESET}`,
433
+ );
434
+ console.log(
435
+ ` ${GREEN}\u2713${RESET} Model ${DIM}${args.model || "(default)"}${RESET}`,
436
+ );
437
+ console.log(
438
+ ` ${GREEN}\u2713${RESET} Permissions ${DIM}--dangerously-skip-permissions${RESET}`,
439
+ );
440
+ console.log("");
441
+ console.log(
442
+ ` ${YELLOW}\u26A0 Claude can execute code, edit files, and run commands without confirmation.${RESET}`,
443
+ );
444
+ console.log(
445
+ ` ${DIM} Only use with trusted contacts and directories.${RESET}`,
446
+ );
447
+ console.log("");
448
+ console.log(
449
+ ` ${DIM}Disclaimer: Clawty is provided "as is". The owners and maintainers are not${RESET}`,
450
+ );
451
+ console.log(
452
+ ` ${DIM}responsible for any damages, data loss, messages sent, API charges, or${RESET}`,
453
+ );
454
+ console.log(
455
+ ` ${DIM}security vulnerabilities. There may be bugs. Use entirely at your own risk.${RESET}`,
456
+ );
457
+ console.log("");
458
+ }
459
+
460
+ // --- Slash commands ---
461
+
462
+ const HELP_TEXT = `Available commands:
463
+ /help — Show this help
464
+ /new — Start a new conversation (fresh session)
465
+ /compact — Compact context into a fresh session
466
+ /status — Show session info & stats
467
+ /model <name> — Switch model (e.g. /model opus)
468
+ /dir <path> — Change working directory
469
+ /cost — Show cost breakdown
470
+ /resume <id> — Resume a previous session by ID`;
471
+
472
+ interface CommandResult {
473
+ handled: boolean;
474
+ reply: string | null;
475
+ sendToClaude: boolean;
476
+ claudeMessage?: string;
477
+ }
478
+
479
+ function handleSlashCommand(
480
+ text: string,
481
+ session: SessionState,
482
+ args: CliArgs,
483
+ ): CommandResult {
484
+ const trimmed = text.trim();
485
+ if (!trimmed.startsWith("/")) {
486
+ return { handled: false, reply: null, sendToClaude: true };
487
+ }
488
+
489
+ const parts = trimmed.split(/\s+/);
490
+ const cmd = parts[0]!.toLowerCase();
491
+ const rest = parts.slice(1).join(" ");
492
+
493
+ switch (cmd) {
494
+ case "/help":
495
+ case "/commands":
496
+ return { handled: true, reply: HELP_TEXT, sendToClaude: false };
497
+
498
+ case "/new":
499
+ case "/reset": {
500
+ const oldId = session.sessionId.slice(0, 8);
501
+ session.sessionId = crypto.randomUUID();
502
+ session.isFirstMessage = true;
503
+ session.messageCount = 0;
504
+ session.totalCostUsd = 0;
505
+ session.totalDurationMs = 0;
506
+ session.startedAt = new Date();
507
+ const newId = session.sessionId.slice(0, 8);
508
+ console.log(
509
+ ` ${GREEN}\u2713${RESET} New session: ${DIM}${oldId}... \u2192 ${newId}...${RESET}`,
510
+ );
511
+ return {
512
+ handled: true,
513
+ reply: `\u2726 New session started (${newId}...).\nPrevious session (${oldId}...) ended.\nSend a message to begin.`,
514
+ sendToClaude: false,
515
+ };
516
+ }
517
+
518
+ case "/compact":
519
+ // Flag that we want to compact — the main loop will handle the two-step process
520
+ console.log(
521
+ ` ${YELLOW}\u26A0${RESET} Compacting context...`,
522
+ );
523
+ return {
524
+ handled: true,
525
+ reply: null,
526
+ sendToClaude: true,
527
+ claudeMessage: "COMPACT_CONTEXT",
528
+ };
529
+
530
+ case "/status": {
531
+ const uptime = Math.floor(
532
+ (Date.now() - session.startedAt.getTime()) / 1000,
533
+ );
534
+ const uptimeStr = formatDuration(uptime);
535
+ const costStr = session.totalCostUsd > 0
536
+ ? `$${session.totalCostUsd.toFixed(4)}`
537
+ : "$0.00";
538
+ const reply = [
539
+ `\u2726 Bridge Status`,
540
+ `Session: ${session.sessionId.slice(0, 8)}...`,
541
+ `Model: ${session.model || args.model || "(default)"}`,
542
+ `Directory: ${session.workingDir}`,
543
+ `Messages: ${session.messageCount}`,
544
+ `Total cost: ${costStr}`,
545
+ `Uptime: ${uptimeStr}`,
546
+ `First message: ${session.isFirstMessage ? "yes (no context yet)" : "no (session active)"}`,
547
+ ].join("\n");
548
+ return { handled: true, reply, sendToClaude: false };
549
+ }
550
+
551
+ case "/model": {
552
+ if (!rest) {
553
+ const current = session.model || args.model || "(default)";
554
+ return {
555
+ handled: true,
556
+ reply: `Current model: ${current}\nUsage: /model <name>\nExamples: /model opus, /model sonnet, /model haiku`,
557
+ sendToClaude: false,
558
+ };
559
+ }
560
+ const oldModel = session.model || args.model || "(default)";
561
+ session.model = rest;
562
+ args.model = rest;
563
+ console.log(
564
+ ` ${GREEN}\u2713${RESET} Model: ${DIM}${oldModel} \u2192 ${rest}${RESET}`,
565
+ );
566
+ return {
567
+ handled: true,
568
+ reply: `Model switched: ${oldModel} \u2192 ${rest}`,
569
+ sendToClaude: false,
570
+ };
571
+ }
572
+
573
+ case "/dir":
574
+ case "/cd": {
575
+ if (!rest) {
576
+ return {
577
+ handled: true,
578
+ reply: `Current directory: ${session.workingDir}\nUsage: /dir <path>`,
579
+ sendToClaude: false,
580
+ };
581
+ }
582
+ const newDir = resolve(rest.replace(/^~/, process.env.HOME ?? "~"));
583
+ if (!existsSync(newDir)) {
584
+ return {
585
+ handled: true,
586
+ reply: `Directory not found: ${newDir}`,
587
+ sendToClaude: false,
588
+ };
589
+ }
590
+ const oldDir = session.workingDir;
591
+ session.workingDir = newDir;
592
+ console.log(
593
+ ` ${GREEN}\u2713${RESET} Dir: ${DIM}${oldDir} \u2192 ${newDir}${RESET}`,
594
+ );
595
+ return {
596
+ handled: true,
597
+ reply: `Working directory changed:\n${oldDir} \u2192 ${newDir}`,
598
+ sendToClaude: false,
599
+ };
600
+ }
601
+
602
+ case "/cost": {
603
+ const costStr = session.totalCostUsd > 0
604
+ ? `$${session.totalCostUsd.toFixed(4)}`
605
+ : "$0.00";
606
+ const durationStr = formatDuration(
607
+ Math.floor(session.totalDurationMs / 1000),
608
+ );
609
+ return {
610
+ handled: true,
611
+ reply: `\u2726 Cost: ${costStr} over ${session.messageCount} messages (${durationStr} API time)`,
612
+ sendToClaude: false,
613
+ };
614
+ }
615
+
616
+ case "/resume": {
617
+ if (!rest) {
618
+ return {
619
+ handled: true,
620
+ reply: `Usage: /resume <session-id>\nCurrent session: ${session.sessionId}`,
621
+ sendToClaude: false,
622
+ };
623
+ }
624
+ const oldId = session.sessionId.slice(0, 8);
625
+ session.sessionId = rest;
626
+ session.isFirstMessage = false; // resuming means it's not the first message
627
+ console.log(
628
+ ` ${GREEN}\u2713${RESET} Resumed session: ${DIM}${oldId}... \u2192 ${rest.slice(0, 8)}...${RESET}`,
629
+ );
630
+ return {
631
+ handled: true,
632
+ reply: `Resumed session ${rest.slice(0, 8)}...\nSend a message to continue.`,
633
+ sendToClaude: false,
634
+ };
635
+ }
636
+
637
+ case "/whoami":
638
+ return {
639
+ handled: true,
640
+ reply: `You are ${args.contact}, talking to Claude Code via iMessage bridge.`,
641
+ sendToClaude: false,
642
+ };
643
+
644
+ default:
645
+ return {
646
+ handled: true,
647
+ reply: `Unknown command: ${cmd}\nType /help for available commands.`,
648
+ sendToClaude: false,
649
+ };
650
+ }
651
+ }
652
+
653
+ function formatDuration(seconds: number): string {
654
+ if (seconds < 60) return `${seconds}s`;
655
+ const m = Math.floor(seconds / 60);
656
+ const s = seconds % 60;
657
+ if (m < 60) return `${m}m ${s}s`;
658
+ const h = Math.floor(m / 60);
659
+ const rm = m % 60;
660
+ return `${h}h ${rm}m`;
661
+ }
662
+
663
+ // --- Streaming output writer (set from main() to handle scroll region) ---
664
+ // Before main() sets this, it just writes directly.
665
+ let writeOutput: (text: string) => void = (text) => process.stdout.write(text);
666
+
667
+ // --- Streaming JSON display ---
668
+
669
+ interface StreamState {
670
+ currentThinkingText: string;
671
+ thinkingPrinted: boolean;
672
+ currentToolName: string;
673
+ currentToolInput: string;
674
+ currentTextChunks: string[];
675
+ textStarted: boolean;
676
+ assistantCol: number;
677
+ resultText: string;
678
+ totalCost: number;
679
+ numTurns: number;
680
+ durationMs: number;
681
+ sessionId: string;
682
+ model: string;
683
+ mdRenderer: MarkdownRenderer;
684
+ }
685
+
686
+ function newStreamState(): StreamState {
687
+ return {
688
+ currentThinkingText: "",
689
+ thinkingPrinted: false,
690
+ currentToolName: "",
691
+ currentToolInput: "",
692
+ currentTextChunks: [],
693
+ textStarted: false,
694
+ assistantCol: 0,
695
+ resultText: "",
696
+ totalCost: 0,
697
+ numTurns: 0,
698
+ durationMs: 0,
699
+ sessionId: "",
700
+ model: "",
701
+ mdRenderer: new MarkdownRenderer(),
702
+ };
703
+ }
704
+
705
+ function handleSystemInit(data: any, state: StreamState): void {
706
+ state.sessionId = data.session_id ?? "";
707
+ state.model = data.model ?? "";
708
+ const tools = (data.tools ?? []) as string[];
709
+ const mcpServers = (data.mcp_servers ?? []) as any[];
710
+ const version = data.claude_code_version ?? "";
711
+
712
+ console.log(
713
+ ` ${DIM}\u250C\u2500 Session ${state.sessionId.slice(0, 8)}... \u2502 ${state.model} \u2502 v${version}${RESET}`,
714
+ );
715
+
716
+ if (mcpServers.length > 0) {
717
+ const connected = mcpServers
718
+ .filter((s: any) => s.status === "connected")
719
+ .map((s: any) => s.name);
720
+ if (connected.length > 0) {
721
+ console.log(
722
+ ` ${DIM}\u2502 MCP: ${connected.join(", ")}${RESET}`,
723
+ );
724
+ }
725
+ }
726
+
727
+ const mcpTools = tools.filter((t: string) => t.startsWith("mcp__"));
728
+ const builtinTools = tools.filter((t: string) => !t.startsWith("mcp__"));
729
+ console.log(
730
+ ` ${DIM}\u2502 Tools: ${builtinTools.length} built-in, ${mcpTools.length} MCP${RESET}`,
731
+ );
732
+ }
733
+
734
+ const ASSISTANT_INDENT = " "; // 16 spaces — aligns with text after " ◀ [assistant] "
735
+
736
+ /** Wrap text for assistant output, respecting terminal width and indentation. */
737
+ function wrapAssistantText(
738
+ text: string,
739
+ startCol: number,
740
+ ): { wrapped: string; endCol: number } {
741
+ const cols = process.stdout.columns || 80;
742
+ let col = startCol;
743
+ let result = "";
744
+
745
+ for (let i = 0; i < text.length; i++) {
746
+ const ch = text[i]!;
747
+ if (ch === "\x1b") {
748
+ // Pass through ANSI escape sequences without counting columns
749
+ result += ch;
750
+ i++;
751
+ if (i < text.length && text[i] === "[") {
752
+ result += text[i]!;
753
+ i++;
754
+ while (
755
+ i < text.length &&
756
+ text.charCodeAt(i) >= 0x20 &&
757
+ text.charCodeAt(i) <= 0x3f
758
+ ) {
759
+ result += text[i]!;
760
+ i++;
761
+ }
762
+ if (i < text.length) result += text[i]!;
763
+ }
764
+ } else if (ch === "\n") {
765
+ result += "\n" + ASSISTANT_INDENT;
766
+ col = ASSISTANT_INDENT.length;
767
+ } else {
768
+ if (col >= cols) {
769
+ result += "\n" + ASSISTANT_INDENT;
770
+ col = ASSISTANT_INDENT.length;
771
+ }
772
+ result += ch;
773
+ col++;
774
+ }
775
+ }
776
+
777
+ return { wrapped: result, endCol: col };
778
+ }
779
+
780
+ function handleStreamEvent(event: any, state: StreamState): void {
781
+ const etype = event.type;
782
+
783
+ if (etype === "content_block_start") {
784
+ const block = event.content_block ?? {};
785
+ if (block.type === "thinking") {
786
+ state.currentThinkingText = "";
787
+ state.thinkingPrinted = false;
788
+ } else if (block.type === "tool_use") {
789
+ state.currentToolName = block.name ?? "";
790
+ state.currentToolInput = "";
791
+ }
792
+ } else if (etype === "content_block_delta") {
793
+ const delta = event.delta ?? {};
794
+ if (delta.type === "thinking_delta") {
795
+ const chunk = delta.thinking ?? "";
796
+ state.currentThinkingText += chunk;
797
+ if (!state.thinkingPrinted) {
798
+ state.thinkingPrinted = true;
799
+ writeOutput(
800
+ ` ${MAGENTA}${ITALIC} thinking...${RESET} `,
801
+ );
802
+ }
803
+ if (state.currentThinkingText.length % 100 < chunk.length) {
804
+ writeOutput(`${DIM}.${RESET}`);
805
+ }
806
+ } else if (delta.type === "input_json_delta") {
807
+ state.currentToolInput += delta.partial_json ?? "";
808
+ } else if (delta.type === "text_delta") {
809
+ const text = delta.text ?? "";
810
+ if (!state.textStarted) {
811
+ state.textStarted = true;
812
+ state.assistantCol = ASSISTANT_INDENT.length;
813
+ writeOutput(
814
+ ` ${RED}\u25C0${RESET} ${DIM}[assistant]${RESET} `,
815
+ );
816
+ }
817
+ state.currentTextChunks.push(text);
818
+ const rendered = state.mdRenderer.push(text);
819
+ const { wrapped, endCol } = wrapAssistantText(rendered, state.assistantCol);
820
+ state.assistantCol = endCol;
821
+ writeOutput(wrapped);
822
+ }
823
+ } else if (etype === "content_block_stop") {
824
+ // Flush any buffered markdown tokens from the text stream
825
+ if (state.textStarted) {
826
+ const trailing = state.mdRenderer.flush();
827
+ if (trailing) {
828
+ const { wrapped, endCol } = wrapAssistantText(trailing, state.assistantCol);
829
+ state.assistantCol = endCol;
830
+ writeOutput(wrapped);
831
+ }
832
+ }
833
+ if (state.thinkingPrinted) {
834
+ const lines = state.currentThinkingText.split("\n").length;
835
+ console.log(
836
+ ` ${DIM}(${state.currentThinkingText.length} chars, ${lines} lines)${RESET}`,
837
+ );
838
+ const preview = state.currentThinkingText.split("\n").slice(0, 3);
839
+ for (const line of preview) {
840
+ if (line.trim()) {
841
+ console.log(
842
+ ` ${DIM}${MAGENTA} \u2502 ${line.trim().slice(0, 120)}${RESET}`,
843
+ );
844
+ }
845
+ }
846
+ if (state.currentThinkingText.split("\n").length > 3) {
847
+ console.log(
848
+ ` ${DIM}${MAGENTA} \u2502 ...${RESET}`,
849
+ );
850
+ }
851
+ state.currentThinkingText = "";
852
+ state.thinkingPrinted = false;
853
+ }
854
+ }
855
+ // Intentionally NOT logging message_delta context_management here —
856
+ // it fires on every turn even when nothing was compacted.
857
+ }
858
+
859
+ function handleAssistantMessage(data: any, state: StreamState): void {
860
+ const msg = data.message ?? {};
861
+ const content = msg.content ?? [];
862
+
863
+ for (const block of content) {
864
+ if (block.type === "thinking" && block.thinking) {
865
+ const text = block.thinking as string;
866
+ const lines = text.split("\n").length;
867
+ console.log(
868
+ ` ${MAGENTA}${ITALIC} thinking${RESET} ${DIM}(${text.length} chars, ${lines} lines)${RESET}`,
869
+ );
870
+ const preview = text.split("\n").slice(0, 3);
871
+ for (const line of preview) {
872
+ if (line.trim()) {
873
+ console.log(
874
+ ` ${DIM}${MAGENTA} \u2502 ${line.trim().slice(0, 120)}${RESET}`,
875
+ );
876
+ }
877
+ }
878
+ if (lines > 3) {
879
+ console.log(
880
+ ` ${DIM}${MAGENTA} \u2502 ...${RESET}`,
881
+ );
882
+ }
883
+ } else if (block.type === "tool_use") {
884
+ const name = block.name ?? "unknown";
885
+ const input = block.input ?? {};
886
+ const isMcp = name.startsWith("mcp__");
887
+ const color = isMcp ? BRIGHT_CYAN : CYAN;
888
+ const label = isMcp ? "MCP Tool" : "Tool";
889
+
890
+ console.log(
891
+ ` ${color}\u25B6 ${label}: ${BOLD}${name}${RESET}`,
892
+ );
893
+
894
+ const inputStr = formatToolInput(name, input);
895
+ if (inputStr) {
896
+ console.log(` ${DIM}${inputStr}${RESET}`);
897
+ }
898
+ } else if (block.type === "text") {
899
+ const text = block.text ?? "";
900
+ if (text) {
901
+ if (state.currentTextChunks.length === 0) {
902
+ // Non-streamed text block — render markdown and show with assistant label
903
+ const rendered = state.mdRenderer.push(text) + state.mdRenderer.flush();
904
+ const { wrapped } = wrapAssistantText(rendered, ASSISTANT_INDENT.length);
905
+ console.log(` ${RED}\u25C0${RESET} ${DIM}[assistant]${RESET} ${wrapped}`);
906
+ } else {
907
+ // Already streamed via text_delta — just close the line
908
+ console.log("");
909
+ }
910
+ state.currentTextChunks = [];
911
+ state.textStarted = false;
912
+ state.mdRenderer = new MarkdownRenderer();
913
+ }
914
+ }
915
+ }
916
+
917
+ // Context management is handled by /compact command; don't log API-level events
918
+ // as they fire on every turn even when nothing was actually compacted.
919
+ }
920
+
921
+ function handleToolResult(data: any): void {
922
+ const toolResult = data.tool_use_result;
923
+ if (!toolResult) return;
924
+
925
+ const stdout = toolResult.stdout ?? "";
926
+ const stderr = toolResult.stderr ?? "";
927
+ const isError = toolResult.is_error ?? false;
928
+ const isImage = toolResult.isImage ?? false;
929
+
930
+ if (isImage) {
931
+ console.log(` ${DIM}\u2192 [image result]${RESET}`);
932
+ return;
933
+ }
934
+
935
+ if (isError) {
936
+ const errText = stderr || stdout;
937
+ const preview = errText.split("\n").slice(0, 3).join("\n ");
938
+ console.log(` ${RED}\u2192 Error: ${preview.slice(0, 200)}${RESET}`);
939
+ return;
940
+ }
941
+
942
+ const output = stdout || "";
943
+ if (output) {
944
+ const lines = output.split("\n");
945
+ const preview = lines.slice(0, 5);
946
+ for (const line of preview) {
947
+ console.log(` ${DIM}\u2192 ${line.slice(0, 150)}${RESET}`);
948
+ }
949
+ if (lines.length > 5) {
950
+ console.log(
951
+ ` ${DIM}\u2192 ... (${lines.length - 5} more lines)${RESET}`,
952
+ );
953
+ }
954
+ }
955
+ }
956
+
957
+ function handleResult(data: any, state: StreamState): void {
958
+ state.resultText = data.result ?? "";
959
+ state.totalCost = data.total_cost_usd ?? 0;
960
+ state.numTurns = data.num_turns ?? 0;
961
+ state.durationMs = data.duration_ms ?? 0;
962
+
963
+ const costStr =
964
+ state.totalCost > 0 ? `$${state.totalCost.toFixed(4)}` : "";
965
+ const durationStr =
966
+ state.durationMs > 0 ? `${(state.durationMs / 1000).toFixed(1)}s` : "";
967
+
968
+ console.log(
969
+ ` ${DIM}\u2514\u2500 ${state.numTurns} turn${state.numTurns !== 1 ? "s" : ""} \u2502 ${durationStr} \u2502 ${costStr}${RESET}`,
970
+ );
971
+ }
972
+
973
+ function formatToolInput(name: string, input: any): string {
974
+ if (name === "Bash" || name === "bash") {
975
+ return input.command
976
+ ? `$ ${(input.command as string).slice(0, 150)}`
977
+ : "";
978
+ }
979
+ if (name === "Read" || name === "read") {
980
+ return input.file_path ? `file: ${input.file_path}` : "";
981
+ }
982
+ if (name === "Write" || name === "write") {
983
+ return input.file_path ? `file: ${input.file_path}` : "";
984
+ }
985
+ if (name === "Edit" || name === "edit") {
986
+ return input.file_path ? `file: ${input.file_path}` : "";
987
+ }
988
+ if (name === "Glob" || name === "glob") {
989
+ return input.pattern ? `pattern: ${input.pattern}` : "";
990
+ }
991
+ if (name === "Grep" || name === "grep") {
992
+ return input.pattern ? `pattern: ${input.pattern}` : "";
993
+ }
994
+ if (name === "WebFetch") {
995
+ return input.url ? `url: ${input.url}` : "";
996
+ }
997
+ if (name === "WebSearch") {
998
+ return input.query ? `query: ${input.query}` : "";
999
+ }
1000
+ if (name === "Task") {
1001
+ return input.description
1002
+ ? `task: ${input.description}`
1003
+ : input.prompt
1004
+ ? `prompt: ${(input.prompt as string).slice(0, 100)}`
1005
+ : "";
1006
+ }
1007
+
1008
+ // MCP tools — show first meaningful param
1009
+ if (name.startsWith("mcp__")) {
1010
+ const keys = Object.keys(input).filter(
1011
+ (k) => input[k] !== null && input[k] !== undefined && input[k] !== "",
1012
+ );
1013
+ if (keys.length > 0) {
1014
+ const first = keys[0]!;
1015
+ const val = String(input[first]).slice(0, 120);
1016
+ return `${first}: ${val}`;
1017
+ }
1018
+ }
1019
+
1020
+ // Fallback: compact JSON
1021
+ const str = JSON.stringify(input);
1022
+ return str.length > 2 ? str.slice(0, 150) : "";
1023
+ }
1024
+
1025
+ // --- Claude streaming subprocess ---
1026
+
1027
+ async function resolveClaudePath(): Promise<string> {
1028
+ const proc = Bun.spawn(["which", "claude"], { stdout: "pipe" });
1029
+ const out = await new Response(proc.stdout).text();
1030
+ await proc.exited;
1031
+ return out.trim() || "claude";
1032
+ }
1033
+
1034
+ async function runClaudeStreaming(
1035
+ claudePath: string,
1036
+ message: string,
1037
+ session: SessionState,
1038
+ args: CliArgs,
1039
+ onFirstOutput?: () => void,
1040
+ ): Promise<{ text: string; cost: number; durationMs: number }> {
1041
+ const cmd: string[] = [
1042
+ claudePath,
1043
+ "-p",
1044
+ message,
1045
+ "--output-format",
1046
+ "stream-json",
1047
+ "--verbose",
1048
+ "--include-partial-messages",
1049
+ "--dangerously-skip-permissions",
1050
+ ];
1051
+
1052
+ if (session.isFirstMessage) {
1053
+ cmd.push("--session-id", session.sessionId);
1054
+ cmd.push("--append-system-prompt", buildSystemPrompt(args.contact, session.compactSummary));
1055
+ } else {
1056
+ cmd.push("-r", session.sessionId);
1057
+ }
1058
+
1059
+ if (args.model) {
1060
+ cmd.push("--model", args.model);
1061
+ }
1062
+
1063
+ if (args.permissionMode && args.permissionMode !== "bypassPermissions") {
1064
+ cmd.push("--permission-mode", args.permissionMode);
1065
+ }
1066
+
1067
+ const proc = Bun.spawn(cmd, {
1068
+ cwd: session.workingDir,
1069
+ stdout: "pipe",
1070
+ stderr: "pipe",
1071
+ env: {
1072
+ ...process.env,
1073
+ CLAUDECODE: "", // Avoid nested session guard
1074
+ },
1075
+ });
1076
+
1077
+ const state = newStreamState();
1078
+ let resultText = "";
1079
+ let cost = 0;
1080
+ let durationMs = 0;
1081
+ let firstOutputFired = false;
1082
+
1083
+ // Read stderr in background
1084
+ const stderrPromise = new Response(proc.stderr).text();
1085
+
1086
+ // Process stdout line by line as NDJSON
1087
+ const reader = proc.stdout.getReader();
1088
+ const decoder = new TextDecoder();
1089
+ let buffer = "";
1090
+
1091
+ while (true) {
1092
+ const { done, value } = await reader.read();
1093
+ if (done) break;
1094
+
1095
+ buffer += decoder.decode(value, { stream: true });
1096
+
1097
+ let newlineIdx: number;
1098
+ while ((newlineIdx = buffer.indexOf("\n")) !== -1) {
1099
+ const line = buffer.slice(0, newlineIdx).trim();
1100
+ buffer = buffer.slice(newlineIdx + 1);
1101
+
1102
+ if (!line) continue;
1103
+
1104
+ try {
1105
+ const data = JSON.parse(line);
1106
+ const type = data.type as string;
1107
+
1108
+ if (!firstOutputFired) {
1109
+ firstOutputFired = true;
1110
+ onFirstOutput?.();
1111
+ }
1112
+
1113
+ switch (type) {
1114
+ case "system":
1115
+ if (data.subtype === "init") {
1116
+ handleSystemInit(data, state);
1117
+ // Update session model from what Claude actually used
1118
+ if (data.model) session.model = data.model;
1119
+ }
1120
+ break;
1121
+
1122
+ case "stream_event":
1123
+ handleStreamEvent(data.event ?? {}, state);
1124
+ break;
1125
+
1126
+ case "assistant":
1127
+ handleAssistantMessage(data, state);
1128
+ break;
1129
+
1130
+ case "user":
1131
+ handleToolResult(data);
1132
+ break;
1133
+
1134
+ case "result":
1135
+ handleResult(data, state);
1136
+ resultText = data.result ?? "";
1137
+ cost = data.total_cost_usd ?? 0;
1138
+ durationMs = data.duration_ms ?? 0;
1139
+ break;
1140
+ }
1141
+ } catch {
1142
+ // Skip malformed JSON lines
1143
+ }
1144
+ }
1145
+ }
1146
+
1147
+ const exitCode = await proc.exited;
1148
+ const stderr = await stderrPromise;
1149
+
1150
+ if (exitCode !== 0 && !resultText) {
1151
+ const errMsg = stderr.trim() || `claude exited with code ${exitCode}`;
1152
+ throw new Error(errMsg);
1153
+ }
1154
+
1155
+ return { text: resultText, cost, durationMs };
1156
+ }
1157
+
1158
+ // --- Main ---
1159
+
1160
+ async function main(): Promise<void> {
1161
+ const args = parseArgs(process.argv.slice(2));
1162
+
1163
+ // Single stdin reader — used for both the contact prompt and ongoing input
1164
+ const stdinReader = Bun.stdin.stream().getReader();
1165
+ const stdinDecoder = new TextDecoder();
1166
+ let stdinBuf = "";
1167
+
1168
+ // Helper: read one line from stdin
1169
+ async function readLine(): Promise<string> {
1170
+ while (true) {
1171
+ const nl = stdinBuf.indexOf("\n");
1172
+ if (nl !== -1) {
1173
+ const line = stdinBuf.slice(0, nl).trim();
1174
+ stdinBuf = stdinBuf.slice(nl + 1);
1175
+ return line;
1176
+ }
1177
+ const { done, value } = await stdinReader.read();
1178
+ if (done) return stdinBuf.trim();
1179
+ stdinBuf += stdinDecoder.decode(value, { stream: true });
1180
+ }
1181
+ }
1182
+
1183
+ if (!args.contact) {
1184
+ console.log("");
1185
+ console.log(
1186
+ ` ${BRIGHT_MAGENTA}\u2726${RESET} ${BOLD}iMessage ${BRIGHT_MAGENTA}\u2194${RESET}${BOLD} Claude Code${RESET}`,
1187
+ );
1188
+ console.log("");
1189
+ process.stdout.write(
1190
+ ` ${BRIGHT_MAGENTA}Phone or email:${RESET} `,
1191
+ );
1192
+ const input = await readLine();
1193
+ if (!input) {
1194
+ console.error(
1195
+ `\n ${RED}\u2717${RESET} ${RED}No contact provided.${RESET}\n`,
1196
+ );
1197
+ process.exit(1);
1198
+ }
1199
+ args.contact = input;
1200
+ // Clear the interactive prompt and continue to full banner
1201
+ process.stdout.write("\x1b[A\x1b[2K\x1b[A\x1b[2K\x1b[A\x1b[2K\x1b[A\x1b[2K\x1b[A\x1b[2K");
1202
+ }
1203
+
1204
+ const workingDir = resolve(args.dir);
1205
+ if (!existsSync(workingDir)) {
1206
+ console.error(
1207
+ ` ${RED}\u2717${RESET} ${RED}Working directory does not exist: ${workingDir}${RESET}`,
1208
+ );
1209
+ process.exit(1);
1210
+ }
1211
+
1212
+ if (process.platform !== "darwin") {
1213
+ console.error(
1214
+ ` ${RED}\u2717${RESET} ${RED}This tool only works on macOS (required for iMessage access)${RESET}`,
1215
+ );
1216
+ process.exit(1);
1217
+ }
1218
+
1219
+ // Preflight checks
1220
+ const hasClaude = await verifyClaudeInstalled();
1221
+ if (!hasClaude) {
1222
+ console.error(
1223
+ ` ${RED}\u2717${RESET} ${RED}Claude Code CLI not found. Install: npm install -g @anthropic-ai/claude-code${RESET}`,
1224
+ );
1225
+ process.exit(1);
1226
+ }
1227
+
1228
+ const canSend = await verifySendPermission();
1229
+ if (!canSend) {
1230
+ console.error(
1231
+ ` ${RED}\u2717${RESET} ${RED}Cannot connect to Messages.app. Open Messages.app and allow Automation.${RESET}`,
1232
+ );
1233
+ process.exit(1);
1234
+ }
1235
+
1236
+ const claudePath = await resolveClaudePath();
1237
+
1238
+ // Print banner
1239
+ printBanner(args, workingDir);
1240
+
1241
+ // Session state
1242
+ const session: SessionState = {
1243
+ sessionId: crypto.randomUUID(),
1244
+ isFirstMessage: true,
1245
+ model: args.model,
1246
+ workingDir,
1247
+ messageCount: 0,
1248
+ totalCostUsd: 0,
1249
+ totalDurationMs: 0,
1250
+ startedAt: new Date(),
1251
+ compactSummary: "",
1252
+ };
1253
+
1254
+ let processing = false;
1255
+ let promptVisible = false;
1256
+
1257
+ // Signal to wake the main loop immediately when a local message arrives
1258
+ let wakeResolve: (() => void) | null = null;
1259
+ function wakeMainLoop(): void {
1260
+ if (wakeResolve) {
1261
+ wakeResolve();
1262
+ wakeResolve = null;
1263
+ }
1264
+ }
1265
+ function interruptibleSleep(ms: number): Promise<void> {
1266
+ return new Promise((resolve) => {
1267
+ wakeResolve = resolve;
1268
+ setTimeout(resolve, ms);
1269
+ });
1270
+ }
1271
+
1272
+ // Initialize iMessage database reader
1273
+ const db = new IMessageDatabase();
1274
+ db.initialize();
1275
+
1276
+ // Resolve contact to the exact handle ID in the database.
1277
+ // This handles cases like "6692333038" → "+16692333038".
1278
+ let contactResolved = false;
1279
+ function tryResolveContact(): void {
1280
+ if (contactResolved) return;
1281
+ const resolved = db.findContact(args.contact);
1282
+ if (resolved) {
1283
+ if (resolved.id !== args.contact) {
1284
+ console.log(
1285
+ ` ${GREEN}\u2713${RESET} Resolved contact ${DIM}${args.contact} \u2192 ${resolved.id}${RESET}`,
1286
+ );
1287
+ args.contact = resolved.id;
1288
+ }
1289
+ contactResolved = true;
1290
+ }
1291
+ }
1292
+ tryResolveContact();
1293
+ if (!contactResolved) {
1294
+ console.log(
1295
+ ` ${YELLOW}\u26A0${RESET} ${YELLOW}Contact "${args.contact}" not found in iMessage database yet.${RESET}`,
1296
+ );
1297
+ console.log(
1298
+ ` ${DIM} Will keep trying — once they text you, the handle will appear.${RESET}`,
1299
+ );
1300
+ }
1301
+
1302
+ // Track recently sent messages to avoid echo loops
1303
+ const recentlySent = new Map<string, number>();
1304
+
1305
+ function isEcho(text: string): boolean {
1306
+ const now = Date.now();
1307
+ for (const [key, ts] of recentlySent) {
1308
+ if (now - ts > 300_000) recentlySent.delete(key);
1309
+ }
1310
+ const fingerprint = text.slice(0, 150).toLowerCase().trim();
1311
+ for (const [sent] of recentlySent) {
1312
+ if (fingerprint.startsWith(sent) || sent.startsWith(fingerprint)) {
1313
+ return true;
1314
+ }
1315
+ }
1316
+ return false;
1317
+ }
1318
+
1319
+ function trackSent(text: string): void {
1320
+ const fingerprint = text.slice(0, 150).toLowerCase().trim();
1321
+ recentlySent.set(fingerprint, Date.now());
1322
+ }
1323
+
1324
+ // Send startup message via iMessage
1325
+ try {
1326
+ const startupMsg = [
1327
+ "\u2726 iMessage \u2194 Claude Code bridge is active.",
1328
+ "",
1329
+ "Send a message to chat with Claude Code.",
1330
+ "Type /help for available commands.",
1331
+ ].join("\n");
1332
+ trackSent(startupMsg);
1333
+ await sendIMessage(args.contact, startupMsg);
1334
+ await db.advanceCursorWithDelay();
1335
+ console.log(
1336
+ ` ${GREEN}\u2713${RESET} Startup message sent to ${BOLD}${args.contact}${RESET}`,
1337
+ );
1338
+ } catch (e: any) {
1339
+ console.log(
1340
+ ` ${YELLOW}\u26A0${RESET} Could not send startup message: ${DIM}${e.message}${RESET}`,
1341
+ );
1342
+ }
1343
+
1344
+ console.log(
1345
+ ` ${GREEN}\u2713${RESET} Session: ${DIM}${session.sessionId}${RESET}`,
1346
+ );
1347
+ console.log(
1348
+ `\n ${DIM}Type /help for available commands${RESET}`,
1349
+ );
1350
+ console.log("");
1351
+
1352
+ // Unified message queue — both local stdin and iMessages go here
1353
+ interface QueuedMessage {
1354
+ text: string;
1355
+ source: "local" | "imessage";
1356
+ sender?: string;
1357
+ time?: string;
1358
+ }
1359
+ const messageQueue: QueuedMessage[] = [];
1360
+
1361
+ // Background iMessage poller — runs during processing to detect queued iMessages
1362
+ let imessagePollerTimer: ReturnType<typeof setInterval> | null = null;
1363
+
1364
+ function startImessagePoller(): void {
1365
+ if (imessagePollerTimer) return;
1366
+ imessagePollerTimer = setInterval(() => {
1367
+ try {
1368
+ const messages = db.getNewMessages(args.contact);
1369
+ const incoming = messages.filter((msg) => !isEcho(msg.text));
1370
+ for (const msg of incoming) {
1371
+ const time = msg.date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
1372
+ messageQueue.push({ text: msg.text, source: "imessage", sender: msg.sender, time });
1373
+ const queueNum = messageQueue.length;
1374
+ const cols = process.stdout.columns || 80;
1375
+ const prefix = ` ○ queued (${queueNum} pending) [${time}] ${msg.sender}: `;
1376
+ const maxText = cols - prefix.length;
1377
+ const displayMsg = msg.text.length > maxText ? msg.text.slice(0, maxText - 1) + '\u2026' : msg.text;
1378
+ console.log(
1379
+ ` ${YELLOW}\u25CB${RESET} ${DIM}queued (${queueNum} pending)${RESET} ${DIM}[${time}]${RESET} ${BOLD}${msg.sender}${RESET}: ${displayMsg}`,
1380
+ );
1381
+ }
1382
+ } catch {
1383
+ // Ignore DB errors during background polling
1384
+ }
1385
+ }, args.interval);
1386
+ }
1387
+
1388
+ function stopImessagePoller(): void {
1389
+ if (imessagePollerTimer) {
1390
+ clearInterval(imessagePollerTimer);
1391
+ imessagePollerTimer = null;
1392
+ }
1393
+ }
1394
+
1395
+ // --- Raw mode input handling ---
1396
+ // We take full control of stdin so typed chars never leak into output.
1397
+
1398
+ let inputBuffer = ""; // Current line being typed
1399
+ let spinnerTimer: ReturnType<typeof setInterval> | null = null;
1400
+ let dynamicLineCount = 0; // How many "dynamic" lines at bottom to erase before next update
1401
+ let streamingCol = 0; // Track cursor column during streaming so we can restore position
1402
+
1403
+ const SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
1404
+
1405
+ // Enable raw mode so we control echo
1406
+ if (process.stdin.isTTY) {
1407
+ process.stdin.setRawMode(true);
1408
+ }
1409
+
1410
+ // --- Erase-and-redraw UI (Ink pattern) ---
1411
+ // Static output is written permanently and scrolls up.
1412
+ // Dynamic content (prompt, spinner) is erased and redrawn at the bottom.
1413
+ // No scroll regions. No cursor save/restore. Just erase + redraw.
1414
+
1415
+ /** Erase N lines starting from cursor (going up). */
1416
+ function eraseLines(count: number): void {
1417
+ for (let i = 0; i < count; i++) {
1418
+ process.stdout.write('\x1b[2K'); // clear entire line
1419
+ if (i < count - 1) {
1420
+ process.stdout.write('\x1b[1A'); // cursor up
1421
+ }
1422
+ }
1423
+ process.stdout.write('\r'); // carriage return to col 0
1424
+ }
1425
+
1426
+ // Get the visible portion of the current input line (handles multi-line)
1427
+ function getVisibleInput(): string {
1428
+ const cols = process.stdout.columns || 80;
1429
+ const maxInput = cols - 5; // " > " or " .. " = 4-5 chars + margin
1430
+ const lastNl = inputBuffer.lastIndexOf("\n");
1431
+ const currentLine = lastNl >= 0 ? inputBuffer.slice(lastNl + 1) : inputBuffer;
1432
+ return currentLine.length > maxInput
1433
+ ? currentLine.slice(currentLine.length - maxInput)
1434
+ : currentLine;
1435
+ }
1436
+
1437
+ /** Erase dynamic section, then redraw the appropriate dynamic content. */
1438
+ function redrawPrompt(): void {
1439
+ const hadDynamic = dynamicLineCount > 0;
1440
+ if (dynamicLineCount > 0) {
1441
+ eraseLines(dynamicLineCount);
1442
+ dynamicLineCount = 0;
1443
+ }
1444
+ // If spinner is running, redraw spinner + prompt (not just prompt)
1445
+ if (spinnerTimer) {
1446
+ drawSpinnerAndPrompt();
1447
+ return;
1448
+ }
1449
+ // During streaming, put prompt on its own line below the streaming text.
1450
+ // If we just erased a dynamic line, cursor is already on a separate line
1451
+ // (col 0 of where the old prompt was). If not, cursor is at the end of
1452
+ // streaming text, so we need a newline first.
1453
+ if (streamingActive && !hadDynamic) {
1454
+ process.stdout.write('\n');
1455
+ }
1456
+ const inContinuation = inputBuffer.includes("\n");
1457
+ const prefix = inContinuation
1458
+ ? ` ${DIM}..${RESET} `
1459
+ : ` ${BRIGHT_MAGENTA}>${RESET} `;
1460
+ process.stdout.write(`${prefix}${getVisibleInput()}`);
1461
+ dynamicLineCount = 1;
1462
+ promptVisible = true;
1463
+ }
1464
+
1465
+ /** Erase dynamic section, write permanent output, redraw dynamic section. */
1466
+ let streamingActive = false;
1467
+ function writePermanent(text: string): void {
1468
+ // Erase the current dynamic section (prompt + maybe spinner)
1469
+ const hadDynamic = dynamicLineCount > 0;
1470
+ if (dynamicLineCount > 0) {
1471
+ eraseLines(dynamicLineCount);
1472
+ dynamicLineCount = 0;
1473
+ }
1474
+ if (streamingActive) {
1475
+ if (hadDynamic) {
1476
+ // Prompt was on its own line below streaming text.
1477
+ // After erasing, cursor is at col 0 of where the prompt was.
1478
+ // That's fine — just write the permanent output here.
1479
+ } else {
1480
+ // No prompt was drawn, cursor is at end of streaming text.
1481
+ // Need a newline to start permanent output on a new line.
1482
+ process.stdout.write('\n');
1483
+ }
1484
+ streamingActive = false;
1485
+ streamingCol = 0;
1486
+ }
1487
+ // Write the permanent output (becomes part of scroll history)
1488
+ process.stdout.write(text + '\n');
1489
+ // Redraw the dynamic section
1490
+ if (spinnerTimer) {
1491
+ drawSpinnerAndPrompt();
1492
+ } else if (promptVisible) {
1493
+ redrawPrompt();
1494
+ }
1495
+ }
1496
+
1497
+ // Override console.log/error to go through writePermanent.
1498
+ const _origLog = console.log.bind(console);
1499
+ const _origError = console.error.bind(console);
1500
+ console.log = (...args: any[]) => {
1501
+ const text = args.map(a => typeof a === 'string' ? a : String(a)).join(' ');
1502
+ writePermanent(text);
1503
+ };
1504
+ console.error = (...args: any[]) => {
1505
+ const text = args.map(a => typeof a === 'string' ? a : String(a)).join(' ');
1506
+ writePermanent(text);
1507
+ };
1508
+
1509
+ /** Update streamingCol based on text just written. Skips ANSI escape sequences. */
1510
+ function updateStreamingCol(text: string): void {
1511
+ const cols = process.stdout.columns || 80;
1512
+ let i = 0;
1513
+ while (i < text.length) {
1514
+ const ch = text[i]!;
1515
+ if (ch === '\x1b') {
1516
+ // Skip ANSI escape sequence: ESC [ ... <letter>
1517
+ i++;
1518
+ if (i < text.length && text[i] === '[') {
1519
+ i++;
1520
+ while (i < text.length && text.charCodeAt(i) >= 0x20 && text.charCodeAt(i) <= 0x3f) {
1521
+ i++; // skip parameter bytes and intermediate bytes
1522
+ }
1523
+ if (i < text.length) i++; // skip final byte
1524
+ }
1525
+ continue;
1526
+ }
1527
+ if (ch === '\n') {
1528
+ streamingCol = 0;
1529
+ } else if (ch === '\r') {
1530
+ streamingCol = 0;
1531
+ } else {
1532
+ streamingCol++;
1533
+ if (streamingCol >= cols) {
1534
+ streamingCol = 0;
1535
+ }
1536
+ }
1537
+ i++;
1538
+ }
1539
+ }
1540
+
1541
+ /**
1542
+ * Write streaming text directly. Erases the prompt if visible, restores cursor
1543
+ * to the streaming line, writes text, then redraws the prompt below.
1544
+ */
1545
+ function writeStreaming(text: string): void {
1546
+ // If there's a dynamic section (prompt below streaming text), erase it
1547
+ // and restore cursor to the end of the streaming text line
1548
+ if (dynamicLineCount > 0) {
1549
+ eraseLines(dynamicLineCount);
1550
+ dynamicLineCount = 0;
1551
+ // After erasing, cursor is at col 0 of the prompt line.
1552
+ // If streaming was active, the prompt was on its own line below the
1553
+ // streaming text — move up and restore column position.
1554
+ if (streamingActive) {
1555
+ process.stdout.write(`\x1b[1A`); // move up to streaming line
1556
+ if (streamingCol > 0) {
1557
+ process.stdout.write(`\x1b[${streamingCol}C`); // restore column
1558
+ }
1559
+ }
1560
+ }
1561
+ // Write the streaming text directly
1562
+ process.stdout.write(text);
1563
+ streamingActive = true;
1564
+ // Track column position for cursor restoration
1565
+ updateStreamingCol(text);
1566
+ // Redraw prompt below the streaming text
1567
+ redrawPrompt();
1568
+ }
1569
+
1570
+ // Connect the global writeOutput
1571
+ writeOutput = writeStreaming;
1572
+
1573
+ let spinnerFrame = 0; // Current spinner frame — shared so keystrokes don't reset it
1574
+
1575
+ /** Draw spinner line + prompt line (2 dynamic lines). */
1576
+ function drawSpinnerAndPrompt(): void {
1577
+ const inC = inputBuffer.includes("\n");
1578
+ const p = inC ? ` ${DIM}..${RESET} ` : ` ${BRIGHT_MAGENTA}>${RESET} `;
1579
+ process.stdout.write(` ${DIM}${SPINNER_FRAMES[spinnerFrame]} Thinking...${RESET}\n`);
1580
+ process.stdout.write(`${p}${getVisibleInput()}`);
1581
+ dynamicLineCount = 2;
1582
+ }
1583
+
1584
+ /** Stop spinner and erase it, leaving just the prompt. */
1585
+ function clearSpinner(): void {
1586
+ stopSpinner();
1587
+ // Erase dynamic section (spinner + prompt) and redraw just prompt
1588
+ if (dynamicLineCount > 0) {
1589
+ eraseLines(dynamicLineCount);
1590
+ dynamicLineCount = 0;
1591
+ }
1592
+ redrawPrompt();
1593
+ }
1594
+
1595
+ function startSpinner(): void {
1596
+ spinnerFrame = 0;
1597
+ streamingCol = 0;
1598
+ // Erase current dynamic section and draw spinner + prompt
1599
+ if (dynamicLineCount > 0) {
1600
+ eraseLines(dynamicLineCount);
1601
+ dynamicLineCount = 0;
1602
+ }
1603
+ drawSpinnerAndPrompt();
1604
+
1605
+ spinnerTimer = setInterval(() => {
1606
+ spinnerFrame = (spinnerFrame + 1) % SPINNER_FRAMES.length;
1607
+ // Erase the 2 dynamic lines, redraw
1608
+ eraseLines(dynamicLineCount);
1609
+ dynamicLineCount = 0;
1610
+ drawSpinnerAndPrompt();
1611
+ }, 80);
1612
+ }
1613
+
1614
+ function stopSpinner(): void {
1615
+ if (spinnerTimer) {
1616
+ clearInterval(spinnerTimer);
1617
+ spinnerTimer = null;
1618
+ }
1619
+ }
1620
+
1621
+ // Read raw stdin bytes in background
1622
+ async function readStdinRaw(): Promise<void> {
1623
+ while (true) {
1624
+ // Drain leftover buffer from cooked-mode readLine
1625
+ if (stdinBuf.length > 0) {
1626
+ for (const ch of stdinBuf) {
1627
+ handleChar(ch);
1628
+ }
1629
+ stdinBuf = "";
1630
+ }
1631
+
1632
+ const { done, value } = await stdinReader.read();
1633
+ if (done) break;
1634
+ const chunk = stdinDecoder.decode(value, { stream: true });
1635
+ for (const ch of chunk) {
1636
+ handleChar(ch);
1637
+ }
1638
+ }
1639
+ }
1640
+
1641
+ let escapeSeq = 0; // 0=normal, 1=got ESC, 2=got ESC+[
1642
+
1643
+ function handleChar(ch: string): void {
1644
+ const code = ch.charCodeAt(0);
1645
+
1646
+ // Swallow escape sequences (e.g. arrow keys: ESC [ A)
1647
+ if (escapeSeq === 1) {
1648
+ escapeSeq = ch === "[" ? 2 : 0;
1649
+ return;
1650
+ }
1651
+ if (escapeSeq === 2) {
1652
+ escapeSeq = 0;
1653
+ return;
1654
+ }
1655
+ if (code === 27) {
1656
+ escapeSeq = 1;
1657
+ return;
1658
+ }
1659
+
1660
+ // Ctrl+C
1661
+ if (code === 3) {
1662
+ shutdown();
1663
+ return;
1664
+ }
1665
+
1666
+ // Ctrl+D on empty line
1667
+ if (code === 4 && inputBuffer.length === 0) {
1668
+ shutdown();
1669
+ return;
1670
+ }
1671
+
1672
+ // Enter
1673
+ if (ch === "\r" || ch === "\n") {
1674
+ // Backslash at end = line continuation (add newline to buffer, keep typing)
1675
+ if (inputBuffer.endsWith("\\")) {
1676
+ inputBuffer = inputBuffer.slice(0, -1) + "\n";
1677
+ redrawPrompt(); // Shows ".." prefix for continuation
1678
+ return;
1679
+ }
1680
+
1681
+ const line = inputBuffer.trim();
1682
+ inputBuffer = "";
1683
+
1684
+ if (!line) {
1685
+ redrawPrompt();
1686
+ return;
1687
+ }
1688
+
1689
+ messageQueue.push({ text: line, source: "local" });
1690
+ wakeMainLoop();
1691
+
1692
+ if (processing) {
1693
+ const queueNum = messageQueue.length;
1694
+ const cols = process.stdout.columns || 80;
1695
+ const queuePrefix = ` \u25CB queued (${queueNum} pending) `;
1696
+ const maxQueueText = cols - queuePrefix.length;
1697
+ const queueDisplay = line.length > maxQueueText ? line.slice(0, maxQueueText - 1) + '\u2026' : line;
1698
+
1699
+ console.log(
1700
+ ` ${YELLOW}\u25CB${RESET} ${DIM}queued (${queueNum} pending)${RESET} ${queueDisplay}`,
1701
+ );
1702
+ }
1703
+
1704
+ // Redraw prompt to clear the typed text
1705
+ redrawPrompt();
1706
+ return;
1707
+ }
1708
+
1709
+ // Backspace / Delete
1710
+ if (code === 127 || code === 8) {
1711
+ if (inputBuffer.length > 0) {
1712
+ inputBuffer = inputBuffer.slice(0, -1);
1713
+ redrawPrompt();
1714
+ }
1715
+ return;
1716
+ }
1717
+
1718
+ // Ignore control characters
1719
+ if (code < 32) return;
1720
+
1721
+ // Normal printable character
1722
+ inputBuffer += ch;
1723
+ redrawPrompt();
1724
+ }
1725
+
1726
+ // Start reading stdin (non-blocking — runs in background)
1727
+ readStdinRaw().catch(() => {});
1728
+
1729
+ // Show initial prompt
1730
+ redrawPrompt();
1731
+
1732
+ // Graceful shutdown
1733
+ let running = true;
1734
+
1735
+ function shutdown(): void {
1736
+ if (!running) return;
1737
+ running = false;
1738
+ stopSpinner();
1739
+ stopImessagePoller();
1740
+ if (dynamicLineCount > 0) {
1741
+ eraseLines(dynamicLineCount);
1742
+ dynamicLineCount = 0;
1743
+ }
1744
+ promptVisible = false;
1745
+ if (process.stdin.isTTY) {
1746
+ process.stdin.setRawMode(false);
1747
+ }
1748
+ _origLog(`\n ${DIM}Shutting down...${RESET}`);
1749
+ db.close();
1750
+ process.exit(0);
1751
+ }
1752
+
1753
+ process.on("SIGINT", shutdown);
1754
+ process.on("SIGTERM", shutdown);
1755
+
1756
+ // --- Process a batch of messages ---
1757
+ async function processMessages(
1758
+ messages: QueuedMessage[],
1759
+ ): Promise<void> {
1760
+ // Build combined prompt from all sources
1761
+ const parts: string[] = [];
1762
+
1763
+ console.log(`${"─".repeat(60)}`);
1764
+ for (const msg of messages) {
1765
+ const cols = process.stdout.columns || 80;
1766
+ if (msg.source === "imessage") {
1767
+ const time = msg.time ?? "";
1768
+ const sender = msg.sender ?? args.contact;
1769
+ const prefixLen = 6 + time.length + sender.length + 2;
1770
+ const maxText = cols - prefixLen;
1771
+ const displayMsg = msg.text.length > maxText ? msg.text.slice(0, maxText - 1) + '\u2026' : msg.text;
1772
+ console.log(
1773
+ ` ${BRIGHT_MAGENTA}\u2726${RESET} ${DIM}[${time}]${RESET} ${BOLD}${sender}${RESET}: ${displayMsg}`,
1774
+ );
1775
+ } else {
1776
+ const displayText = msg.text.length > cols - 13 ? msg.text.slice(0, cols - 14) + '\u2026' : msg.text;
1777
+ console.log(
1778
+ ` ${GREEN}\u25B6${RESET} ${DIM}[local]${RESET} ${displayText}`,
1779
+ );
1780
+ }
1781
+ parts.push(msg.text);
1782
+ }
1783
+
1784
+ const combined = parts.join("\n\n");
1785
+ console.log("");
1786
+
1787
+ // Check for slash commands
1788
+ const cmdResult = handleSlashCommand(combined, session, args);
1789
+
1790
+ if (cmdResult.handled && !cmdResult.sendToClaude) {
1791
+ if (cmdResult.reply) {
1792
+ // Show the response locally
1793
+ for (const line of cmdResult.reply.split("\n")) {
1794
+ console.log(` ${RED}\u25C0${RESET} ${DIM}${line}${RESET}`);
1795
+ }
1796
+ trackSent(cmdResult.reply);
1797
+ await sendIMessage(args.contact, cmdResult.reply);
1798
+ await db.advanceCursorWithDelay();
1799
+ console.log(
1800
+ ` ${GREEN}\u2713 Command response sent${RESET}`,
1801
+ );
1802
+ }
1803
+ } else if (cmdResult.claudeMessage === "COMPACT_CONTEXT") {
1804
+ // Two-step compact: ask for summary, then start fresh session with it
1805
+ startSpinner();
1806
+ try {
1807
+ const summaryResult = await runClaudeStreaming(
1808
+ claudePath,
1809
+ "Summarize our entire conversation so far in a few concise bullet points. Include: what we're working on, key decisions, current state of the work, and any important context. Be brief but complete. Output ONLY the summary, nothing else.",
1810
+ session,
1811
+ args,
1812
+ clearSpinner,
1813
+ );
1814
+ session.totalCostUsd += summaryResult.cost;
1815
+ session.totalDurationMs += summaryResult.durationMs;
1816
+
1817
+ const summary = summaryResult.text || "";
1818
+ const oldId = session.sessionId.slice(0, 8);
1819
+
1820
+ // Start new session with summary baked in
1821
+ session.sessionId = crypto.randomUUID();
1822
+ session.isFirstMessage = true;
1823
+ session.compactSummary = summary;
1824
+ const newId = session.sessionId.slice(0, 8);
1825
+
1826
+ console.log(
1827
+ `\n ${GREEN}\u2713${RESET} Compacted: ${DIM}${oldId}... \u2192 ${newId}...${RESET}`,
1828
+ );
1829
+
1830
+ const reply = summary
1831
+ ? `\u2726 Context compacted (${oldId}... \u2192 ${newId}...)\n\nCarried over:\n${summary}`
1832
+ : `\u2726 Context compacted (${oldId}... \u2192 ${newId}...)`;
1833
+ trackSent(reply);
1834
+ await sendIMessage(args.contact, reply);
1835
+ await db.advanceCursorWithDelay();
1836
+ console.log(
1837
+ ` ${GREEN}\u2713 Compact response sent${RESET}`,
1838
+ );
1839
+ } catch (e: any) {
1840
+ clearSpinner();
1841
+ console.error(
1842
+ ` ${RED}\u2717 Compact failed: ${e.message}${RESET}`,
1843
+ );
1844
+ try {
1845
+ await sendIMessage(
1846
+ args.contact,
1847
+ `[Compact failed: ${e.message.slice(0, 200)}]`,
1848
+ );
1849
+ await db.advanceCursorWithDelay();
1850
+ } catch {
1851
+ // Ignore
1852
+ }
1853
+ }
1854
+ } else {
1855
+ // Send to Claude (normal message or command that proxies to Claude)
1856
+ const prompt = cmdResult.claudeMessage ?? combined;
1857
+ startSpinner();
1858
+
1859
+ try {
1860
+ const result = await runClaudeStreaming(
1861
+ claudePath,
1862
+ prompt,
1863
+ session,
1864
+ args,
1865
+ clearSpinner,
1866
+ );
1867
+ session.isFirstMessage = false;
1868
+ session.messageCount++;
1869
+ session.totalCostUsd += result.cost;
1870
+ session.totalDurationMs += result.durationMs;
1871
+
1872
+ if (result.text) {
1873
+ console.log("");
1874
+ trackSent(result.text);
1875
+ await sendIMessage(args.contact, result.text);
1876
+ await db.advanceCursorWithDelay();
1877
+ console.log(
1878
+ ` ${GREEN}\u2713 Response sent via iMessage${RESET} ${DIM}(${result.text.length} chars)${RESET}`,
1879
+ );
1880
+ } else {
1881
+ console.log(
1882
+ ` ${YELLOW}\u26A0${RESET} ${DIM}Empty response from Claude${RESET}`,
1883
+ );
1884
+ }
1885
+ } catch (e: any) {
1886
+ clearSpinner();
1887
+ console.error(
1888
+ ` ${RED}\u2717 Error: ${e.message}${RESET}`,
1889
+ );
1890
+ try {
1891
+ await sendIMessage(
1892
+ args.contact,
1893
+ `[Error: ${e.message.slice(0, 200)}]`,
1894
+ );
1895
+ await db.advanceCursorWithDelay();
1896
+ } catch {
1897
+ // Ignore send failure for error messages
1898
+ }
1899
+ }
1900
+ }
1901
+
1902
+ console.log("");
1903
+ }
1904
+
1905
+ // Main polling loop
1906
+ while (running) {
1907
+ try {
1908
+ if (!processing) {
1909
+ // Retry contact resolution if not yet matched (e.g. new number)
1910
+ tryResolveContact();
1911
+ // Check iMessage DB for new messages and add to unified queue
1912
+ const dbMessages = db.getNewMessages(args.contact);
1913
+ const incoming = dbMessages.filter((msg) => !isEcho(msg.text));
1914
+ for (const msg of incoming) {
1915
+ const time = msg.date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
1916
+ messageQueue.push({ text: msg.text, source: "imessage", sender: msg.sender, time });
1917
+ }
1918
+
1919
+ if (messageQueue.length > 0) {
1920
+ processing = true;
1921
+ startImessagePoller();
1922
+
1923
+ // Drain the queue batch
1924
+ const batch: QueuedMessage[] = [];
1925
+ while (messageQueue.length > 0) {
1926
+ batch.push(messageQueue.shift()!);
1927
+ }
1928
+ await processMessages(batch);
1929
+
1930
+ // Drain any messages that were queued while processing
1931
+ while (running) {
1932
+ if (messageQueue.length === 0) break;
1933
+ const nextBatch: QueuedMessage[] = [];
1934
+ while (messageQueue.length > 0) {
1935
+ nextBatch.push(messageQueue.shift()!);
1936
+ }
1937
+ await processMessages(nextBatch);
1938
+ }
1939
+
1940
+ stopImessagePoller();
1941
+ processing = false;
1942
+ streamingActive = false;
1943
+ streamingCol = 0;
1944
+ // Force-draw the prompt
1945
+ redrawPrompt();
1946
+ }
1947
+ }
1948
+ } catch {
1949
+ // Silently ignore DB errors during polling
1950
+ }
1951
+
1952
+ await interruptibleSleep(args.interval);
1953
+ }
1954
+ }
1955
+
1956
+ main().catch((e) => {
1957
+ console.error(` ${RED}\u2717${RESET} ${RED}${e.message}${RESET}`);
1958
+ process.exit(1);
1959
+ });