spudmobile-bridge 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,263 @@
1
+ #!/usr/bin/env node
2
+ import 'dotenv/config';
3
+ import { program } from 'commander';
4
+ import { getConfig, saveConfig } from './config.js';
5
+ import { SupabaseBridge } from './supabase.js';
6
+ import { findCodexCLI, runCodex } from './codex.js';
7
+ import { startPairingServer } from './pairing.js';
8
+ import { installAutostart, uninstallAutostart, isAutostartInstalled } from './autostart.js';
9
+ // ─── State ────────────────────────────────────────────────
10
+ let isFirstMessage = true;
11
+ // ─── CLI ──────────────────────────────────────────────────
12
+ program
13
+ .name('spudmobile-bridge')
14
+ .description('Bridge your mobile app with OpenAI Codex CLI')
15
+ .version('1.0.0')
16
+ .option('-p, --path <dir>', 'Project directory for Codex to work in')
17
+ .option('--port <number>', 'Pairing server port', '38474')
18
+ .option('--install', 'Install auto-launch (start on login)')
19
+ .option('--uninstall', 'Remove auto-launch')
20
+ .parse();
21
+ const opts = program.opts();
22
+ // Handle install/uninstall commands
23
+ if (opts.install) {
24
+ installAutostart(opts.path);
25
+ process.exit(0);
26
+ }
27
+ if (opts.uninstall) {
28
+ uninstallAutostart();
29
+ process.exit(0);
30
+ }
31
+ const config = getConfig({ path: opts.path, port: parseInt(opts.port) });
32
+ // ─── State ────────────────────────────────────────────────
33
+ let cliPath = null;
34
+ let bridge;
35
+ let activeProcess = null;
36
+ let activeMessageId = null;
37
+ let pairingServer = null;
38
+ // ─── Main ─────────────────────────────────────────────────
39
+ async function main() {
40
+ console.log('');
41
+ console.log('╔══════════════════════════════════════════╗');
42
+ console.log('║ SpudMobile Bridge v1.0 ║');
43
+ console.log('║ OpenAI Codex CLI ↔ iOS ║');
44
+ console.log('╚══════════════════════════════════════════╝');
45
+ console.log('');
46
+ // 1. Find Codex CLI
47
+ cliPath = findCodexCLI();
48
+ if (!cliPath) {
49
+ console.error('❌ Codex CLI not found!');
50
+ console.error(' Install it: npm i -g @openai/codex');
51
+ console.error(' Or visit: https://developers.openai.com/codex/cli');
52
+ process.exit(1);
53
+ }
54
+ console.log(`✅ Codex CLI found: ${cliPath}`);
55
+ // 2. Init Supabase bridge
56
+ bridge = new SupabaseBridge(config);
57
+ // 3. Handle pairing
58
+ let needsPairing = !config.pairId;
59
+ // Verify saved pair is still active
60
+ if (config.pairId) {
61
+ const isActive = await bridge.isPairActive(config.pairId);
62
+ if (isActive) {
63
+ console.log(`✅ Using saved pair: ${config.pairId}`);
64
+ }
65
+ else {
66
+ console.log('⚠️ Saved pair is no longer active. Re-pairing...');
67
+ saveConfig({ pairId: undefined, pairCode: undefined });
68
+ config.pairId = null;
69
+ config.pairCode = null;
70
+ bridge.pairId = null;
71
+ needsPairing = true;
72
+ }
73
+ }
74
+ if (needsPairing) {
75
+ await waitForPairing();
76
+ // Auto-install background service after first successful pairing
77
+ if (!isAutostartInstalled()) {
78
+ console.log('');
79
+ installAutostart(config.projectPath || undefined);
80
+ }
81
+ }
82
+ // Start operations with current pair
83
+ await startOperations();
84
+ console.log('');
85
+ console.log(`📁 Project: ${config.projectPath}`);
86
+ console.log('🤖 Waiting for messages from mobile app...');
87
+ console.log(' Press Ctrl+C to stop.\n');
88
+ // Always keep pairing server running in background for re-pairing
89
+ ensurePairingServerRunning();
90
+ }
91
+ /**
92
+ * Wait for initial pairing to complete (one-time, closes after pair)
93
+ */
94
+ async function waitForPairing() {
95
+ console.log('🔗 No saved pair found. Starting pairing server...');
96
+ const { server, paired } = startPairingServer(config.port, async (pairCode) => {
97
+ console.log(`🔑 Received pair code: ${pairCode}`);
98
+ const pairId = await bridge.activatePair(pairCode);
99
+ console.log(`✅ Paired successfully! ID: ${pairId}`);
100
+ }, false); // one-time mode — closes after first pair
101
+ pairingServer = server;
102
+ await paired;
103
+ pairingServer = null;
104
+ }
105
+ /**
106
+ * Start persistent pairing server for re-connections.
107
+ * Server stays alive and handles unlimited re-pairings.
108
+ */
109
+ function ensurePairingServerRunning() {
110
+ if (pairingServer)
111
+ return;
112
+ const { server } = startPairingServer(config.port, async (pairCode) => {
113
+ console.log(`\n🔑 Re-pair request received: ${pairCode}`);
114
+ // End old session
115
+ await bridge.endSession();
116
+ isFirstMessage = true;
117
+ // Activate new pair
118
+ const pairId = await bridge.activatePair(pairCode);
119
+ console.log(`✅ Re-paired successfully! ID: ${pairId}`);
120
+ // Start fresh operations
121
+ await startOperations();
122
+ console.log('');
123
+ console.log(`📁 Project: ${config.projectPath}`);
124
+ console.log('🤖 Waiting for messages from mobile app...\n');
125
+ }, true); // persistent mode — keeps running
126
+ pairingServer = server;
127
+ }
128
+ /**
129
+ * Start/restart all bridge operations (session, presence, subscriptions)
130
+ */
131
+ async function startOperations() {
132
+ // Reuse active session or create new one
133
+ const session = await bridge.ensureSession();
134
+ // If session was resumed, continue Codex conversation (not fresh start)
135
+ if (session.resumed) {
136
+ isFirstMessage = false;
137
+ }
138
+ // Start presence heartbeat
139
+ bridge.startPresenceHeartbeat();
140
+ // Process any pending messages
141
+ const pending = await bridge.getPendingMessages();
142
+ if (pending.length > 0) {
143
+ console.log(`📬 Found ${pending.length} pending message(s)`);
144
+ for (const msg of pending) {
145
+ await processMessage(msg);
146
+ }
147
+ }
148
+ // Subscribe to new messages
149
+ bridge.subscribeToMessages(async (message) => {
150
+ await processMessage(message);
151
+ }, (messageId) => {
152
+ handleCancellation(messageId);
153
+ });
154
+ // Listen for session changes (iOS "New Session" button)
155
+ bridge.subscribeToSessionChange(() => {
156
+ isFirstMessage = true;
157
+ console.log('🔄 Session reset — next message starts fresh Codex conversation');
158
+ });
159
+ // Listen for disconnect from mobile app
160
+ bridge.subscribeToDisconnect(async () => {
161
+ console.log('\n🔌 Disconnected by mobile app!');
162
+ console.log(' Waiting for re-connection...\n');
163
+ // End current session
164
+ await bridge.endSession();
165
+ isFirstMessage = true;
166
+ // Clear saved config
167
+ saveConfig({ pairId: undefined, pairCode: undefined });
168
+ config.pairId = null;
169
+ config.pairCode = null;
170
+ bridge.pairId = null;
171
+ // Pairing server is already running in background,
172
+ // so user can just tap Connect again on iOS
173
+ ensurePairingServerRunning();
174
+ });
175
+ }
176
+ // ─── Process Message ──────────────────────────────────────
177
+ async function processMessage(message) {
178
+ const prompt = message.content;
179
+ const model = message.model;
180
+ console.log(`\n📩 New message: "${prompt.substring(0, 80)}${prompt.length > 80 ? '...' : ''}"`);
181
+ if (model)
182
+ console.log(` Model: ${model}`);
183
+ // Sync session (in case iOS created new session)
184
+ const oldSessionId = bridge.sessionId;
185
+ await bridge.refreshSessionId();
186
+ if (bridge.sessionId !== oldSessionId) {
187
+ isFirstMessage = true;
188
+ console.log('🔄 Session changed — starting fresh Codex conversation');
189
+ }
190
+ // Mark as processing
191
+ await bridge.markAsProcessing(message.id);
192
+ activeMessageId = message.id;
193
+ // Run Codex CLI
194
+ const { process: proc, result } = runCodex(prompt, cliPath, {
195
+ model: mapModel(model) || undefined,
196
+ cwd: config.projectPath || undefined,
197
+ continueSession: !isFirstMessage,
198
+ },
199
+ // Streaming callback
200
+ async (chunk, fullOutput) => {
201
+ // For streaming we just update, but for simplicity
202
+ // we'll send the final result once complete
203
+ });
204
+ isFirstMessage = false;
205
+ activeProcess = proc;
206
+ // Wait for result
207
+ const codexResult = await result;
208
+ activeProcess = null;
209
+ activeMessageId = null;
210
+ if (codexResult.killed) {
211
+ console.log('⏱️ Timed out');
212
+ await bridge.sendReply('⚠️ Request timed out after 10 minutes.', message.id, model, 'error');
213
+ return;
214
+ }
215
+ // Send reply
216
+ const status = codexResult.exitCode === 0 ? 'completed' : 'error';
217
+ await bridge.sendReply(codexResult.output, message.id, model, status);
218
+ console.log(`✅ Reply sent (${codexResult.output.length} chars, status: ${status})`);
219
+ }
220
+ // ─── Cancellation ─────────────────────────────────────────
221
+ function handleCancellation(messageId) {
222
+ if (activeMessageId === messageId && activeProcess) {
223
+ console.log('🚫 Cancellation received — killing Codex process');
224
+ activeProcess.kill('SIGTERM');
225
+ activeProcess = null;
226
+ activeMessageId = null;
227
+ }
228
+ }
229
+ // ─── Model Mapping ────────────────────────────────────────
230
+ function mapModel(model) {
231
+ if (!model || model.toLowerCase() === 'default')
232
+ return undefined;
233
+ // Map iOS model names to Codex CLI model flags
234
+ const mapping = {
235
+ 'gpt-5.4': 'gpt-5.4',
236
+ 'gpt-5.3-codex': 'gpt-5.3-codex',
237
+ 'gpt-5.3-codex-spark': 'gpt-5.3-codex-spark',
238
+ 'codex': 'gpt-5.3-codex',
239
+ 'spark': 'gpt-5.3-codex-spark',
240
+ };
241
+ return mapping[model.toLowerCase()] || model;
242
+ }
243
+ // ─── Graceful Shutdown ────────────────────────────────────
244
+ async function shutdown() {
245
+ console.log('\n🛑 Shutting down...');
246
+ if (activeProcess) {
247
+ activeProcess.kill('SIGTERM');
248
+ }
249
+ if (bridge) {
250
+ await bridge.cleanup();
251
+ }
252
+ if (pairingServer) {
253
+ pairingServer.close();
254
+ }
255
+ process.exit(0);
256
+ }
257
+ process.on('SIGINT', shutdown);
258
+ process.on('SIGTERM', shutdown);
259
+ // ─── Run ──────────────────────────────────────────────────
260
+ main().catch((err) => {
261
+ console.error('💥 Fatal error:', err.message);
262
+ process.exit(1);
263
+ });
@@ -0,0 +1,14 @@
1
+ import { Server } from 'http';
2
+ /**
3
+ * Start a local HTTP server to receive pairing callbacks from the web page.
4
+ * The `connect` edge function redirects to http://127.0.0.1:38473/callback?token=PAIR_CODE
5
+ *
6
+ * @param port Port to listen on (default 38473)
7
+ * @param onPairCode Called when a valid pair code is received
8
+ * @param persistent If true, server stays running after pairing (for re-connections)
9
+ * @returns Server instance and a promise that resolves when pairing succeeds
10
+ */
11
+ export declare function startPairingServer(port: number, onPairCode: (code: string) => Promise<void>, persistent?: boolean): {
12
+ server: Server;
13
+ paired: Promise<void>;
14
+ };
@@ -0,0 +1,137 @@
1
+ import { createServer } from 'http';
2
+ import { readFileSync } from 'fs';
3
+ import { join, dirname } from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import { execSync } from 'child_process';
6
+ /**
7
+ * Start a local HTTP server to receive pairing callbacks from the web page.
8
+ * The `connect` edge function redirects to http://127.0.0.1:38473/callback?token=PAIR_CODE
9
+ *
10
+ * @param port Port to listen on (default 38473)
11
+ * @param onPairCode Called when a valid pair code is received
12
+ * @param persistent If true, server stays running after pairing (for re-connections)
13
+ * @returns Server instance and a promise that resolves when pairing succeeds
14
+ */
15
+ export function startPairingServer(port, onPairCode, persistent = false) {
16
+ let resolvePaired;
17
+ const paired = new Promise((resolve) => {
18
+ resolvePaired = resolve;
19
+ });
20
+ const server = createServer(async (req, res) => {
21
+ // CORS headers for browser requests
22
+ res.setHeader('Access-Control-Allow-Origin', '*');
23
+ res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
24
+ if (req.method === 'OPTIONS') {
25
+ res.writeHead(204);
26
+ res.end();
27
+ return;
28
+ }
29
+ const url = new URL(req.url || '/', `http://localhost:${port}`);
30
+ if (url.pathname === '/callback') {
31
+ const token = url.searchParams.get('token');
32
+ if (!token) {
33
+ res.writeHead(400, { 'Content-Type': 'text/html' });
34
+ res.end('<h1>Missing token</h1>');
35
+ return;
36
+ }
37
+ try {
38
+ await onPairCode(token);
39
+ // Send success response
40
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
41
+ res.end(`
42
+ <!DOCTYPE html>
43
+ <html>
44
+ <head>
45
+ <title>Connected!</title>
46
+ <meta charset="UTF-8">
47
+ <meta name="viewport" content="width=device-width, initial-scale=1">
48
+ <style>
49
+ body { font-family: -apple-system, system-ui, sans-serif; background: #0a0a0b; color: white; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
50
+ .container { text-align: center; }
51
+ .icon { width: 80px; height: 80px; margin-bottom: 20px; opacity: 0.85; }
52
+ h1 { font-size: 24px; margin-bottom: 8px; font-weight: 700; }
53
+ p { color: #71717a; font-size: 15px; line-height: 1.5; }
54
+ </style>
55
+ </head>
56
+ <body>
57
+ <div class="container">
58
+ <img class="icon" src="/icon" alt="App Icon" />
59
+ <h1>Connected!</h1>
60
+ <p>You can close this tab and return to your mobile app.</p>
61
+ </div>
62
+ </body>
63
+ </html>`);
64
+ if (!persistent) {
65
+ // Close server after successful pairing (one-time mode)
66
+ setTimeout(() => {
67
+ server.close();
68
+ resolvePaired();
69
+ }, 500);
70
+ }
71
+ else {
72
+ // In persistent mode, just resolve the promise but keep server running
73
+ resolvePaired();
74
+ }
75
+ }
76
+ catch (err) {
77
+ const message = err instanceof Error ? err.message : String(err);
78
+ res.writeHead(500, { 'Content-Type': 'text/html' });
79
+ res.end(`<h1>Pairing failed</h1><p>${message}</p>`);
80
+ }
81
+ }
82
+ else if (url.pathname === '/icon') {
83
+ // Serve the app icon
84
+ try {
85
+ const iconPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'appicon.png');
86
+ const icon = readFileSync(iconPath);
87
+ res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=3600' });
88
+ res.end(icon);
89
+ }
90
+ catch {
91
+ res.writeHead(404);
92
+ res.end();
93
+ }
94
+ }
95
+ else if (url.pathname === '/health') {
96
+ res.writeHead(200, { 'Content-Type': 'application/json' });
97
+ res.end(JSON.stringify({ status: 'waiting_for_pair' }));
98
+ }
99
+ else {
100
+ res.writeHead(404);
101
+ res.end('Not found');
102
+ }
103
+ });
104
+ server.on('error', (err) => {
105
+ if (err.code === 'EADDRINUSE') {
106
+ if (persistent) {
107
+ // Already paired, just skip the pairing server
108
+ console.log(`\n⚠️ Port ${port} is already in use (another bridge may be running).`);
109
+ console.log(' Pairing server skipped — bridge continues working.\n');
110
+ resolvePaired();
111
+ }
112
+ else {
113
+ // Need to pair! Kill the existing process on port and retry
114
+ console.log(`\n⚠️ Port ${port} is busy. Freeing port...`);
115
+ try {
116
+ execSync(`lsof -ti:${port} | xargs kill -9 2>/dev/null`, { stdio: 'ignore' });
117
+ }
118
+ catch { /* ignore */ }
119
+ // Retry after a short delay
120
+ setTimeout(() => {
121
+ server.listen(port, '127.0.0.1', () => {
122
+ console.log(`\n🔗 Pairing server ready at http://127.0.0.1:${port}`);
123
+ console.log(' 👉 Now go to the mobile app and tap "Connect" on the setup page.\n');
124
+ });
125
+ }, 1000);
126
+ }
127
+ }
128
+ else {
129
+ console.error('❌ Pairing server error:', err.message);
130
+ }
131
+ });
132
+ server.listen(port, '127.0.0.1', () => {
133
+ console.log(`\n🔗 Pairing server ready at http://127.0.0.1:${port}`);
134
+ console.log(' 👉 Now go to the mobile app and tap "Connect" on the setup page.\n');
135
+ });
136
+ return { server, paired };
137
+ }
@@ -0,0 +1,96 @@
1
+ import { BridgeConfig } from './config.js';
2
+ export interface Message {
3
+ id: string;
4
+ created_at: string;
5
+ role: 'user' | 'agent' | 'system';
6
+ content: string;
7
+ status: 'pending' | 'processing' | 'streaming' | 'completed' | 'error';
8
+ session_id: string | null;
9
+ model: string | null;
10
+ user_id: string | null;
11
+ client_type: string;
12
+ parent_message_id: string | null;
13
+ pair_id: string | null;
14
+ sender: string | null;
15
+ processed_by_mac_at: string | null;
16
+ is_cancelled: boolean;
17
+ cancelled_at: string | null;
18
+ }
19
+ export declare class SupabaseBridge {
20
+ private config;
21
+ private client;
22
+ private messageChannel;
23
+ private presenceInterval;
24
+ private pollingInterval;
25
+ private processedMessageIds;
26
+ private deviceId;
27
+ pairId: string | null;
28
+ sessionId: string | null;
29
+ constructor(config: BridgeConfig);
30
+ /**
31
+ * Create a new session, deactivating any old sessions for this pair
32
+ */
33
+ createSession(): Promise<string>;
34
+ /**
35
+ * Reuse existing active session or create a new one.
36
+ * This preserves chat history across bridge restarts.
37
+ * Returns session id and project_name (if available).
38
+ */
39
+ ensureSession(): Promise<{
40
+ id: string;
41
+ projectName: string | null;
42
+ resumed: boolean;
43
+ }>;
44
+ /**
45
+ * End the current session
46
+ */
47
+ endSession(): Promise<void>;
48
+ /**
49
+ * Subscribe to session changes (iOS creates new session)
50
+ */
51
+ subscribeToSessionChange(onNewSession: (sessionId: string) => void): void;
52
+ /**
53
+ * Refresh sessionId from Supabase (polling fallback for session changes)
54
+ */
55
+ refreshSessionId(): Promise<void>;
56
+ /**
57
+ * Check if a pair is still active in Supabase
58
+ */
59
+ isPairActive(pairId: string): Promise<boolean>;
60
+ /**
61
+ * Activate a device pair using the pair code from callback
62
+ */
63
+ activatePair(pairCode: string): Promise<string>;
64
+ /**
65
+ * Subscribe to new user messages for processing
66
+ */
67
+ subscribeToMessages(onNewMessage: (message: Message) => void, onCancellation: (messageId: string) => void): void;
68
+ /**
69
+ * Subscribe to disconnect events from mobile app
70
+ */
71
+ subscribeToDisconnect(onDisconnect: () => void): void;
72
+ /**
73
+ * Mark message as being processed
74
+ */
75
+ markAsProcessing(messageId: string): Promise<void>;
76
+ /**
77
+ * Send a reply message from the bridge (agent)
78
+ */
79
+ sendReply(content: string, parentMessageId: string, model: string | null, status?: 'streaming' | 'completed' | 'error'): Promise<string | null>;
80
+ /**
81
+ * Update an existing reply (for streaming updates)
82
+ */
83
+ updateReply(replyId: string, content: string, status?: 'streaming' | 'completed' | 'error'): Promise<void>;
84
+ /**
85
+ * Start heartbeat to ide_presence table
86
+ */
87
+ startPresenceHeartbeat(): void;
88
+ /**
89
+ * Check if there are pending messages to process (on startup or polling)
90
+ */
91
+ getPendingMessages(): Promise<Message[]>;
92
+ /**
93
+ * Clean up on shutdown
94
+ */
95
+ cleanup(): Promise<void>;
96
+ }