@tigorhutasuhut/claude-retry 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -29
- package/dist/cli.js +26 -16
- package/dist/monitor.d.ts +15 -7
- package/dist/monitor.js +46 -33
- package/dist/patterns.js +1 -0
- package/dist/zellij.d.ts +33 -0
- package/dist/zellij.js +86 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# claude-retry
|
|
2
2
|
|
|
3
|
-
Watches every
|
|
3
|
+
Watches every pane across **all** your [zellij](https://zellij.dev/) sessions. When a pane hits Anthropic's usage/session limit, it detects the rate-limit banner, waits until that pane's reset time, then clears the input and injects `continue` to resume automatically. One daemon covers every session at once — even detached ones.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -12,35 +12,31 @@ The package is scoped (`@tigorhutasuhut/claude-retry`); the installed command is
|
|
|
12
12
|
|
|
13
13
|
## Usage
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
1. In your main pane, run `claude` as normal.
|
|
18
|
-
2. Open a second pane (e.g. `Ctrl+p` then `d` to split down).
|
|
19
|
-
3. In the new pane, start the daemon:
|
|
15
|
+
Run it as a foreground daemon, ideally in its **own dedicated zellij session** (the daemon skips its own session, so this keeps it from scanning itself):
|
|
20
16
|
|
|
21
17
|
```bash
|
|
18
|
+
# in a session you keep around, e.g. "Claude Retry Monitor":
|
|
22
19
|
claude-retry start
|
|
23
20
|
```
|
|
24
21
|
|
|
25
|
-
`start`
|
|
22
|
+
`start` re-scans **every pass** (60s): it walks all live zellij sessions and every pane in them, dumps each pane's screen, and acts on the ones showing a rate-limit banner. This means:
|
|
23
|
+
|
|
24
|
+
- **One** daemon covers every session — projects, branches, all of them.
|
|
25
|
+
- It works on **detached** sessions (uses zellij's global `--session` flag, not attached clients).
|
|
26
|
+
- Open a brand-new Claude session anywhere → picked up on the next pass. **No restart needed.**
|
|
27
|
+
- Close a pane → dropped from the watch list silently.
|
|
28
|
+
- Each pane keeps its own independent rate-limit state.
|
|
26
29
|
|
|
27
|
-
|
|
28
|
-
- Open a new Claude session in a new pane → it's picked up automatically on the next pass. **No restart needed.**
|
|
29
|
-
- Close a Claude pane → it's dropped from the watch list silently.
|
|
30
|
-
- Each pane gets its own independent rate-limit state.
|
|
30
|
+
Leave it running — the pane *is* the daemon. zellij keeps it alive across detach/attach, so you don't need systemd or any external supervisor. Chatty logs stream to stderr so you always see signs of life.
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
> **Start Claude the right way for detection.** Launch the session with the plain `claude` command, then run the `/remote-control` slash command *inside* it. Do **not** use the `claude remote-control` CLI subcommand directly — that mode silences the on-screen text, so `dump-screen` captures nothing and the rate-limit banner can't be detected. Running `claude` → `/remote-control` keeps the session "live" and visible to the monitor.
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
### Single-pane mode (legacy)
|
|
35
35
|
|
|
36
|
-
To
|
|
36
|
+
To watch just one pane in the **current** session, by ID:
|
|
37
37
|
|
|
38
38
|
```bash
|
|
39
|
-
# Watch one specific pane by ID:
|
|
40
39
|
claude-retry monitor 3
|
|
41
|
-
|
|
42
|
-
# Or restrict 'start' to one pane via env:
|
|
43
|
-
CLAUDE_PANE_ID=3 claude-retry start
|
|
44
40
|
```
|
|
45
41
|
|
|
46
42
|
### Optional: shell wrapper
|
|
@@ -57,22 +53,16 @@ source (npm root -g)/claude-retry/shell/wrapper.fish
|
|
|
57
53
|
source "$(npm root -g)/claude-retry/shell/wrapper.bash"
|
|
58
54
|
```
|
|
59
55
|
|
|
60
|
-
## Configuration
|
|
61
|
-
|
|
62
|
-
```bash
|
|
63
|
-
CLAUDE_PANE_ID=3 claude-retry start # pin to one pane, skip auto-discovery
|
|
64
|
-
```
|
|
65
|
-
|
|
66
56
|
## How it works
|
|
67
57
|
|
|
68
58
|
Every pass (60s for `start`, 5s for single-pane `monitor`):
|
|
69
59
|
|
|
70
|
-
1. **Discover** — `
|
|
71
|
-
2. **Capture** —
|
|
72
|
-
3. **Match** —
|
|
73
|
-
4. **Retry** — on detection,
|
|
60
|
+
1. **Discover** — `zellij list-sessions` enumerates live sessions (EXITED ones and the daemon's own `$ZELLIJ_SESSION_NAME` are skipped). For each, `zellij --session <name> action list-panes -j` lists its panes; plugins and exited panes are dropped. New panes are added, gone ones pruned.
|
|
61
|
+
2. **Capture** — each pane's visible screen is dumped with `zellij --session <name> action dump-screen --pane-id <id>` (ANSI stripped). This works on detached sessions, no attached client required.
|
|
62
|
+
3. **Match** — the text is checked against the rate-limit patterns. Panes that aren't showing a limit banner are simply left alone — no pane-identification guesswork needed.
|
|
63
|
+
4. **Retry** — on detection, the reset time is parsed and the pane is marked `waiting`. Once the reset elapses, the daemon sends **Ctrl+C** (clears any half-typed input — a single Ctrl+C in Claude Code doesn't quit), then types `continue` and Enter via `write-chars` / `write`.
|
|
74
64
|
|
|
75
|
-
Per-pane state persists across passes, so a pane mid-wait isn't disturbed by rediscovery. It runs as a plain foreground process
|
|
65
|
+
Per-pane state (keyed by `session:paneId`) persists across passes, so a pane mid-wait isn't disturbed by rediscovery. It runs as a plain foreground process — no transparent session wrapping, no external daemon. The zellij pane is the daemon.
|
|
76
66
|
|
|
77
67
|
## Requirements
|
|
78
68
|
|
package/dist/cli.js
CHANGED
|
@@ -1,31 +1,41 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { capturePane, inject,
|
|
2
|
+
import { capturePane, inject, listPaneTargets, captureTarget, injectTarget, } from "./zellij.js";
|
|
3
3
|
import { runMonitor, runMultiMonitor } from "./monitor.js";
|
|
4
4
|
const USAGE = `claude-retry — Auto-inject 'continue' when Claude hits a rate limit in zellij
|
|
5
5
|
|
|
6
6
|
Usage:
|
|
7
|
-
claude-retry start Watch ALL Claude panes
|
|
8
|
-
claude-retry monitor <pane-id> Watch one
|
|
7
|
+
claude-retry start Watch ALL Claude panes across ALL sessions
|
|
8
|
+
claude-retry monitor <pane-id> Watch one pane by ID in the current session
|
|
9
9
|
claude-retry help Show this help
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
Run as a foreground daemon in any zellij pane (a dedicated session is ideal).
|
|
12
|
+
'start' polls every 60s, walks every live zellij session and every pane, and
|
|
13
|
+
auto-injects 'continue' after each Claude pane's rate-limit reset time. It works
|
|
14
|
+
on detached sessions and skips its own session. New Claude panes are picked up
|
|
15
|
+
automatically; closed ones are dropped. Logs go to stderr.
|
|
13
16
|
|
|
14
|
-
|
|
15
|
-
60s, finds every pane running the 'claude' CLI, and injects 'continue' after
|
|
16
|
-
each one's rate-limit reset time. New Claude sessions are picked up
|
|
17
|
-
automatically; closed panes are dropped. Logs go to stderr.`;
|
|
17
|
+
'monitor <pane-id>' is the legacy single-pane mode (current session only).`;
|
|
18
18
|
/** Timestamped stderr logger — chatty so the daemon shows clear signs of life. */
|
|
19
19
|
function log(msg) {
|
|
20
20
|
const ts = new Date().toISOString().slice(11, 19); // HH:MM:SS
|
|
21
21
|
process.stderr.write(`[${ts}] ${msg}\n`);
|
|
22
22
|
}
|
|
23
|
-
const
|
|
23
|
+
const now = () => Date.now();
|
|
24
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
25
|
+
// Single-pane (legacy 'monitor') deps — addressed by bare pane id.
|
|
26
|
+
const singleDeps = {
|
|
24
27
|
capture: (id) => capturePane(id),
|
|
25
28
|
inject: (id, text) => inject(id, text),
|
|
26
|
-
now
|
|
27
|
-
sleep
|
|
28
|
-
|
|
29
|
+
now,
|
|
30
|
+
sleep,
|
|
31
|
+
};
|
|
32
|
+
// Multi-session ('start') deps — addressed by PaneTarget across sessions.
|
|
33
|
+
const multiDeps = {
|
|
34
|
+
listTargets: () => listPaneTargets(),
|
|
35
|
+
capture: (t) => captureTarget(t),
|
|
36
|
+
inject: (t, text) => injectTarget(t, text),
|
|
37
|
+
now,
|
|
38
|
+
sleep,
|
|
29
39
|
log,
|
|
30
40
|
};
|
|
31
41
|
const [, , subcommand, ...rest] = process.argv;
|
|
@@ -39,12 +49,12 @@ async function main() {
|
|
|
39
49
|
process.exit(1);
|
|
40
50
|
}
|
|
41
51
|
log(`monitoring single pane ${paneId} (poll 5s)`);
|
|
42
|
-
await runMonitor(paneId,
|
|
52
|
+
await runMonitor(paneId, singleDeps);
|
|
43
53
|
break;
|
|
44
54
|
}
|
|
45
55
|
case 'start': {
|
|
46
|
-
log('claude-retry daemon starting —
|
|
47
|
-
await runMultiMonitor(
|
|
56
|
+
log('claude-retry daemon starting — walking all sessions/panes (poll 60s)');
|
|
57
|
+
await runMultiMonitor(multiDeps);
|
|
48
58
|
break;
|
|
49
59
|
}
|
|
50
60
|
case 'help': {
|
package/dist/monitor.d.ts
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
|
+
import type { PaneTarget } from './zellij.ts';
|
|
1
2
|
export interface MonitorDeps {
|
|
2
3
|
capture: (paneId: string) => Promise<string>;
|
|
3
4
|
inject: (paneId: string, text: string) => Promise<void>;
|
|
4
5
|
now: () => number;
|
|
5
6
|
sleep: (ms: number) => Promise<void>;
|
|
6
7
|
}
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
/** Deps for watching many Claude panes across sessions. Capture/inject are
|
|
9
|
+
* addressed by PaneTarget rather than a bare pane id. */
|
|
10
|
+
export interface MultiMonitorDeps {
|
|
11
|
+
listTargets: () => Promise<PaneTarget[]>;
|
|
12
|
+
capture: (target: PaneTarget) => Promise<string>;
|
|
13
|
+
inject: (target: PaneTarget, text: string) => Promise<void>;
|
|
14
|
+
now: () => number;
|
|
15
|
+
sleep: (ms: number) => Promise<void>;
|
|
9
16
|
/** Optional sink for chatty progress logs (wired to stderr by the CLI). */
|
|
10
17
|
log?: (msg: string) => void;
|
|
11
18
|
}
|
|
@@ -19,12 +26,13 @@ export declare function createState(): MonitorState;
|
|
|
19
26
|
export declare function tick(paneId: string, state: MonitorState, deps: MonitorDeps, marginSeconds?: number, fallbackHours?: number): Promise<MonitorStatus>;
|
|
20
27
|
export declare function runMonitor(paneId: string, deps: MonitorDeps, pollIntervalMs?: number, marginSeconds?: number, fallbackHours?: number): Promise<void>;
|
|
21
28
|
/**
|
|
22
|
-
* One discovery+monitor pass over every
|
|
29
|
+
* One discovery+monitor pass over every Claude pane in every live session.
|
|
23
30
|
*
|
|
24
|
-
* Re-discovers
|
|
25
|
-
* closed
|
|
26
|
-
* and persists across calls. A failed
|
|
27
|
-
* capture/inject error is swallowed so one bad
|
|
31
|
+
* Re-discovers targets each call so new Claude sessions/panes are picked up and
|
|
32
|
+
* closed ones are pruned. Per-pane state lives in `states`, keyed by the
|
|
33
|
+
* target's label (session:paneId), and persists across calls. A failed
|
|
34
|
+
* discovery or a single pane's capture/inject error is swallowed so one bad
|
|
35
|
+
* pane never stops the others.
|
|
28
36
|
*/
|
|
29
37
|
export declare function multiTick(states: PaneStates, deps: MultiMonitorDeps, marginSeconds?: number, fallbackHours?: number): Promise<void>;
|
|
30
38
|
export declare function runMultiMonitor(deps: MultiMonitorDeps, pollIntervalMs?: number, marginSeconds?: number, fallbackHours?: number): Promise<void>;
|
package/dist/monitor.js
CHANGED
|
@@ -3,14 +3,18 @@ import { parseResetTime, calculateWaitMs } from "./time-parser.js";
|
|
|
3
3
|
export function createState() {
|
|
4
4
|
return { status: 'monitoring', waitUntil: 0 };
|
|
5
5
|
}
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Core state transition for one pane, given its current screen text.
|
|
8
|
+
* `injectContinue` is called only when a wait period has elapsed. Shared by
|
|
9
|
+
* single-pane (tick) and multi-session (tickTarget) monitoring.
|
|
10
|
+
*/
|
|
11
|
+
async function stepState(state, screenText, now, injectContinue, marginSeconds, fallbackHours) {
|
|
8
12
|
if (state.status === 'waiting') {
|
|
9
|
-
if (
|
|
13
|
+
if (now < state.waitUntil) {
|
|
10
14
|
return 'rate-limited';
|
|
11
15
|
}
|
|
12
16
|
// Wait period elapsed — inject continue
|
|
13
|
-
await
|
|
17
|
+
await injectContinue();
|
|
14
18
|
state.status = 'monitoring';
|
|
15
19
|
state.waitUntil = 0;
|
|
16
20
|
return 'retried';
|
|
@@ -20,13 +24,21 @@ export async function tick(paneId, state, deps, marginSeconds, fallbackHours) {
|
|
|
20
24
|
if (result.limited) {
|
|
21
25
|
const resetLine = result.resetLine ?? '';
|
|
22
26
|
const parsed = parseResetTime(resetLine);
|
|
23
|
-
const waitMs = calculateWaitMs(parsed, marginSeconds, fallbackHours, new Date(
|
|
24
|
-
state.waitUntil =
|
|
27
|
+
const waitMs = calculateWaitMs(parsed, marginSeconds, fallbackHours, new Date(now));
|
|
28
|
+
state.waitUntil = now + waitMs;
|
|
25
29
|
state.status = 'waiting';
|
|
26
30
|
return 'rate-limited';
|
|
27
31
|
}
|
|
28
32
|
return 'monitoring';
|
|
29
33
|
}
|
|
34
|
+
export async function tick(paneId, state, deps, marginSeconds, fallbackHours) {
|
|
35
|
+
const screenText = await deps.capture(paneId);
|
|
36
|
+
return stepState(state, screenText, deps.now(), () => deps.inject(paneId, 'continue'), marginSeconds, fallbackHours);
|
|
37
|
+
}
|
|
38
|
+
async function tickTarget(target, state, deps, marginSeconds, fallbackHours) {
|
|
39
|
+
const screenText = await deps.capture(target);
|
|
40
|
+
return stepState(state, screenText, deps.now(), () => deps.inject(target, 'continue'), marginSeconds, fallbackHours);
|
|
41
|
+
}
|
|
30
42
|
export async function runMonitor(paneId, deps, pollIntervalMs, marginSeconds, fallbackHours) {
|
|
31
43
|
const state = createState();
|
|
32
44
|
for (;;) {
|
|
@@ -35,66 +47,67 @@ export async function runMonitor(paneId, deps, pollIntervalMs, marginSeconds, fa
|
|
|
35
47
|
}
|
|
36
48
|
}
|
|
37
49
|
/**
|
|
38
|
-
* One discovery+monitor pass over every
|
|
50
|
+
* One discovery+monitor pass over every Claude pane in every live session.
|
|
39
51
|
*
|
|
40
|
-
* Re-discovers
|
|
41
|
-
* closed
|
|
42
|
-
* and persists across calls. A failed
|
|
43
|
-
* capture/inject error is swallowed so one bad
|
|
52
|
+
* Re-discovers targets each call so new Claude sessions/panes are picked up and
|
|
53
|
+
* closed ones are pruned. Per-pane state lives in `states`, keyed by the
|
|
54
|
+
* target's label (session:paneId), and persists across calls. A failed
|
|
55
|
+
* discovery or a single pane's capture/inject error is swallowed so one bad
|
|
56
|
+
* pane never stops the others.
|
|
44
57
|
*/
|
|
45
58
|
export async function multiTick(states, deps, marginSeconds, fallbackHours) {
|
|
46
59
|
const log = deps.log ?? (() => { });
|
|
47
|
-
let
|
|
60
|
+
let targets;
|
|
48
61
|
try {
|
|
49
|
-
|
|
62
|
+
targets = await deps.listTargets();
|
|
50
63
|
}
|
|
51
64
|
catch {
|
|
52
65
|
// Discovery failed this round — keep existing states, retry next tick.
|
|
53
|
-
log('scan failed: could not list panes (will retry)');
|
|
66
|
+
log('scan failed: could not list sessions/panes (will retry)');
|
|
54
67
|
return;
|
|
55
68
|
}
|
|
56
69
|
// Prune state for panes that no longer exist.
|
|
57
|
-
const live = new Set(
|
|
58
|
-
for (const
|
|
59
|
-
if (!live.has(
|
|
60
|
-
states.delete(
|
|
61
|
-
log(
|
|
70
|
+
const live = new Set(targets.map((t) => t.label));
|
|
71
|
+
for (const key of [...states.keys()]) {
|
|
72
|
+
if (!live.has(key)) {
|
|
73
|
+
states.delete(key);
|
|
74
|
+
log(`${key} gone — dropped from watch`);
|
|
62
75
|
}
|
|
63
76
|
}
|
|
64
|
-
log(
|
|
77
|
+
log(targets.length === 0
|
|
65
78
|
? 'scan: no Claude panes found'
|
|
66
|
-
: `scan: watching ${
|
|
67
|
-
for (const
|
|
68
|
-
let state = states.get(
|
|
79
|
+
: `scan: watching ${targets.length} Claude pane(s) [${targets.map((t) => t.label).join(', ')}]`);
|
|
80
|
+
for (const target of targets) {
|
|
81
|
+
let state = states.get(target.label);
|
|
69
82
|
if (!state) {
|
|
70
83
|
state = createState();
|
|
71
|
-
states.set(
|
|
72
|
-
log(
|
|
84
|
+
states.set(target.label, state);
|
|
85
|
+
log(`${target.label} — new Claude pane, now watching`);
|
|
73
86
|
}
|
|
74
87
|
const before = state.status;
|
|
75
88
|
try {
|
|
76
|
-
const status = await
|
|
77
|
-
logPaneStatus(log,
|
|
89
|
+
const status = await tickTarget(target, state, deps, marginSeconds, fallbackHours);
|
|
90
|
+
logPaneStatus(log, target.label, before, state, status);
|
|
78
91
|
}
|
|
79
92
|
catch {
|
|
80
93
|
// This pane's capture/inject failed — leave its state, keep going.
|
|
81
|
-
log(
|
|
94
|
+
log(`${target.label} — capture/inject error (skipped this round)`);
|
|
82
95
|
}
|
|
83
96
|
}
|
|
84
97
|
}
|
|
85
|
-
function logPaneStatus(log,
|
|
98
|
+
function logPaneStatus(log, label, before, state, status) {
|
|
86
99
|
if (status === 'rate-limited' && before === 'monitoring') {
|
|
87
100
|
const until = new Date(state.waitUntil).toISOString();
|
|
88
|
-
log(
|
|
101
|
+
log(`${label} — RATE LIMITED, waiting until ${until}`);
|
|
89
102
|
}
|
|
90
103
|
else if (status === 'rate-limited') {
|
|
91
|
-
log(
|
|
104
|
+
log(`${label} — still waiting for reset`);
|
|
92
105
|
}
|
|
93
106
|
else if (status === 'retried') {
|
|
94
|
-
log(
|
|
107
|
+
log(`${label} — reset reached, cleared input + injected 'continue'`);
|
|
95
108
|
}
|
|
96
109
|
else {
|
|
97
|
-
log(
|
|
110
|
+
log(`${label} — ok`);
|
|
98
111
|
}
|
|
99
112
|
}
|
|
100
113
|
export async function runMultiMonitor(deps, pollIntervalMs, marginSeconds, fallbackHours) {
|
package/dist/patterns.js
CHANGED
package/dist/zellij.d.ts
CHANGED
|
@@ -4,6 +4,39 @@ export type ExecFileFn = (cmd: string, args: string[]) => Promise<{
|
|
|
4
4
|
}>;
|
|
5
5
|
export declare function capturePane(paneId: string | number, execFileFn?: ExecFileFn): Promise<string>;
|
|
6
6
|
export declare function inject(paneId: string | number, text: string, execFileFn?: ExecFileFn): Promise<void>;
|
|
7
|
+
/**
|
|
8
|
+
* A single Claude pane to watch, addressable across zellij sessions.
|
|
9
|
+
* `label` is a stable human-readable key (session:paneId) used for state
|
|
10
|
+
* tracking and logs.
|
|
11
|
+
*/
|
|
12
|
+
export interface PaneTarget {
|
|
13
|
+
session: string;
|
|
14
|
+
paneId: string;
|
|
15
|
+
label: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* List all live zellij session names, skipping EXITED/resurrectable ones and
|
|
19
|
+
* the daemon's own session (ZELLIJ_SESSION_NAME) so it never watches itself.
|
|
20
|
+
*/
|
|
21
|
+
export declare function listSessions(execFileFn?: ExecFileFn): Promise<string[]>;
|
|
22
|
+
/**
|
|
23
|
+
* Walk every live session and return every non-plugin, non-exited pane as a
|
|
24
|
+
* target. We do NOT try to identify which pane is Claude — pane titles and
|
|
25
|
+
* commands are unreliable (interactive `claude` reports the shell, titles are
|
|
26
|
+
* the cwd). Instead the monitor dumps each pane's screen and only acts on the
|
|
27
|
+
* ones actually showing a rate-limit banner. Works on detached sessions via
|
|
28
|
+
* the global `--session` flag; the daemon's own session is already excluded by
|
|
29
|
+
* listSessions, so its logs are never scanned.
|
|
30
|
+
*/
|
|
31
|
+
export declare function listPaneTargets(execFileFn?: ExecFileFn): Promise<PaneTarget[]>;
|
|
32
|
+
/** Dump a target pane's visible screen across sessions. */
|
|
33
|
+
export declare function captureTarget(t: PaneTarget, execFileFn?: ExecFileFn): Promise<string>;
|
|
34
|
+
/**
|
|
35
|
+
* Inject into a target pane across sessions: Ctrl+C to clear any half-typed
|
|
36
|
+
* input first, then type text + Enter. A single Ctrl+C in Claude Code only
|
|
37
|
+
* clears the input box (shows "Press Ctrl-C again to exit"), it does not quit.
|
|
38
|
+
*/
|
|
39
|
+
export declare function injectTarget(t: PaneTarget, text: string, execFileFn?: ExecFileFn): Promise<void>;
|
|
7
40
|
/**
|
|
8
41
|
* Discover every live Claude pane (deduped pane IDs).
|
|
9
42
|
*
|
package/dist/zellij.js
CHANGED
|
@@ -12,9 +12,95 @@ export async function capturePane(paneId, execFileFn = defaultExecFile) {
|
|
|
12
12
|
return stdout;
|
|
13
13
|
}
|
|
14
14
|
export async function inject(paneId, text, execFileFn = defaultExecFile) {
|
|
15
|
+
// Ctrl+C first clears any half-typed input (does not quit Claude), then type + Enter.
|
|
16
|
+
await execFileFn('zellij', ['action', 'write', '--pane-id', String(paneId), '3']);
|
|
15
17
|
await execFileFn('zellij', ['action', 'write-chars', '--pane-id', String(paneId), text]);
|
|
16
18
|
await execFileFn('zellij', ['action', 'write', '--pane-id', String(paneId), '13']);
|
|
17
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* List all live zellij session names, skipping EXITED/resurrectable ones and
|
|
22
|
+
* the daemon's own session (ZELLIJ_SESSION_NAME) so it never watches itself.
|
|
23
|
+
*/
|
|
24
|
+
export async function listSessions(execFileFn = defaultExecFile) {
|
|
25
|
+
const own = process.env['ZELLIJ_SESSION_NAME'];
|
|
26
|
+
const names = [];
|
|
27
|
+
const { stdout } = await execFileFn('zellij', ['list-sessions', '-n']);
|
|
28
|
+
for (const line of stdout.split('\n')) {
|
|
29
|
+
const trimmed = line.trim();
|
|
30
|
+
if (!trimmed)
|
|
31
|
+
continue;
|
|
32
|
+
if (/\(EXITED/.test(trimmed))
|
|
33
|
+
continue; // skip dead/resurrectable sessions
|
|
34
|
+
// Session names may contain spaces ("Rainbow Road"); the name is everything
|
|
35
|
+
// before the " [Created ...]" suffix that zellij appends.
|
|
36
|
+
const idx = trimmed.indexOf(' [');
|
|
37
|
+
const name = (idx >= 0 ? trimmed.slice(0, idx) : trimmed).trim();
|
|
38
|
+
if (!name)
|
|
39
|
+
continue;
|
|
40
|
+
if (own && name === own)
|
|
41
|
+
continue; // never watch our own session
|
|
42
|
+
names.push(name);
|
|
43
|
+
}
|
|
44
|
+
return names;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Walk every live session and return every non-plugin, non-exited pane as a
|
|
48
|
+
* target. We do NOT try to identify which pane is Claude — pane titles and
|
|
49
|
+
* commands are unreliable (interactive `claude` reports the shell, titles are
|
|
50
|
+
* the cwd). Instead the monitor dumps each pane's screen and only acts on the
|
|
51
|
+
* ones actually showing a rate-limit banner. Works on detached sessions via
|
|
52
|
+
* the global `--session` flag; the daemon's own session is already excluded by
|
|
53
|
+
* listSessions, so its logs are never scanned.
|
|
54
|
+
*/
|
|
55
|
+
export async function listPaneTargets(execFileFn = defaultExecFile) {
|
|
56
|
+
const sessions = await listSessions(execFileFn);
|
|
57
|
+
const targets = [];
|
|
58
|
+
for (const session of sessions) {
|
|
59
|
+
let panes;
|
|
60
|
+
try {
|
|
61
|
+
const { stdout } = await execFileFn('zellij', [
|
|
62
|
+
'--session',
|
|
63
|
+
session,
|
|
64
|
+
'action',
|
|
65
|
+
'list-panes',
|
|
66
|
+
'-j',
|
|
67
|
+
]);
|
|
68
|
+
panes = JSON.parse(stdout);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
continue; // session vanished or output unparseable — skip this round
|
|
72
|
+
}
|
|
73
|
+
for (const p of panes) {
|
|
74
|
+
if (p.is_plugin || p.exited)
|
|
75
|
+
continue;
|
|
76
|
+
targets.push({ session, paneId: String(p.id), label: `${session}:${p.id}` });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return targets;
|
|
80
|
+
}
|
|
81
|
+
/** Dump a target pane's visible screen across sessions. */
|
|
82
|
+
export async function captureTarget(t, execFileFn = defaultExecFile) {
|
|
83
|
+
const { stdout } = await execFileFn('zellij', [
|
|
84
|
+
'--session',
|
|
85
|
+
t.session,
|
|
86
|
+
'action',
|
|
87
|
+
'dump-screen',
|
|
88
|
+
'--pane-id',
|
|
89
|
+
t.paneId,
|
|
90
|
+
]);
|
|
91
|
+
return stdout;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Inject into a target pane across sessions: Ctrl+C to clear any half-typed
|
|
95
|
+
* input first, then type text + Enter. A single Ctrl+C in Claude Code only
|
|
96
|
+
* clears the input box (shows "Press Ctrl-C again to exit"), it does not quit.
|
|
97
|
+
*/
|
|
98
|
+
export async function injectTarget(t, text, execFileFn = defaultExecFile) {
|
|
99
|
+
const base = ['--session', t.session, 'action'];
|
|
100
|
+
await execFileFn('zellij', [...base, 'write', '--pane-id', t.paneId, '3']); // Ctrl+C clears input
|
|
101
|
+
await execFileFn('zellij', [...base, 'write-chars', '--pane-id', t.paneId, text]);
|
|
102
|
+
await execFileFn('zellij', [...base, 'write', '--pane-id', t.paneId, '13']); // Enter
|
|
103
|
+
}
|
|
18
104
|
/** True when a list-clients RUNNING_COMMAND is the `claude` CLI itself.
|
|
19
105
|
* Excludes `claude-retry` (the monitor's own pane) and other claude-* tools. */
|
|
20
106
|
function paneCommandIsClaude(runningCommand) {
|