claude-code-remote-pilot 0.1.4 → 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 CHANGED
@@ -1,388 +1,184 @@
1
1
  # Claude Code Remote Pilot
2
2
 
3
- Interactive Claude Code supervisor using `tmux`, Node.js, and notifications.
3
+ Spawn and supervise multiple Claude Code sessions from a single interactive terminal.
4
4
 
5
- The current MVP keeps Claude Code usable in the normal terminal while adding a small supervisor layer that can detect usage limits, notify you, wait, and send `continue` automatically.
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
- Longer term, this project is moving toward a local multi-session Claude Code dashboard.
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
- ## Current Status
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
- Future mode:
35
-
36
- ```text
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
- **Fedora / RHEL / CentOS:**
124
-
125
- ```bash
126
- sudo dnf install tmux curl python3
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
- **Arch Linux:**
36
+ From any other terminal, interact with a session directly:
130
37
 
131
38
  ```bash
132
- sudo pacman -S tmux curl python3
39
+ tmux attach -t api-refactor
40
+ # Ctrl+B then D to detach back to your terminal
133
41
  ```
134
42
 
135
- **Windows (WSL):**
43
+ ---
136
44
 
137
- Run inside your WSL terminal (Ubuntu recommended):
45
+ ## Install
138
46
 
139
47
  ```bash
140
- sudo apt update && sudo apt install tmux curl python3
48
+ npx claude-code-remote-pilot
141
49
  ```
142
50
 
143
- Verify tmux is working:
51
+ Or install globally:
144
52
 
145
53
  ```bash
146
- tmux -V
54
+ npm install -g claude-code-remote-pilot
55
+ claude-remote-pilot
147
56
  ```
148
57
 
149
58
  ---
150
59
 
151
- ## Install
60
+ ## Requirements
152
61
 
153
- ### From GitHub
62
+ - Node.js >= 18
63
+ - tmux
64
+ - Claude Code CLI (`npm install -g @anthropic-ai/claude-code`)
154
65
 
155
- ```bash
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
- Run locally:
68
+ ### Installing tmux manually
162
69
 
70
+ **macOS:**
163
71
  ```bash
164
- yarn pilot
72
+ brew install tmux
165
73
  ```
166
74
 
167
- or:
168
-
75
+ **Ubuntu / Debian:**
169
76
  ```bash
170
- node bin/claude-pilot.js
77
+ sudo apt update && sudo apt install tmux
171
78
  ```
172
79
 
173
- ### As npm package
174
-
80
+ **Fedora / RHEL:**
175
81
  ```bash
176
- npm install -g claude-code-remote-pilot
177
- claude-remote-pilot
82
+ sudo dnf install tmux
178
83
  ```
179
84
 
180
- or:
181
-
85
+ **Arch:**
182
86
  ```bash
183
- npx claude-code-remote-pilot
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
- curl "https://api.telegram.org/bot<BOT_TOKEN>/getUpdates"
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
- Run:
207
-
208
- ```bash
209
- export TELEGRAM_BOT_TOKEN="xxx"
210
- export TELEGRAM_CHAT_ID="123456789"
95
+ ---
211
96
 
212
- yarn pilot
213
- ```
97
+ ## Commands
214
98
 
215
- Telegram is optional. If not configured, messages are printed locally.
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
- ## Basic Usage
111
+ ## Telegram setup
220
112
 
221
- Start pilot:
113
+ Create a bot via `@BotFather` and get your token.
222
114
 
223
- ```bash
224
- yarn pilot
225
- ```
226
-
227
- Attach to Claude:
115
+ Get your chat ID:
228
116
 
229
117
  ```bash
230
- tmux attach -t claude
118
+ curl "https://api.telegram.org/bot<TOKEN>/getUpdates"
231
119
  ```
232
120
 
233
- Detach without killing Claude:
121
+ Run the pilot — it will ask for these values interactively, or set them as environment variables:
234
122
 
