claude-code-remote-pilot 0.1.5 → 0.2.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 +93 -297
- package/bin/claude-pilot.js +214 -107
- package/lib/SessionManager.js +51 -0
- package/lib/Watcher.js +116 -0
- package/lib/notifier.js +16 -0
- package/package.json +6 -7
- package/claude-pilot.sh +0 -223
package/README.md
CHANGED
|
@@ -1,388 +1,184 @@
|
|
|
1
1
|
# Claude Code Remote Pilot
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Spawn and supervise multiple Claude Code sessions from a single interactive terminal.
|
|
4
4
|
|
|
5
|
-
|
|
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.
|
|
6
6
|
|
|
7
|
-
|
|
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.
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
11
|
-
##
|
|
11
|
+
## How it works
|
|
12
12
|
|
|
13
|
-
This repository currently contains:
|
|
14
|
-
|
|
15
|
-
- `claude-pilot.sh` — bash/tmux watcher MVP
|
|
16
|
-
- `bin/claude-pilot.js` — npm CLI wrapper
|
|
17
|
-
- `package.json` — npm package entry
|
|
18
|
-
- `docs/ARCHITECTURE_UPDATE.md` — architecture direction
|
|
19
|
-
- `TASKS.md` — implementation roadmap
|
|
20
|
-
|
|
21
|
-
Current mode:
|
|
22
|
-
|
|
23
|
-
```text
|
|
24
|
-
Human → tmux terminal → Claude Code
|
|
25
|
-
↑
|
|
26
|
-
│
|
|
27
|
-
Claude Code Remote Pilot
|
|
28
|
-
- watches output
|
|
29
|
-
- detects limits
|
|
30
|
-
- sends notifications
|
|
31
|
-
- resumes automatically
|
|
32
13
|
```
|
|
14
|
+
npx claude-code-remote-pilot
|
|
15
|
+
│
|
|
16
|
+
├── asks: mount current directory as a session?
|
|
17
|
+
├── asks: set up Telegram? (optional)
|
|
18
|
+
└── starts interactive prompt
|
|
33
19
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
Browser Dashboard
|
|
38
|
-
↓
|
|
39
|
-
WebSocket / REST API
|
|
40
|
-
↓
|
|
41
|
-
Node.js Supervisor
|
|
42
|
-
↓
|
|
43
|
-
Session Manager
|
|
44
|
-
↓
|
|
45
|
-
tmux sessions
|
|
46
|
-
↓
|
|
47
|
-
Claude Code instances
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
---
|
|
51
|
-
|
|
52
|
-
## Why This Exists
|
|
53
|
-
|
|
54
|
-
Claude Code is useful for long-running coding tasks, but usage/rate limits can interrupt the flow.
|
|
55
|
-
|
|
56
|
-
This project aims to provide:
|
|
57
|
-
|
|
58
|
-
- persistent Claude Code sessions
|
|
59
|
-
- automatic resume after limit reset
|
|
60
|
-
- human-supervised automation
|
|
61
|
-
- local-first workflow
|
|
62
|
-
- multi-session management
|
|
63
|
-
- eventual web dashboard control
|
|
64
|
-
|
|
65
|
-
It is intentionally **not** designed as an unsafe fully autonomous agent loop.
|
|
66
|
-
|
|
67
|
-
---
|
|
68
|
-
|
|
69
|
-
## Features
|
|
70
|
-
|
|
71
|
-
Current MVP:
|
|
72
|
-
|
|
73
|
-
- tmux session persistence
|
|
74
|
-
- auto-create Claude tmux session
|
|
75
|
-
- terminal output capture
|
|
76
|
-
- usage/rate limit detection
|
|
77
|
-
- reset-time parsing
|
|
78
|
-
- Telegram notification support
|
|
79
|
-
- automatic `continue` after waiting
|
|
80
|
-
- duplicate event protection
|
|
81
|
-
- resume cooldown protection
|
|
82
|
-
- npm CLI wrapper
|
|
83
|
-
|
|
84
|
-
Planned:
|
|
85
|
-
|
|
86
|
-
- Node.js runtime refactor
|
|
87
|
-
- multiple Claude sessions
|
|
88
|
-
- web dashboard
|
|
89
|
-
- WebSocket live output
|
|
90
|
-
- session registry
|
|
91
|
-
- pluggable detectors
|
|
92
|
-
- notification providers
|
|
93
|
-
- persistent session state
|
|
94
|
-
- policy/safety engine
|
|
95
|
-
|
|
96
|
-
---
|
|
97
|
-
|
|
98
|
-
## Requirements
|
|
99
|
-
|
|
100
|
-
- Node.js >= 18
|
|
101
|
-
- bash
|
|
102
|
-
- tmux
|
|
103
|
-
- curl
|
|
104
|
-
- python3 recommended for better reset-time parsing
|
|
105
|
-
- Claude Code CLI installed and authenticated
|
|
106
|
-
|
|
107
|
-
---
|
|
108
|
-
|
|
109
|
-
## Installing tmux
|
|
110
|
-
|
|
111
|
-
**macOS (Homebrew):**
|
|
112
|
-
|
|
113
|
-
```bash
|
|
114
|
-
brew install tmux
|
|
20
|
+
claude-pilot> spawn ~/projects/api-refactor
|
|
21
|
+
claude-pilot> spawn ~/projects/mobile-app
|
|
22
|
+
claude-pilot> watch
|
|
115
23
|
```
|
|
116
24
|
|
|
117
|
-
**Ubuntu / Debian:**
|
|
118
|
-
|
|
119
|
-
```bash
|
|
120
|
-
sudo apt update && sudo apt install tmux curl python3
|
|
121
25
|
```
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
26
|
+
Claude Code Remote Pilot
|
|
27
|
+
─────────────────────────────────────────────────────────────────
|
|
28
|
+
SESSION STATUS DIRECTORY
|
|
29
|
+
─────────────────────────────────────────────────────────────────
|
|
30
|
+
api-refactor running ~/projects/api-refactor
|
|
31
|
+
mobile-app limit 42m ~/projects/mobile-app
|
|
32
|
+
─────────────────────────────────────────────────────────────────
|
|
33
|
+
2 sessions 14:23:05 q to exit
|
|
127
34
|
```
|
|
128
35
|
|
|
129
|
-
|
|
36
|
+
From any other terminal, interact with a session directly:
|
|
130
37
|
|
|
131
38
|
```bash
|
|
132
|
-
|
|
39
|
+
tmux attach -t api-refactor
|
|
40
|
+
# Ctrl+B then D to detach back to your terminal
|
|
133
41
|
```
|
|
134
42
|
|
|
135
|
-
|
|
43
|
+
---
|
|
136
44
|
|
|
137
|
-
|
|
45
|
+
## Install
|
|
138
46
|
|
|
139
47
|
```bash
|
|
140
|
-
|
|
48
|
+
npx claude-code-remote-pilot
|
|
141
49
|
```
|
|
142
50
|
|
|
143
|
-
|
|
51
|
+
Or install globally:
|
|
144
52
|
|
|
145
53
|
```bash
|
|
146
|
-
|
|
54
|
+
npm install -g claude-code-remote-pilot
|
|
55
|
+
claude-remote-pilot
|
|
147
56
|
```
|
|
148
57
|
|
|
149
58
|
---
|
|
150
59
|
|
|
151
|
-
##
|
|
60
|
+
## Requirements
|
|
152
61
|
|
|
153
|
-
|
|
62
|
+
- Node.js >= 18
|
|
63
|
+
- tmux
|
|
64
|
+
- Claude Code CLI (`npm install -g @anthropic-ai/claude-code`)
|
|
154
65
|
|
|
155
|
-
|
|
156
|
-
git clone https://github.com/mekku/claude-code-remote-pilot.git
|
|
157
|
-
cd claude-code-remote-pilot
|
|
158
|
-
yarn install
|
|
159
|
-
```
|
|
66
|
+
The pilot will prompt you to install missing dependencies on first run.
|
|
160
67
|
|
|
161
|
-
|
|
68
|
+
### Installing tmux manually
|
|
162
69
|
|
|
70
|
+
**macOS:**
|
|
163
71
|
```bash
|
|
164
|
-
|
|
72
|
+
brew install tmux
|
|
165
73
|
```
|
|
166
74
|
|
|
167
|
-
|
|
168
|
-
|
|
75
|
+
**Ubuntu / Debian:**
|
|
169
76
|
```bash
|
|
170
|
-
|
|
77
|
+
sudo apt update && sudo apt install tmux
|
|
171
78
|
```
|
|
172
79
|
|
|
173
|
-
|
|
174
|
-
|
|
80
|
+
**Fedora / RHEL:**
|
|
175
81
|
```bash
|
|
176
|
-
|
|
177
|
-
claude-remote-pilot
|
|
82
|
+
sudo dnf install tmux
|
|
178
83
|
```
|
|
179
84
|
|
|
180
|
-
|
|
181
|
-
|
|
85
|
+
**Arch:**
|
|
182
86
|
```bash
|
|
183
|
-
|
|
87
|
+
sudo pacman -S tmux
|
|
184
88
|
```
|
|
185
89
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
## Telegram Setup
|
|
189
|
-
|
|
190
|
-
Create a bot with `@BotFather` and get your bot token.
|
|
191
|
-
|
|
192
|
-
Send a message to your bot, then get your chat ID:
|
|
193
|
-
|
|
90
|
+
**Windows (WSL):**
|
|
194
91
|
```bash
|
|
195
|
-
|
|
196
|
-
```
|
|
197
|
-
|
|
198
|
-
Look for:
|
|
199
|
-
|
|
200
|
-
```json
|
|
201
|
-
"chat": {
|
|
202
|
-
"id": 123456789
|
|
203
|
-
}
|
|
92
|
+
sudo apt update && sudo apt install tmux
|
|
204
93
|
```
|
|
205
94
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
```bash
|
|
209
|
-
export TELEGRAM_BOT_TOKEN="xxx"
|
|
210
|
-
export TELEGRAM_CHAT_ID="123456789"
|
|
95
|
+
---
|
|
211
96
|
|
|
212
|
-
|
|
213
|
-
```
|
|
97
|
+
## Commands
|
|
214
98
|
|
|
215
|
-
|
|
99
|
+
| Command | Description |
|
|
100
|
+
|---|---|
|
|
101
|
+
| `spawn <path> [name]` | Start Claude at a path. Name defaults to the directory name. |
|
|
102
|
+
| `list` | One-shot status of all sessions. |
|
|
103
|
+
| `watch` | Live-updating session monitor. Press `q` to exit. |
|
|
104
|
+
| `attach <name>` | Open a tmux session in the current terminal. |
|
|
105
|
+
| `kill <name>` | Stop a session. |
|
|
106
|
+
| `help` | Show command reference. |
|
|
107
|
+
| `exit` | Quit the pilot. Sessions keep running in tmux. |
|
|
216
108
|
|
|
217
109
|
---
|
|
218
110
|
|
|
219
|
-
##
|
|
111
|
+
## Telegram setup
|
|
220
112
|
|
|
221
|
-
|
|
113
|
+
Create a bot via `@BotFather` and get your token.
|
|
222
114
|
|
|
223
|
-
|
|
224
|
-
yarn pilot
|
|
225
|
-
```
|
|
226
|
-
|
|
227
|
-
Attach to Claude:
|
|
115
|
+
Get your chat ID:
|
|
228
116
|
|
|
229
117
|
```bash
|
|
230
|
-
|
|
118
|
+
curl "https://api.telegram.org/bot<TOKEN>/getUpdates"
|
|
231
119
|
```
|
|
232
120
|
|
|
233
|
-
|
|
121
|
+
Run the pilot — it will ask for these values interactively, or set them as environment variables:
|
|
234
122
|
|
|
235
|
-
```
|
|
236
|
-
|
|
123
|
+
```bash
|
|
124
|
+
export TELEGRAM_BOT_TOKEN="your-token"
|
|
125
|
+
export TELEGRAM_CHAT_ID="your-chat-id"
|
|
126
|
+
npx claude-code-remote-pilot
|
|
237
127
|
```
|
|
238
128
|
|
|
239
|
-
The watcher keeps running and watches the tmux session.
|
|
240
|
-
|
|
241
129
|
---
|
|
242
130
|
|
|
243
|
-
## Environment
|
|
131
|
+
## Environment variables
|
|
244
132
|
|
|
245
133
|
| Variable | Default | Description |
|
|
246
|
-
|
|
247
|
-
| `TELEGRAM_BOT_TOKEN` |
|
|
248
|
-
| `TELEGRAM_CHAT_ID` |
|
|
249
|
-
| `
|
|
250
|
-
| `CLAUDE_COMMAND` | `claude` | command used to start Claude |
|
|
251
|
-
| `CHECK_INTERVAL_SECONDS` | `30` | watcher interval |
|
|
252
|
-
| `LIMIT_FALLBACK_WAIT_SECONDS` | `300` | fallback wait if reset time cannot be parsed |
|
|
253
|
-
| `POST_RESUME_COOLDOWN_SECONDS` | `180` | avoid repeated resume spam |
|
|
254
|
-
| `CAPTURE_LINES` | `500` | number of tmux output lines to inspect |
|
|
255
|
-
| `RESUME_COMMAND` | `continue` | command sent after reset |
|
|
256
|
-
| `START_IF_MISSING` | `1` | auto-create tmux session if missing |
|
|
257
|
-
| `LIMIT_REGEX` | built-in | custom regex for limit detection |
|
|
258
|
-
| `PERMISSION_REGEX` | built-in | custom regex for permission detection |
|
|
259
|
-
|
|
260
|
-
Example:
|
|
261
|
-
|
|
262
|
-
```bash
|
|
263
|
-
CLAUDE_SESSION=claude-buildx-api \
|
|
264
|
-
CLAUDE_COMMAND="claude" \
|
|
265
|
-
TELEGRAM_BOT_TOKEN="xxx" \
|
|
266
|
-
TELEGRAM_CHAT_ID="123456789" \
|
|
267
|
-
yarn pilot
|
|
268
|
-
```
|
|
134
|
+
|---|---|---|
|
|
135
|
+
| `TELEGRAM_BOT_TOKEN` | — | Telegram bot token |
|
|
136
|
+
| `TELEGRAM_CHAT_ID` | — | Telegram chat ID |
|
|
137
|
+
| `CLAUDE_COMMAND` | `claude` | Command used to start Claude |
|
|
269
138
|
|
|
270
139
|
---
|
|
271
140
|
|
|
272
|
-
## Recommended Claude
|
|
141
|
+
## Recommended Claude workflow
|
|
273
142
|
|
|
274
|
-
For long-running
|
|
143
|
+
For long-running tasks, ask Claude to keep external state:
|
|
275
144
|
|
|
276
|
-
```
|
|
145
|
+
```
|
|
277
146
|
Maintain TASK_STATE.md.
|
|
278
|
-
After every meaningful step:
|
|
279
|
-
|
|
280
|
-
- update current status
|
|
281
|
-
- update next exact action
|
|
282
|
-
|
|
283
|
-
If interrupted:
|
|
284
|
-
- read TASK_STATE.md
|
|
285
|
-
- resume from the next unfinished step.
|
|
147
|
+
After every meaningful step update: what's done, current status, next exact action.
|
|
148
|
+
If interrupted, read TASK_STATE.md and resume from where you left off.
|
|
286
149
|
```
|
|
287
150
|
|
|
288
|
-
|
|
151
|
+
Context can drift over long sessions. External state is the real resume brain.
|
|
289
152
|
|
|
290
153
|
---
|
|
291
154
|
|
|
292
|
-
## Safety
|
|
155
|
+
## Safety
|
|
293
156
|
|
|
294
|
-
|
|
157
|
+
Start Claude without `--dangerously-skip-permissions` unless you know what you're doing. The pilot is designed for human-supervised workflows:
|
|
295
158
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
That mode allows Claude to execute commands and modify files without asking.
|
|
301
|
-
|
|
302
|
-
Claude Code Pilot should begin as a **human-supervised** tool:
|
|
303
|
-
|
|
304
|
-
Allowed early:
|
|
305
|
-
|
|
306
|
-
- watch output
|
|
307
|
-
- notify
|
|
308
|
-
- send `continue`
|
|
309
|
-
- send user-provided input
|
|
310
|
-
- show latest results
|
|
311
|
-
|
|
312
|
-
Avoid early:
|
|
313
|
-
|
|
314
|
-
- auto-approve permissions
|
|
315
|
-
- arbitrary remote shell execution
|
|
316
|
-
- autonomous destructive actions
|
|
159
|
+
- watches output
|
|
160
|
+
- sends notifications
|
|
161
|
+
- sends `continue` after limit resets
|
|
162
|
+
- does **not** auto-approve permissions or execute arbitrary commands
|
|
317
163
|
|
|
318
164
|
---
|
|
319
165
|
|
|
320
166
|
## Roadmap
|
|
321
167
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
- [
|
|
325
|
-
- [
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
3. multi-session support
|
|
332
|
-
4. detection engine
|
|
333
|
-
5. notification providers
|
|
334
|
-
6. web dashboard
|
|
335
|
-
7. persistent state
|
|
336
|
-
8. safety/policy engine
|
|
337
|
-
9. optional node-pty backend
|
|
338
|
-
|
|
339
|
-
---
|
|
340
|
-
|
|
341
|
-
## Target Dashboard Concept
|
|
342
|
-
|
|
343
|
-
Planned local dashboard:
|
|
344
|
-
|
|
345
|
-
```text
|
|
346
|
-
Sidebar
|
|
347
|
-
├── claude-buildx-api
|
|
348
|
-
├── claude-pos-mobile
|
|
349
|
-
├── claude-auth-refactor
|
|
350
|
-
└── claude-research
|
|
351
|
-
|
|
352
|
-
Main Panel
|
|
353
|
-
├── live output
|
|
354
|
-
├── status badge
|
|
355
|
-
├── input box
|
|
356
|
-
├── continue button
|
|
357
|
-
└── auto-resume toggle
|
|
358
|
-
```
|
|
359
|
-
|
|
360
|
-
This allows normal local terminal interaction when at the machine, plus remote-lite control when away.
|
|
168
|
+
- [x] tmux session management
|
|
169
|
+
- [x] usage limit detection and auto-resume
|
|
170
|
+
- [x] Telegram notifications
|
|
171
|
+
- [x] interactive REPL — spawn, watch, attach, kill
|
|
172
|
+
- [x] multi-session support
|
|
173
|
+
- [ ] web dashboard (sessions connect to pilot server)
|
|
174
|
+
- [ ] persistent session state
|
|
175
|
+
- [ ] pluggable notification providers
|
|
176
|
+
- [ ] safety / policy engine
|
|
361
177
|
|
|
362
178
|
---
|
|
363
179
|
|
|
364
180
|
## Philosophy
|
|
365
181
|
|
|
366
|
-
Claude Code
|
|
367
|
-
|
|
368
|
-
```text
|
|
369
|
-
human-supervised local AI runtime
|
|
370
|
-
```
|
|
371
|
-
|
|
372
|
-
not:
|
|
373
|
-
|
|
374
|
-
```text
|
|
375
|
-
fully autonomous AI operator
|
|
376
|
-
```
|
|
377
|
-
|
|
378
|
-
The system prioritizes:
|
|
379
|
-
|
|
380
|
-
- persistence
|
|
381
|
-
- observability
|
|
382
|
-
- recoverability
|
|
383
|
-
- resumability
|
|
384
|
-
- multi-session control
|
|
385
|
-
- human intervention
|
|
386
|
-
- practical workflows
|
|
182
|
+
A human-supervised local runtime for Claude Code. Not a fully autonomous agent loop.
|
|
387
183
|
|
|
388
|
-
Small tools first.
|
|
184
|
+
Small tools first. Dashboard later.
|
package/bin/claude-pilot.js
CHANGED
|
@@ -1,31 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
2
3
|
|
|
3
|
-
const {
|
|
4
|
+
const { execSync, spawn } = require('child_process');
|
|
4
5
|
const path = require('path');
|
|
5
6
|
const fs = require('fs');
|
|
6
|
-
|
|
7
|
-
const scriptPath = path.resolve(__dirname, '..', 'claude-pilot.sh');
|
|
8
|
-
|
|
9
|
-
if (!fs.existsSync(scriptPath)) {
|
|
10
|
-
console.error('claude-pilot.sh not found');
|
|
11
|
-
process.exit(1);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
7
|
const readline = require('readline');
|
|
8
|
+
const SessionManager = require('../lib/SessionManager');
|
|
9
|
+
|
|
10
|
+
// ─── dependency checks ────────────────────────────────────────────────────────
|
|
15
11
|
|
|
16
|
-
function
|
|
17
|
-
try { execSync(`command -v ${cmd}`, { stdio: 'ignore' }); return true; }
|
|
18
|
-
catch { return false; }
|
|
12
|
+
function has(cmd) {
|
|
13
|
+
try { execSync(`command -v ${cmd}`, { stdio: 'ignore' }); return true; } catch { return false; }
|
|
19
14
|
}
|
|
20
15
|
|
|
21
16
|
function detectPlatform() {
|
|
22
|
-
|
|
23
|
-
if (platform === 'darwin') return 'macos';
|
|
24
|
-
if (platform === 'win32') return 'windows';
|
|
17
|
+
if (process.platform === 'darwin') return 'macos';
|
|
25
18
|
try {
|
|
26
|
-
const
|
|
27
|
-
if (/ID=arch/i.test(
|
|
28
|
-
if (/ID=(fedora|rhel|centos)/i.test(
|
|
19
|
+
const r = fs.readFileSync('/etc/os-release', 'utf8');
|
|
20
|
+
if (/ID=arch/i.test(r)) return 'arch';
|
|
21
|
+
if (/ID=(fedora|rhel|centos)/i.test(r)) return 'fedora';
|
|
29
22
|
} catch {}
|
|
30
23
|
return 'debian';
|
|
31
24
|
}
|
|
@@ -38,126 +31,240 @@ function tmuxInstallCmd() {
|
|
|
38
31
|
return 'sudo apt-get install -y tmux';
|
|
39
32
|
}
|
|
40
33
|
|
|
41
|
-
function
|
|
42
|
-
|
|
43
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
44
|
-
rl.question(question, (answer) => { rl.close(); resolve(answer.trim().toLowerCase()); });
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
async function ensureDep(cmd, label, installCmd) {
|
|
49
|
-
if (checkDep(cmd)) return;
|
|
34
|
+
async function ensureDep(rl, cmd, label, installCmd) {
|
|
35
|
+
if (has(cmd)) return;
|
|
50
36
|
console.log(`\n${label} is not installed.`);
|
|
51
|
-
const answer = await
|
|
37
|
+
const answer = await question(rl, 'Install it now? (y/n) ');
|
|
52
38
|
if (answer !== 'y' && answer !== 'yes') {
|
|
53
|
-
console.log(`
|
|
39
|
+
console.log(`Run manually: ${installCmd}`);
|
|
54
40
|
process.exit(1);
|
|
55
41
|
}
|
|
56
42
|
console.log(`Running: ${installCmd}\n`);
|
|
57
|
-
try {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
43
|
+
try { execSync(installCmd, { stdio: 'inherit' }); console.log(`\n${label} installed.\n`); }
|
|
44
|
+
catch { console.error(`Install failed. Run manually: ${installCmd}`); process.exit(1); }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
function question(rl, q) {
|
|
50
|
+
return new Promise(r => rl.question(q, a => r(a.trim().toLowerCase())));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function questionRaw(rl, q) {
|
|
54
|
+
return new Promise(r => rl.question(q, a => r(a.trim())));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── telegram setup ───────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
async function setupTelegram(rl) {
|
|
60
|
+
if (process.env.TELEGRAM_BOT_TOKEN && process.env.TELEGRAM_CHAT_ID) {
|
|
61
|
+
return { token: process.env.TELEGRAM_BOT_TOKEN, chatId: process.env.TELEGRAM_CHAT_ID };
|
|
63
62
|
}
|
|
63
|
+
console.log('\nTelegram notifications (optional).');
|
|
64
|
+
const answer = await question(rl, 'Set up Telegram now? (y/n) ');
|
|
65
|
+
if (answer !== 'y' && answer !== 'yes') { console.log('Skipping.\n'); return {}; }
|
|
66
|
+
const token = await questionRaw(rl, 'Bot token: ');
|
|
67
|
+
const chatId = await questionRaw(rl, 'Chat ID: ');
|
|
68
|
+
console.log('Telegram configured.\n');
|
|
69
|
+
return { token, chatId };
|
|
64
70
|
}
|
|
65
71
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
72
|
+
// ─── table rendering ──────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
const C = {
|
|
75
|
+
reset: '\x1b[0m',
|
|
76
|
+
green: '\x1b[32m',
|
|
77
|
+
yellow: '\x1b[33m',
|
|
78
|
+
dim: '\x1b[2m',
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
function formatStatus(session) {
|
|
82
|
+
if (session.status === 'running') return { plain: 'running', colored: `${C.green}running${C.reset}` };
|
|
83
|
+
if (session.status === 'limit') {
|
|
84
|
+
const secs = session.resumeAt ? Math.max(0, Math.round((session.resumeAt - Date.now()) / 1000)) : 0;
|
|
85
|
+
const label = `limit ${Math.ceil(secs / 60)}m`;
|
|
86
|
+
return { plain: label, colored: `${C.yellow}${label}${C.reset}` };
|
|
73
87
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (chatId) process.env.TELEGRAM_CHAT_ID = chatId.trim();
|
|
78
|
-
console.log('Telegram configured for this session.\n');
|
|
88
|
+
if (session.status === 'needs-input') return { plain: 'needs input', colored: `${C.yellow}needs input${C.reset}` };
|
|
89
|
+
if (session.status === 'ended') return { plain: 'ended', colored: `${C.dim}ended${C.reset}` };
|
|
90
|
+
return { plain: session.status, colored: session.status };
|
|
79
91
|
}
|
|
80
92
|
|
|
81
|
-
function
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
console.log(`
|
|
85
|
-
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
86
|
-
Claude Code Remote Pilot — Ready
|
|
87
|
-
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
93
|
+
function trunc(str, len) {
|
|
94
|
+
return str.length <= len ? str.padEnd(len) : str.slice(0, len - 1) + '…';
|
|
95
|
+
}
|
|
88
96
|
|
|
89
|
-
|
|
90
|
-
|
|
97
|
+
function renderTable(sessions) {
|
|
98
|
+
const NW = 20, SW = 14;
|
|
99
|
+
const bar = ' ' + '─'.repeat(NW + SW + 42);
|
|
100
|
+
const header = ` ${'SESSION'.padEnd(NW)} ${'STATUS'.padEnd(SW)} DIRECTORY`;
|
|
101
|
+
const rows = sessions.map(s => {
|
|
102
|
+
const { plain, colored } = formatStatus(s);
|
|
103
|
+
const pad = ' '.repeat(Math.max(0, SW - plain.length));
|
|
104
|
+
return ` ${trunc(s.name, NW)} ${colored}${pad} ${trunc(s.path, 40)}`;
|
|
105
|
+
});
|
|
106
|
+
const footer = ` ${sessions.length} session${sessions.length !== 1 ? 's' : ''} ${new Date().toLocaleTimeString()} q to exit`;
|
|
107
|
+
return ['\n', ' Claude Code Remote Pilot', bar, header, bar, ...rows, bar, footer, ''].join('\n');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─── watch mode ───────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
function startWatch(manager, rl) {
|
|
113
|
+
rl.close();
|
|
114
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(true);
|
|
115
|
+
process.stdin.resume();
|
|
91
116
|
|
|
92
|
-
|
|
93
|
-
|
|
117
|
+
function draw() {
|
|
118
|
+
process.stdout.write('\x1B[2J\x1B[0f');
|
|
119
|
+
process.stdout.write(renderTable(manager.list()));
|
|
120
|
+
}
|
|
94
121
|
|
|
95
|
-
|
|
96
|
-
|
|
122
|
+
draw();
|
|
123
|
+
const interval = setInterval(draw, 2000);
|
|
97
124
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
125
|
+
function onData(buf) {
|
|
126
|
+
const key = buf.toString();
|
|
127
|
+
if (key === 'q' || key === 'Q' || buf[0] === 3) {
|
|
128
|
+
clearInterval(interval);
|
|
129
|
+
process.stdin.removeListener('data', onData);
|
|
130
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
131
|
+
process.stdout.write('\x1B[2J\x1B[0f');
|
|
132
|
+
startREPL(manager);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
103
135
|
|
|
104
|
-
|
|
105
|
-
`);
|
|
136
|
+
process.stdin.on('data', onData);
|
|
106
137
|
}
|
|
107
138
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
139
|
+
// ─── REPL ─────────────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
const HELP = `
|
|
142
|
+
spawn <path> [name] Start Claude at path (name defaults to dir name)
|
|
143
|
+
list Show all sessions
|
|
144
|
+
watch Live session monitor (q to exit)
|
|
145
|
+
attach <name> Open tmux session in this terminal
|
|
146
|
+
kill <name> Stop a session
|
|
147
|
+
help Show this help
|
|
148
|
+
exit Quit pilot (sessions keep running in tmux)
|
|
149
|
+
`;
|
|
150
|
+
|
|
151
|
+
function startREPL(manager) {
|
|
152
|
+
const rl = readline.createInterface({
|
|
153
|
+
input: process.stdin,
|
|
154
|
+
output: process.stdout,
|
|
155
|
+
prompt: 'claude-pilot> ',
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
rl.prompt();
|
|
159
|
+
|
|
160
|
+
rl.on('line', async (line) => {
|
|
161
|
+
const parts = line.trim().split(/\s+/).filter(Boolean);
|
|
162
|
+
const [cmd, ...args] = parts;
|
|
163
|
+
|
|
164
|
+
if (!cmd) { rl.prompt(); return; }
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
switch (cmd) {
|
|
168
|
+
case 'spawn': {
|
|
169
|
+
if (!args[0]) { console.log(' Usage: spawn <path> [name]'); break; }
|
|
170
|
+
const session = manager.spawn(args[0], args[1]);
|
|
171
|
+
console.log(` ✓ "${session.name}" started at ${session.path}`);
|
|
172
|
+
console.log(` tmux attach -t ${session.name}`);
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
case 'list': {
|
|
176
|
+
const sessions = manager.list();
|
|
177
|
+
if (!sessions.length) { console.log(' No sessions.'); break; }
|
|
178
|
+
console.log('');
|
|
179
|
+
sessions.forEach(s => {
|
|
180
|
+
const { plain } = formatStatus(s);
|
|
181
|
+
console.log(` ${s.name.padEnd(22)} ${plain.padEnd(14)} ${s.path}`);
|
|
182
|
+
});
|
|
183
|
+
console.log('');
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
case 'watch': {
|
|
187
|
+
const sessions = manager.list();
|
|
188
|
+
if (!sessions.length) { console.log(' No sessions.'); break; }
|
|
189
|
+
startWatch(manager, rl);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
case 'attach': {
|
|
193
|
+
if (!args[0]) { console.log(' Usage: attach <name>'); break; }
|
|
194
|
+
rl.pause();
|
|
195
|
+
const child = spawn('tmux', ['attach-session', '-t', args[0]], { stdio: 'inherit' });
|
|
196
|
+
child.on('exit', () => { process.stdout.write('\n'); rl.resume(); rl.prompt(); });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
case 'kill': {
|
|
200
|
+
if (!args[0]) { console.log(' Usage: kill <name>'); break; }
|
|
201
|
+
manager.kill(args[0]);
|
|
202
|
+
console.log(` ✓ "${args[0]}" killed.`);
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
case 'help': {
|
|
206
|
+
console.log(HELP);
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
case 'exit':
|
|
210
|
+
case 'quit': {
|
|
211
|
+
console.log('\n Sessions keep running. Use tmux to attach.\n');
|
|
212
|
+
process.exit(0);
|
|
213
|
+
}
|
|
214
|
+
default:
|
|
215
|
+
console.log(` Unknown command: ${cmd}. Type help.`);
|
|
216
|
+
}
|
|
217
|
+
} catch (err) {
|
|
218
|
+
console.error(` Error: ${err.message}`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
rl.prompt();
|
|
117
222
|
});
|
|
118
223
|
|
|
119
|
-
|
|
120
|
-
|
|
224
|
+
rl.on('close', () => {
|
|
225
|
+
console.log('\n Sessions keep running. Use tmux to attach.\n');
|
|
226
|
+
process.exit(0);
|
|
121
227
|
});
|
|
122
228
|
}
|
|
123
229
|
|
|
124
|
-
|
|
125
|
-
|
|
230
|
+
// ─── main ─────────────────────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
async function main() {
|
|
233
|
+
if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
234
|
+
console.log(`
|
|
126
235
|
Claude Code Remote Pilot
|
|
127
236
|
|
|
128
237
|
Usage:
|
|
129
|
-
claude-remote-pilot
|
|
238
|
+
claude-remote-pilot
|
|
130
239
|
|
|
131
|
-
|
|
132
|
-
|
|
240
|
+
Interactive commands:
|
|
241
|
+
${HELP}`);
|
|
242
|
+
process.exit(0);
|
|
243
|
+
}
|
|
133
244
|
|
|
134
|
-
|
|
135
|
-
TELEGRAM_BOT_TOKEN
|
|
136
|
-
TELEGRAM_CHAT_ID
|
|
137
|
-
CLAUDE_COMMAND
|
|
245
|
+
const setupRl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
138
246
|
|
|
139
|
-
|
|
140
|
-
claude
|
|
141
|
-
|
|
247
|
+
await ensureDep(setupRl, 'tmux', 'tmux', tmuxInstallCmd());
|
|
248
|
+
await ensureDep(setupRl, 'claude', 'Claude Code CLI', 'npm install -g @anthropic-ai/claude-code');
|
|
249
|
+
const telegram = await setupTelegram(setupRl);
|
|
142
250
|
|
|
143
|
-
|
|
144
|
-
tmux attach -t <name>
|
|
145
|
-
`);
|
|
146
|
-
process.exit(0);
|
|
147
|
-
}
|
|
251
|
+
const manager = new SessionManager({ telegram });
|
|
148
252
|
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
const
|
|
152
|
-
? process.argv[sessionFlagIndex + 1]
|
|
153
|
-
: path.basename(process.cwd());
|
|
253
|
+
const cwd = process.cwd();
|
|
254
|
+
const defaultName = path.basename(cwd);
|
|
255
|
+
const mount = await question(setupRl, `Mount current directory as a session? (${defaultName}) [y/n] `);
|
|
154
256
|
|
|
155
|
-
if (
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
257
|
+
if (mount === 'y' || mount === 'yes') {
|
|
258
|
+
const rawName = await questionRaw(setupRl, `Session name [${defaultName}]: `);
|
|
259
|
+
const session = manager.spawn(cwd, rawName || defaultName);
|
|
260
|
+
console.log(` ✓ "${session.name}" started at ${session.path}`);
|
|
261
|
+
console.log(` tmux attach -t ${session.name}\n`);
|
|
262
|
+
}
|
|
159
263
|
|
|
160
|
-
|
|
161
|
-
|
|
264
|
+
setupRl.close();
|
|
265
|
+
|
|
266
|
+
console.log(' Type help for commands.\n');
|
|
267
|
+
startREPL(manager);
|
|
268
|
+
}
|
|
162
269
|
|
|
163
|
-
main();
|
|
270
|
+
main().catch(err => { console.error(err.message); process.exit(1); });
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { execSync } = require('child_process');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const Watcher = require('./Watcher');
|
|
6
|
+
|
|
7
|
+
function sanitizeName(name) {
|
|
8
|
+
return name.replace(/[.:\s]/g, '-');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
class SessionManager {
|
|
12
|
+
constructor({ telegram = {} } = {}) {
|
|
13
|
+
this.sessions = new Map();
|
|
14
|
+
this.telegram = telegram;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
spawn(dirPath, name) {
|
|
18
|
+
const resolved = path.resolve(dirPath.replace(/^~/, process.env.HOME || ''));
|
|
19
|
+
if (!fs.existsSync(resolved)) throw new Error(`Path not found: ${resolved}`);
|
|
20
|
+
|
|
21
|
+
const sessionName = sanitizeName(name || path.basename(resolved));
|
|
22
|
+
if (this.sessions.has(sessionName)) throw new Error(`Session "${sessionName}" already exists.`);
|
|
23
|
+
|
|
24
|
+
execSync(`tmux new-session -d -s "${sessionName}" -c "${resolved}" "claude"`, { stdio: 'ignore' });
|
|
25
|
+
|
|
26
|
+
const session = { name: sessionName, path: resolved, status: 'running', startedAt: new Date(), resumeAt: null };
|
|
27
|
+
|
|
28
|
+
const watcher = new Watcher(session, {
|
|
29
|
+
telegram: this.telegram,
|
|
30
|
+
onEnded: (s) => this.sessions.delete(s.name),
|
|
31
|
+
});
|
|
32
|
+
watcher.start();
|
|
33
|
+
|
|
34
|
+
this.sessions.set(sessionName, { session, watcher });
|
|
35
|
+
return session;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
kill(name) {
|
|
39
|
+
const entry = this.sessions.get(name);
|
|
40
|
+
if (!entry) throw new Error(`Session "${name}" not found.`);
|
|
41
|
+
entry.watcher.stop();
|
|
42
|
+
try { execSync(`tmux kill-session -t "${name}"`, { stdio: 'ignore' }); } catch {}
|
|
43
|
+
this.sessions.delete(name);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
list() {
|
|
47
|
+
return [...this.sessions.values()].map(e => e.session);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = SessionManager;
|
package/lib/Watcher.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { execSync } = require('child_process');
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const notifier = require('./notifier');
|
|
5
|
+
|
|
6
|
+
const LIMIT_RE = /hit your limit|usage limit|rate limit|limit reached|try again|resets/i;
|
|
7
|
+
const PERM_RE = /permission|do you want to proceed|approve/i;
|
|
8
|
+
|
|
9
|
+
class Watcher {
|
|
10
|
+
constructor(session, opts = {}) {
|
|
11
|
+
this.session = session;
|
|
12
|
+
this.telegram = opts.telegram || {};
|
|
13
|
+
this.onEnded = opts.onEnded || (() => {});
|
|
14
|
+
this.checkInterval = opts.checkInterval || 30000;
|
|
15
|
+
this.fallbackWait = opts.fallbackWait || 300;
|
|
16
|
+
this.cooldown = opts.cooldown || 180;
|
|
17
|
+
this.captureLines = opts.captureLines || 500;
|
|
18
|
+
this.lastHash = '';
|
|
19
|
+
this.lastResumeAt = 0;
|
|
20
|
+
this._timer = null;
|
|
21
|
+
this._busy = false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
start() {
|
|
25
|
+
this._timer = setInterval(() => this._check(), this.checkInterval);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
stop() {
|
|
29
|
+
if (this._timer) clearInterval(this._timer);
|
|
30
|
+
this._timer = null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
_capture() {
|
|
34
|
+
try {
|
|
35
|
+
return execSync(
|
|
36
|
+
`tmux capture-pane -pt "${this.session.name}" -S "-${this.captureLines}"`,
|
|
37
|
+
{ encoding: 'utf8' }
|
|
38
|
+
);
|
|
39
|
+
} catch { return ''; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
_hash(text) {
|
|
43
|
+
return crypto.createHash('sha256').update(text.slice(-2000)).digest('hex');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
_parseWait(text) {
|
|
47
|
+
const m = text.match(/(?:try again|retry|wait).*?in\s+(\d+)\s*(second|minute|hour)/i);
|
|
48
|
+
if (m) {
|
|
49
|
+
const v = parseInt(m[1]);
|
|
50
|
+
if (m[2].startsWith('second')) return Math.max(10, v);
|
|
51
|
+
if (m[2].startsWith('minute')) return Math.max(10, v * 60);
|
|
52
|
+
return Math.max(10, v * 3600);
|
|
53
|
+
}
|
|
54
|
+
return this.fallbackWait;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async _check() {
|
|
58
|
+
if (this._busy) return;
|
|
59
|
+
this._busy = true;
|
|
60
|
+
try {
|
|
61
|
+
try { execSync(`tmux has-session -t "${this.session.name}"`, { stdio: 'ignore' }); }
|
|
62
|
+
catch {
|
|
63
|
+
this.stop();
|
|
64
|
+
this.onEnded(this.session);
|
|
65
|
+
notifier.send(this.telegram.token, this.telegram.chatId,
|
|
66
|
+
`Pilot: session "${this.session.name}" ended.`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const text = this._capture();
|
|
71
|
+
|
|
72
|
+
if (LIMIT_RE.test(text)) {
|
|
73
|
+
await this._handleLimit(text);
|
|
74
|
+
} else if (PERM_RE.test(text)) {
|
|
75
|
+
if (this.session.status !== 'needs-input') {
|
|
76
|
+
this.session.status = 'needs-input';
|
|
77
|
+
notifier.send(this.telegram.token, this.telegram.chatId,
|
|
78
|
+
`Pilot: "${this.session.name}" needs input.`);
|
|
79
|
+
}
|
|
80
|
+
} else if (this.session.status !== 'running') {
|
|
81
|
+
this.session.status = 'running';
|
|
82
|
+
this.session.resumeAt = null;
|
|
83
|
+
}
|
|
84
|
+
} finally {
|
|
85
|
+
this._busy = false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async _handleLimit(text) {
|
|
90
|
+
const hash = this._hash(text);
|
|
91
|
+
if (hash === this.lastHash) return;
|
|
92
|
+
if ((Date.now() / 1000) - this.lastResumeAt < this.cooldown) return;
|
|
93
|
+
|
|
94
|
+
this.lastHash = hash;
|
|
95
|
+
const wait = this._parseWait(text);
|
|
96
|
+
this.session.status = 'limit';
|
|
97
|
+
this.session.resumeAt = Date.now() + wait * 1000;
|
|
98
|
+
|
|
99
|
+
notifier.send(this.telegram.token, this.telegram.chatId,
|
|
100
|
+
`Pilot: limit in "${this.session.name}". Waiting ${wait}s before resume.`);
|
|
101
|
+
|
|
102
|
+
await new Promise(r => setTimeout(r, wait * 1000));
|
|
103
|
+
|
|
104
|
+
try { execSync(`tmux send-keys -t "${this.session.name}" "continue" Enter`, { stdio: 'ignore' }); }
|
|
105
|
+
catch {}
|
|
106
|
+
|
|
107
|
+
this.lastResumeAt = Date.now() / 1000;
|
|
108
|
+
this.session.status = 'running';
|
|
109
|
+
this.session.resumeAt = null;
|
|
110
|
+
|
|
111
|
+
notifier.send(this.telegram.token, this.telegram.chatId,
|
|
112
|
+
`Pilot: resumed "${this.session.name}".`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = Watcher;
|
package/lib/notifier.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { execSync } = require('child_process');
|
|
3
|
+
|
|
4
|
+
function send(token, chatId, message) {
|
|
5
|
+
if (!token || !chatId) return;
|
|
6
|
+
try {
|
|
7
|
+
execSync(
|
|
8
|
+
`curl -sS -X POST "https://api.telegram.org/bot${token}/sendMessage"` +
|
|
9
|
+
` --data-urlencode "chat_id=${chatId}"` +
|
|
10
|
+
` --data-urlencode "text=${message}"`,
|
|
11
|
+
{ stdio: 'ignore', timeout: 5000 }
|
|
12
|
+
);
|
|
13
|
+
} catch {}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
module.exports = { send };
|
package/package.json
CHANGED
|
@@ -1,21 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-remote-pilot",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Interactive Claude Code supervisor
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Interactive Claude Code supervisor — spawn and monitor multiple Claude sessions from a single terminal.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"bin": {
|
|
7
7
|
"claude-remote-pilot": "bin/claude-pilot.js"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"bin/",
|
|
11
|
-
"
|
|
11
|
+
"lib/",
|
|
12
12
|
"README.md",
|
|
13
13
|
"LICENSE"
|
|
14
14
|
],
|
|
15
15
|
"scripts": {
|
|
16
|
-
"start": "node bin/claude-pilot.js"
|
|
17
|
-
"pilot": "node bin/claude-pilot.js",
|
|
18
|
-
"check": "node bin/claude-pilot.js --help"
|
|
16
|
+
"start": "node bin/claude-pilot.js"
|
|
19
17
|
},
|
|
20
18
|
"keywords": [
|
|
21
19
|
"claude-code",
|
|
@@ -23,7 +21,8 @@
|
|
|
23
21
|
"tmux",
|
|
24
22
|
"telegram",
|
|
25
23
|
"agent",
|
|
26
|
-
"auto-resume"
|
|
24
|
+
"auto-resume",
|
|
25
|
+
"multi-session"
|
|
27
26
|
],
|
|
28
27
|
"author": "mekku",
|
|
29
28
|
"license": "MIT",
|
package/claude-pilot.sh
DELETED
|
@@ -1,223 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
set -u
|
|
3
|
-
|
|
4
|
-
# Claude Code Pilot
|
|
5
|
-
# Interactive Claude Code supervisor using tmux + Telegram notifications.
|
|
6
|
-
#
|
|
7
|
-
# Required env:
|
|
8
|
-
# TELEGRAM_BOT_TOKEN
|
|
9
|
-
# TELEGRAM_CHAT_ID
|
|
10
|
-
#
|
|
11
|
-
# Optional env:
|
|
12
|
-
# CLAUDE_SESSION=claude
|
|
13
|
-
# CLAUDE_COMMAND=claude
|
|
14
|
-
# CHECK_INTERVAL_SECONDS=30
|
|
15
|
-
# LIMIT_FALLBACK_WAIT_SECONDS=300
|
|
16
|
-
# POST_RESUME_COOLDOWN_SECONDS=180
|
|
17
|
-
# CAPTURE_LINES=500
|
|
18
|
-
# RESUME_COMMAND=continue
|
|
19
|
-
# START_IF_MISSING=1
|
|
20
|
-
#
|
|
21
|
-
# Example:
|
|
22
|
-
# TELEGRAM_BOT_TOKEN="123:abc" TELEGRAM_CHAT_ID="123456" ./claude-pilot.sh
|
|
23
|
-
|
|
24
|
-
CLAUDE_SESSION="${CLAUDE_SESSION:-claude}"
|
|
25
|
-
CLAUDE_COMMAND="${CLAUDE_COMMAND:-claude}"
|
|
26
|
-
CLAUDE_WORKDIR="${CLAUDE_WORKDIR:-$PWD}"
|
|
27
|
-
CHECK_INTERVAL_SECONDS="${CHECK_INTERVAL_SECONDS:-30}"
|
|
28
|
-
LIMIT_FALLBACK_WAIT_SECONDS="${LIMIT_FALLBACK_WAIT_SECONDS:-300}"
|
|
29
|
-
POST_RESUME_COOLDOWN_SECONDS="${POST_RESUME_COOLDOWN_SECONDS:-180}"
|
|
30
|
-
CAPTURE_LINES="${CAPTURE_LINES:-500}"
|
|
31
|
-
RESUME_COMMAND="${RESUME_COMMAND:-continue}"
|
|
32
|
-
START_IF_MISSING="${START_IF_MISSING:-1}"
|
|
33
|
-
|
|
34
|
-
TELEGRAM_BOT_TOKEN="${TELEGRAM_BOT_TOKEN:-}"
|
|
35
|
-
TELEGRAM_CHAT_ID="${TELEGRAM_CHAT_ID:-}"
|
|
36
|
-
|
|
37
|
-
LIMIT_REGEX="${LIMIT_REGEX:-hit your limit|usage limit|rate limit|limit reached|try again|resets}"
|
|
38
|
-
PERMISSION_REGEX="${PERMISSION_REGEX:-permission|do you want to proceed|approve}"
|
|
39
|
-
|
|
40
|
-
last_limit_hash=""
|
|
41
|
-
last_resume_epoch=0
|
|
42
|
-
|
|
43
|
-
log() {
|
|
44
|
-
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
require_command() {
|
|
48
|
-
if ! command -v "$1" >/dev/null 2>&1; then
|
|
49
|
-
echo "Missing required command: $1" >&2
|
|
50
|
-
exit 1
|
|
51
|
-
fi
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
send_telegram() {
|
|
55
|
-
local message="$1"
|
|
56
|
-
|
|
57
|
-
if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
|
|
58
|
-
log "Telegram not configured: $message"
|
|
59
|
-
return 0
|
|
60
|
-
fi
|
|
61
|
-
|
|
62
|
-
curl -sS -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
|
63
|
-
--data-urlencode "chat_id=${TELEGRAM_CHAT_ID}" \
|
|
64
|
-
--data-urlencode "text=${message}" >/dev/null || true
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
capture_pane() {
|
|
68
|
-
tmux capture-pane -pt "$CLAUDE_SESSION" -S "-${CAPTURE_LINES}" 2>/dev/null || true
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
hash_text() {
|
|
72
|
-
if command -v sha256sum >/dev/null 2>&1; then
|
|
73
|
-
sha256sum | awk '{print $1}'
|
|
74
|
-
else
|
|
75
|
-
shasum -a 256 | awk '{print $1}'
|
|
76
|
-
fi
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
parse_wait_seconds() {
|
|
80
|
-
local text="$1"
|
|
81
|
-
|
|
82
|
-
if command -v python3 >/dev/null 2>&1; then
|
|
83
|
-
TEXT="$text" FALLBACK="$LIMIT_FALLBACK_WAIT_SECONDS" python3 - <<'PY'
|
|
84
|
-
import os
|
|
85
|
-
import re
|
|
86
|
-
from datetime import datetime, timedelta
|
|
87
|
-
|
|
88
|
-
text = os.environ.get("TEXT", "")
|
|
89
|
-
fallback = int(os.environ.get("FALLBACK", "300"))
|
|
90
|
-
now = datetime.now()
|
|
91
|
-
lower = text.lower()
|
|
92
|
-
|
|
93
|
-
# Examples:
|
|
94
|
-
# try again in 12 minutes
|
|
95
|
-
# retry after 1 hour
|
|
96
|
-
m = re.search(r"(?:try again|retry|wait).*?in\s+(\d+)\s*(second|seconds|minute|minutes|hour|hours)", lower, re.I | re.S)
|
|
97
|
-
if m:
|
|
98
|
-
value = int(m.group(1))
|
|
99
|
-
unit = m.group(2)
|
|
100
|
-
if unit.startswith("second"):
|
|
101
|
-
print(max(10, value))
|
|
102
|
-
elif unit.startswith("minute"):
|
|
103
|
-
print(max(10, value * 60))
|
|
104
|
-
else:
|
|
105
|
-
print(max(10, value * 3600))
|
|
106
|
-
raise SystemExit
|
|
107
|
-
|
|
108
|
-
# Examples:
|
|
109
|
-
# resets 2am
|
|
110
|
-
# resets at 14:30
|
|
111
|
-
# limit resets at 3:05 pm
|
|
112
|
-
m = re.search(r"resets?\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?", lower, re.I)
|
|
113
|
-
if m:
|
|
114
|
-
hour = int(m.group(1))
|
|
115
|
-
minute = int(m.group(2) or 0)
|
|
116
|
-
ampm = m.group(3)
|
|
117
|
-
|
|
118
|
-
if ampm:
|
|
119
|
-
if ampm == "pm" and hour != 12:
|
|
120
|
-
hour += 12
|
|
121
|
-
if ampm == "am" and hour == 12:
|
|
122
|
-
hour = 0
|
|
123
|
-
|
|
124
|
-
if 0 <= hour <= 23 and 0 <= minute <= 59:
|
|
125
|
-
reset = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
|
126
|
-
if reset <= now:
|
|
127
|
-
reset += timedelta(days=1)
|
|
128
|
-
# Add small safety buffer so we do not resume one second too early.
|
|
129
|
-
print(max(10, int((reset - now).total_seconds()) + 30))
|
|
130
|
-
raise SystemExit
|
|
131
|
-
|
|
132
|
-
print(fallback)
|
|
133
|
-
PY
|
|
134
|
-
return 0
|
|
135
|
-
fi
|
|
136
|
-
|
|
137
|
-
echo "$LIMIT_FALLBACK_WAIT_SECONDS"
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
ensure_tmux_session() {
|
|
141
|
-
if tmux has-session -t "$CLAUDE_SESSION" 2>/dev/null; then
|
|
142
|
-
return 0
|
|
143
|
-
fi
|
|
144
|
-
|
|
145
|
-
if [ "$START_IF_MISSING" != "1" ]; then
|
|
146
|
-
echo "tmux session '$CLAUDE_SESSION' not found." >&2
|
|
147
|
-
exit 1
|
|
148
|
-
fi
|
|
149
|
-
|
|
150
|
-
tmux new-session -d -s "$CLAUDE_SESSION" -c "$CLAUDE_WORKDIR" "$CLAUDE_COMMAND"
|
|
151
|
-
log "Started tmux session '$CLAUDE_SESSION' in $CLAUDE_WORKDIR"
|
|
152
|
-
send_telegram "Claude Pilot: started tmux session '$CLAUDE_SESSION'."
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
within_resume_cooldown() {
|
|
156
|
-
local now
|
|
157
|
-
now=$(date +%s)
|
|
158
|
-
[ $((now - last_resume_epoch)) -lt "$POST_RESUME_COOLDOWN_SECONDS" ]
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
handle_limit() {
|
|
162
|
-
local text="$1"
|
|
163
|
-
local fingerprint
|
|
164
|
-
fingerprint=$(printf '%s' "$text" | tail -n 40 | hash_text)
|
|
165
|
-
|
|
166
|
-
if [ "$fingerprint" = "$last_limit_hash" ]; then
|
|
167
|
-
return 0
|
|
168
|
-
fi
|
|
169
|
-
|
|
170
|
-
if within_resume_cooldown; then
|
|
171
|
-
return 0
|
|
172
|
-
fi
|
|
173
|
-
|
|
174
|
-
last_limit_hash="$fingerprint"
|
|
175
|
-
|
|
176
|
-
local wait_seconds
|
|
177
|
-
wait_seconds=$(parse_wait_seconds "$text")
|
|
178
|
-
|
|
179
|
-
log "Limit detected. Waiting ${wait_seconds}s before resume."
|
|
180
|
-
send_telegram "Claude Pilot: usage/rate limit detected. Waiting ${wait_seconds}s before sending '${RESUME_COMMAND}'."
|
|
181
|
-
|
|
182
|
-
sleep "$wait_seconds"
|
|
183
|
-
|
|
184
|
-
tmux send-keys -t "$CLAUDE_SESSION" "$RESUME_COMMAND" Enter
|
|
185
|
-
last_resume_epoch=$(date +%s)
|
|
186
|
-
|
|
187
|
-
log "Resume command sent."
|
|
188
|
-
send_telegram "Claude Pilot: resume command sent to tmux session '$CLAUDE_SESSION'."
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
main() {
|
|
192
|
-
require_command tmux
|
|
193
|
-
require_command curl
|
|
194
|
-
|
|
195
|
-
ensure_tmux_session
|
|
196
|
-
|
|
197
|
-
log "Watching tmux session '$CLAUDE_SESSION'. Attach with: tmux attach -t $CLAUDE_SESSION"
|
|
198
|
-
send_telegram "Claude Pilot: watching tmux session '$CLAUDE_SESSION'."
|
|
199
|
-
|
|
200
|
-
while true; do
|
|
201
|
-
if ! tmux has-session -t "$CLAUDE_SESSION" 2>/dev/null; then
|
|
202
|
-
log "tmux session '$CLAUDE_SESSION' ended."
|
|
203
|
-
send_telegram "Claude Pilot: tmux session '$CLAUDE_SESSION' ended."
|
|
204
|
-
exit 0
|
|
205
|
-
fi
|
|
206
|
-
|
|
207
|
-
text=$(capture_pane)
|
|
208
|
-
|
|
209
|
-
if printf '%s' "$text" | grep -Eiq "$LIMIT_REGEX"; then
|
|
210
|
-
handle_limit "$text"
|
|
211
|
-
fi
|
|
212
|
-
|
|
213
|
-
if printf '%s' "$text" | grep -Eiq "$PERMISSION_REGEX"; then
|
|
214
|
-
# Notification only. Do not auto-approve permissions.
|
|
215
|
-
send_telegram "Claude Pilot: Claude may need permission/input in session '$CLAUDE_SESSION'."
|
|
216
|
-
sleep 60
|
|
217
|
-
fi
|
|
218
|
-
|
|
219
|
-
sleep "$CHECK_INTERVAL_SECONDS"
|
|
220
|
-
done
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
main "$@"
|