claude-code-remote-pilot 0.5.1 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.3 — 2026-05-06
4
+
5
+ ### Fixed
6
+ - **Auto-resume respects clock reset time**: limit recovery now waits until the parsed `resets at HH:MM` timestamp (with next-day rollover) before sending the resume command.
7
+ - **Web UI respawn completed**: offline session respawn now has loading/error feedback and immediately updates session detail state after success.
8
+
9
+ ---
10
+
11
+ ## 0.5.2 — 2026-05-06
12
+
13
+ ### Fixed
14
+ - **Auto-resume now waits for reset time**: when Claude shows an explicit `resets at HH:MM` clock time, `Watcher` now resumes at that exact reset timestamp (including next-day rollover) instead of relying only on relative wait parsing.
15
+ - **Web UI respawn flow completed**: offline session respawn now shows in-button loading, inline errors, and immediately updates the detail view to the newly active session after successful `POST /api/sessions/:name/respawn`.
16
+
17
+ ---
18
+
3
19
  ## 0.5.1 — 2026-05-06
4
20
 
5
21
  ### Fixed
package/README.md CHANGED
@@ -1,10 +1,65 @@
1
1
  # Claude Code Remote Pilot
2
2
 
3
- Spawn and supervise multiple Claude Code sessions from a single interactive terminal.
3
+ Keep Claude Code running while you're away from your desk.
4
4
 
5
- Run it once. Spawn Claude into as many project directories as you want. Walk away — it handles usage limits, waits for resets, sends `continue` automatically, and notifies you on Telegram. Come back to finished work.
5
+ A small self-hosted helper tool for people who run long Claude Code sessions and are tired of manually resuming after token limits.
6
6
 
7
- Each Claude session lives in its own named tmux session. You can `tmux attach -t <name>` from any terminal at any time, independently of the pilot.
7
+ Originally built just for personal use… then it slowly grew 😅
8
+
9
+ ![Dashboard screenshot](docs/screenshot-dashboard.png)
10
+
11
+ ---
12
+
13
+ ## Why?
14
+
15
+ I kept hitting the same problem:
16
+
17
+ - Claude Code stopped after hitting token limits
18
+ - long-running tasks needed babysitting
19
+ - sometimes I wanted to leave home while a task was still running
20
+ - checking progress remotely was annoying
21
+
22
+ At first I only wanted auto-resume. Then it slowly turned into a small remote workflow tool.
23
+
24
+ ---
25
+
26
+ ## What this is NOT
27
+
28
+ - a hosted platform
29
+ - an "AI agent framework"
30
+ - a replacement for Claude Code
31
+ - a polished enterprise product
32
+
33
+ It's just a practical helper tool for people running Claude Code for long periods.
34
+
35
+ ---
36
+
37
+ ## Features
38
+
39
+ - Auto-detect Claude Code limit states and resume automatically
40
+ - Persistent tmux-based sessions that outlive the pilot process
41
+ - Web UI for monitoring and control from phone or any browser — with full ANSI color terminal rendering
42
+ - Telegram notifications when sessions need attention
43
+ - Browser desktop notifications on status changes
44
+ - Broadcast a message to all active sessions at once
45
+ - Lightweight, self-hosted — just Node.js and tmux
46
+ - Experimental but surprisingly useful 👀
47
+
48
+ ---
49
+
50
+ ## Current Status
51
+
52
+ Very experimental. Built quickly to scratch a personal itch, so expect rough edges. If you try it and hit weird issues, feel free to open an issue or PR.
53
+
54
+ ---
55
+
56
+ ## Typical Workflow
57
+
58
+ 1. Start the pilot and spawn Claude Code sessions
59
+ 2. Leave it running — go touch grass
60
+ 3. Pilot detects limit hits and auto-resumes
61
+ 4. Get notified via Telegram or browser notification
62
+ 5. Check progress remotely from your phone
8
63
 
9
64
  ---
10
65
 
@@ -142,6 +197,91 @@ You can also bind to a specific interface IP: `web 3742 192.168.1.10`.
142
197
 
143
198
  ---
144
199
 
