claude-code-remote-pilot 0.5.1 → 0.5.3
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 +16 -0
- package/README.md +153 -6
- package/lib/SessionManager.js +10 -4
- package/lib/Watcher.js +35 -3
- package/lib/WebServer.js +14 -2
- package/lib/config.js +2 -2
- package/lib/ui.html +64 -5
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.3 — 2026-05-06
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **Auto-resume respects clock reset time**: limit recovery now waits until the parsed `resets at HH:MM` timestamp (with next-day rollover) before sending the resume command.
|
|
7
|
+
- **Web UI respawn completed**: offline session respawn now has loading/error feedback and immediately updates session detail state after success.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 0.5.2 — 2026-05-06
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- **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.
|
|
15
|
+
- **Web UI respawn flow completed**: offline session respawn now shows in-button loading, inline errors, and immediately updates the detail view to the newly active session after successful `POST /api/sessions/:name/respawn`.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
3
19
|
## 0.5.1 — 2026-05-06
|
|
4
20
|
|
|
5
21
|
### Fixed
|
package/README.md
CHANGED
|
@@ -1,10 +1,65 @@
|
|
|
1
1
|
# Claude Code Remote Pilot
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Keep Claude Code running while you're away from your desk.
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
7
|
+
Originally built just for personal use… then it slowly grew 😅
|
|
8
|
+
|
|
9
|
+

