claude-code-remote-pilot 0.5.0 → 0.5.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.2 — 2026-05-06
4
+
5
+ ### Fixed
6
+ - **Auto-resume now waits for reset time**: when Claude shows an explicit `resets at HH:MM` clock time, `Watcher` now resumes at that exact reset timestamp (including next-day rollover) instead of relying only on relative wait parsing.
7
+
8
+ ---
9
+
10
+ ## 0.5.1 — 2026-05-06
11
+
12
+ ### Fixed
13
+ - **Terminal always "Connecting…"**: two root causes patched:
14
+ 1. `Cache-Control: no-store` added to all API responses — browsers were heuristic-caching the first (sometimes empty) output response and serving stale data on every subsequent poll.
15
+ 2. `ansiToHtml` is now pre-computed before the JSX return with a try-catch — any parsing edge case falls back to ANSI-stripped plain text instead of silently breaking the render.
16
+ - **Poll errors are now logged** to the browser console (`[ccp] output poll error:`) instead of silently swallowed, so future issues are diagnosable.
17
+ - Added `cache: 'no-store'` to the `fetch` call in the output poll (belt-and-suspenders alongside the server header).
18
+
19
+ ---
20
+
3
21
  ## 0.5.0 — 2026-05-06
4
22
 
5
23
  ### Added
package/README.md CHANGED
@@ -1,10 +1,65 @@
1
1
  # Claude Code Remote Pilot
2
2
 
3
- Spawn and supervise multiple Claude Code sessions from a single interactive terminal.
3
+ Keep Claude Code running while you're away from your desk.
4
4
 
5
- Run it once. Spawn Claude into as many project directories as you want. Walk away — it handles usage limits, waits for resets, sends `continue` automatically, and notifies you on Telegram. Come back to finished work.
5
+ A small self-hosted helper tool for people who run long Claude Code sessions and are tired of manually resuming after token limits.
6
6
 
7
- Each Claude session lives in its own named tmux session. You can `tmux attach -t <name>` from any terminal at any time, independently of the pilot.
7
+ Originally built just for personal use… then it slowly grew 😅
8
+
9
+ ![Dashboard screenshot](docs/screenshot-dashboard.png)
10
+
11
+ ---
12
+
13
+ ## Why?
14
+
15
+ I kept hitting the same problem:
16
+
17
+ - Claude Code stopped after hitting token limits
18
+ - long-running tasks needed babysitting
19
+ - sometimes I wanted to leave home while a task was still running
20
+ - checking progress remotely was annoying
21
+
22
+ At first I only wanted auto-resume. Then it slowly turned into a small remote workflow tool.
23
+
24
+ ---
25
+
26
+ ## What this is NOT
27
+
28
+ - a hosted platform
29
+ - an "AI agent framework"
30
+ - a replacement for Claude Code
31
+ - a polished enterprise product
32
+
33
+ It's just a practical helper tool for people running Claude Code for long periods.
34
+
35
+ ---
36
+
37
+ ## Features
38
+
39
+ - Auto-detect Claude Code limit states and resume automatically
40
+ - Persistent tmux-based sessions that outlive the pilot process
41
+ - Web UI for monitoring and control from phone or any browser — with full ANSI color terminal rendering
42
+ - Telegram notifications when sessions need attention
43
+ - Browser desktop notifications on status changes
44
+ - Broadcast a message to all active sessions at once
45
+ - Lightweight, self-hosted — just Node.js and tmux
46
+ - Experimental but surprisingly useful 👀
47
+
48
+ ---
49
+
50
+ ## Current Status
51
+
52
+ Very experimental. Built quickly to scratch a personal itch, so expect rough edges. If you try it and hit weird issues, feel free to open an issue or PR.
53
+
54
+ ---
55
+
56
+ ## Typical Workflow
57
+
58
+ 1. Start the pilot and spawn Claude Code sessions
59
+ 2. Leave it running — go touch grass
60
+ 3. Pilot detects limit hits and auto-resumes
61
+ 4. Get notified via Telegram or browser notification
62
+ 5. Check progress remotely from your phone
8
63
 
9
64
  ---
10
65
 
