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 +18 -0
- package/README.md +153 -6
- package/lib/SessionManager.js +10 -4
- package/lib/Watcher.js +35 -3
- package/lib/WebServer.js +15 -3
- package/lib/config.js +2 -2
- package/lib/ui.html +55 -7
- package/package.json +3 -3
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
|
-
|
|
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
|
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
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
|
-
:
|
|
712
|
-
?
|
|
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
|
|
802
|
-
<div className="form-hint">Sent
|
|
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.
|
|
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
|
+
}
|