@implicit-ai/relay 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.
Files changed (3) hide show
  1. package/README.md +61 -0
  2. package/dist/cli.js +1558 -0
  3. package/package.json +46 -0
package/dist/cli.js ADDED
@@ -0,0 +1,1558 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { program } from "commander";
5
+
6
+ // src/version.ts
7
+ var version = "1.0.0";
8
+
9
+ // src/commands/connect.ts
10
+ import { createInterface } from "readline";
11
+ import { createClient as createClient2 } from "@supabase/supabase-js";
12
+
13
+ // src/lib/auth.ts
14
+ import { execaSync } from "execa";
15
+ import fs from "fs";
16
+ import path from "path";
17
+ import os from "os";
18
+ function detectClaudeCodeInstalled() {
19
+ try {
20
+ const result = execaSync("claude", ["--version"]);
21
+ const version2 = result.stdout.trim() || null;
22
+ return { installed: true, version: version2 };
23
+ } catch {
24
+ return { installed: false, version: null };
25
+ }
26
+ }
27
+ function detectClaudeAuth() {
28
+ if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
29
+ return { method: "oauth" };
30
+ }
31
+ if (process.env.ANTHROPIC_API_KEY) {
32
+ return { method: "api_key" };
33
+ }
34
+ if (process.platform === "darwin") {
35
+ const keychainServices = ["Claude Code-credentials", "claude.ai"];
36
+ for (const service of keychainServices) {
37
+ try {
38
+ const result = execaSync("security", [
39
+ "find-generic-password",
40
+ "-s",
41
+ service
42
+ ]);
43
+ if (result.exitCode === 0) {
44
+ return { method: "oauth" };
45
+ }
46
+ } catch {
47
+ }
48
+ }
49
+ }
50
+ const credentialsPath = path.join(os.homedir(), ".claude", ".credentials.json");
51
+ try {
52
+ if (fs.existsSync(credentialsPath)) {
53
+ const raw = fs.readFileSync(credentialsPath, "utf-8");
54
+ const parsed = JSON.parse(raw);
55
+ if (parsed !== null && typeof parsed === "object") {
56
+ return { method: "oauth" };
57
+ }
58
+ }
59
+ } catch {
60
+ }
61
+ return { method: "none" };
62
+ }
63
+ function printAuthGuidance() {
64
+ console.log("Claude Code auth not found. To set up authentication:");
65
+ console.log(" 1. Run: claude setup-token");
66
+ console.log(" 2. Then try: implicit connect");
67
+ console.log();
68
+ console.log(
69
+ "This uses your existing Claude subscription (Max plan) \u2014 no API key needed."
70
+ );
71
+ }
72
+
73
+ // src/lib/config.ts
74
+ import fs2 from "fs";
75
+ import path2 from "path";
76
+ import os2 from "os";
77
+ import crypto from "crypto";
78
+ function implicitDir() {
79
+ return process.env.IMPLICIT_DIR_OVERRIDE ?? path2.join(os2.homedir(), ".implicit");
80
+ }
81
+ function authFile() {
82
+ return path2.join(implicitDir(), "auth.json");
83
+ }
84
+ function configFile() {
85
+ return path2.join(implicitDir(), "config.json");
86
+ }
87
+ function ensureDir() {
88
+ fs2.mkdirSync(implicitDir(), { recursive: true });
89
+ }
90
+ function ensureRestrictivePermissions() {
91
+ for (const file of [authFile(), configFile()]) {
92
+ if (!fs2.existsSync(file)) continue;
93
+ const mode = fs2.statSync(file).mode & 511;
94
+ if (mode !== 384) {
95
+ console.warn(`[implicit] Tightening permissions on ${file} (0${mode.toString(8)} \u2192 0600)`);
96
+ fs2.chmodSync(file, 384);
97
+ }
98
+ }
99
+ }
100
+ function loadConfig() {
101
+ try {
102
+ const raw = fs2.readFileSync(configFile(), "utf-8");
103
+ const parsed = JSON.parse(raw);
104
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
105
+ return parsed;
106
+ }
107
+ return null;
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+ function saveConfig(config) {
113
+ ensureDir();
114
+ fs2.writeFileSync(configFile(), JSON.stringify(config, null, 2) + "\n", "utf-8");
115
+ fs2.chmodSync(configFile(), 384);
116
+ }
117
+ function loadAuth() {
118
+ try {
119
+ const raw = fs2.readFileSync(authFile(), "utf-8");
120
+ const parsed = JSON.parse(raw);
121
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
122
+ const obj = parsed;
123
+ if (obj.schemaVersion === 2 && typeof obj.userId === "string" && typeof obj.email === "string" && typeof obj.accessToken === "string" && typeof obj.refreshToken === "string" && typeof obj.expiresAt === "number") {
124
+ return {
125
+ schemaVersion: 2,
126
+ userId: obj.userId,
127
+ email: obj.email,
128
+ authenticatedAt: obj.authenticatedAt ?? "",
129
+ accessToken: obj.accessToken,
130
+ refreshToken: obj.refreshToken,
131
+ expiresAt: obj.expiresAt,
132
+ env: obj.env === "development" ? "development" : "production"
133
+ };
134
+ }
135
+ }
136
+ return null;
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
141
+ function saveAuth(auth) {
142
+ ensureDir();
143
+ fs2.writeFileSync(authFile(), JSON.stringify(auth, null, 2) + "\n", "utf-8");
144
+ fs2.chmodSync(authFile(), 384);
145
+ }
146
+ function clearAuth() {
147
+ try {
148
+ fs2.unlinkSync(authFile());
149
+ } catch {
150
+ }
151
+ }
152
+ function getOrCreateDeviceId() {
153
+ const config = loadConfig();
154
+ if (config && typeof config.deviceId === "string" && config.deviceId.length > 0) {
155
+ return config.deviceId;
156
+ }
157
+ const deviceId = crypto.randomUUID();
158
+ saveConfig({ ...config, deviceId });
159
+ return deviceId;
160
+ }
161
+ var RAILWAY_URLS = {
162
+ production: "https://ably-auth-production.up.railway.app",
163
+ development: "https://ably-auth-production.up.railway.app"
164
+ };
165
+ function getRailwayUrl(env) {
166
+ const config = loadConfig();
167
+ if (config && typeof config.railwayUrl === "string" && config.railwayUrl.length > 0) {
168
+ return config.railwayUrl;
169
+ }
170
+ return RAILWAY_URLS[env];
171
+ }
172
+
173
+ // src/lib/presenter.ts
174
+ function summarizeToolUse(name, input) {
175
+ const inp = input;
176
+ if (inp) {
177
+ switch (name) {
178
+ case "Bash": {
179
+ const cmd = typeof inp.command === "string" ? inp.command.slice(0, 80) : "command";
180
+ return `Running: ${cmd}`;
181
+ }
182
+ case "Read":
183
+ return `Reading: ${typeof inp.file_path === "string" ? basename(inp.file_path) : "file"}`;
184
+ case "Write":
185
+ return `Writing: ${typeof inp.file_path === "string" ? basename(inp.file_path) : "file"}`;
186
+ case "Edit":
187
+ return `Editing: ${typeof inp.file_path === "string" ? basename(inp.file_path) : "file"}`;
188
+ case "Grep":
189
+ return `Searching: ${typeof inp.pattern === "string" ? inp.pattern : "pattern"}`;
190
+ case "Glob":
191
+ return `Finding: ${typeof inp.pattern === "string" ? inp.pattern : "files"}`;
192
+ case "WebSearch":
193
+ return `Searching web: ${typeof inp.query === "string" ? inp.query : "query"}`;
194
+ case "WebFetch":
195
+ return `Fetching: ${typeof inp.url === "string" ? inp.url : "URL"}`;
196
+ case "Agent":
197
+ return `Subagent: ${typeof inp.description === "string" ? inp.description : "task"}`;
198
+ case "Skill":
199
+ return `Skill: ${typeof inp.skill === "string" ? inp.skill : "skill"}`;
200
+ default:
201
+ break;
202
+ }
203
+ }
204
+ return `Using ${name}`;
205
+ }
206
+ function basename(filePath) {
207
+ const parts = filePath.split("/");
208
+ return parts[parts.length - 1] ?? filePath;
209
+ }
210
+
211
+ // src/lib/sdk-runner.ts
212
+ import { query } from "@anthropic-ai/claude-agent-sdk";
213
+ var PERMISSION_TIMEOUT_MS = 5 * 60 * 1e3;
214
+ function startSession(options, callbacks) {
215
+ const pendingPermissions = /* @__PURE__ */ new Map();
216
+ let wasAborted = false;
217
+ let queryInstance = null;
218
+ let emittedTerminalEvent = false;
219
+ const canUseTool = (toolEvent) => {
220
+ return new Promise((resolve) => {
221
+ const toolUseId = toolEvent.tool_use_id ?? toolEvent.toolUseId ?? toolEvent.id ?? "unknown";
222
+ const toolName = toolEvent.tool_name ?? toolEvent.toolName ?? toolEvent.name ?? "unknown";
223
+ const input = toolEvent.input ?? toolEvent.args ?? {};
224
+ const reason = toolEvent.reason ?? toolEvent.description ?? "Tool requires permission";
225
+ const blockedPath = toolEvent.blocked_path ?? toolEvent.blockedPath ?? void 0;
226
+ const timer = setTimeout(() => {
227
+ if (pendingPermissions.has(toolUseId)) {
228
+ pendingPermissions.delete(toolUseId);
229
+ resolve({
230
+ behavior: "deny",
231
+ feedback: "Permission timed out after 5 minutes \u2014 auto-denied."
232
+ });
233
+ }
234
+ }, PERMISSION_TIMEOUT_MS);
235
+ pendingPermissions.set(toolUseId, {
236
+ resolve: (result) => {
237
+ clearTimeout(timer);
238
+ pendingPermissions.delete(toolUseId);
239
+ resolve({
240
+ behavior: result.allow ? "allow" : "deny",
241
+ ...result.feedback ? { feedback: result.feedback } : {}
242
+ });
243
+ },
244
+ timer
245
+ });
246
+ callbacks.onPermissionRequest({
247
+ toolUseId,
248
+ toolName,
249
+ input,
250
+ reason,
251
+ blockedPath
252
+ });
253
+ });
254
+ };
255
+ const queryOptions = {
256
+ prompt: options.prompt,
257
+ options: {
258
+ canUseTool,
259
+ thinking: { type: "adaptive" },
260
+ includePartialMessages: true,
261
+ systemPrompt: { type: "preset", preset: "claude_code" },
262
+ settingSources: ["user", "project"]
263
+ }
264
+ };
265
+ if (options.sessionId) {
266
+ queryOptions.options.resume = options.sessionId;
267
+ }
268
+ if (options.workingDirectory) {
269
+ queryOptions.options.cwd = options.workingDirectory;
270
+ }
271
+ const isBypass = options.options?.permissionMode === "bypassPermissions";
272
+ if (options.options?.permissionMode) {
273
+ queryOptions.options.permissionMode = options.options.permissionMode;
274
+ }
275
+ if (isBypass) {
276
+ queryOptions.options.allowDangerouslySkipPermissions = true;
277
+ }
278
+ if (options.options?.model) {
279
+ queryOptions.options.model = options.options.model;
280
+ }
281
+ if (options.options?.maxTurns) {
282
+ queryOptions.options.maxTurns = options.options.maxTurns;
283
+ }
284
+ if (options.options?.allowedTools) {
285
+ queryOptions.options.allowedTools = options.options.allowedTools;
286
+ }
287
+ if (options.options?.disallowedTools) {
288
+ queryOptions.options.disallowedTools = options.options.disallowedTools;
289
+ }
290
+ if (options.options?.effort) {
291
+ queryOptions.options.effort = options.options.effort;
292
+ }
293
+ (async () => {
294
+ try {
295
+ queryInstance = query(queryOptions);
296
+ for await (const message of queryInstance) {
297
+ if (wasAborted) break;
298
+ const msg = message;
299
+ const type = msg.type ?? "";
300
+ switch (type) {
301
+ // ---- Session init ----
302
+ case "system": {
303
+ const subtype = msg.subtype ?? "";
304
+ if (subtype === "init") {
305
+ callbacks.onSessionCreated({
306
+ sessionId: msg.session_id ?? msg.sessionId ?? "",
307
+ tools: Array.isArray(msg.tools) ? msg.tools.map((t) => t.name ?? String(t)) : [],
308
+ model: msg.model ?? "",
309
+ workingDirectory: msg.cwd ?? options.workingDirectory ?? process.cwd(),
310
+ permissionMode: msg.permission_mode ?? msg.permissionMode ?? options.options?.permissionMode ?? "default"
311
+ });
312
+ }
313
+ break;
314
+ }
315
+ // ---- Streaming deltas ----
316
+ case "stream_event": {
317
+ const eventType = msg.event_type ?? msg.eventType ?? msg.subtype ?? "";
318
+ if (eventType === "text_delta" || eventType === "content_block_delta") {
319
+ const text = msg.text ?? msg.delta?.text ?? msg.data?.text ?? "";
320
+ if (text) {
321
+ callbacks.onText(text);
322
+ }
323
+ } else if (eventType === "thinking_delta") {
324
+ const thinking = msg.thinking ?? msg.delta?.thinking ?? msg.data?.thinking ?? "";
325
+ if (thinking) {
326
+ callbacks.onThinking(thinking);
327
+ }
328
+ }
329
+ break;
330
+ }
331
+ // ---- Assistant turn (tool_use blocks) ----
332
+ case "assistant": {
333
+ const content = msg.content ?? msg.message?.content ?? [];
334
+ if (Array.isArray(content)) {
335
+ for (const block of content) {
336
+ const b = block;
337
+ if (b.type === "tool_use") {
338
+ callbacks.onToolUse(
339
+ b.id ?? "",
340
+ b.name ?? "",
341
+ b.input ?? {},
342
+ b.parent_tool_use_id ?? b.parentToolUseId ?? null
343
+ );
344
+ }
345
+ }
346
+ }
347
+ break;
348
+ }
349
+ // ---- User turn (tool_result blocks) ----
350
+ case "user": {
351
+ const content = msg.content ?? msg.message?.content ?? [];
352
+ if (Array.isArray(content)) {
353
+ for (const block of content) {
354
+ const b = block;
355
+ if (b.type === "tool_result") {
356
+ callbacks.onToolResult(
357
+ b.tool_use_id ?? b.toolUseId ?? "",
358
+ b.content ?? b.output ?? "",
359
+ b.is_error ?? b.isError ?? false
360
+ );
361
+ }
362
+ }
363
+ }
364
+ break;
365
+ }
366
+ // ---- Session result ----
367
+ case "result": {
368
+ const isError = msg.subtype === "error" || msg.is_error === true || msg.error != null;
369
+ if (isError) {
370
+ if (!wasAborted) {
371
+ emittedTerminalEvent = true;
372
+ callbacks.onError(
373
+ msg.error?.message ?? msg.error ?? msg.message ?? "Unknown SDK error"
374
+ );
375
+ }
376
+ } else {
377
+ emittedTerminalEvent = true;
378
+ callbacks.onResult(
379
+ msg.session_id ?? msg.sessionId ?? "",
380
+ msg.result ?? msg.text ?? void 0
381
+ );
382
+ }
383
+ break;
384
+ }
385
+ default:
386
+ break;
387
+ }
388
+ }
389
+ } catch (err) {
390
+ if (wasAborted) {
391
+ return;
392
+ }
393
+ const message = err instanceof Error ? err.message : String(err);
394
+ if (message.includes("ENOENT") || message.includes("not found") || message.includes("Cannot find module")) {
395
+ emittedTerminalEvent = true;
396
+ callbacks.onError(
397
+ `Claude Code SDK not available. Is Claude Code installed? (${message})`
398
+ );
399
+ } else {
400
+ emittedTerminalEvent = true;
401
+ callbacks.onError(message);
402
+ }
403
+ } finally {
404
+ for (const [, entry] of pendingPermissions) {
405
+ clearTimeout(entry.timer);
406
+ }
407
+ pendingPermissions.clear();
408
+ if (!wasAborted && !emittedTerminalEvent) {
409
+ callbacks.onError("Claude session ended without a result.");
410
+ }
411
+ callbacks.onSettled?.();
412
+ }
413
+ })();
414
+ const handle = {
415
+ /**
416
+ * Abort the running session. Calls interrupt() then close() on the
417
+ * query generator to stop Claude mid-execution.
418
+ */
419
+ abort: () => {
420
+ wasAborted = true;
421
+ for (const [toolUseId, entry] of pendingPermissions) {
422
+ clearTimeout(entry.timer);
423
+ entry.resolve({ allow: false, feedback: "Session aborted." });
424
+ pendingPermissions.delete(toolUseId);
425
+ }
426
+ if (queryInstance) {
427
+ try {
428
+ if (typeof queryInstance.interrupt === "function") {
429
+ queryInstance.interrupt();
430
+ }
431
+ } catch {
432
+ }
433
+ try {
434
+ if (typeof queryInstance.close === "function") {
435
+ const result = queryInstance.close();
436
+ if (result && typeof result.catch === "function") {
437
+ result.catch(() => {
438
+ });
439
+ }
440
+ } else if (typeof queryInstance.return === "function") {
441
+ const result = queryInstance.return();
442
+ if (result && typeof result.catch === "function") {
443
+ result.catch(() => {
444
+ });
445
+ }
446
+ }
447
+ } catch {
448
+ }
449
+ }
450
+ },
451
+ /**
452
+ * Resolve a pending permission request (called when the phone user
453
+ * taps Allow or Deny).
454
+ */
455
+ resolvePermission: (toolUseId, allow, feedback) => {
456
+ const entry = pendingPermissions.get(toolUseId);
457
+ if (!entry) {
458
+ return;
459
+ }
460
+ entry.resolve({ allow, feedback });
461
+ }
462
+ };
463
+ return handle;
464
+ }
465
+
466
+ // src/lib/device-info.ts
467
+ import os3 from "os";
468
+ import { execFile } from "child_process";
469
+ import { promisify } from "util";
470
+ var execFileAsync = promisify(execFile);
471
+ async function detectClaudeVersion() {
472
+ try {
473
+ const { stdout } = await execFileAsync("claude", ["--version"], {
474
+ timeout: 5e3
475
+ });
476
+ const trimmed = stdout.trim();
477
+ const match = trimmed.match(/(\d+\.\d+\.\d+[\w.-]*)/);
478
+ return match?.[1] ?? trimmed;
479
+ } catch {
480
+ return void 0;
481
+ }
482
+ }
483
+ async function collectDeviceInfo() {
484
+ const claudeVersion = await detectClaudeVersion();
485
+ return {
486
+ hostname: os3.hostname(),
487
+ platform: os3.platform(),
488
+ arch: os3.arch(),
489
+ claudeVersion,
490
+ cliVersion: version
491
+ };
492
+ }
493
+
494
+ // src/lib/sleep-inhibitor.ts
495
+ import { spawn } from "child_process";
496
+ import os4 from "os";
497
+ function preventIdleSleep() {
498
+ const platform = os4.platform();
499
+ let child = null;
500
+ if (platform === "darwin") {
501
+ try {
502
+ child = spawn("caffeinate", ["-di"], {
503
+ stdio: "ignore",
504
+ detached: false
505
+ });
506
+ child.unref();
507
+ child.on("error", () => {
508
+ child = null;
509
+ });
510
+ } catch {
511
+ child = null;
512
+ }
513
+ } else if (platform === "linux") {
514
+ try {
515
+ child = spawn(
516
+ "systemd-inhibit",
517
+ [
518
+ "--what=idle",
519
+ "--who=implicit-cli",
520
+ "--why=Implicit CLI bridge is active",
521
+ "sleep",
522
+ "infinity"
523
+ ],
524
+ {
525
+ stdio: "ignore",
526
+ detached: false
527
+ }
528
+ );
529
+ child.unref();
530
+ child.on("error", () => {
531
+ child = null;
532
+ });
533
+ } catch {
534
+ child = null;
535
+ }
536
+ }
537
+ return {
538
+ release() {
539
+ if (child) {
540
+ try {
541
+ child.kill("SIGTERM");
542
+ } catch {
543
+ }
544
+ child = null;
545
+ }
546
+ }
547
+ };
548
+ }
549
+
550
+ // src/lib/connection.ts
551
+ var MAX_CONCURRENT_SESSIONS_PER_ACCOUNT = 3;
552
+ var RECENT_CRITICAL_TTL_MS = 10 * 60 * 1e3;
553
+ var MAX_RECENT_CRITICAL_MESSAGES = 50;
554
+ var MAX_RESULT_CHARS = 48e3;
555
+ var MAX_TOOL_RESULT_CHARS = 2e3;
556
+ var ImplicitConnection = class _ImplicitConnection {
557
+ constructor(opts, transport) {
558
+ this.opts = opts;
559
+ this.transport = transport;
560
+ this.transport.onReconnect(() => {
561
+ this.opts.onReconnected();
562
+ void this.sendConnectionStatus("reconnected");
563
+ this.flushPendingMessages();
564
+ this.startPresence();
565
+ });
566
+ this.transport.onError((message) => {
567
+ this.opts.onError(message);
568
+ if (!this.intentionalDisconnect) {
569
+ this.stopPresence();
570
+ this.opts.onDisconnected(message);
571
+ }
572
+ });
573
+ this.transport.on("sdk_prompt", (payload) => {
574
+ this.handlePrompt(payload);
575
+ });
576
+ this.transport.on("sdk_permission_response", (payload) => {
577
+ this.handlePermissionResponse(payload);
578
+ });
579
+ this.transport.on("sdk_cancel", (payload) => {
580
+ this.handleCancel(payload);
581
+ });
582
+ this.transport.on("sdk_list_sessions", (payload) => {
583
+ this.handleListSessions(payload);
584
+ });
585
+ }
586
+ opts;
587
+ transport;
588
+ activeSessions = /* @__PURE__ */ new Map();
589
+ sessionMeta = /* @__PURE__ */ new Map();
590
+ presenceInterval = null;
591
+ sleepLock = null;
592
+ /** Whether we've successfully connected at least once. */
593
+ hasConnected = false;
594
+ /** Whether disconnect() was called intentionally. */
595
+ intentionalDisconnect = false;
596
+ /**
597
+ * Critical messages (results, errors, permission requests) that arrived
598
+ * while the channel was down. Flushed on reconnect.
599
+ */
600
+ pendingMessages = [];
601
+ /** Recent critical messages retained for replay when the phone reconnects. */
602
+ recentCriticalMessages = [];
603
+ publishQueue = Promise.resolve();
604
+ // -------------------------------------------------------------------------
605
+ // Lifecycle
606
+ // -------------------------------------------------------------------------
607
+ connect() {
608
+ this.intentionalDisconnect = false;
609
+ void this.transport.connect().then(() => {
610
+ if (!this.hasConnected) {
611
+ this.hasConnected = true;
612
+ this.opts.onConnected();
613
+ void this.sendConnectionStatus("connected");
614
+ this.flushPendingMessages();
615
+ this.startPresence();
616
+ if (!this.sleepLock) {
617
+ this.sleepLock = preventIdleSleep();
618
+ }
619
+ }
620
+ }).catch((err) => {
621
+ const message = err instanceof Error ? err.message : String(err);
622
+ this.opts.onError(message);
623
+ });
624
+ }
625
+ /**
626
+ * Intentional disconnect (Ctrl+C / shutdown).
627
+ * This DOES abort sessions and tear everything down.
628
+ */
629
+ disconnect() {
630
+ this.intentionalDisconnect = true;
631
+ this.transport.disconnect();
632
+ this.cleanup();
633
+ }
634
+ // -------------------------------------------------------------------------
635
+ // Outbound: send messages to the phone via transport
636
+ // -------------------------------------------------------------------------
637
+ /** Message types that must be delivered even after a reconnect. */
638
+ static CRITICAL_TYPES = /* @__PURE__ */ new Set([
639
+ "sdk_result",
640
+ "sdk_error",
641
+ "sdk_permission_request",
642
+ "sdk_session_created"
643
+ ]);
644
+ send(message) {
645
+ this.rememberCriticalMessage(message);
646
+ if (!this.transport.isConnected) {
647
+ if (_ImplicitConnection.CRITICAL_TYPES.has(message.type)) {
648
+ this.pendingMessages.push(message);
649
+ }
650
+ return;
651
+ }
652
+ this.enqueuePublish(message);
653
+ }
654
+ enqueuePublish(message) {
655
+ this.publishQueue = this.publishQueue.then(() => this.transport.publish(message.type, message)).catch((err) => {
656
+ const detail = err instanceof Error ? err.message : String(err);
657
+ const requestId = "requestId" in message && typeof message.requestId === "string" ? message.requestId : void 0;
658
+ const label = requestId ? `${message.type} for ${requestId.slice(0, 8)}` : message.type;
659
+ const errorMessage = `Failed to publish ${label}: ${detail}`;
660
+ this.opts.onError(errorMessage);
661
+ this.opts.onActivity({ icon: "\u2717", message: errorMessage, sessionId: requestId });
662
+ });
663
+ }
664
+ /** Send connection status to the phone. */
665
+ async sendConnectionStatus(status2) {
666
+ try {
667
+ const msg = {
668
+ type: "sdk_connection_status",
669
+ status: status2,
670
+ activeSessions: this.activeSessionCountForAccount(this.opts.userId),
671
+ activeSessionIds: this.activeSessionIdsForAccount(this.opts.userId)
672
+ };
673
+ await this.transport.publish(msg.type, msg);
674
+ } catch (err) {
675
+ console.warn("[Relay] Failed to send connection status:", err);
676
+ }
677
+ }
678
+ /** Flush messages that were buffered while disconnected. */
679
+ flushPendingMessages() {
680
+ if (this.pendingMessages.length === 0) return;
681
+ const messages = this.pendingMessages;
682
+ this.pendingMessages = [];
683
+ for (const msg of messages) {
684
+ this.send(msg);
685
+ }
686
+ }
687
+ rememberCriticalMessage(message) {
688
+ if (!_ImplicitConnection.CRITICAL_TYPES.has(message.type)) return;
689
+ this.pruneRecentCriticalMessages();
690
+ this.recentCriticalMessages.push({ message, timestamp: Date.now() });
691
+ if (this.recentCriticalMessages.length > MAX_RECENT_CRITICAL_MESSAGES) {
692
+ this.recentCriticalMessages.splice(
693
+ 0,
694
+ this.recentCriticalMessages.length - MAX_RECENT_CRITICAL_MESSAGES
695
+ );
696
+ }
697
+ }
698
+ pruneRecentCriticalMessages() {
699
+ const cutoff = Date.now() - RECENT_CRITICAL_TTL_MS;
700
+ this.recentCriticalMessages = this.recentCriticalMessages.filter(
701
+ ({ timestamp }) => timestamp >= cutoff
702
+ );
703
+ }
704
+ replayRecentCriticalMessages() {
705
+ this.pruneRecentCriticalMessages();
706
+ for (const { message } of this.recentCriticalMessages) {
707
+ this.enqueuePublish(message);
708
+ }
709
+ }
710
+ // -------------------------------------------------------------------------
711
+ // Presence: let the phone know the CLI is online via Ably Presence
712
+ // -------------------------------------------------------------------------
713
+ startPresence() {
714
+ this.stopPresence();
715
+ void this.enterPresence();
716
+ }
717
+ async enterPresence() {
718
+ try {
719
+ const deviceInfo = await collectDeviceInfo();
720
+ await this.transport.presenceEnter({
721
+ role: "cli",
722
+ deviceId: this.opts.deviceId,
723
+ hostname: deviceInfo.hostname,
724
+ platform: deviceInfo.platform,
725
+ claudeVersion: deviceInfo.claudeVersion ?? null,
726
+ cliVersion: deviceInfo.cliVersion
727
+ });
728
+ this.broadcastPresence();
729
+ } catch (err) {
730
+ console.warn("[Relay] presenceEnter failed:", err);
731
+ }
732
+ }
733
+ stopPresence() {
734
+ if (this.presenceInterval) {
735
+ clearInterval(this.presenceInterval);
736
+ this.presenceInterval = null;
737
+ }
738
+ void this.transport.presenceLeave().catch(() => {
739
+ });
740
+ }
741
+ /** R10 legacy backstop: one-shot sdk_presence broadcast for old phones. */
742
+ broadcastPresence() {
743
+ void collectDeviceInfo().then((deviceInfo) => {
744
+ const msg = {
745
+ type: "sdk_presence",
746
+ deviceId: this.opts.deviceId,
747
+ hostname: deviceInfo.hostname,
748
+ platform: deviceInfo.platform,
749
+ claudeVersion: deviceInfo.claudeVersion ?? void 0,
750
+ cliVersion: deviceInfo.cliVersion,
751
+ activeSessions: this.activeSessionCountForAccount(this.opts.userId)
752
+ };
753
+ this.send(msg);
754
+ });
755
+ }
756
+ // -------------------------------------------------------------------------
757
+ // Inbound handlers
758
+ // -------------------------------------------------------------------------
759
+ handlePrompt(msg) {
760
+ const { id, prompt: prompt2, sessionId, workingDirectory, options } = msg;
761
+ const accountSessionCount = this.activeSessionCountForAccount(this.opts.userId);
762
+ if (accountSessionCount >= MAX_CONCURRENT_SESSIONS_PER_ACCOUNT) {
763
+ this.send({
764
+ type: "sdk_error",
765
+ requestId: id,
766
+ message: `Max ${MAX_CONCURRENT_SESSIONS_PER_ACCOUNT} concurrent sessions reached for this account. Cancel one first.`
767
+ });
768
+ this.opts.onActivity({ icon: "\u2717", message: "Session rejected (max concurrent)", sessionId: id });
769
+ return;
770
+ }
771
+ this.sessionMeta.set(id, {
772
+ requestId: id,
773
+ userId: this.opts.userId,
774
+ prompt: prompt2,
775
+ workingDirectory,
776
+ status: "running",
777
+ startedAt: Date.now()
778
+ });
779
+ this.opts.onActivity({
780
+ icon: "\u25B6",
781
+ message: `Session started: ${prompt2.slice(0, 60)}${prompt2.length > 60 ? "\u2026" : ""}`,
782
+ sessionId: id
783
+ });
784
+ const resolvedCwd = workingDirectory ?? this.opts.defaultCwd;
785
+ console.log(
786
+ `[relay] starting CC session \u2014 cwd=${resolvedCwd} (source: ${workingDirectory ? "iOS prompt" : "defaultCwd"}) requestId=${id}`
787
+ );
788
+ let settledBeforeHandleRegistered = false;
789
+ const complete = (status2 = "done") => {
790
+ settledBeforeHandleRegistered = true;
791
+ return this.completeSession(id, status2);
792
+ };
793
+ try {
794
+ const handle = startSession(
795
+ {
796
+ prompt: prompt2,
797
+ sessionId,
798
+ workingDirectory: resolvedCwd,
799
+ options
800
+ },
801
+ {
802
+ onSessionCreated: (info) => {
803
+ const resumed = sessionId != null && sessionId === info.sessionId;
804
+ this.opts.onActivity({
805
+ icon: resumed ? "\u21BB" : "\u2726",
806
+ message: `CC session ${resumed ? "resumed" : "new"}: ${info.sessionId.slice(0, 8)}${sessionId && !resumed ? ` (requested resume of ${sessionId.slice(0, 8)})` : ""}`,
807
+ sessionId: id
808
+ });
809
+ this.send({
810
+ type: "sdk_session_created",
811
+ requestId: id,
812
+ sessionId: info.sessionId,
813
+ prompt: prompt2,
814
+ tools: info.tools,
815
+ model: info.model,
816
+ workingDirectory: info.workingDirectory,
817
+ permissionMode: info.permissionMode
818
+ });
819
+ },
820
+ onText: (text) => {
821
+ this.send({ type: "sdk_text", requestId: id, text });
822
+ },
823
+ onThinking: (thinking) => {
824
+ this.send({ type: "sdk_thinking", requestId: id, thinking });
825
+ },
826
+ onToolUse: (toolId, name, input, parentToolUseId) => {
827
+ const summary = summarizeToolUse(name, input);
828
+ this.send({
829
+ type: "sdk_tool_use",
830
+ requestId: id,
831
+ toolId,
832
+ name,
833
+ input,
834
+ parentToolUseId,
835
+ summary
836
+ });
837
+ this.opts.onActivity({ icon: "\u25B6", message: summary, sessionId: id });
838
+ },
839
+ onToolResult: (toolUseId, content, isError) => {
840
+ this.send({
841
+ type: "sdk_tool_result",
842
+ requestId: id,
843
+ toolUseId,
844
+ content: truncatePayload(content, MAX_TOOL_RESULT_CHARS),
845
+ isError
846
+ });
847
+ },
848
+ onPermissionRequest: (req) => {
849
+ const meta = this.sessionMeta.get(id);
850
+ if (meta) meta.status = "waiting";
851
+ this.opts.onActivity({ icon: "\u25CF", message: `Permission: ${req.toolName}`, sessionId: id });
852
+ this.send({
853
+ type: "sdk_permission_request",
854
+ requestId: id,
855
+ toolUseId: req.toolUseId,
856
+ toolName: req.toolName,
857
+ input: req.input,
858
+ reason: req.reason,
859
+ blockedPath: req.blockedPath,
860
+ summary: summarizeToolUse(req.toolName, req.input)
861
+ });
862
+ },
863
+ onResult: (sid, result) => {
864
+ const meta = complete();
865
+ const shortResult = result && result.length > 100 ? result.slice(0, 100) + "\u2026" : result ?? "Done";
866
+ const safeResult = result ? truncateString(result, MAX_RESULT_CHARS) : result;
867
+ this.send({
868
+ type: "sdk_result",
869
+ requestId: id,
870
+ sessionId: sid,
871
+ prompt: meta?.prompt ?? prompt2,
872
+ workingDirectory: meta?.workingDirectory ?? workingDirectory,
873
+ result: safeResult,
874
+ summary: shortResult
875
+ });
876
+ this.opts.onActivity({ icon: "\u2713", message: shortResult, sessionId: id });
877
+ },
878
+ onError: (message) => {
879
+ const meta = complete("error");
880
+ this.send({
881
+ type: "sdk_error",
882
+ requestId: id,
883
+ prompt: meta?.prompt ?? prompt2,
884
+ workingDirectory: meta?.workingDirectory ?? workingDirectory,
885
+ message
886
+ });
887
+ this.opts.onActivity({ icon: "\u2717", message: `Error: ${message}`, sessionId: id });
888
+ },
889
+ onSettled: () => {
890
+ complete();
891
+ }
892
+ }
893
+ );
894
+ if (!settledBeforeHandleRegistered) {
895
+ this.activeSessions.set(id, handle);
896
+ }
897
+ } catch (err) {
898
+ const meta = complete("error");
899
+ const message = err instanceof Error ? err.message : String(err);
900
+ this.send({
901
+ type: "sdk_error",
902
+ requestId: id,
903
+ prompt: meta?.prompt ?? prompt2,
904
+ workingDirectory: meta?.workingDirectory ?? workingDirectory,
905
+ message
906
+ });
907
+ this.opts.onActivity({ icon: "\u2717", message: `Error: ${message}`, sessionId: id });
908
+ }
909
+ }
910
+ completeSession(requestId, status2 = "done") {
911
+ const meta = this.sessionMeta.get(requestId);
912
+ if (meta) meta.status = status2;
913
+ this.activeSessions.delete(requestId);
914
+ this.sessionMeta.delete(requestId);
915
+ return meta;
916
+ }
917
+ activeSessionCountForAccount(userId) {
918
+ return this.activeSessionIdsForAccount(userId).length;
919
+ }
920
+ activeSessionIdsForAccount(userId) {
921
+ return Array.from(this.sessionMeta.values()).filter((session) => session.userId.toLowerCase() === userId.toLowerCase()).map((session) => session.requestId);
922
+ }
923
+ handlePermissionResponse(msg) {
924
+ const handle = this.activeSessions.get(msg.requestId);
925
+ if (handle) {
926
+ handle.resolvePermission(msg.toolUseId, msg.allow, msg.feedback);
927
+ const meta = this.sessionMeta.get(msg.requestId);
928
+ if (meta) meta.status = "running";
929
+ this.opts.onActivity({
930
+ icon: msg.allow ? "\u2713" : "\u2717",
931
+ message: msg.allow ? "Permission allowed" : "Permission denied",
932
+ sessionId: msg.requestId
933
+ });
934
+ }
935
+ }
936
+ handleCancel(msg) {
937
+ const handle = this.activeSessions.get(msg.requestId);
938
+ if (handle) {
939
+ handle.abort();
940
+ this.activeSessions.delete(msg.requestId);
941
+ this.sessionMeta.delete(msg.requestId);
942
+ this.opts.onActivity({ icon: "\u2717", message: "Session cancelled", sessionId: msg.requestId });
943
+ }
944
+ }
945
+ handleListSessions(msg) {
946
+ const sessions = Array.from(this.sessionMeta.values()).filter((session) => session.userId.toLowerCase() === this.opts.userId.toLowerCase()).map(({ userId: _userId, ...session }) => session);
947
+ this.send({
948
+ type: "sdk_sessions_list",
949
+ requestId: msg.id,
950
+ sessions
951
+ });
952
+ this.replayRecentCriticalMessages();
953
+ }
954
+ // -------------------------------------------------------------------------
955
+ // Cleanup — only called on intentional disconnect
956
+ // -------------------------------------------------------------------------
957
+ cleanup() {
958
+ this.stopPresence();
959
+ this.pendingMessages = [];
960
+ this.recentCriticalMessages = [];
961
+ if (this.sleepLock) {
962
+ this.sleepLock.release();
963
+ this.sleepLock = null;
964
+ }
965
+ for (const handle of this.activeSessions.values()) {
966
+ handle.abort();
967
+ }
968
+ this.activeSessions.clear();
969
+ this.sessionMeta.clear();
970
+ }
971
+ };
972
+ function truncatePayload(value, maxChars) {
973
+ if (typeof value === "string") return truncateString(value, maxChars);
974
+ if (value == null) return value;
975
+ try {
976
+ return truncateString(JSON.stringify(value), maxChars);
977
+ } catch {
978
+ return truncateString(String(value), maxChars);
979
+ }
980
+ }
981
+ function truncateString(value, maxChars) {
982
+ if (value.length <= maxChars) return value;
983
+ const omitted = value.length - maxChars;
984
+ return `${value.slice(0, maxChars)}
985
+
986
+ [truncated ${omitted} chars for realtime delivery]`;
987
+ }
988
+
989
+ // src/lib/ably-transport.ts
990
+ import Ably from "ably";
991
+ import pino from "pino";
992
+
993
+ // src/lib/auth-session.ts
994
+ import { createClient, isAuthRetryableFetchError } from "@supabase/supabase-js";
995
+ var SUPABASE_CONFIGS = {
996
+ production: {
997
+ url: "https://fqvwludchxxqfpvjzktf.supabase.co",
998
+ anonKey: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImZxdndsdWRjaHh4cWZwdmp6a3RmIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzE5NzAzMzAsImV4cCI6MjA4NzU0NjMzMH0.m7r_vCpWSA5kkQyIl2ABggtEDyx3IB3ml7j3v3ZBGGU"
999
+ },
1000
+ development: {
1001
+ url: "https://pxmhujfsundhgqgxqrjs.supabase.co",
1002
+ anonKey: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InB4bWh1amZzdW5kaGdxZ3hxcmpzIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzIxMzY3MDgsImV4cCI6MjA4NzcxMjcwOH0.l4vcbtVN3P8iZnbign-VmSvZ6UcI3mJ896bdO34eo6s"
1003
+ }
1004
+ };
1005
+ var REFRESH_SAFETY_MARGIN_MS = 6e4;
1006
+ var NeedsRePairingError = class extends Error {
1007
+ constructor() {
1008
+ super("Your session has expired. Run `implicit pair` to re-authenticate.");
1009
+ this.name = "NeedsRePairingError";
1010
+ }
1011
+ };
1012
+ var TransientAuthError = class extends Error {
1013
+ constructor(message) {
1014
+ super(message);
1015
+ this.name = "TransientAuthError";
1016
+ }
1017
+ };
1018
+ async function getFreshAccessToken() {
1019
+ const auth = loadAuth();
1020
+ if (!auth || !auth.refreshToken) {
1021
+ throw new NeedsRePairingError();
1022
+ }
1023
+ if (Date.now() < auth.expiresAt - REFRESH_SAFETY_MARGIN_MS) {
1024
+ return auth.accessToken;
1025
+ }
1026
+ const { url, anonKey } = SUPABASE_CONFIGS[auth.env ?? "production"];
1027
+ const supabase = createClient(url, anonKey);
1028
+ let result;
1029
+ try {
1030
+ result = await supabase.auth.refreshSession({
1031
+ refresh_token: auth.refreshToken
1032
+ });
1033
+ } catch (err) {
1034
+ if (isAuthRetryableFetchError(err) || isNetworkLikeError(err)) {
1035
+ throw new TransientAuthError(
1036
+ `Network error refreshing session: ${err instanceof Error ? err.message : String(err)}`
1037
+ );
1038
+ }
1039
+ throw err;
1040
+ }
1041
+ const { data, error } = result;
1042
+ if (error) {
1043
+ if (isAuthRetryableFetchError(error) || isNetworkLikeError(error)) {
1044
+ throw new TransientAuthError(`Network error refreshing session: ${error.message}`);
1045
+ }
1046
+ clearAuth();
1047
+ throw new NeedsRePairingError();
1048
+ }
1049
+ if (!data.session) {
1050
+ clearAuth();
1051
+ throw new NeedsRePairingError();
1052
+ }
1053
+ const newAuth = {
1054
+ ...auth,
1055
+ accessToken: data.session.access_token,
1056
+ refreshToken: data.session.refresh_token,
1057
+ expiresAt: (data.session.expires_at ?? Math.floor(Date.now() / 1e3) + 3600) * 1e3
1058
+ };
1059
+ saveAuth(newAuth);
1060
+ return data.session.access_token;
1061
+ }
1062
+ function isNetworkLikeError(err) {
1063
+ if (!err || typeof err !== "object") return false;
1064
+ const e = err;
1065
+ const cause = e.cause?.code;
1066
+ if (cause === "ENOTFOUND" || cause === "ETIMEDOUT" || cause === "ECONNREFUSED" || cause === "ECONNRESET" || cause === "EAI_AGAIN") {
1067
+ return true;
1068
+ }
1069
+ return typeof e.message === "string" && e.message.toLowerCase().includes("fetch failed");
1070
+ }
1071
+
1072
+ // src/lib/ably-auth.ts
1073
+ async function fetchAblyToken(opts) {
1074
+ const jwt = opts.getAccessToken ? await opts.getAccessToken() : await getFreshAccessToken();
1075
+ const url = `${opts.railwayUrl}/auth/ably-token`;
1076
+ let response;
1077
+ try {
1078
+ response = await fetch(url, {
1079
+ method: "POST",
1080
+ headers: {
1081
+ "Authorization": `Bearer ${jwt}`,
1082
+ "Content-Type": "application/json"
1083
+ },
1084
+ body: "{}"
1085
+ });
1086
+ } catch (err) {
1087
+ throw new Error(
1088
+ `Network error fetching Ably token from ${url}: ${err instanceof Error ? err.message : String(err)}`
1089
+ );
1090
+ }
1091
+ if (!response.ok) {
1092
+ const body = await response.text().catch(() => "(no body)");
1093
+ throw new Error(
1094
+ `Failed to fetch Ably token: HTTP ${response.status} from ${url} \u2014 ${body}`
1095
+ );
1096
+ }
1097
+ const tokenDetails = await response.json();
1098
+ return tokenDetails;
1099
+ }
1100
+
1101
+ // src/lib/ably-transport.ts
1102
+ var log = pino({ name: "ably-transport" });
1103
+ var AblyTransport = class {
1104
+ railwayUrl;
1105
+ userId;
1106
+ channelName;
1107
+ getAccessToken;
1108
+ realtime = null;
1109
+ channel = null;
1110
+ _isConnected = false;
1111
+ firstConnectDone = false;
1112
+ /** Guards against re-registering connection state listeners on reconnect. */
1113
+ connectionListenersWired = false;
1114
+ reconnectHandlers = [];
1115
+ errorHandlers = [];
1116
+ connectionStateHandlers = [];
1117
+ constructor(opts) {
1118
+ this.railwayUrl = opts.railwayUrl;
1119
+ this.userId = opts.userId;
1120
+ this.channelName = `sdk:${opts.userId.toLowerCase()}`;
1121
+ this.getAccessToken = opts.getAccessToken;
1122
+ }
1123
+ get isConnected() {
1124
+ return this._isConnected;
1125
+ }
1126
+ async connect() {
1127
+ const { railwayUrl } = this;
1128
+ if (this.realtime) {
1129
+ this.realtime.close();
1130
+ this.realtime = null;
1131
+ this.channel = null;
1132
+ }
1133
+ this.realtime = new Ably.Realtime({
1134
+ authCallback: async (_tokenParams, callback) => {
1135
+ try {
1136
+ const tokenDetails = await fetchAblyToken({ railwayUrl, getAccessToken: this.getAccessToken });
1137
+ callback(null, tokenDetails);
1138
+ } catch (err) {
1139
+ if (err instanceof NeedsRePairingError) {
1140
+ for (const h of this.errorHandlers) h(err.message);
1141
+ }
1142
+ callback(err instanceof Error ? err.message : String(err), null);
1143
+ }
1144
+ }
1145
+ });
1146
+ if (!this.connectionListenersWired) {
1147
+ this.connectionListenersWired = true;
1148
+ this.realtime.connection.on("connected", () => {
1149
+ log.info("connection state: connected");
1150
+ this._isConnected = true;
1151
+ if (this.firstConnectDone) {
1152
+ log.info({ handlers: this.reconnectHandlers.length }, "reconnected \u2014 firing handlers");
1153
+ for (const h of this.reconnectHandlers) h();
1154
+ }
1155
+ this.firstConnectDone = true;
1156
+ for (const h of this.connectionStateHandlers) h("connected");
1157
+ });
1158
+ this.realtime.connection.on("disconnected", (stateChange) => {
1159
+ log.warn({ reason: stateChange?.reason?.message }, "connection state: disconnected");
1160
+ this._isConnected = false;
1161
+ for (const h of this.connectionStateHandlers) h("disconnected");
1162
+ });
1163
+ this.realtime.connection.on("failed", (stateChange) => {
1164
+ const msg = stateChange?.reason?.message ?? "Ably connection failed";
1165
+ log.error({ reason: msg }, "connection state: failed");
1166
+ this._isConnected = false;
1167
+ for (const h of this.errorHandlers) h(msg);
1168
+ for (const h of this.connectionStateHandlers) h("failed");
1169
+ });
1170
+ this.realtime.connection.on("suspended", (stateChange) => {
1171
+ const msg = stateChange?.reason?.message ?? "Ably connection suspended";
1172
+ log.warn({ reason: msg }, "connection state: suspended");
1173
+ this._isConnected = false;
1174
+ for (const h of this.errorHandlers) h(msg);
1175
+ for (const h of this.connectionStateHandlers) h("suspended");
1176
+ });
1177
+ this.realtime.connection.on("closed", () => {
1178
+ log.info("connection state: closed");
1179
+ this._isConnected = false;
1180
+ for (const h of this.connectionStateHandlers) h("closed");
1181
+ });
1182
+ }
1183
+ this.channel = this.realtime.channels.get(this.channelName);
1184
+ await this.channel.attach();
1185
+ for (const { event, handler } of this.pendingSubscriptions) {
1186
+ this.channel.subscribe(event, (msg) => {
1187
+ handler(msg.data);
1188
+ });
1189
+ }
1190
+ this.pendingSubscriptions = [];
1191
+ for (const handler of this.pendingPresenceEnterHandlers) {
1192
+ this.channel.presence.subscribe("enter", (msg) => {
1193
+ handler({ clientId: msg.clientId ?? "unknown", data: msg.data ?? {} });
1194
+ });
1195
+ }
1196
+ this.pendingPresenceEnterHandlers = [];
1197
+ for (const handler of this.pendingPresenceLeaveHandlers) {
1198
+ this.channel.presence.subscribe("leave", (msg) => {
1199
+ handler({ clientId: msg.clientId ?? "unknown", data: msg.data ?? {} });
1200
+ });
1201
+ }
1202
+ this.pendingPresenceLeaveHandlers = [];
1203
+ }
1204
+ disconnect() {
1205
+ this._isConnected = false;
1206
+ if (this.channel) {
1207
+ this.channel.unsubscribe();
1208
+ this.channel.presence.unsubscribe();
1209
+ this.channel = null;
1210
+ }
1211
+ this.realtime?.close();
1212
+ this.realtime = null;
1213
+ this.firstConnectDone = false;
1214
+ }
1215
+ async publish(event, payload) {
1216
+ if (!this.channel) {
1217
+ throw new Error("AblyTransport: not connected \u2014 call connect() first");
1218
+ }
1219
+ await this.channel.publish(event, payload);
1220
+ }
1221
+ pendingSubscriptions = [];
1222
+ on(event, handler) {
1223
+ if (!this.channel) {
1224
+ this.pendingSubscriptions.push({ event, handler });
1225
+ return;
1226
+ }
1227
+ this.channel.subscribe(event, (msg) => {
1228
+ handler(msg.data);
1229
+ });
1230
+ }
1231
+ onReconnect(handler) {
1232
+ this.reconnectHandlers.push(handler);
1233
+ }
1234
+ onError(handler) {
1235
+ this.errorHandlers.push(handler);
1236
+ }
1237
+ onConnectionStateChange(handler) {
1238
+ this.connectionStateHandlers.push(handler);
1239
+ }
1240
+ // --- Presence ---
1241
+ async presenceEnter(data) {
1242
+ if (!this.channel) throw new Error("AblyTransport: not connected \u2014 call connect() first");
1243
+ log.info({ data }, "entering presence");
1244
+ await this.channel.presence.enter(data);
1245
+ }
1246
+ async presenceLeave() {
1247
+ if (!this.channel) return;
1248
+ await this.channel.presence.leave();
1249
+ }
1250
+ async presenceGet() {
1251
+ if (!this.channel) throw new Error("AblyTransport: not connected");
1252
+ const members = await this.channel.presence.get();
1253
+ return (members ?? []).map((msg) => ({
1254
+ clientId: msg.clientId ?? "unknown",
1255
+ data: msg.data ?? {}
1256
+ }));
1257
+ }
1258
+ pendingPresenceEnterHandlers = [];
1259
+ pendingPresenceLeaveHandlers = [];
1260
+ onPresenceEnter(handler) {
1261
+ if (!this.channel) {
1262
+ this.pendingPresenceEnterHandlers.push(handler);
1263
+ return;
1264
+ }
1265
+ this.channel.presence.subscribe("enter", (msg) => {
1266
+ const clientId = msg.clientId ?? "unknown";
1267
+ log.info({ clientId }, "presence enter");
1268
+ handler({ clientId, data: msg.data ?? {} });
1269
+ });
1270
+ }
1271
+ onPresenceLeave(handler) {
1272
+ if (!this.channel) {
1273
+ this.pendingPresenceLeaveHandlers.push(handler);
1274
+ return;
1275
+ }
1276
+ this.channel.presence.subscribe("leave", (msg) => {
1277
+ const clientId = msg.clientId ?? "unknown";
1278
+ log.info({ clientId }, "presence leave");
1279
+ handler({ clientId, data: msg.data ?? {} });
1280
+ });
1281
+ }
1282
+ // --- History ---
1283
+ async fetchHistory(limit) {
1284
+ if (!this.channel) throw new Error("AblyTransport: not connected");
1285
+ log.info({ limit }, "fetching channel history");
1286
+ const result = await this.channel.history({ limit, direction: "backwards" });
1287
+ const messages = (result.items ?? []).map((msg) => ({
1288
+ event: msg.name ?? "",
1289
+ data: msg.data ?? {},
1290
+ timestamp: msg.timestamp ?? 0
1291
+ }));
1292
+ log.info({ count: messages.length }, "history fetched");
1293
+ return messages;
1294
+ }
1295
+ };
1296
+
1297
+ // src/commands/connect.ts
1298
+ var SUPABASE_CONFIGS2 = {
1299
+ production: {
1300
+ url: "https://fqvwludchxxqfpvjzktf.supabase.co",
1301
+ anonKey: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImZxdndsdWRjaHh4cWZwdmp6a3RmIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzE5NzAzMzAsImV4cCI6MjA4NzU0NjMzMH0.m7r_vCpWSA5kkQyIl2ABggtEDyx3IB3ml7j3v3ZBGGU"
1302
+ },
1303
+ development: {
1304
+ url: "https://pxmhujfsundhgqgxqrjs.supabase.co",
1305
+ anonKey: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InB4bWh1amZzdW5kaGdxZ3hxcmpzIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzIxMzY3MDgsImV4cCI6MjA4NzcxMjcwOH0.l4vcbtVN3P8iZnbign-VmSvZ6UcI3mJ896bdO34eo6s"
1306
+ }
1307
+ };
1308
+ async function connect(options) {
1309
+ const isHeadless = !!options.background;
1310
+ const env = options.env === "production" ? "production" : "development";
1311
+ ensureRestrictivePermissions();
1312
+ if (!isHeadless) console.log("\n Checking prerequisites...");
1313
+ const claude = detectClaudeCodeInstalled();
1314
+ if (!claude.installed) {
1315
+ console.error(
1316
+ " \u2717 Claude Code is not installed.\n Install from: https://docs.anthropic.com/en/docs/claude-code"
1317
+ );
1318
+ process.exit(1);
1319
+ }
1320
+ if (!isHeadless) console.log(` \u2713 Claude Code ${claude.version ?? ""} installed`);
1321
+ const claudeAuth = detectClaudeAuth();
1322
+ if (claudeAuth.method === "none") {
1323
+ console.error(" \u2717 Claude Code auth not found.\n");
1324
+ printAuthGuidance();
1325
+ process.exit(1);
1326
+ }
1327
+ if (!isHeadless) console.log(` \u2713 Claude auth detected (${claudeAuth.method})`);
1328
+ let auth = options.token ? authFromAccessToken(options.token, env) : loadAuth();
1329
+ if (!auth) {
1330
+ if (isHeadless) {
1331
+ console.error("Not signed in yet. Run `implicit connect` interactively first.");
1332
+ process.exit(1);
1333
+ }
1334
+ auth = await signInWithOtp(env);
1335
+ } else {
1336
+ if (!isHeadless) console.log(` \u2713 Signed in as ${auth.email}`);
1337
+ }
1338
+ const deviceId = getOrCreateDeviceId();
1339
+ const cwdFromFlag = options.cwd;
1340
+ const cwdFromConfig = loadConfig()?.defaultCwd;
1341
+ const defaultCwd = cwdFromFlag ?? cwdFromConfig ?? process.env.HOME ?? "/tmp";
1342
+ const railwayUrl = getRailwayUrl(env);
1343
+ if (!isHeadless) {
1344
+ console.log("\n \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510");
1345
+ console.log(" \u2502 Implicit bridge is running \u2502");
1346
+ console.log(" \u2502 Send prompts from your phone \u2502");
1347
+ console.log(" \u2502 Press Ctrl+C to disconnect \u2502");
1348
+ console.log(" \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n");
1349
+ console.log(` \u2937 defaultCwd: ${defaultCwd}`);
1350
+ console.log(
1351
+ ` sources: --cwd=${cwdFromFlag ?? "(none)"}, config=${cwdFromConfig ?? "(none)"}, $HOME=${process.env.HOME ?? "(unset)"}`
1352
+ );
1353
+ console.log(` process.cwd()=${process.cwd()} (NOT used as default)`);
1354
+ }
1355
+ const transport = new AblyTransport({
1356
+ railwayUrl,
1357
+ userId: auth.userId,
1358
+ getAccessToken: options.token ? async () => options.token : void 0
1359
+ });
1360
+ const connection = new ImplicitConnection(
1361
+ {
1362
+ userId: auth.userId,
1363
+ deviceId,
1364
+ defaultCwd,
1365
+ railwayUrl,
1366
+ onConnected: () => {
1367
+ if (isHeadless) console.log("[connected]");
1368
+ else console.log(" \u25CF Connected to Ably Realtime");
1369
+ },
1370
+ onDisconnected: (reason) => {
1371
+ if (isHeadless) console.log(`[disconnected] ${reason}`);
1372
+ else console.log(` \u25CB Disconnected: ${reason} \u2014 reconnecting...`);
1373
+ },
1374
+ onReconnected: () => {
1375
+ if (isHeadless) console.log("[reconnected]");
1376
+ else console.log(" \u25CF Reconnected to Ably Realtime");
1377
+ },
1378
+ onError: (msg) => {
1379
+ if (isHeadless) console.error(`[error] ${msg}`);
1380
+ else console.error(` \u2717 ${msg}`);
1381
+ if (msg.includes("implicit pair")) process.exit(1);
1382
+ },
1383
+ onActivity: (entry) => {
1384
+ if (isHeadless) {
1385
+ console.log(`[${entry.icon}] ${entry.message}${entry.sessionId ? ` (${entry.sessionId.slice(0, 8)})` : ""}`);
1386
+ } else {
1387
+ console.log(` ${entry.icon} ${entry.message}${entry.sessionId ? ` [${entry.sessionId.slice(0, 8)}]` : ""}`);
1388
+ }
1389
+ }
1390
+ },
1391
+ transport
1392
+ );
1393
+ connection.connect();
1394
+ const shutdown = () => {
1395
+ if (!isHeadless) console.log("\n Disconnecting...");
1396
+ connection.disconnect();
1397
+ process.exit(0);
1398
+ };
1399
+ process.on("SIGINT", shutdown);
1400
+ process.on("SIGTERM", shutdown);
1401
+ await new Promise(() => {
1402
+ });
1403
+ }
1404
+ async function signInWithOtp(env) {
1405
+ const config = SUPABASE_CONFIGS2[env];
1406
+ const supabase = createClient2(config.url, config.anonKey);
1407
+ console.log("\n Sign in with your Implicit account (same email as the phone app).\n");
1408
+ const email = await prompt(" Email: ");
1409
+ if (!email || !email.includes("@")) {
1410
+ console.error(" \u2717 Invalid email.");
1411
+ process.exit(1);
1412
+ }
1413
+ const { error: otpError } = await supabase.auth.signInWithOtp({ email });
1414
+ if (otpError) {
1415
+ console.error(` \u2717 Failed to send code: ${otpError.message}`);
1416
+ process.exit(1);
1417
+ }
1418
+ console.log(" \u2713 Code sent! Check your email.\n");
1419
+ const code = await prompt(" Enter 6-digit code: ");
1420
+ if (!code || code.length < 6) {
1421
+ console.error(" \u2717 Invalid code.");
1422
+ process.exit(1);
1423
+ }
1424
+ const { data, error: verifyError } = await supabase.auth.verifyOtp({
1425
+ email,
1426
+ token: code.trim(),
1427
+ type: "email"
1428
+ });
1429
+ if (verifyError || !data.user) {
1430
+ console.error(` \u2717 Verification failed: ${verifyError?.message ?? "No user returned"}`);
1431
+ process.exit(1);
1432
+ }
1433
+ const session = data.session;
1434
+ if (!session) {
1435
+ console.error(" \u2717 OTP verified but no session returned. Please try again.");
1436
+ process.exit(1);
1437
+ }
1438
+ const auth = {
1439
+ schemaVersion: 2,
1440
+ userId: data.user.id,
1441
+ email: data.user.email ?? email,
1442
+ authenticatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1443
+ accessToken: session.access_token,
1444
+ refreshToken: session.refresh_token,
1445
+ expiresAt: session.expires_at ? session.expires_at * 1e3 : Date.now() + 36e5,
1446
+ env
1447
+ };
1448
+ saveAuth(auth);
1449
+ console.log(` \u2713 Signed in as ${auth.email}
1450
+ `);
1451
+ return auth;
1452
+ }
1453
+ function authFromAccessToken(token, env) {
1454
+ const claims = decodeJwtPayload(token);
1455
+ const userId = readStringClaim(claims, "sub");
1456
+ if (!userId) {
1457
+ console.error(" \u2717 --token is not a valid Supabase JWT: missing sub claim.");
1458
+ process.exit(1);
1459
+ }
1460
+ const expiresAt = readNumberClaim(claims, "exp") * 1e3;
1461
+ if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) {
1462
+ console.error(" \u2717 --token is expired.");
1463
+ process.exit(1);
1464
+ }
1465
+ return {
1466
+ schemaVersion: 2,
1467
+ userId,
1468
+ email: readStringClaim(claims, "email") ?? "(token auth)",
1469
+ authenticatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1470
+ accessToken: token,
1471
+ refreshToken: "",
1472
+ expiresAt,
1473
+ env
1474
+ };
1475
+ }
1476
+ function decodeJwtPayload(token) {
1477
+ const parts = token.split(".");
1478
+ if (parts.length < 2) return {};
1479
+ try {
1480
+ const normalized = parts[1].replace(/-/g, "+").replace(/_/g, "/");
1481
+ const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
1482
+ const raw = Buffer.from(padded, "base64").toString("utf8");
1483
+ const parsed = JSON.parse(raw);
1484
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
1485
+ } catch {
1486
+ return {};
1487
+ }
1488
+ }
1489
+ function readStringClaim(claims, key) {
1490
+ const value = claims[key];
1491
+ return typeof value === "string" && value.length > 0 ? value : void 0;
1492
+ }
1493
+ function readNumberClaim(claims, key) {
1494
+ const value = claims[key];
1495
+ return typeof value === "number" ? value : Number.NaN;
1496
+ }
1497
+ function prompt(question) {
1498
+ const rl = createInterface({
1499
+ input: process.stdin,
1500
+ output: process.stdout
1501
+ });
1502
+ return new Promise((resolve) => {
1503
+ rl.question(question, (answer) => {
1504
+ rl.close();
1505
+ resolve(answer.trim());
1506
+ });
1507
+ });
1508
+ }
1509
+
1510
+ // src/commands/login.ts
1511
+ async function login(options) {
1512
+ clearAuth();
1513
+ console.log(" Cleared existing session.\n");
1514
+ await connect({ env: options.env });
1515
+ }
1516
+
1517
+ // src/commands/logout.ts
1518
+ async function logout() {
1519
+ clearAuth();
1520
+ console.log(" \u2713 Logged out. Credentials cleared.");
1521
+ }
1522
+
1523
+ // src/commands/status.ts
1524
+ async function status() {
1525
+ console.log("\n Implicit Status\n");
1526
+ const claude = detectClaudeCodeInstalled();
1527
+ console.log(` Claude Code: ${claude.installed ? `\u2713 ${claude.version}` : "\u2717 not installed"}`);
1528
+ const claudeAuth = detectClaudeAuth();
1529
+ console.log(` Claude auth: ${claudeAuth.method === "none" ? "\u2717 not configured" : `\u2713 ${claudeAuth.method}`}`);
1530
+ const auth = loadAuth();
1531
+ console.log(` Implicit account: ${auth ? `\u2713 ${auth.email}` : "\u2717 not signed in"}`);
1532
+ const config = loadConfig();
1533
+ if (config?.deviceId) console.log(` Device: ${config.deviceId.slice(0, 8)}...`);
1534
+ console.log("");
1535
+ }
1536
+
1537
+ // src/cli.ts
1538
+ process.on("unhandledRejection", (reason) => {
1539
+ const detail = reason instanceof Error ? reason.stack ?? reason.message : String(reason);
1540
+ console.error(`[unhandledRejection] ${detail}`);
1541
+ });
1542
+ program.name("implicit").version(version).description("Control Claude Code from your phone with Implicit");
1543
+ program.command("connect").description("Connect this machine to Implicit (pairs on first run)").option("-b, --background", "Run without interactive UI (headless mode)").option("-s, --server <url>", "Server URL").option("-t, --token <token>", "Authentication token").option("-n, --name <name>", "Custom device display name").option("--cwd <directory>", "Default working directory for sessions").option("--env <env>", "Environment: production or development", "development").action(connect);
1544
+ program.command("login").description("Re-pair with the Implicit app").option("-s, --server <url>", "Server URL").action(login);
1545
+ program.command("logout").description("Clear stored credentials").action(logout);
1546
+ program.command("status").description("Show connection status").action(status);
1547
+ program.action(() => {
1548
+ program.help();
1549
+ });
1550
+ async function main() {
1551
+ try {
1552
+ await program.parseAsync();
1553
+ } catch (err) {
1554
+ console.error(err instanceof Error ? err.message : String(err));
1555
+ process.exit(1);
1556
+ }
1557
+ }
1558
+ main();