200
+ ## Remote access via Cloudflare Tunnel (recommended)
201
+
202
+ Binding to `0.0.0.0` exposes the dashboard on your local network but not the internet. For secure remote access from anywhere — phone, another machine, a coffee shop — use a [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/).
203
+
204
+ This is the recommended setup for remote work: the dashboard stays on `127.0.0.1` (never directly exposed), and Cloudflare handles TLS, authentication, and routing.
205
+
206
+ ### Quick start (no domain required)
207
+
208
+ Install `cloudflared`:
209
+
210
+ ```bash
211
+ # macOS
212
+ brew install cloudflare/cloudflare/cloudflared
213
+
214
+ # Linux
215
+ curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o cloudflared
216
+ chmod +x cloudflared && sudo mv cloudflared /usr/local/bin/
217
+ ```
218
+
219
+ Start the pilot's web dashboard, then in a second terminal run:
220
+
221
+ ```bash
222
+ cloudflared tunnel --url http://127.0.0.1:3742
223
+ ```
224
+
225
+ Cloudflare prints a random `https://*.trycloudflare.com` URL. Open it on any device. The tunnel closes when you stop `cloudflared`.
226
+
227
+ ### Persistent tunnel with a custom domain
228
+
229
+ If you have a domain on Cloudflare, you can get a stable URL and add Cloudflare Access (zero-trust auth) in front of the dashboard.
230
+
231
+ **1. Authenticate and create a tunnel:**
232
+
233
+ ```bash
234
+ cloudflared tunnel login
235
+ cloudflared tunnel create claude-pilot
236
+ ```
237
+
238
+ **2. Create `~/.cloudflared/config.yml`:**
239
+
240
+ ```yaml
241
+ tunnel: <your-tunnel-id>
242
+ credentials-file: /home/<user>/.cloudflared/<tunnel-id>.json
243
+
244
+ ingress:
245
+ - hostname: pilot.yourdomain.com
246
+ service: http://127.0.0.1:3742
247
+ - service: http_status:404
248
+ ```
249
+
250
+ **3. Add a DNS record:**
251
+
252
+ ```bash
253
+ cloudflared tunnel route dns claude-pilot pilot.yourdomain.com
254
+ ```
255
+
256
+ **4. Start the tunnel:**
257
+
258
+ ```bash
259
+ cloudflared tunnel run claude-pilot
260
+ ```
261
+
262
+ The dashboard is now reachable at `https://pilot.yourdomain.com`.
263
+
264
+ ### Adding authentication (Cloudflare Access)
265
+
266
+ Cloudflare Access puts a login wall in front of the tunnel — no inbound ports, no VPN.
267
+
268
+ 1. Go to **Cloudflare Zero Trust → Access → Applications → Add an application**
269
+ 2. Choose **Self-hosted**, set the domain to `pilot.yourdomain.com`
270
+ 3. Add a policy: allow your email address (or Google/GitHub OAuth)
271
+
272
+ After this, anyone reaching `pilot.yourdomain.com` must authenticate with Cloudflare before the dashboard loads.
273
+
274
+ ### Run the tunnel as a background service
275
+
276
+ ```bash
277
+ # Install as a system service (runs on boot)
278
+ sudo cloudflared service install
279
+ sudo systemctl start cloudflared # Linux
280
+ sudo launchctl start cloudflared # macOS
281
+ ```
282
+
283
+ ---
284
+
145
285
  ## Telegram setup
146
286
 
147
287
  Create a bot via `@BotFather` and get your token.
@@ -211,12 +351,19 @@ Start Claude without `--dangerously-skip-permissions` unless you know what you'r
211
351
  - [x] broadcast message to all sessions
212
352
  - [x] auto-discover untracked tmux sessions on startup
213
353
  - [ ] auto-yes rules — confirm prompts automatically by pattern
354
+ - [ ] smarter retry logic
355
+ - [ ] usage statistics and session timeline
356
+ - [ ] remote command queue
214
357
  - [ ] pluggable notification providers
215
358
 
216
359
  ---
217
360
 
218
- ## Philosophy
361
+ ## Contributing
362
+
363
+ PRs, ideas, and weird experiments are welcome 😄
364
+
365
+ ---
219
366
 
