mobileclaude-agent 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,116 @@
1
+ # MobileClaude Agent
2
+
3
+ Desktop agent for **MobileIDE** — control Claude Code from your iPhone via Supabase Realtime.
4
+
5
+ ## Requirements
6
+
7
+ - Node.js 18+
8
+ - [Claude Code CLI](https://code.claude.ai) installed and accessible in PATH
9
+ - A [Supabase](https://supabase.com) project with the schema applied
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ npm install -g mobileclaude-agent
15
+ ```
16
+
17
+ Or run locally from this directory:
18
+
19
+ ```bash
20
+ npm install
21
+ npm run build
22
+ ```
23
+
24
+ ## Setup
25
+
26
+ ### 1. Apply Supabase Migrations
27
+
28
+ In your Supabase project → SQL Editor, run in order:
29
+
30
+ 1. `../supabase/migrations/001_initial_schema.sql`
31
+ 2. `../supabase/migrations/002_realtime.sql`
32
+
33
+ Also enable Realtime in Supabase Dashboard → Database → Replication for:
34
+ `prompts`, `results`, `agent_status`, `session_metas`
35
+
36
+ ### 2. Configure the Agent
37
+
38
+ ```bash
39
+ mobileclaude-agent --setup
40
+ ```
41
+
42
+ You'll be prompted for:
43
+ - **Supabase Project URL** — from Project Settings → API
44
+ - **Supabase Anon Key** — public key, safe to share
45
+ - **Supabase Service Role Key** — secret key, stored with `chmod 600`
46
+ - **Your account email + password** — for initial authentication
47
+
48
+ Config is saved to `~/.mobileclaude/config.json` (chmod 600).
49
+
50
+ ### 3. Start the Agent
51
+
52
+ ```bash
53
+ mobileclaude-agent
54
+ ```
55
+
56
+ The agent will:
57
+ 1. Connect to Supabase and restore your session
58
+ 2. Start watching `~/.claude/projects/` for Claude Code sessions
59
+ 3. Subscribe to new prompts from your iPhone
60
+ 4. Send heartbeats every 30s so the iOS app knows you're online
61
+
62
+ ## How It Works
63
+
64
+ ```
65
+ iPhone (iOS App)
66
+ → INSERT prompt into Supabase `prompts` table (status=pending)
67
+
68
+ Desktop Agent
69
+ → Receives prompt via Supabase Realtime
70
+ → Checks freemium quota (1 prompt/day free)
71
+ → Spawns: claude --print --dangerously-skip-permissions --output-format stream-json
72
+ → Streams output to `results` table every ~500 bytes
73
+ → iOS app receives live updates via Realtime
74
+
75
+ On completion:
76
+ → status=done, session_id saved back to prompts table
77
+ → iOS can send follow-up prompts with --resume
78
+ ```
79
+
80
+ ## Session Indexing
81
+
82
+ The agent watches `~/.claude/projects/**/*.jsonl` and syncs all Claude Code sessions (including those started in the terminal) to Supabase. This makes them visible in the iOS app's session list — tap any session to continue it from your iPhone.
83
+
84
+ ## Freemium Model
85
+
86
+ - **Free tier**: 1 prompt per day
87
+ - **Premium**: Unlimited (set by iOS app after StoreKit purchase)
88
+
89
+ The freemium gate is enforced server-side via the `check_and_increment_daily_usage` PostgreSQL function, using the service role key. The iOS app cannot forge its own count.
90
+
91
+ ## Development
92
+
93
+ ```bash
94
+ npm run dev # Run with tsx (no build step)
95
+ npm run test:types # TypeScript type check
96
+ npm test # Run Jest unit tests
97
+ npm run build # Compile to dist/
98
+ ```
99
+
100
+ ## Autostart
101
+
102
+ The agent will prompt you to enable autostart during setup. This uses:
103
+ - **macOS**: LaunchAgent plist (`~/Library/LaunchAgents/`)
104
+ - **Windows**: Registry run key (via node-auto-launch)
105
+ - **Linux**: XDG autostart entry (`~/.config/autostart/`)
106
+
107
+ ## System Tray
108
+
109
+ On desktop systems with a GUI, a system tray icon shows agent status. On headless servers, the agent runs without a tray (no error, just a log message).
110
+
111
+ ## Security Notes
112
+
113
+ - The service role key bypasses Supabase RLS — keep it secret
114
+ - `~/.mobileclaude/config.json` is `chmod 600` — only your user can read it
115
+ - `--dangerously-skip-permissions` is required for unattended claude CLI execution
116
+ - The freemium gate uses `SECURITY DEFINER` PostgreSQL function to prevent tampering
@@ -0,0 +1,260 @@
1
+ /**
2
+ * agent_controller.ts — Core agent logic
3
+ *
4
+ * 1. Subscribes to Supabase Realtime for new pending prompts
5
+ * 2. Polls on startup to catch prompts missed while offline
6
+ * 3. Enforces freemium gate via daily_usage DB function
7
+ * 4. Spawns claude CLI, streams output to results table
8
+ * 5. On completion: sets status=done, upserts session_id back to prompt
9
+ */
10
+ import { spawn } from 'child_process';
11
+ import { randomUUID } from 'crypto';
12
+ import { supabase } from './supabase.js';
13
+ import { findClaudeBinary, getEnhancedPath } from './platform.js';
14
+ const FREE_DAILY_LIMIT = 10;
15
+ const STREAM_FLUSH_BYTES = 500;
16
+ let isProcessing = false;
17
+ const pendingQueue = [];
18
+ let channel = null;
19
+ let claudeBinary = null;
20
+ let currentConfig;
21
+ export function initAgentController(config, platform) {
22
+ currentConfig = config;
23
+ claudeBinary = findClaudeBinary();
24
+ if (!claudeBinary) {
25
+ console.error('❌ Claude CLI not found. Install it with:\n' +
26
+ ' npm install -g @anthropic-ai/claude-code\n' +
27
+ 'Then run: mobileclaude-agent');
28
+ process.exit(1);
29
+ }
30
+ console.log(`✓ Claude binary: ${claudeBinary}`);
31
+ }
32
+ /** Start listening for pending prompts via Realtime + initial poll */
33
+ export async function startAgentController() {
34
+ // 1. Poll on startup — catch anything queued while offline
35
+ await pollPendingPrompts();
36
+ // 2. Subscribe to Realtime inserts on prompts table
37
+ channel = supabase
38
+ .channel('agent-prompts')
39
+ .on('postgres_changes', {
40
+ event: 'INSERT',
41
+ schema: 'public',
42
+ table: 'prompts',
43
+ filter: `user_id=eq.${currentConfig.relayToken}`,
44
+ }, (payload) => {
45
+ const prompt = payload.new;
46
+ if (prompt.status === 'pending') {
47
+ enqueuePrompt(prompt);
48
+ }
49
+ })
50
+ .subscribe((status) => {
51
+ if (status === 'SUBSCRIBED') {
52
+ console.log('✓ Subscribed to prompts channel');
53
+ }
54
+ });
55
+ }
56
+ export async function stopAgentController() {
57
+ if (channel) {
58
+ await supabase.removeChannel(channel);
59
+ channel = null;
60
+ }
61
+ }
62
+ async function pollPendingPrompts() {
63
+ const { data, error } = await supabase
64
+ .from('prompts')
65
+ .select('*')
66
+ .eq('user_id', currentConfig.relayToken)
67
+ .eq('status', 'pending')
68
+ .order('created_at', { ascending: true });
69
+ if (error) {
70
+ console.error('Failed to poll pending prompts:', error.message);
71
+ return;
72
+ }
73
+ for (const prompt of data ?? []) {
74
+ enqueuePrompt(prompt);
75
+ }
76
+ }
77
+ function enqueuePrompt(prompt) {
78
+ if (isProcessing) {
79
+ pendingQueue.push(prompt);
80
+ console.log(`Queued prompt ${prompt.prompt_id} (queue length: ${pendingQueue.length})`);
81
+ return;
82
+ }
83
+ processPrompt(prompt);
84
+ }
85
+ async function processPrompt(prompt) {
86
+ isProcessing = true;
87
+ console.log(`\n→ Processing prompt ${prompt.prompt_id}: "${prompt.prompt_text.slice(0, 60)}..."`);
88
+ try {
89
+ // 1. Freemium gate
90
+ const allowed = await checkFreemiumGate(currentConfig.relayToken);
91
+ if (!allowed) {
92
+ await markPromptError(prompt.prompt_id, 'Daily free limit reached. Upgrade to Premium for unlimited prompts.');
93
+ return;
94
+ }
95
+ // 2. Mark as processing
96
+ await supabase
97
+ .from('prompts')
98
+ .update({ status: 'processing' })
99
+ .eq('prompt_id', prompt.prompt_id);
100
+ // 3. Create result row upfront so iOS can subscribe before streaming starts
101
+ const resultId = randomUUID();
102
+ await supabase.from('results').insert({
103
+ result_id: resultId,
104
+ user_id: currentConfig.relayToken,
105
+ prompt_id: prompt.prompt_id,
106
+ session_id: prompt.session_id || '',
107
+ output_text: '',
108
+ is_final: false,
109
+ });
110
+ // 4. Execute claude CLI
111
+ const { sessionId, outputText, error: execError } = await executeClaudeCli(prompt, resultId);
112
+ if (execError) {
113
+ await markPromptError(prompt.prompt_id, execError);
114
+ await supabase
115
+ .from('results')
116
+ .update({ output_text: `Error: ${execError}`, is_final: true })
117
+ .eq('result_id', resultId);
118
+ return;
119
+ }
120
+ // 5. Final updates
121
+ const resolvedSessionId = sessionId || prompt.session_id || '';
122
+ await Promise.all([
123
+ supabase
124
+ .from('prompts')
125
+ .update({ status: 'done', session_id: resolvedSessionId })
126
+ .eq('prompt_id', prompt.prompt_id),
127
+ supabase
128
+ .from('results')
129
+ .update({ output_text: outputText, session_id: resolvedSessionId, is_final: true })
130
+ .eq('result_id', resultId),
131
+ ]);
132
+ console.log(`✓ Prompt ${prompt.prompt_id} completed (session: ${resolvedSessionId})`);
133
+ }
134
+ catch (err) {
135
+ const msg = err instanceof Error ? err.message : String(err);
136
+ console.error(`Error processing prompt ${prompt.prompt_id}:`, msg);
137
+ await markPromptError(prompt.prompt_id, msg);
138
+ }
139
+ finally {
140
+ isProcessing = false;
141
+ const next = pendingQueue.shift();
142
+ if (next)
143
+ processPrompt(next);
144
+ }
145
+ }
146
+ /** Build claude CLI arguments from a prompt row */
147
+ export function buildClaudeArgs(prompt) {
148
+ const args = [
149
+ '--print',
150
+ '--dangerously-skip-permissions',
151
+ '--output-format', 'stream-json',
152
+ '--verbose',
153
+ ];
154
+ if (prompt.model && prompt.model !== 'default') {
155
+ args.push('--model', prompt.model);
156
+ }
157
+ if (prompt.session_id && prompt.session_id !== '') {
158
+ args.push('--resume', prompt.session_id);
159
+ }
160
+ return args;
161
+ }
162
+ async function executeClaudeCli(prompt, resultId) {
163
+ return new Promise((resolve) => {
164
+ const args = buildClaudeArgs(prompt);
165
+ const cwd = prompt.project_path || process.cwd();
166
+ const child = spawn(claudeBinary, args, {
167
+ cwd,
168
+ env: {
169
+ ...process.env,
170
+ PATH: getEnhancedPath(),
171
+ ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY ?? '',
172
+ },
173
+ stdio: ['pipe', 'pipe', 'pipe'],
174
+ });
175
+ child.stdin.write(prompt.prompt_text);
176
+ child.stdin.end();
177
+ let stdoutBuffer = '';
178
+ let textAccumulator = '';
179
+ let bytesSinceFlush = 0;
180
+ let detectedSessionId = null;
181
+ child.stdout.on('data', async (chunk) => {
182
+ stdoutBuffer += chunk.toString('utf-8');
183
+ bytesSinceFlush += chunk.length;
184
+ const lines = stdoutBuffer.split('\n');
185
+ stdoutBuffer = lines.pop() ?? '';
186
+ for (const line of lines) {
187
+ if (!line.trim())
188
+ continue;
189
+ try {
190
+ const obj = JSON.parse(line);
191
+ extractTextFromStreamEvent(obj, (text) => { textAccumulator += text; });
192
+ if (obj.type === 'result' && typeof obj.session_id === 'string') {
193
+ detectedSessionId = obj.session_id;
194
+ }
195
+ }
196
+ catch { /* non-JSON line — ignore */ }
197
+ }
198
+ if (bytesSinceFlush >= STREAM_FLUSH_BYTES) {
199
+ bytesSinceFlush = 0;
200
+ await supabase
201
+ .from('results')
202
+ .update({ output_text: textAccumulator })
203
+ .eq('result_id', resultId);
204
+ }
205
+ });
206
+ child.stderr.on('data', (chunk) => {
207
+ console.error('[claude stderr]', chunk.toString('utf-8').trim());
208
+ });
209
+ child.on('close', (code) => {
210
+ resolve({
211
+ sessionId: detectedSessionId,
212
+ outputText: textAccumulator,
213
+ error: code !== 0 ? `Claude exited with code ${code}` : null,
214
+ });
215
+ });
216
+ child.on('error', (err) => {
217
+ resolve({ sessionId: null, outputText: '', error: err.message });
218
+ });
219
+ });
220
+ }
221
+ function extractTextFromStreamEvent(obj, append) {
222
+ if (obj.type === 'assistant') {
223
+ const content = obj.message?.content;
224
+ if (Array.isArray(content)) {
225
+ for (const block of content) {
226
+ const b = block;
227
+ if (b.type === 'text' && typeof b.text === 'string')
228
+ append(b.text);
229
+ }
230
+ }
231
+ }
232
+ if (obj.type === 'content_block_delta') {
233
+ const delta = obj.delta;
234
+ if (delta?.type === 'text_delta' && typeof delta.text === 'string')
235
+ append(delta.text);
236
+ }
237
+ }
238
+ /** Atomically check and increment daily usage via SECURITY DEFINER function */
239
+ async function checkFreemiumGate(relayToken) {
240
+ const today = new Date().toISOString().slice(0, 10);
241
+ const { data, error } = await supabase.rpc('check_and_increment_daily_usage', {
242
+ p_user_id: relayToken,
243
+ p_date: today,
244
+ p_free_limit: FREE_DAILY_LIMIT,
245
+ });
246
+ if (error) {
247
+ // Function not deployed — allow prompt
248
+ console.warn('Freemium gate unavailable (allowing prompt):', error.message);
249
+ return true;
250
+ }
251
+ return data === true;
252
+ }
253
+ async function markPromptError(promptId, message) {
254
+ await supabase
255
+ .from('prompts')
256
+ .update({ status: 'error' })
257
+ .eq('prompt_id', promptId);
258
+ console.error(`✗ Prompt ${promptId} error: ${message}`);
259
+ }
260
+ //# sourceMappingURL=agent_controller.js.map
package/dist/config.js ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * config.ts — Agent configuration
3
+ *
4
+ * Supabase credentials are baked in at publish time — users never enter them.
5
+ * Identity = relay token (UUID stored in ~/.mobileclaude/token).
6
+ * No login, no Supabase account required from the user.
7
+ */
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
9
+ import { homedir } from 'os';
10
+ import { join } from 'path';
11
+ // ─── BAKED-IN BACKEND CREDENTIALS ────────────────────────────────────────────
12
+ // These point to the developer's Supabase project — same for every user.
13
+ // Replace with your real values before `npm publish`.
14
+ export const SUPABASE_URL = process.env.MOBILECLAUDE_SUPABASE_URL
15
+ ?? 'https://zxmgprkewaejozfezpwx.supabase.co';
16
+ export const SUPABASE_ANON_KEY = process.env.MOBILECLAUDE_SUPABASE_ANON_KEY
17
+ ?? 'sb_publishable_C9DAeRtk0fPPuhau1Iw4OQ_k75bz8kC';
18
+ // ─────────────────────────────────────────────────────────────────────────────
19
+ export const AGENT_VERSION = '0.1.0';
20
+ // Relay token file — permanent device identity, created once
21
+ const configDir = join(homedir(), '.mobileclaude');
22
+ const tokenPath = join(configDir, 'token');
23
+ export function getRelayToken() {
24
+ if (!existsSync(configDir)) {
25
+ mkdirSync(configDir, { recursive: true });
26
+ }
27
+ if (existsSync(tokenPath)) {
28
+ const saved = readFileSync(tokenPath, 'utf-8').trim();
29
+ if (saved.length > 0)
30
+ return saved;
31
+ }
32
+ // Generate new UUID token
33
+ const token = crypto.randomUUID();
34
+ writeFileSync(tokenPath, token, 'utf-8');
35
+ return token;
36
+ }
37
+ export function loadConfig() {
38
+ return {
39
+ supabaseUrl: SUPABASE_URL,
40
+ supabaseAnonKey: SUPABASE_ANON_KEY,
41
+ relayToken: getRelayToken(),
42
+ agentVersion: AGENT_VERSION,
43
+ maxConcurrentPrompts: 1,
44
+ };
45
+ }
46
+ // Legacy — keep for backwards compat, but no longer used
47
+ export function configExists() { return true; }
48
+ export async function runSetupWizard() { return loadConfig(); }
49
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1,44 @@
1
+ /**
2
+ * heartbeat.ts — Sends periodic heartbeats to agent_status table.
3
+ * iOS shows "Mac offline" banner if last_heartbeat > 60s ago.
4
+ */
5
+ import { supabase } from './supabase.js';
6
+ import { getPlatform } from './platform.js';
7
+ const HEARTBEAT_INTERVAL_MS = 30_000;
8
+ const AGENT_VERSION = '0.1.0';
9
+ let heartbeatTimer = null;
10
+ let relayToken;
11
+ let sessionCount = 0;
12
+ export function incrementSessionCount() {
13
+ sessionCount++;
14
+ }
15
+ export async function startHeartbeat(config) {
16
+ relayToken = config.relayToken;
17
+ await sendHeartbeat(true);
18
+ heartbeatTimer = setInterval(() => { sendHeartbeat(true); }, HEARTBEAT_INTERVAL_MS);
19
+ }
20
+ export function stopHeartbeat() {
21
+ if (heartbeatTimer) {
22
+ clearInterval(heartbeatTimer);
23
+ heartbeatTimer = null;
24
+ }
25
+ // Fire-and-forget offline signal
26
+ sendHeartbeat(false).catch(() => { });
27
+ }
28
+ async function sendHeartbeat(isOnline) {
29
+ const platform = getPlatform();
30
+ const { error } = await supabase
31
+ .from('agent_status')
32
+ .upsert({
33
+ user_id: relayToken,
34
+ is_online: isOnline,
35
+ platform,
36
+ agent_version: AGENT_VERSION,
37
+ last_heartbeat: new Date().toISOString(),
38
+ session_count: sessionCount,
39
+ }, { onConflict: 'user_id' });
40
+ if (error) {
41
+ console.warn('Heartbeat error:', error.message);
42
+ }
43
+ }
44
+ //# sourceMappingURL=heartbeat.js.map
package/dist/index.js ADDED
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * index.ts — MobileClaude Agent entry point
4
+ *
5
+ * No setup required — credentials are baked in.
6
+ * Just run: npx mobileclaude-agent
7
+ */
8
+ import { loadConfig } from './config.js';
9
+ import { initSupabase } from './supabase.js';
10
+ import { initAgentController, startAgentController, stopAgentController } from './agent_controller.js';
11
+ import { startSessionIndexer, stopSessionIndexer } from './session_indexer.js';
12
+ import { startHeartbeat, stopHeartbeat } from './heartbeat.js';
13
+ import { startTray, stopTray } from './tray.js';
14
+ import { getPlatform, findClaudeBinary } from './platform.js';
15
+ async function main() {
16
+ const { default: chalk } = await import('chalk');
17
+ console.log(chalk.bold.cyan('🤖 MobileClaude Agent v0.1.0'));
18
+ console.log(chalk.dim(' Remote Claude Code control from your iPhone\n'));
19
+ // ── Load config (baked-in credentials + relay token) ─────────────────────
20
+ const config = loadConfig();
21
+ console.log(chalk.dim(` Relay token: ${config.relayToken.slice(0, 8)}...`));
22
+ // ── Initialize Supabase ───────────────────────────────────────────────────
23
+ initSupabase(config);
24
+ // ── Validate claude binary ────────────────────────────────────────────────
25
+ const claude = findClaudeBinary();
26
+ if (!claude) {
27
+ console.error(chalk.red('❌ Claude CLI not found. Install it with:'));
28
+ console.error(chalk.dim(' npm install -g @anthropic-ai/claude-code'));
29
+ process.exit(1);
30
+ }
31
+ console.log(chalk.green(`✓ Claude CLI: ${claude}`));
32
+ // ── Start subsystems ──────────────────────────────────────────────────────
33
+ const platform = getPlatform();
34
+ initAgentController(config, platform);
35
+ console.log('\nStarting agent subsystems...');
36
+ await startHeartbeat(config);
37
+ console.log(chalk.green('✓ Heartbeat started (30s interval)'));
38
+ startSessionIndexer(config);
39
+ console.log(chalk.green('✓ Session indexer watching ~/.claude/projects/'));
40
+ await startAgentController();
41
+ console.log(chalk.green('✓ Agent controller listening for prompts'));
42
+ await startTray(() => gracefulShutdown());
43
+ const platformName = { darwin: 'macOS', win32: 'Windows', linux: 'Linux' }[platform] ?? platform;
44
+ console.log(chalk.bold.green(`\n✓ Agent running on ${platformName} — waiting for prompts from iPhone\n`));
45
+ console.log(chalk.dim(' Press Ctrl+C to stop\n'));
46
+ // ── Graceful shutdown ─────────────────────────────────────────────────────
47
+ process.on('SIGTERM', gracefulShutdown);
48
+ process.on('SIGINT', gracefulShutdown);
49
+ }
50
+ let shuttingDown = false;
51
+ async function gracefulShutdown() {
52
+ if (shuttingDown)
53
+ return;
54
+ shuttingDown = true;
55
+ const { default: chalk } = await import('chalk');
56
+ console.log(chalk.yellow('\nShutting down agent...'));
57
+ stopHeartbeat();
58
+ stopSessionIndexer();
59
+ await stopAgentController();
60
+ stopTray();
61
+ console.log(chalk.green('✓ Agent stopped cleanly'));
62
+ process.exit(0);
63
+ }
64
+ main().catch((err) => {
65
+ console.error('Fatal error:', err);
66
+ process.exit(1);
67
+ });
68
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,107 @@
1
+ /**
2
+ * platform.ts — OS detection and platform-specific paths/utilities
3
+ * Supports macOS (darwin), Windows (win32), and Linux.
4
+ */
5
+ import { existsSync } from 'fs';
6
+ import { homedir } from 'os';
7
+ import { join } from 'path';
8
+ import { execSync } from 'child_process';
9
+ export function getPlatform() {
10
+ const p = process.platform;
11
+ if (p === 'darwin' || p === 'win32' || p === 'linux')
12
+ return p;
13
+ return 'linux'; // fallback
14
+ }
15
+ /** ~/.claude/projects/ — where Claude Code stores session JSONL files */
16
+ export function claudeProjectsDir() {
17
+ return join(homedir(), '.claude', 'projects');
18
+ }
19
+ /** ~/.mobileclaude/ — config directory for this agent */
20
+ export function agentConfigDir() {
21
+ return join(homedir(), '.mobileclaude');
22
+ }
23
+ /** Full path to the agent's config file */
24
+ export function agentConfigPath() {
25
+ return join(agentConfigDir(), 'config.json');
26
+ }
27
+ /** Full path to the agent's log file */
28
+ export function agentLogPath() {
29
+ return join(agentConfigDir(), 'agent.log');
30
+ }
31
+ /**
32
+ * Finds the claude CLI binary on the current system.
33
+ * Checks PATH first, then common install locations for nvm/fnm/mise/brew.
34
+ * Returns null if not found.
35
+ */
36
+ export function findClaudeBinary() {
37
+ // 1. Try PATH
38
+ try {
39
+ const result = execSync('which claude 2>/dev/null || where claude 2>/dev/null', {
40
+ encoding: 'utf-8',
41
+ stdio: ['pipe', 'pipe', 'pipe'],
42
+ }).trim();
43
+ if (result && existsSync(result))
44
+ return result;
45
+ }
46
+ catch {
47
+ // not on PATH
48
+ }
49
+ // 2. Try common macOS/Linux install paths
50
+ const commonPaths = [];
51
+ const home = homedir();
52
+ if (process.platform !== 'win32') {
53
+ commonPaths.push('/usr/local/bin/claude', '/usr/bin/claude', '/opt/homebrew/bin/claude', join(home, '.local/bin/claude'), join(home, '.npm-global/bin/claude'),
54
+ // nvm
55
+ join(home, '.nvm/versions/node/*/bin/claude'),
56
+ // fnm
57
+ join(home, '.fnm/node-versions/*/installation/bin/claude'),
58
+ // mise (formerly rtx)
59
+ join(home, '.local/share/mise/shims/claude'),
60
+ // pnpm global
61
+ join(home, '.local/share/pnpm/claude'));
62
+ }
63
+ else {
64
+ // Windows common paths
65
+ const appData = process.env.APPDATA ?? '';
66
+ const localAppData = process.env.LOCALAPPDATA ?? '';
67
+ commonPaths.push(join(appData, 'npm', 'claude.cmd'), join(localAppData, 'pnpm', 'claude.cmd'), 'C:\\Program Files\\nodejs\\claude.cmd');
68
+ }
69
+ for (const p of commonPaths) {
70
+ // Handle glob patterns (simple expansion for *)
71
+ if (p.includes('*')) {
72
+ try {
73
+ const globResult = execSync(`ls ${p} 2>/dev/null | head -1`, {
74
+ encoding: 'utf-8',
75
+ stdio: ['pipe', 'pipe', 'pipe'],
76
+ }).trim();
77
+ if (globResult && existsSync(globResult))
78
+ return globResult;
79
+ }
80
+ catch {
81
+ // skip
82
+ }
83
+ }
84
+ else if (existsSync(p)) {
85
+ return p;
86
+ }
87
+ }
88
+ return null;
89
+ }
90
+ /**
91
+ * Returns the PATH string to use when spawning child processes.
92
+ * On macOS/Linux, sources common shell locations so claude is found
93
+ * even when the agent runs as a LaunchAgent/systemd service.
94
+ */
95
+ export function getEnhancedPath() {
96
+ const home = homedir();
97
+ const extraPaths = [
98
+ '/usr/local/bin',
99
+ '/opt/homebrew/bin',
100
+ join(home, '.local/bin'),
101
+ join(home, '.npm-global/bin'),
102
+ join(home, '.local/share/pnpm'),
103
+ ];
104
+ const currentPath = process.env.PATH ?? '';
105
+ return [...extraPaths, currentPath].join(':');
106
+ }
107
+ //# sourceMappingURL=platform.js.map
@@ -0,0 +1,119 @@
1
+ /**
2
+ * session_indexer.ts — Watches ~/.claude/projects/ for JSONL files
3
+ * and upserts session metadata to Supabase.
4
+ *
5
+ * Makes ALL Claude Code sessions (including desktop-created ones)
6
+ * visible in the iOS app.
7
+ */
8
+ import { readFileSync, existsSync } from 'fs';
9
+ import { basename, dirname } from 'path';
10
+ import chokidar from 'chokidar';
11
+ import { supabase } from './supabase.js';
12
+ import { claudeProjectsDir } from './platform.js';
13
+ let watcher = null;
14
+ let relayToken;
15
+ export function startSessionIndexer(config) {
16
+ relayToken = config.relayToken;
17
+ const watchDir = claudeProjectsDir();
18
+ if (!existsSync(watchDir)) {
19
+ console.log('Session indexer: ~/.claude/projects/ not found yet, will watch once created');
20
+ }
21
+ watcher = chokidar.watch(`${watchDir}/**/*.jsonl`, {
22
+ persistent: true,
23
+ ignoreInitial: false,
24
+ depth: 3,
25
+ awaitWriteFinish: { stabilityThreshold: 500, pollInterval: 100 },
26
+ });
27
+ watcher
28
+ .on('add', (filePath) => { parseAndUpsertSession(filePath, false); })
29
+ .on('change', (filePath) => { parseAndUpsertSession(filePath, false); })
30
+ .on('error', (err) => { console.error('Session indexer watcher error:', err); })
31
+ .on('ready', () => { console.log('✓ Session indexer watching ~/.claude/projects/'); });
32
+ }
33
+ export function stopSessionIndexer() {
34
+ watcher?.close();
35
+ watcher = null;
36
+ }
37
+ export async function parseAndUpsertSession(filePath, startedFromMobile) {
38
+ if (!existsSync(filePath))
39
+ return;
40
+ let raw;
41
+ try {
42
+ raw = readFileSync(filePath, 'utf-8');
43
+ }
44
+ catch {
45
+ return;
46
+ }
47
+ const parsed = parseJSONLSession(raw);
48
+ if (!parsed)
49
+ return;
50
+ const meta = {
51
+ session_id: parsed.sessionId,
52
+ user_id: relayToken,
53
+ project_path: parsed.projectPath,
54
+ project_name: parsed.projectName,
55
+ first_prompt: parsed.firstPrompt,
56
+ last_prompt: parsed.lastPrompt,
57
+ message_count: parsed.messageCount,
58
+ model: parsed.model,
59
+ started_from_mobile: startedFromMobile,
60
+ created_at: parsed.createdAt,
61
+ updated_at: parsed.updatedAt,
62
+ };
63
+ const { error } = await supabase
64
+ .from('session_metas')
65
+ .upsert(meta, { onConflict: 'session_id', ignoreDuplicates: false });
66
+ if (error) {
67
+ console.error(`Session indexer upsert error for ${basename(filePath)}:`, error.message);
68
+ }
69
+ }
70
+ export function parseJSONLSession(raw) {
71
+ const lines = raw.split('\n').filter((l) => l.trim().length > 0);
72
+ const entries = [];
73
+ for (const line of lines) {
74
+ try {
75
+ entries.push(JSON.parse(line));
76
+ }
77
+ catch { /* malformed line — skip */ }
78
+ }
79
+ if (entries.length === 0)
80
+ return null;
81
+ const userEntries = entries.filter((e) => e.type === 'user' &&
82
+ e.message !== null &&
83
+ typeof e.message === 'object' &&
84
+ typeof e.message.content === 'string');
85
+ if (userEntries.length === 0)
86
+ return null;
87
+ const assistantEntries = entries.filter((e) => e.type === 'assistant');
88
+ const firstEntry = userEntries[0];
89
+ const lastEntry = userEntries[userEntries.length - 1];
90
+ const sessionId = String(firstEntry.sessionId ?? '');
91
+ if (!sessionId)
92
+ return null;
93
+ const projectPath = String(firstEntry.cwd ?? '');
94
+ const projectName = projectPath
95
+ ? basename(dirname(projectPath + '/x')) || basename(projectPath)
96
+ : null;
97
+ const firstPromptFull = String(firstEntry.message.content ?? '');
98
+ const lastPromptFull = String(lastEntry.message.content ?? '');
99
+ let model = null;
100
+ if (assistantEntries.length > 0) {
101
+ const msg = assistantEntries[0].message;
102
+ if (msg && typeof msg.model === 'string')
103
+ model = msg.model;
104
+ }
105
+ const createdAt = String(firstEntry.timestamp ?? new Date().toISOString());
106
+ const updatedAt = String(entries[entries.length - 1].timestamp ?? createdAt);
107
+ return {
108
+ sessionId,
109
+ projectPath,
110
+ projectName,
111
+ firstPrompt: firstPromptFull.slice(0, 200) || null,
112
+ lastPrompt: lastPromptFull.slice(0, 200) || null,
113
+ messageCount: userEntries.length + assistantEntries.length,
114
+ model,
115
+ createdAt,
116
+ updatedAt,
117
+ };
118
+ }
119
+ //# sourceMappingURL=session_indexer.js.map
@@ -0,0 +1,14 @@
1
+ /**
2
+ * supabase.ts — Supabase client (single anon-key client, no auth)
3
+ *
4
+ * Identity = relay token (UUID in ~/.mobileclaude/token).
5
+ * All tables have permissive RLS for the anon role.
6
+ */
7
+ import { createClient } from '@supabase/supabase-js';
8
+ export let supabase;
9
+ export function initSupabase(config) {
10
+ supabase = createClient(config.supabaseUrl, config.supabaseAnonKey, {
11
+ auth: { persistSession: false, autoRefreshToken: false },
12
+ });
13
+ }
14
+ //# sourceMappingURL=supabase.js.map
package/dist/tray.js ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * tray.ts — Optional system tray icon (macOS/Windows/Linux with GUI).
3
+ * Falls back to headless mode if node-systray2 is unavailable.
4
+ *
5
+ * node-systray2 is an optionalDependency — if native compilation fails
6
+ * (e.g., on headless servers or CI), the agent continues without a tray.
7
+ */
8
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
9
+ let trayInstance = null;
10
+ export async function startTray(onQuit) {
11
+ try {
12
+ // Dynamic import of optional dep — TypeScript can't verify this statically
13
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
14
+ const mod = await import('node-systray2');
15
+ const Systray = mod.default ?? mod;
16
+ const systray = new Systray({
17
+ menu: {
18
+ icon: getIconBase64(),
19
+ title: 'MobileClaude',
20
+ tooltip: 'MobileClaude Agent',
21
+ items: [
22
+ {
23
+ title: 'MobileClaude Agent v0.1.0',
24
+ tooltip: 'Agent version',
25
+ enabled: false,
26
+ click: () => { },
27
+ },
28
+ Systray.separator ?? { title: '—', enabled: false, click: () => { } },
29
+ {
30
+ title: '● Status: running',
31
+ tooltip: 'Agent online status',
32
+ enabled: false,
33
+ click: () => { },
34
+ },
35
+ Systray.separator ?? { title: '—', enabled: false, click: () => { } },
36
+ {
37
+ title: 'Stop Agent',
38
+ tooltip: 'Stop the agent',
39
+ enabled: true,
40
+ click: onQuit,
41
+ },
42
+ {
43
+ title: 'Open Logs',
44
+ tooltip: 'Open agent log file',
45
+ enabled: true,
46
+ click: () => openLogs(),
47
+ },
48
+ Systray.separator ?? { title: '—', enabled: false, click: () => { } },
49
+ {
50
+ title: 'Quit',
51
+ tooltip: 'Quit MobileClaude Agent',
52
+ enabled: true,
53
+ click: onQuit,
54
+ },
55
+ ],
56
+ },
57
+ debug: false,
58
+ copyDir: true,
59
+ });
60
+ trayInstance = systray;
61
+ console.log('✓ System tray started');
62
+ }
63
+ catch {
64
+ console.log('System tray not available — running headlessly (no GUI required)');
65
+ }
66
+ }
67
+ export function stopTray() {
68
+ if (trayInstance && typeof trayInstance.kill === 'function') {
69
+ try {
70
+ trayInstance.kill(true);
71
+ }
72
+ catch {
73
+ // ignore errors during tray shutdown
74
+ }
75
+ }
76
+ trayInstance = null;
77
+ }
78
+ function openLogs() {
79
+ import('./platform.js').then(({ agentLogPath }) => {
80
+ const path = agentLogPath();
81
+ import('child_process').then(({ execSync }) => {
82
+ try {
83
+ if (process.platform === 'darwin') {
84
+ execSync(`open "${path}"`);
85
+ }
86
+ else if (process.platform === 'win32') {
87
+ execSync(`start "" "${path}"`);
88
+ }
89
+ else {
90
+ execSync(`xdg-open "${path}" 2>/dev/null || true`);
91
+ }
92
+ }
93
+ catch {
94
+ console.log(`Log file: ${path}`);
95
+ }
96
+ });
97
+ });
98
+ }
99
+ /** Minimal 16x16 transparent PNG as base64 for the tray icon placeholder */
100
+ function getIconBase64() {
101
+ // In production, replace with actual app icon (16x16 PNG, base64 encoded)
102
+ return 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAAB3RJTUUH6AELEAgSO5GSTQAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAAZklEQVQ4y2NgGAWkgP9EMP8z/P//nxgNZP//E6X5H6T5PzHO+E+M5v9E2f+JMP8TY/s/Eb7/R4T9nwjf/yPC/k+E7f8RYf8nwvb/iLD/E2H7f0TY/4mw/T8i7P9E2P4fUQMANyYtJpBzSj8AAAAASUVORK5CYII=';
103
+ }
104
+ //# sourceMappingURL=tray.js.map
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "mobileclaude-agent",
3
+ "version": "0.1.0",
4
+ "description": "Desktop agent for MobileIDE — controls Claude Code from your iPhone via Supabase",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "mobileclaude-agent": "dist/index.js"
9
+ },
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "prepublishOnly": "npm run build",
13
+ "dev": "tsx src/index.ts",
14
+ "start": "node dist/index.js",
15
+ "test": "node --experimental-vm-modules node_modules/.bin/jest",
16
+ "test:types": "tsc --noEmit",
17
+ "test:watch": "node --experimental-vm-modules node_modules/.bin/jest --watch"
18
+ },
19
+ "dependencies": {
20
+ "@supabase/supabase-js": "^2.103.0",
21
+ "chokidar": "^4.0.0",
22
+ "chalk": "^5.3.0"
23
+ },
24
+ "optionalDependencies": {
25
+ "node-systray2": "^2.0.0"
26
+ },
27
+ "devDependencies": {
28
+ "@jest/globals": "^29.7.0",
29
+ "@types/node": "^22.0.0",
30
+ "jest": "^29.7.0",
31
+ "ts-jest": "^29.2.0",
32
+ "tsx": "^4.7.0",
33
+ "typescript": "^5.6.0"
34
+ },
35
+ "files": [
36
+ "dist/**/*.js",
37
+ "README.md"
38
+ ],
39
+ "engines": {
40
+ "node": ">=18.0.0"
41
+ },
42
+ "keywords": [
43
+ "claude",
44
+ "claude-code",
45
+ "mobile",
46
+ "ios",
47
+ "agent",
48
+ "supabase"
49
+ ],
50
+ "license": "MIT"
51
+ }