@@ -142,6 +197,91 @@ You can also bind to a specific interface IP: `web 3742 192.168.1.10`.
142
197
 
143
198
  ---
144
199
 
200
+ ## Remote access via Cloudflare Tunnel (recommended)
201
+
202
+ Binding to `0.0.0.0` exposes the dashboard on your local network but not the internet. For secure remote access from anywhere — phone, another machine, a coffee shop — use a [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/).
203
+
204
+ This is the recommended setup for remote work: the dashboard stays on `127.0.0.1` (never directly exposed), and Cloudflare handles TLS, authentication, and routing.
205
+
206
+ ### Quick start (no domain required)
207
+
208
+ Install `cloudflared`:
209
+
210
+ ```bash
211
+ # macOS
212
+ brew install cloudflare/cloudflare/cloudflared
213
+
214
+ # Linux
215
+ curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o cloudflared
216
+ chmod +x cloudflared && sudo mv cloudflared /usr/local/bin/
217
+ ```
218
+
219
+ Start the pilot's web dashboard, then in a second terminal run:
220
+
221
+ ```bash
222
+ cloudflared tunnel --url http://127.0.0.1:3742
223
+ ```
224
+
225
+ Cloudflare prints a random `https://*.trycloudflare.com` URL. Open it on any device. The tunnel closes when you stop `cloudflared`.
226
+
227
+ ### Persistent tunnel with a custom domain
228
+
229
+ If you have a domain on Cloudflare, you can get a stable URL and add Cloudflare Access (zero-trust auth) in front of the dashboard.
230
+
231
+ **1. Authenticate and create a tunnel:**
232
+
233
+ ```bash
234
+ cloudflared tunnel login
235
+ cloudflared tunnel create claude-pilot
236
+ ```
237
+
238
+ **2. Create `~/.cloudflared/config.yml`:**
239
+
240
+ ```yaml
241
+ tunnel: <your-tunnel-id>
242
+ credentials-file: /home/<user>/.cloudflared/<tunnel-id>.json
243
+
244
+ ingress:
245
+ - hostname: pilot.yourdomain.com
246
+ service: http://127.0.0.1:3742
247
+ - service: http_status:404
248
+ ```
249
+
250
+ **3. Add a DNS record:**
251
+
252
+ ```bash
253
+ cloudflared tunnel route dns claude-pilot pilot.yourdomain.com
254
+ ```
255
+
256
+ **4. Start the tunnel:**
257
+
258
+ ```bash
259
+ cloudflared tunnel run claude-pilot
260
+ ```
261
+
262
+ The dashboard is now reachable at `https://pilot.yourdomain.com`.
263
+
264
+ ### Adding authentication (Cloudflare Access)
265
+
266
+ Cloudflare Access puts a login wall in front of the tunnel — no inbound ports, no VPN.
267
+
268
+ 1. Go to **Cloudflare Zero Trust → Access → Applications → Add an application**
269
+ 2. Choose **Self-hosted**, set the domain to `pilot.yourdomain.com`
270
+ 3. Add a policy: allow your email address (or Google/GitHub OAuth)
271
+
272
+ After this, anyone reaching `pilot.yourdomain.com` must authenticate with Cloudflare before the dashboard loads.
273
+
274
+ ### Run the tunnel as a background service
275
+
276
+ ```bash
277
+ # Install as a system service (runs on boot)
278
+ sudo cloudflared service install
279
+ sudo systemctl start cloudflared # Linux
280
+ sudo launchctl start cloudflared # macOS
281
+ ```
282
+
283
+ ---
284
+
145
285
  ## Telegram setup
146
286
 
147
287
  Create a bot via `@BotFather` and get your token.
@@ -211,12 +351,19 @@ Start Claude without `--dangerously-skip-permissions` unless you know what you'r
211
351
  - [x] broadcast message to all sessions
212
352
  - [x] auto-discover untracked tmux sessions on startup
213
353
  - [ ] auto-yes rules — confirm prompts automatically by pattern
354
+ - [ ] smarter retry logic
355
+ - [ ] usage statistics and session timeline
356
+ - [ ] remote command queue
214
357
  - [ ] pluggable notification providers
215
358
 