235
- ```text
236
- Ctrl+B then D
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 Variables
131
+ ## Environment variables
244
132
 
245
133
  | Variable | Default | Description |
246
- |---|---:|---|
247
- | `TELEGRAM_BOT_TOKEN` | empty | Telegram bot token |
248
- | `TELEGRAM_CHAT_ID` | empty | Telegram chat ID |
249
- | `CLAUDE_SESSION` | `claude` | tmux session name |
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 Workflow
141
+ ## Recommended Claude workflow
273
142
 
274
- For long-running work, ask Claude to maintain external state:
143
+ For long-running tasks, ask Claude to keep external state:
275
144
 
276
- ```text
145
+ ```
277
146
  Maintain TASK_STATE.md.
278
- After every meaningful step:
279
- - update completed work
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
- This matters because LLM context can drift over long sessions. External state is the real resume brain.
151
+ Context can drift over long sessions. External state is the real resume brain.
289
152
 
290
153
  ---
291
154
 
292
- ## Safety Notes
155
+ ## Safety
293
156
 
294
- Avoid starting with:
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
- ```bash
297
- claude --dangerously-skip-permissions
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
- See:
323
-
324
- - [`docs/ARCHITECTURE_UPDATE.md`](docs/ARCHITECTURE_UPDATE.md)
325
- - [`TASKS.md`](TASKS.md)
326
-
327
- High-level phases:
328
-
329
- 1. tmux watcher MVP
330
- 2. Node.js runtime refactor
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 Pilot is intended to become:
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. Spaceship later.
184
+ Small tools first. Dashboard later.
@@ -1,31 +1,24 @@
1
1
  #!/usr/bin/env node
2
+ 'use strict';
2
3
 