|
|
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
|
-
##
|
|
361
|
+
## Contributing
|
|
362
|
+
|
|
363
|
+
PRs, ideas, and weird experiments are welcome 😄
|
|
364
|
+
|
|
365
|
+
---
|
|
219
366
|
|
|
220
|
-
|
|
367
|
+
## License
|
|
221
368
|
|
|
222
|
-
|
|
369
|
+
MIT
|
package/lib/SessionManager.js
CHANGED
|
@@ -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}" "
|
|
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 =
|
|
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(
|
|
179
|
+
`Pilot: limit in "${this.session.name}". Resets ${resetTime || `in ${Math.ceil(effectiveWaitSeconds / 60)}m`}.`);
|
|
148
180
|
|
|
149
|
-
await new Promise(r => setTimeout(r,
|
|
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
|
@@ -574,11 +574,13 @@ function DashboardScreen({ onNavigate, sessions, activity, serverStatus }) {
|
|
|
574
574
|
}
|
|
575
575
|
|
|
576
576
|
/* --- Session Detail --- */
|
|
577
|
-
function SessionDetailScreen({ session, onBack, onKilled }) {
|
|
577
|
+
function SessionDetailScreen({ session, onBack, onKilled, onRespawned }) {
|
|
578
578
|
const [output, setOutput] = useState('');
|
|
579
579
|
const [msg, setMsg] = useState('');
|
|
580
580
|
const [sending, setSending] = useState(false);
|
|
581
581
|
const [killing, setKilling] = useState(false);
|
|
582
|
+
const [respawning, setRespawning] = useState(false);
|
|
583
|
+
const [respawnError, setRespawnError] = useState('');
|
|
582
584
|
const [copyOk, setCopyOk] = useState(false);
|
|
583
585
|
const terminalRef = useRef(null);
|
|
584
586
|
const inputRef = useRef(null);
|
|
@@ -660,6 +662,25 @@ function SessionDetailScreen({ session, onBack, onKilled }) {
|
|
|
660
662
|
}
|
|
661
663
|
};
|
|
662
664
|
|
|
665
|
+
const handleRespawn = async () => {
|
|
666
|
+
if (respawning) return;
|
|
667
|
+
setRespawning(true);
|
|
668
|
+
setRespawnError('');
|
|
669
|
+
try {
|
|
670
|
+
const res = await apiFetch(`/api/sessions/${encodeURIComponent(session.name)}/respawn`, { method: 'POST' });
|
|
671
|
+
const data = await res.json();
|
|
672
|
+
if (!res.ok) {
|
|
673
|
+
setRespawnError(data.error || 'Failed to respawn');
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
onRespawned(data);
|
|
677
|
+
} catch (e) {
|
|
678
|
+
if (e.message !== 'Unauthorized') setRespawnError('Network error');
|
|
679
|
+
} finally {
|
|
680
|
+
setRespawning(false);
|
|
681
|
+
}
|
|
682
|
+
};
|
|
683
|
+
|
|
663
684
|
// Render terminal HTML (ANSI → HTML, with plain-text fallback)
|
|
664
685
|
let _termHtml = '';
|
|
665
686
|
if (output) {
|
|
@@ -687,6 +708,18 @@ function SessionDetailScreen({ session, onBack, onKilled }) {
|
|
|
687
708
|
<div className="detail-meta" style={{ maxWidth: 480, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{session.path}</div>
|
|
688
709
|
</div>
|
|
689
710
|
<div className="detail-actions">
|
|
711
|
+
{isOffline && (
|
|
712
|
+
<>
|
|
713
|
+
<button className="btn btn-sm btn-primary" onClick={handleRespawn} disabled={respawning}>
|
|
714
|
+
{respawning ? 'Respawning…' : '↺ Respawn'}
|
|
715
|
+
</button>
|
|
716
|
+
{respawnError && (
|
|
717
|
+
<span style={{ color: 'var(--error)', fontSize: 12, alignSelf: 'center' }}>
|
|
718
|
+
{respawnError}
|
|
719
|
+
</span>
|
|
720
|
+
)}
|
|
721
|
+
</>
|
|
722
|
+
)}
|
|
690
723
|
{!isOffline && (
|
|
691
724
|
<button className="btn btn-sm" onClick={copyAttachCmd} title={`tmux attach -t ${session.name}`}>
|
|
692
725
|
{copyOk ? '✓ Copied' : '⊞ New Terminal'}
|
|
@@ -752,6 +785,7 @@ function SessionDetailScreen({ session, onBack, onKilled }) {
|
|
|
752
785
|
<div className="card">
|
|
753
786
|
<h3>Session Info</h3>
|
|
754
787
|
<div className="info-row"><span className="info-label">Status</span><StatusPill status={session.status} /></div>
|
|
788
|
+
<div className="info-row"><span className="info-label">Agent</span><span className="info-value" style={{ fontSize: 11 }}>{session.command || 'claude'}</span></div>
|
|
755
789
|
<div className="info-row"><span className="info-label">Started</span><span className="info-value" style={{ fontSize: 11 }}>{relativeTime(session.startedAt)}</span></div>
|
|
756
790
|
<div className="info-row"><span className="info-label">Tokens</span><span className="info-value" style={{ fontSize: 11 }}>{formatTokens(session.tokens)}</span></div>
|
|
757
791
|
{session.status === 'limit' && session.resumeAt && (
|
|
@@ -770,18 +804,23 @@ function CreateSessionScreen({ onBack, onCreated }) {
|
|
|
770
804
|
const [name, setName] = useState('');
|
|
771
805
|
const [path, setPath] = useState('');
|
|
772
806
|
const [prompt, setPrompt] = useState('');
|
|
807
|
+
const [command, setCommand] = useState('claude');
|
|
808
|
+
const [customCommand, setCustomCommand] = useState('');
|
|
773
809
|
const [error, setError] = useState('');
|
|
774
810
|
const [loading, setLoading] = useState(false);
|
|
775
811
|
|
|
812
|
+
const resolvedCommand = command === 'custom' ? customCommand.trim() : command;
|
|
813
|
+
|
|
776
814
|
const handleCreate = async () => {
|
|
777
815
|
if (!name.trim() || !path.trim()) { setError('Session name and working directory are required.'); return; }
|
|
816
|
+
if (command === 'custom' && !customCommand.trim()) { setError('Enter a custom command.'); return; }
|
|
778
817
|
setError('');
|
|
779
818
|
setLoading(true);
|
|
780
819
|
try {
|
|
781
820
|
const res = await apiFetch('/api/sessions', {
|
|
782
821
|
method: 'POST',
|
|
783
822
|
headers: { 'Content-Type': 'application/json' },
|
|
784
|
-
body: JSON.stringify({ name: name.trim(), path: path.trim(), prompt: prompt.trim() || undefined }),
|
|
823
|
+
body: JSON.stringify({ name: name.trim(), path: path.trim(), prompt: prompt.trim() || undefined, command: resolvedCommand }),
|
|
785
824
|
});
|
|
786
825
|
const data = await res.json();
|
|
787
826
|
if (!res.ok) { setError(data.error || 'Failed to create session.'); return; }
|
|
@@ -807,10 +846,24 @@ function CreateSessionScreen({ onBack, onCreated }) {
|
|
|
807
846
|
<label className="form-label">Working directory</label>
|
|
808
847
|
<input className="form-input" placeholder="~/projects/app" value={path} onChange={e => setPath(e.target.value)} />
|
|
809
848
|
</div>
|
|
849
|
+
<div className="form-group">
|
|
850
|
+
<label className="form-label">Agent</label>
|
|
851
|
+
<select className="form-select" value={command} onChange={e => setCommand(e.target.value)}>
|
|
852
|
+
<option value="claude">claude (Claude Code)</option>
|
|
853
|
+
<option value="opencode">opencode</option>
|
|
854
|
+
<option value="custom">custom…</option>
|
|
855
|
+
</select>
|
|
856
|
+
{command === 'custom' && (
|
|
857
|
+
<input className="form-input" style={{ marginTop: 8 }} placeholder="e.g. aider" value={customCommand} onChange={e => setCustomCommand(e.target.value)} />
|
|
858
|
+
)}
|
|
859
|
+
{command === 'opencode' && (
|
|
860
|
+
<div className="form-hint">Status indicators (running/idle) are claude-only — opencode sessions will show as idle but are fully controllable.</div>
|
|
861
|
+
)}
|
|
862
|
+
</div>
|
|
810
863
|
<div className="form-group">
|
|
811
864
|
<label className="form-label">Initial prompt <span style={{ fontWeight: 400, color: 'var(--muted)' }}>(optional)</span></label>
|
|
812
|
-
<textarea className="form-textarea" placeholder="What should
|
|
813
|
-
<div className="form-hint">Sent
|
|
865
|
+
<textarea className="form-textarea" placeholder="What should the agent work on?" value={prompt} onChange={e => setPrompt(e.target.value)} rows={4} />
|
|
866
|
+
<div className="form-hint">Sent 2 seconds after the session starts.</div>
|
|
814
867
|
</div>
|
|
815
868
|
{error && <div style={{ marginBottom: 16, padding: '10px 14px', background: 'var(--error-soft)', color: 'var(--error)', borderRadius: 'var(--radius-sm)', fontSize: 13 }}>{error}</div>}
|
|
816
869
|
<div className="form-actions">
|
|
@@ -1010,7 +1063,13 @@ function App() {
|
|
|
1010
1063
|
case 'dashboard': return <DashboardScreen onNavigate={navigate} sessions={sessions} activity={activity} serverStatus={serverStatus} />;
|
|
1011
1064
|
case 'sessions': return <SessionsScreen sessions={sessions} onNavigate={navigate} />;
|
|
1012
1065
|
case 'create': return <CreateSessionScreen onBack={() => navigate('dashboard')} onCreated={s => navigate('detail', s)} />;
|
|
1013
|
-
case 'detail': return <SessionDetailScreen session={selectedSession} onBack={() => navigate('dashboard')} onKilled={() => navigate('sessions')}
|
|
1066
|
+
case 'detail': return <SessionDetailScreen session={selectedSession} onBack={() => navigate('dashboard')} onKilled={() => navigate('sessions')} onRespawned={(respawned) => {
|
|
1067
|
+
setSessions(prev => {
|
|
1068
|
+
const next = prev.filter(s => s.name !== respawned.name);
|
|
1069
|
+
return [{ ...respawned, id: respawned.name }, ...next];
|
|
1070
|
+
});
|
|
1071
|
+
setSelectedSession({ ...respawned, id: respawned.name });
|
|
1072
|
+
}} />;
|
|
1014
1073
|
default: return <DashboardScreen onNavigate={navigate} sessions={sessions} activity={activity} serverStatus={serverStatus} />;
|
|
1015
1074
|
}
|
|
1016
1075
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-remote-pilot",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.3",
|
|
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
|
+
}
|