claude-code-remote-pilot 0.5.1 → 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,12 @@
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
+
3
10
  ## 0.5.1 — 2026-05-06
4
11
 
5
12
  ### Fixed
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
@@ -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
@@ -660,6 +660,18 @@ 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
+
663
675
  // Render terminal HTML (ANSI → HTML, with plain-text fallback)
664
676
  let _termHtml = '';
665
677
  if (output) {
@@ -687,6 +699,11 @@ function SessionDetailScreen({ session, onBack, onKilled }) {
687
699
  <div className="detail-meta" style={{ maxWidth: 480, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{session.path}</div>
688
700
  </div>
689
701
  <div className="detail-actions">
702
+ {isOffline && (
703
+ <button className="btn btn-sm btn-primary" onClick={handleRespawn}>
704
+ ↺ Respawn
705
+ </button>
706
+ )}
690
707
  {!isOffline && (
691
708
  <button className="btn btn-sm" onClick={copyAttachCmd} title={`tmux attach -t ${session.name}`}>
692
709
  {copyOk ? '✓ Copied' : '⊞ New Terminal'}
@@ -752,6 +769,7 @@ function SessionDetailScreen({ session, onBack, onKilled }) {
752
769
  <div className="card">
753
770
  <h3>Session Info</h3>
754
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>
755
773
  <div className="info-row"><span className="info-label">Started</span><span className="info-value" style={{ fontSize: 11 }}>{relativeTime(session.startedAt)}</span></div>
756
774
  <div className="info-row"><span className="info-label">Tokens</span><span className="info-value" style={{ fontSize: 11 }}>{formatTokens(session.tokens)}</span></div>
757
775
  {session.status === 'limit' && session.resumeAt && (
@@ -770,18 +788,23 @@ function CreateSessionScreen({ onBack, onCreated }) {
770
788
  const [name, setName] = useState('');
771
789
  const [path, setPath] = useState('');
772
790
  const [prompt, setPrompt] = useState('');
791
+ const [command, setCommand] = useState('claude');
792
+ const [customCommand, setCustomCommand] = useState('');
773
793
  const [error, setError] = useState('');
774
794
  const [loading, setLoading] = useState(false);
775
795
 
796
+ const resolvedCommand = command === 'custom' ? customCommand.trim() : command;
797
+
776
798
  const handleCreate = async () => {
777
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; }
778
801
  setError('');
779
802
  setLoading(true);
780
803
  try {
781
804
  const res = await apiFetch('/api/sessions', {
782
805
  method: 'POST',
783
806
  headers: { 'Content-Type': 'application/json' },
784
- 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 }),
785
808
  });
786
809
  const data = await res.json();
787
810
  if (!res.ok) { setError(data.error || 'Failed to create session.'); return; }
@@ -807,10 +830,24 @@ function CreateSessionScreen({ onBack, onCreated }) {
807
830
  <label className="form-label">Working directory</label>
808
831
  <input className="form-input" placeholder="~/projects/app" value={path} onChange={e => setPath(e.target.value)} />
809
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>
810
847
  <div className="form-group">
811
848
  <label className="form-label">Initial prompt <span style={{ fontWeight: 400, color: 'var(--muted)' }}>(optional)</span></label>
812
- <textarea className="form-textarea" placeholder="What should Claude work on?" value={prompt} onChange={e => setPrompt(e.target.value)} rows={4} />
813
- <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>
814
851
  </div>
815
852
  {error && <div style={{ marginBottom: 16, padding: '10px 14px', background: 'var(--error-soft)', color: 'var(--error)', borderRadius: 'var(--radius-sm)', fontSize: 13 }}>{error}</div>}
816
853
  <div className="form-actions">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-remote-pilot",
3
- "version": "0.5.1",
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": {
@@ -38,4 +38,4 @@
38
38
  "engines": {
39
39
  "node": ">=18"
40
40
  }
41
- }
41
+ }