3
- const { spawn, execSync } = require('child_process');
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 checkDep(cmd) {
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
- const platform = process.platform;
23
- if (platform === 'darwin') return 'macos';
24
- if (platform === 'win32') return 'windows';
17
+ if (process.platform === 'darwin') return 'macos';
25
18
  try {
26
- const release = fs.readFileSync('/etc/os-release', 'utf8');
27
- if (/ID=arch/i.test(release)) return 'arch';
28
- if (/ID=(fedora|rhel|centos)/i.test(release)) return 'fedora';
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,107 +31,240 @@ function tmuxInstallCmd() {
38
31
  return 'sudo apt-get install -y tmux';
39
32
  }
40
33
 
41
- function prompt(question) {
42
- return new Promise((resolve) => {
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 prompt(`Install it now? (y/n) `);
37
+ const answer = await question(rl, 'Install it now? (y/n) ');
52
38
  if (answer !== 'y' && answer !== 'yes') {
53
- console.log(`Skipping. Run manually: ${installCmd}`);
39
+ console.log(`Run manually: ${installCmd}`);
54
40
  process.exit(1);
55
41
  }
56
42
  console.log(`Running: ${installCmd}\n`);
57
- try {
58
- execSync(installCmd, { stdio: 'inherit' });
59
- console.log(`\n${label} installed successfully.\n`);
60
- } catch {
61
- console.error(`\nInstall failed. Try running manually: ${installCmd}`);
62
- process.exit(1);
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
- async function setupTelegram() {
67
- if (process.env.TELEGRAM_BOT_TOKEN && process.env.TELEGRAM_CHAT_ID) return;
68
- console.log('\nTelegram notifications are not configured (optional).');
69
- const answer = await prompt('Set up Telegram now? (y/n) ');
70
- if (answer !== 'y' && answer !== 'yes') {
71
- console.log('Skipping. You can set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID later.\n');
72
- return;
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
- const token = await prompt('Enter your Telegram bot token: ');
75
- const chatId = await prompt('Enter your Telegram chat ID: ');
76
- if (token) process.env.TELEGRAM_BOT_TOKEN = token.trim();
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 };
91
+ }
92
+
93
+ function trunc(str, len) {
94
+ return str.length <= len ? str.padEnd(len) : str.slice(0, len - 1) + '…';
79
95
  }
80
96
 
81
- function printGuide() {
82
- const session = process.env.CLAUDE_SESSION || 'claude';
83
- console.log(`
84
- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
85
- Claude Code Remote Pilot Ready
86
- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
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 ───────────────────────────────────────────────────────────────
87
111
 
88
- The watcher is starting in the background.
112
+ function startWatch(manager, rl) {
113
+ rl.close();
114
+ if (process.stdin.isTTY) process.stdin.setRawMode(true);
115
+ process.stdin.resume();
89
116
 
90
- To talk to Claude:
91
- tmux attach -t ${session}
117
+ function draw() {
118
+ process.stdout.write('\x1B[2J\x1B[0f');
119
+ process.stdout.write(renderTable(manager.list()));
120
+ }
92
121
 
93
- To detach without stopping Claude:
94
- Press Ctrl+B then D
122
+ draw();
123
+ const interval = setInterval(draw, 2000);
95
124
 
96
- The pilot will:
97
- detect usage/rate limits
98
- wait for the reset
99
- • send "continue" automatically
100
- • notify you via Telegram (if configured)
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
+ }
101
135
 
102
- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
103
- `);
136
+ process.stdin.on('data', onData);
104
137
  }
105
138
 
106
- async function main() {
107
- await ensureDep('tmux', 'tmux', tmuxInstallCmd());
108
- await ensureDep('claude', 'Claude Code CLI', 'npm install -g @anthropic-ai/claude-code');
109
- await setupTelegram();
110
- printGuide();
111
-
112
- const child = spawn('bash', [scriptPath], {
113
- stdio: 'inherit',
114
- env: process.env,
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> ',
115
156
  });
116
157
 
117
- child.on('exit', (code) => {
118
- process.exit(code || 0);
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();
222
+ });
223
+
224
+ rl.on('close', () => {
225
+ console.log('\n Sessions keep running. Use tmux to attach.\n');
226
+ process.exit(0);
119
227
  });
120
228
  }
121
229
 
122
- if (process.argv.includes('--help') || process.argv.includes('-h')) {
123
- console.log(`
230
+ // ─── main ─────────────────────────────────────────────────────────────────────
231
+
232
+ async function main() {
233
+ if (process.argv.includes('--help') || process.argv.includes('-h')) {
234
+ console.log(`
124
235
  Claude Code Remote Pilot
125
236
 
126
237
  Usage:
127
238
  claude-remote-pilot
128
239
 
129
- Environment variables:
130
- TELEGRAM_BOT_TOKEN
131
- TELEGRAM_CHAT_ID
132
- CLAUDE_SESSION
133
- CLAUDE_COMMAND
240
+ Interactive commands:
241
+ ${HELP}`);
242
+ process.exit(0);
243
+ }
244
+
245
+ const setupRl = readline.createInterface({ input: process.stdin, output: process.stdout });
246
+
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);
250
+
251
+ const manager = new SessionManager({ telegram });
252
+
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] `);
256
+
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
+ }
134
263
 
135
- Example:
136
- TELEGRAM_BOT_TOKEN=xxx TELEGRAM_CHAT_ID=123456 claude-remote-pilot
264
+ setupRl.close();
137
265
 
138
- Attach to tmux:
139
- tmux attach -t claude
140
- `);
141
- process.exit(0);
266
+ console.log(' Type help for commands.\n');
267
+ startREPL(manager);
142
268
  }
143
269
 
144
- 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;
@@ -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.1.4",
4
- "description": "Interactive Claude Code supervisor with tmux, Telegram notifications, and auto-resume after usage limits.",
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
- "claude-pilot.sh",
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,222 +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
- CHECK_INTERVAL_SECONDS="${CHECK_INTERVAL_SECONDS:-30}"
27
- LIMIT_FALLBACK_WAIT_SECONDS="${LIMIT_FALLBACK_WAIT_SECONDS:-300}"
28
- POST_RESUME_COOLDOWN_SECONDS="${POST_RESUME_COOLDOWN_SECONDS:-180}"
29
- CAPTURE_LINES="${CAPTURE_LINES:-500}"
30
- RESUME_COMMAND="${RESUME_COMMAND:-continue}"
31
- START_IF_MISSING="${START_IF_MISSING:-1}"
32
-
33
- TELEGRAM_BOT_TOKEN="${TELEGRAM_BOT_TOKEN:-}"
34
- TELEGRAM_CHAT_ID="${TELEGRAM_CHAT_ID:-}"
35
-
36
- LIMIT_REGEX="${LIMIT_REGEX:-hit your limit|usage limit|rate limit|limit reached|try again|resets}"
37
- PERMISSION_REGEX="${PERMISSION_REGEX:-permission|do you want to proceed|approve}"
38
-
39
- last_limit_hash=""
40
- last_resume_epoch=0
41
-
42
- log() {
43
- printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"
44
- }
45
-
46
- require_command() {
47
- if ! command -v "$1" >/dev/null 2>&1; then
48
- echo "Missing required command: $1" >&2
49
- exit 1
50
- fi
51
- }
52
-
53
- send_telegram() {
54
- local message="$1"
55
-
56
- if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then
57
- log "Telegram not configured: $message"
58
- return 0
59
- fi
60
-
61
- curl -sS -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
62
- --data-urlencode "chat_id=${TELEGRAM_CHAT_ID}" \
63
- --data-urlencode "text=${message}" >/dev/null || true
64
- }
65
-
66
- capture_pane() {
67
- tmux capture-pane -pt "$CLAUDE_SESSION" -S "-${CAPTURE_LINES}" 2>/dev/null || true
68
- }
69
-
70
- hash_text() {
71
- if command -v sha256sum >/dev/null 2>&1; then
72
- sha256sum | awk '{print $1}'
73
- else
74
- shasum -a 256 | awk '{print $1}'
75
- fi
76
- }
77
-
78
- parse_wait_seconds() {
79
- local text="$1"
80
-
81
- if command -v python3 >/dev/null 2>&1; then
82
- TEXT="$text" FALLBACK="$LIMIT_FALLBACK_WAIT_SECONDS" python3 - <<'PY'
83
- import os
84
- import re
85
- from datetime import datetime, timedelta
86
-
87
- text = os.environ.get("TEXT", "")
88
- fallback = int(os.environ.get("FALLBACK", "300"))
89
- now = datetime.now()
90
- lower = text.lower()
91
-
92
- # Examples:
93
- # try again in 12 minutes
94
- # retry after 1 hour
95
- m = re.search(r"(?:try again|retry|wait).*?in\s+(\d+)\s*(second|seconds|minute|minutes|hour|hours)", lower, re.I | re.S)
96
- if m:
97
- value = int(m.group(1))
98
- unit = m.group(2)
99
- if unit.startswith("second"):
100
- print(max(10, value))
101
- elif unit.startswith("minute"):
102
- print(max(10, value * 60))
103
- else:
104
- print(max(10, value * 3600))
105
- raise SystemExit
106
-
107
- # Examples:
108
- # resets 2am
109
- # resets at 14:30
110
- # limit resets at 3:05 pm
111
- m = re.search(r"resets?\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?", lower, re.I)
112
- if m:
113
- hour = int(m.group(1))
114
- minute = int(m.group(2) or 0)
115
- ampm = m.group(3)
116
-
117
- if ampm:
118
- if ampm == "pm" and hour != 12:
119
- hour += 12
120
- if ampm == "am" and hour == 12:
121
- hour = 0
122
-
123
- if 0 <= hour <= 23 and 0 <= minute <= 59:
124
- reset = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
125
- if reset <= now:
126
- reset += timedelta(days=1)
127
- # Add small safety buffer so we do not resume one second too early.
128
- print(max(10, int((reset - now).total_seconds()) + 30))
129
- raise SystemExit
130
-
131
- print(fallback)
132
- PY
133
- return 0
134
- fi
135
-
136
- echo "$LIMIT_FALLBACK_WAIT_SECONDS"
137
- }
138
-
139
- ensure_tmux_session() {
140
- if tmux has-session -t "$CLAUDE_SESSION" 2>/dev/null; then
141
- return 0
142
- fi
143
-
144
- if [ "$START_IF_MISSING" != "1" ]; then
145
- echo "tmux session '$CLAUDE_SESSION' not found." >&2
146
- exit 1
147
- fi
148
-
149
- tmux new-session -d -s "$CLAUDE_SESSION" "$CLAUDE_COMMAND"
150
- log "Started tmux session '$CLAUDE_SESSION' with command: $CLAUDE_COMMAND"
151
- send_telegram "Claude Pilot: started tmux session '$CLAUDE_SESSION'."
152
- }
153
-
154
- within_resume_cooldown() {
155
- local now
156
- now=$(date +%s)
157
- [ $((now - last_resume_epoch)) -lt "$POST_RESUME_COOLDOWN_SECONDS" ]
158
- }
159
-
160
- handle_limit() {
161
- local text="$1"
162
- local fingerprint
163
- fingerprint=$(printf '%s' "$text" | tail -n 40 | hash_text)
164
-
165
- if [ "$fingerprint" = "$last_limit_hash" ]; then
166
- return 0
167
- fi
168
-
169
- if within_resume_cooldown; then
170
- return 0
171
- fi
172
-
173
- last_limit_hash="$fingerprint"
174
-
175
- local wait_seconds
176
- wait_seconds=$(parse_wait_seconds "$text")
177
-
178
- log "Limit detected. Waiting ${wait_seconds}s before resume."
179
- send_telegram "Claude Pilot: usage/rate limit detected. Waiting ${wait_seconds}s before sending '${RESUME_COMMAND}'."
180
-
181
- sleep "$wait_seconds"
182
-
183
- tmux send-keys -t "$CLAUDE_SESSION" "$RESUME_COMMAND" Enter
184
- last_resume_epoch=$(date +%s)
185
-
186
- log "Resume command sent."
187
- send_telegram "Claude Pilot: resume command sent to tmux session '$CLAUDE_SESSION'."
188
- }
189
-
190
- main() {
191
- require_command tmux
192
- require_command curl
193
-
194
- ensure_tmux_session
195
-
196
- log "Watching tmux session '$CLAUDE_SESSION'. Attach with: tmux attach -t $CLAUDE_SESSION"
197
- send_telegram "Claude Pilot: watching tmux session '$CLAUDE_SESSION'."
198
-
199
- while true; do
200
- if ! tmux has-session -t "$CLAUDE_SESSION" 2>/dev/null; then
201
- log "tmux session '$CLAUDE_SESSION' ended."
202
- send_telegram "Claude Pilot: tmux session '$CLAUDE_SESSION' ended."
203
- exit 0
204
- fi
205
-
206
- text=$(capture_pane)
207
-
208
- if printf '%s' "$text" | grep -Eiq "$LIMIT_REGEX"; then
209
- handle_limit "$text"
210
- fi
211
-
212
- if printf '%s' "$text" | grep -Eiq "$PERMISSION_REGEX"; then
213
- # Notification only. Do not auto-approve permissions.
214
- send_telegram "Claude Pilot: Claude may need permission/input in session '$CLAUDE_SESSION'."
215
- sleep 60
216
- fi
217
-
218
- sleep "$CHECK_INTERVAL_SECONDS"
219
- done
220
- }
221
-
222
- main "$@"