216
359
  ---
217
360
 
218
- ## Philosophy
361
+ ## Contributing
362
+
363
+ PRs, ideas, and weird experiments are welcome 😄
364
+
365
+ ---
219
366
 
220
- A human-supervised local runtime for Claude Code. Not a fully autonomous agent loop.
367
+ ## License
221
368
 
222
- Small tools first. Dashboard later.
369
+ MIT
@@ -26,7 +26,7 @@ class SessionManager {
26
26
  });
27
27
  }
28
28
 
29
- spawn(dirPath, name) {
29
+ spawn(dirPath, name, command = 'claude') {
30
30
  const resolved = path.resolve(dirPath.replace(/^~/, process.env.HOME || ''));
31
31
  if (!fs.existsSync(resolved)) throw new Error(`Path not found: ${resolved}`);
32
32
 
@@ -39,16 +39,22 @@ class SessionManager {
39
39
  execSync(`tmux kill-session -t "${sessionName}"`, { stdio: 'ignore' });
40
40
  } catch {}
41
41
 
42
- execSync(`tmux new-session -d -s "${sessionName}" -c "${resolved}" "claude"`, { stdio: 'ignore' });
42
+ execSync(`tmux new-session -d -s "${sessionName}" -c "${resolved}" "${command}"`, { stdio: 'ignore' });
43
43
 
44
- const session = { name: sessionName, path: resolved, status: 'running', startedAt: new Date(), resumeAt: null };
44
+ const session = { name: sessionName, path: resolved, command, status: 'running', startedAt: new Date(), resumeAt: null };
45
45
  const watcher = this._makeWatcher(session);
46
46
  watcher.start();
47
47
  this.sessions.set(sessionName, { session, watcher });
48
- config.addToHistory(sessionName, resolved);
48
+ config.addToHistory(sessionName, resolved, command);
49
49
  return session;
50
50
  }
51
51
 
52
+ respawn(name) {
53
+ const h = config.getHistory().find(e => e.name === name);
54
+ if (!h) throw new Error(`No history for session "${name}"`);
55
+ return this.spawn(h.path, name, h.command || 'claude');
56
+ }
57
+
52
58
  adopt(name, dirPath) {
53
59
  try { execSync(`tmux has-session -t "${name}"`, { stdio: 'ignore' }); }
54
60
  catch { throw new Error(`tmux session "${name}" not found.`); }
package/lib/Watcher.js CHANGED
@@ -80,6 +80,35 @@ class Watcher {
80
80
  return null;
81
81
  }
82
82
 
83
+ _parseResetAtMs(text) {
84
+ const atMatch = text.match(RESET_AT_RE);
85
+ if (atMatch) {
86
+ const raw = atMatch[1].trim().toLowerCase();
87
+ const m = raw.match(/^(\d{1,2}):(\d{2})\s*(am|pm)?$/);
88
+ if (m) {
89
+ let hour = parseInt(m[1], 10);
90
+ const minute = parseInt(m[2], 10);
91
+ const suffix = m[3];
92
+ if (suffix === 'pm' && hour !== 12) hour += 12;
93
+ if (suffix === 'am' && hour === 12) hour = 0;
94
+
95
+ const now = new Date();
96
+ const resetAt = new Date(now);
97
+ resetAt.setHours(hour, minute, 0, 0);
98
+ if (resetAt.getTime() <= now.getTime()) {
99
+ resetAt.setDate(resetAt.getDate() + 1);
100
+ }
101
+ return resetAt.getTime();
102
+ }
103
+ }
104
+
105
+ const inMatch = text.match(/(?:try again|retry|wait).*?in\s+(\d+)\s*(second|minute|hour)/i);
106
+ if (!inMatch) return null;
107
+ const v = parseInt(inMatch[1], 10);
108
+ const mult = inMatch[2].startsWith('second') ? 1 : inMatch[2].startsWith('minute') ? 60 : 3600;
109
+ return Date.now() + (v * mult * 1000);
110
+ }
111
+
83
112
  async _check() {
84
113
  if (this._busy) return;
85
114
  this._busy = true;
@@ -137,16 +166,19 @@ class Watcher {
137
166
 
138
167
  this.lastHash = hash;
139
168
  const wait = this._parseWait(text);
169
+ const resetAtMs = this._parseResetAtMs(text);
140
170
  const resetTime = this._parseResetTime(text);
171
+ const effectiveResumeAtMs = resetAtMs || (Date.now() + wait * 1000);
172
+ const effectiveWaitSeconds = Math.max(1, Math.ceil((effectiveResumeAtMs - Date.now()) / 1000));
141
173
 
142
174
  this.session.status = 'limit';
143
- this.session.resumeAt = Date.now() + wait * 1000;
175
+ this.session.resumeAt = effectiveResumeAtMs;
144
176
  this.session.resetTime = resetTime;
145
177
 
146
178
  notifier.send(this.telegram.token, this.telegram.chatId,
147
- `Pilot: limit in "${this.session.name}". Resets ${resetTime || `in ${Math.ceil(wait / 60)}m`}.`);
179
+ `Pilot: limit in "${this.session.name}". Resets ${resetTime || `in ${Math.ceil(effectiveWaitSeconds / 60)}m`}.`);
148
180
 
149
- await new Promise(r => setTimeout(r, wait * 1000));
181
+ await new Promise(r => setTimeout(r, effectiveWaitSeconds * 1000));
150
182
 
151
183
  try { spawnSync('tmux', ['send-keys', '-t', this.session.name, this.resumeCommand, 'Enter'], { stdio: 'ignore' }); }
152
184
  catch {}
package/lib/WebServer.js CHANGED
@@ -38,7 +38,7 @@ class WebServer {
38
38
  }
39
39
 
40
40
  _json(res, code, data) {
41
- res.writeHead(code, { 'Content-Type': 'application/json' });
41
+ res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
42
42
  res.end(JSON.stringify(data));
43
43
  }
44
44
 
@@ -93,9 +93,9 @@ class WebServer {
93
93
  if (req.method === 'POST' && pathname === '/api/sessions') {
94
94
  return this._readBody(req, (err, body) => {
95
95
  if (err) return this._json(res, 400, { error: err.message });
96
- const { name, path: dirPath, prompt: initialPrompt } = body;
96
+ const { name, path: dirPath, prompt: initialPrompt, command } = body;
97
97
  try {
98
- const session = this.manager.spawn(dirPath, name);
98
+ const session = this.manager.spawn(dirPath, name, command || 'claude');
99
99
  if (initialPrompt) {
100
100
  setTimeout(() => {
101
101
  spawnSync('tmux', ['send-keys', '-t', session.name, initialPrompt, 'Enter']);
@@ -108,6 +108,18 @@ class WebServer {
108
108
  });
109
109
  }
110
110
 
111
+ // POST /api/sessions/:name/respawn
112
+ const respawnMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/respawn$/);
113
+ if (req.method === 'POST' && respawnMatch) {
114
+ const name = decodeURIComponent(respawnMatch[1]);
115
+ try {
116
+ const session = this.manager.respawn(name);
117
+ return this._json(res, 200, { ...session, id: session.name });
118
+ } catch (e) {
119
+ return this._json(res, 400, { error: e.message });
120
+ }
121
+ }
122
+
111
123
  // GET /api/sessions/:name/output
112
124
  const outputMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/output$/);
113
125
  if (req.method === 'GET' && outputMatch) {
package/lib/config.js CHANGED
@@ -34,10 +34,10 @@ function saveResumeCommand(cmd) {
34
34
  save({ resumeCommand: cmd });
35
35
  }
36
36
 
37
- function addToHistory(name, path) {
37
+ function addToHistory(name, path, command = 'claude') {
38
38
  const cfg = load();
39
39
  const history = (cfg.sessionHistory || []).filter(s => s.name !== name);
40
- history.unshift({ name, path, lastSeen: new Date().toISOString() });
40
+ history.unshift({ name, path, command, lastSeen: new Date().toISOString() });
41
41
  save({ sessionHistory: history.slice(0, 30) }); // cap at 30
42
42
  }
43
43
 
package/lib/ui.html CHANGED
@@ -601,10 +601,10 @@ function SessionDetailScreen({ session, onBack, onKilled }) {
601
601
  useEffect(() => {
602
602
  if (isOffline) return;
603
603
  const poll = () => {
604
- apiFetch(`/api/sessions/${encodeURIComponent(session.name)}/output`)
604
+ apiFetch(`/api/sessions/${encodeURIComponent(session.name)}/output`, { cache: 'no-store' })
605
605
  .then(r => r.json())
606
606
  .then(d => setOutput(d.output || ''))
607
- .catch(() => {});
607
+ .catch(e => { if (e && e.message !== 'Unauthorized') console.error('[ccp] output poll error:', e); });
608
608
  };
609
609
  poll();
610
610
  const t = setInterval(poll, 2000);
@@ -660,6 +660,29 @@ function SessionDetailScreen({ session, onBack, onKilled }) {
660
660
  }
661
661
  };
662
662
 
663
+ const handleRespawn = async () => {
664
+ try {
665
+ const res = await apiFetch(`/api/sessions/${encodeURIComponent(session.name)}/respawn`, { method: 'POST' });
666
+ if (!res.ok) {
667
+ const d = await res.json();
668
+ alert(d.error || 'Failed to respawn');
669
+ }
670
+ } catch (e) {
671
+ if (e.message !== 'Unauthorized') alert('Network error');
672
+ }
673
+ };
674
+
675
+ // Render terminal HTML (ANSI → HTML, with plain-text fallback)
676
+ let _termHtml = '';
677
+ if (output) {
678
+ try {
679
+ _termHtml = ansiToHtml(output);
680
+ } catch(e) {
681
+ console.error('[ccp] ansiToHtml error:', e);
682
+ _termHtml = output.replace(/\x1b\[[0-9;]*m/g,'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
683
+ }
684
+ }
685
+
663
686
  // Terminal height: fill viewport minus chrome
664
687
  const terminalStyle = {
665
688
  height: 'calc(100vh - 210px)',
@@ -676,6 +699,11 @@ function SessionDetailScreen({ session, onBack, onKilled }) {
676
699
  <div className="detail-meta" style={{ maxWidth: 480, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{session.path}</div>
677
700
  </div>
678
701
  <div className="detail-actions">
702
+ {isOffline && (
703
+ <button className="btn btn-sm btn-primary" onClick={handleRespawn}>
704
+ ↺ Respawn
705
+ </button>
706
+ )}
679
707
  {!isOffline && (
680
708
  <button className="btn btn-sm" onClick={copyAttachCmd} title={`tmux attach -t ${session.name}`}>
681
709
  {copyOk ? '✓ Copied' : '⊞ New Terminal'}
@@ -708,8 +736,8 @@ function SessionDetailScreen({ session, onBack, onKilled }) {
708
736
  dangerouslySetInnerHTML={{ __html:
709
737
  isOffline
710
738
  ? '<span style="color:oklch(50% 0.018 50)">Session is offline — no output available.</span>'
711
- : output
712
- ? ansiToHtml(output) + '<span style="opacity:0.4">▊</span>'
739
+ : _termHtml
740
+ ? _termHtml + '<span style="opacity:0.4">▊</span>'
713
741
  : '<span style="color:oklch(50% 0.018 50)">Connecting…</span>'
714
742
  }}
715
743
  />
@@ -741,6 +769,7 @@ function SessionDetailScreen({ session, onBack, onKilled }) {
741
769
  <div className="card">
742
770
  <h3>Session Info</h3>
743
771
  <div className="info-row"><span className="info-label">Status</span><StatusPill status={session.status} /></div>
772
+ <div className="info-row"><span className="info-label">Agent</span><span className="info-value" style={{ fontSize: 11 }}>{session.command || 'claude'}</span></div>
744
773
  <div className="info-row"><span className="info-label">Started</span><span className="info-value" style={{ fontSize: 11 }}>{relativeTime(session.startedAt)}</span></div>
745
774
  <div className="info-row"><span className="info-label">Tokens</span><span className="info-value" style={{ fontSize: 11 }}>{formatTokens(session.tokens)}</span></div>
746
775
  {session.status === 'limit' && session.resumeAt && (
@@ -759,18 +788,23 @@ function CreateSessionScreen({ onBack, onCreated }) {
759
788
  const [name, setName] = useState('');
760
789
  const [path, setPath] = useState('');
761
790
  const [prompt, setPrompt] = useState('');
791
+ const [command, setCommand] = useState('claude');
792
+ const [customCommand, setCustomCommand] = useState('');
762
793
  const [error, setError] = useState('');
763
794
  const [loading, setLoading] = useState(false);
764
795
 
796
+ const resolvedCommand = command === 'custom' ? customCommand.trim() : command;
797
+
765
798
  const handleCreate = async () => {
766
799
  if (!name.trim() || !path.trim()) { setError('Session name and working directory are required.'); return; }
800
+ if (command === 'custom' && !customCommand.trim()) { setError('Enter a custom command.'); return; }
767
801
  setError('');
768
802
  setLoading(true);
769
803
  try {
770
804
  const res = await apiFetch('/api/sessions', {
771
805
  method: 'POST',
772
806
  headers: { 'Content-Type': 'application/json' },
773
- body: JSON.stringify({ name: name.trim(), path: path.trim(), prompt: prompt.trim() || undefined }),
807
+ body: JSON.stringify({ name: name.trim(), path: path.trim(), prompt: prompt.trim() || undefined, command: resolvedCommand }),
774
808
  });
775
809
  const data = await res.json();
776
810
  if (!res.ok) { setError(data.error || 'Failed to create session.'); return; }
@@ -796,10 +830,24 @@ function CreateSessionScreen({ onBack, onCreated }) {
796
830
  <label className="form-label">Working directory</label>
797
831
  <input className="form-input" placeholder="~/projects/app" value={path} onChange={e => setPath(e.target.value)} />
798
832
  </div>
833
+ <div className="form-group">
834
+ <label className="form-label">Agent</label>
835
+ <select className="form-select" value={command} onChange={e => setCommand(e.target.value)}>
836
+ <option value="claude">claude (Claude Code)</option>
837
+ <option value="opencode">opencode</option>
838
+ <option value="custom">custom…</option>
839
+ </select>
840
+ {command === 'custom' && (
841
+ <input className="form-input" style={{ marginTop: 8 }} placeholder="e.g. aider" value={customCommand} onChange={e => setCustomCommand(e.target.value)} />
842
+ )}
843
+ {command === 'opencode' && (
844
+ <div className="form-hint">Status indicators (running/idle) are claude-only — opencode sessions will show as idle but are fully controllable.</div>
845
+ )}
846
+ </div>
799
847
  <div className="form-group">
800
848
  <label className="form-label">Initial prompt <span style={{ fontWeight: 400, color: 'var(--muted)' }}>(optional)</span></label>
801
- <textarea className="form-textarea" placeholder="What should Claude work on?" value={prompt} onChange={e => setPrompt(e.target.value)} rows={4} />
802
- <div className="form-hint">Sent to Claude 2 seconds after the session starts.</div>
849
+ <textarea className="form-textarea" placeholder="What should the agent work on?" value={prompt} onChange={e => setPrompt(e.target.value)} rows={4} />
850
+ <div className="form-hint">Sent 2 seconds after the session starts.</div>
803
851
  </div>
804
852
  {error && <div style={{ marginBottom: 16, padding: '10px 14px', background: 'var(--error-soft)', color: 'var(--error)', borderRadius: 'var(--radius-sm)', fontSize: 13 }}>{error}</div>}
805
853
  <div className="form-actions">
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "claude-code-remote-pilot",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Interactive Claude Code supervisor — spawn and monitor multiple Claude sessions from a single terminal.",
5
5
  "type": "commonjs",
6
6
  "repository": {
7
7
  "type": "git",
8
- "url": "https://github.com/mekku/claude-code-remote-pilot.git"
8
+ "url": "git+https://github.com/mekku/claude-code-remote-pilot.git"
9
9
  },
10
10
  "homepage": "https://github.com/mekku/claude-code-remote-pilot#readme",
11
11
  "bugs": {
@@ -38,4 +38,4 @@
38
38
  "engines": {
39
39
  "node": ">=18"
40
40
  }
41
- }
41
+ }