220
- A human-supervised local runtime for Claude Code. Not a fully autonomous agent loop.
367
+ ## License
221
368
 
222
- Small tools first. Dashboard later.
369
+ MIT
@@ -26,7 +26,7 @@ class SessionManager {
26
26
  });
27
27
  }
28
28
 
29
- spawn(dirPath, name) {
29
+ spawn(dirPath, name, command = 'claude') {
30
30
  const resolved = path.resolve(dirPath.replace(/^~/, process.env.HOME || ''));
31
31
  if (!fs.existsSync(resolved)) throw new Error(`Path not found: ${resolved}`);
32
32
 
@@ -39,16 +39,22 @@ class SessionManager {
39
39
  execSync(`tmux kill-session -t "${sessionName}"`, { stdio: 'ignore' });
40
40
  } catch {}
41
41
 
42
- execSync(`tmux new-session -d -s "${sessionName}" -c "${resolved}" "claude"`, { stdio: 'ignore' });
42
+ execSync(`tmux new-session -d -s "${sessionName}" -c "${resolved}" "${command}"`, { stdio: 'ignore' });
43
43
 
44
- const session = { name: sessionName, path: resolved, status: 'running', startedAt: new Date(), resumeAt: null };
44
+ const session = { name: sessionName, path: resolved, command, status: 'running', startedAt: new Date(), resumeAt: null };
45
45
  const watcher = this._makeWatcher(session);
46
46
  watcher.start();
47
47
  this.sessions.set(sessionName, { session, watcher });
48
- config.addToHistory(sessionName, resolved);
48
+ config.addToHistory(sessionName, resolved, command);
49
49
  return session;
50
50
  }
51
51
 
52
+ respawn(name) {
53
+ const h = config.getHistory().find(e => e.name === name);
54
+ if (!h) throw new Error(`No history for session "${name}"`);
55
+ return this.spawn(h.path, name, h.command || 'claude');
56
+ }
57
+
52
58
  adopt(name, dirPath) {
53
59
  try { execSync(`tmux has-session -t "${name}"`, { stdio: 'ignore' }); }
54
60
  catch { throw new Error(`tmux session "${name}" not found.`); }
package/lib/Watcher.js CHANGED
@@ -80,6 +80,35 @@ class Watcher {
80
80
  return null;
81
81
  }
82
82
 
83
+ _parseResetAtMs(text) {
84
+ const atMatch = text.match(RESET_AT_RE);
85
+ if (atMatch) {
86
+ const raw = atMatch[1].trim().toLowerCase();
87
+ const m = raw.match(/^(\d{1,2}):(\d{2})\s*(am|pm)?$/);
88
+ if (m) {
89
+ let hour = parseInt(m[1], 10);
90
+ const minute = parseInt(m[2], 10);
91
+ const suffix = m[3];
92
+ if (suffix === 'pm' && hour !== 12) hour += 12;
93
+ if (suffix === 'am' && hour === 12) hour = 0;
94
+
95
+ const now = new Date();
96
+ const resetAt = new Date(now);
97
+ resetAt.setHours(hour, minute, 0, 0);
98
+ if (resetAt.getTime() <= now.getTime()) {
99
+ resetAt.setDate(resetAt.getDate() + 1);
100
+ }
101
+ return resetAt.getTime();
102
+ }
103
+ }
104
+
105
+ const inMatch = text.match(/(?:try again|retry|wait).*?in\s+(\d+)\s*(second|minute|hour)/i);
106
+ if (!inMatch) return null;
107
+ const v = parseInt(inMatch[1], 10);
108
+ const mult = inMatch[2].startsWith('second') ? 1 : inMatch[2].startsWith('minute') ? 60 : 3600;
109
+ return Date.now() + (v * mult * 1000);
110
+ }
111
+
83
112
  async _check() {
84
113
  if (this._busy) return;
85
114
  this._busy = true;
@@ -137,16 +166,19 @@ class Watcher {
137
166
 
138
167
  this.lastHash = hash;
139
168
  const wait = this._parseWait(text);
169
+ const resetAtMs = this._parseResetAtMs(text);
140
170
  const resetTime = this._parseResetTime(text);
171
+ const effectiveResumeAtMs = resetAtMs || (Date.now() + wait * 1000);
172
+ const effectiveWaitSeconds = Math.max(1, Math.ceil((effectiveResumeAtMs - Date.now()) / 1000));
141
173
 
142
174
  this.session.status = 'limit';
143
- this.session.resumeAt = Date.now() + wait * 1000;
175
+ this.session.resumeAt = effectiveResumeAtMs;
144
176
  this.session.resetTime = resetTime;
145
177
 
146
178
  notifier.send(this.telegram.token, this.telegram.chatId,
147
- `Pilot: limit in "${this.session.name}". Resets ${resetTime || `in ${Math.ceil(wait / 60)}m`}.`);
179
+ `Pilot: limit in "${this.session.name}". Resets ${resetTime || `in ${Math.ceil(effectiveWaitSeconds / 60)}m`}.`);
148
180
 
149
- await new Promise(r => setTimeout(r, wait * 1000));
181
+ await new Promise(r => setTimeout(r, effectiveWaitSeconds * 1000));
150
182
 
151
183
  try { spawnSync('tmux', ['send-keys', '-t', this.session.name, this.resumeCommand, 'Enter'], { stdio: 'ignore' }); }
152
184
  catch {}
package/lib/WebServer.js CHANGED
@@ -93,9 +93,9 @@ class WebServer {
93
93
  if (req.method === 'POST' && pathname === '/api/sessions') {
94
94
  return this._readBody(req, (err, body) => {
95
95
  if (err) return this._json(res, 400, { error: err.message });
96
- const { name, path: dirPath, prompt: initialPrompt } = body;
96
+ const { name, path: dirPath, prompt: initialPrompt, command } = body;
97
97
  try {
98
- const session = this.manager.spawn(dirPath, name);
98
+ const session = this.manager.spawn(dirPath, name, command || 'claude');
99
99
  if (initialPrompt) {
100
100
  setTimeout(() => {
101
101
  spawnSync('tmux', ['send-keys', '-t', session.name, initialPrompt, 'Enter']);
@@ -108,6 +108,18 @@ class WebServer {
108
108
  });
109
109
  }
110
110
 
111
+ // POST /api/sessions/:name/respawn
112
+ const respawnMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/respawn$/);
113
+ if (req.method === 'POST' && respawnMatch) {
114
+ const name = decodeURIComponent(respawnMatch[1]);
115
+ try {
116
+ const session = this.manager.respawn(name);
117
+ return this._json(res, 200, { ...session, id: session.name });
118
+ } catch (e) {
119
+ return this._json(res, 400, { error: e.message });
120
+ }
121
+ }
122
+
111
123
  // GET /api/sessions/:name/output
112
124
  const outputMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/output$/);
113
125
  if (req.method === 'GET' && outputMatch) {
package/lib/config.js CHANGED
@@ -34,10 +34,10 @@ function saveResumeCommand(cmd) {
34
34
  save({ resumeCommand: cmd });
35
35
  }
36
36
 
37
- function addToHistory(name, path) {
37
+ function addToHistory(name, path, command = 'claude') {
38
38
  const cfg = load();
39
39
  const history = (cfg.sessionHistory || []).filter(s => s.name !== name);
40
- history.unshift({ name, path, lastSeen: new Date().toISOString() });
40
+ history.unshift({ name, path, command, lastSeen: new Date().toISOString() });
41
41
  save({ sessionHistory: history.slice(0, 30) }); // cap at 30
42
42
  }
43
43
 
package/lib/ui.html CHANGED
@@ -574,11 +574,13 @@ function DashboardScreen({ onNavigate, sessions, activity, serverStatus }) {
574
574
  }
575
575
 
576
576
  /* --- Session Detail --- */
577
- function SessionDetailScreen({ session, onBack, onKilled }) {
577
+ function SessionDetailScreen({ session, onBack, onKilled, onRespawned }) {
578
578
  const [output, setOutput] = useState('');
579
579
  const [msg, setMsg] = useState('');
580
580
  const [sending, setSending] = useState(false);
581
581
  const [killing, setKilling] = useState(false);
582
+ const [respawning, setRespawning] = useState(false);
583
+ const [respawnError, setRespawnError] = useState('');
582
584
  const [copyOk, setCopyOk] = useState(false);
583
585
  const terminalRef = useRef(null);
584
586
  const inputRef = useRef(null);
@@ -660,6 +662,25 @@ function SessionDetailScreen({ session, onBack, onKilled }) {
660
662
  }
661
663
  };
662
664
 
665
+ const handleRespawn = async () => {
666
+ if (respawning) return;
667
+ setRespawning(true);
668
+ setRespawnError('');
669
+ try {
670
+ const res = await apiFetch(`/api/sessions/${encodeURIComponent(session.name)}/respawn`, { method: 'POST' });
671
+ const data = await res.json();
672
+ if (!res.ok) {
673
+ setRespawnError(data.error || 'Failed to respawn');
674
+ return;
675
+ }
676
+ onRespawned(data);
677
+ } catch (e) {
678
+ if (e.message !== 'Unauthorized') setRespawnError('Network error');
679
+ } finally {
680
+ setRespawning(false);
681
+ }
682
+ };
683
+
663
684
  // Render terminal HTML (ANSI → HTML, with plain-text fallback)
664
685
  let _termHtml = '';
665
686
  if (output) {
@@ -687,6 +708,18 @@ function SessionDetailScreen({ session, onBack, onKilled }) {
687
708
  <div className="detail-meta" style={{ maxWidth: 480, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{session.path}</div>
688
709
  </div>
689
710
  <div className="detail-actions">
711
+ {isOffline && (
712
+ <>
713
+ <button className="btn btn-sm btn-primary" onClick={handleRespawn} disabled={respawning}>
714
+ {respawning ? 'Respawning…' : '↺ Respawn'}
715
+ </button>
716
+ {respawnError && (
717
+ <span style={{ color: 'var(--error)', fontSize: 12, alignSelf: 'center' }}>
718
+ {respawnError}
719
+ </span>
720
+ )}
721
+ </>
722
+ )}
690
723
  {!isOffline && (
691
724
  <button className="btn btn-sm" onClick={copyAttachCmd} title={`tmux attach -t ${session.name}`}>
692
725
  {copyOk ? '✓ Copied' : '⊞ New Terminal'}
@@ -752,6 +785,7 @@ function SessionDetailScreen({ session, onBack, onKilled }) {
752
785
  <div className="card">
753
786
  <h3>Session Info</h3>
754
787
  <div className="info-row"><span className="info-label">Status</span><StatusPill status={session.status} /></div>
788
+ <div className="info-row"><span className="info-label">Agent</span><span className="info-value" style={{ fontSize: 11 }}>{session.command || 'claude'}</span></div>
755
789
  <div className="info-row"><span className="info-label">Started</span><span className="info-value" style={{ fontSize: 11 }}>{relativeTime(session.startedAt)}</span></div>
756
790
  <div className="info-row"><span className="info-label">Tokens</span><span className="info-value" style={{ fontSize: 11 }}>{formatTokens(session.tokens)}</span></div>
757
791
  {session.status === 'limit' && session.resumeAt && (
@@ -770,18 +804,23 @@ function CreateSessionScreen({ onBack, onCreated }) {
770
804
  const [name, setName] = useState('');
771
805
  const [path, setPath] = useState('');
772
806
  const [prompt, setPrompt] = useState('');
807
+ const [command, setCommand] = useState('claude');
808
+ const [customCommand, setCustomCommand] = useState('');
773
809
  const [error, setError] = useState('');
774
810
  const [loading, setLoading] = useState(false);
775
811
 
812
+ const resolvedCommand = command === 'custom' ? customCommand.trim() : command;
813
+
776
814
  const handleCreate = async () => {
777
815
  if (!name.trim() || !path.trim()) { setError('Session name and working directory are required.'); return; }
816
+ if (command === 'custom' && !customCommand.trim()) { setError('Enter a custom command.'); return; }
778
817
  setError('');
779
818
  setLoading(true);
780
819
  try {
781
820
  const res = await apiFetch('/api/sessions', {
782
821
  method: 'POST',
783
822
  headers: { 'Content-Type': 'application/json' },
784
- body: JSON.stringify({ name: name.trim(), path: path.trim(), prompt: prompt.trim() || undefined }),
823
+ body: JSON.stringify({ name: name.trim(), path: path.trim(), prompt: prompt.trim() || undefined, command: resolvedCommand }),
785
824
  });
786
825
  const data = await res.json();
787
826
  if (!res.ok) { setError(data.error || 'Failed to create session.'); return; }
@@ -807,10 +846,24 @@ function CreateSessionScreen({ onBack, onCreated }) {
807
846
  <label className="form-label">Working directory</label>
808
847
  <input className="form-input" placeholder="~/projects/app" value={path} onChange={e => setPath(e.target.value)} />
809
848
  </div>
849
+ <div className="form-group">
850
+ <label className="form-label">Agent</label>
851
+ <select className="form-select" value={command} onChange={e => setCommand(e.target.value)}>
852
+ <option value="claude">claude (Claude Code)</option>
853
+ <option value="opencode">opencode</option>
854
+ <option value="custom">custom…</option>
855
+ </select>
856
+ {command === 'custom' && (
857
+ <input className="form-input" style={{ marginTop: 8 }} placeholder="e.g. aider" value={customCommand} onChange={e => setCustomCommand(e.target.value)} />
858
+ )}
859
+ {command === 'opencode' && (
860
+ <div className="form-hint">Status indicators (running/idle) are claude-only — opencode sessions will show as idle but are fully controllable.</div>
861
+ )}
862
+ </div>
810
863
  <div className="form-group">
811
864
  <label className="form-label">Initial prompt <span style={{ fontWeight: 400, color: 'var(--muted)' }}>(optional)</span></label>
812
- <textarea className="form-textarea" placeholder="What should Claude work on?" value={prompt} onChange={e => setPrompt(e.target.value)} rows={4} />
813
- <div className="form-hint">Sent to Claude 2 seconds after the session starts.</div>
865
+ <textarea className="form-textarea" placeholder="What should the agent work on?" value={prompt} onChange={e => setPrompt(e.target.value)} rows={4} />
866
+ <div className="form-hint">Sent 2 seconds after the session starts.</div>
814
867
  </div>
815
868
  {error && <div style={{ marginBottom: 16, padding: '10px 14px', background: 'var(--error-soft)', color: 'var(--error)', borderRadius: 'var(--radius-sm)', fontSize: 13 }}>{error}</div>}
816
869
  <div className="form-actions">
@@ -1010,7 +1063,13 @@ function App() {
1010
1063
  case 'dashboard': return <DashboardScreen onNavigate={navigate} sessions={sessions} activity={activity} serverStatus={serverStatus} />;
1011
1064
  case 'sessions': return <SessionsScreen sessions={sessions} onNavigate={navigate} />;
1012
1065
  case 'create': return <CreateSessionScreen onBack={() => navigate('dashboard')} onCreated={s => navigate('detail', s)} />;
1013
- case 'detail': return <SessionDetailScreen session={selectedSession} onBack={() => navigate('dashboard')} onKilled={() => navigate('sessions')} />;
1066
+ case 'detail': return <SessionDetailScreen session={selectedSession} onBack={() => navigate('dashboard')} onKilled={() => navigate('sessions')} onRespawned={(respawned) => {
1067
+ setSessions(prev => {
1068
+ const next = prev.filter(s => s.name !== respawned.name);
1069
+ return [{ ...respawned, id: respawned.name }, ...next];
1070
+ });
1071
+ setSelectedSession({ ...respawned, id: respawned.name });
1072
+ }} />;
1014
1073
  default: return <DashboardScreen onNavigate={navigate} sessions={sessions} activity={activity} serverStatus={serverStatus} />;
1015
1074
  }
1016
1075
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-remote-pilot",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "Interactive Claude Code supervisor — spawn and monitor multiple Claude sessions from a single terminal.",
5
5
  "type": "commonjs",
6
6
  "repository": {
@@ -38,4 +38,4 @@
38
38
  "engines": {
39
39
  "node": ">=18"
40
40
  }
41
- }
41
+ }