@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # claude-retry
2
2
 
3
- Watches every Claude CLI pane in a [zellij](https://zellij.dev/) terminal session. When a pane hits Anthropic's 5-hour usage limit, it detects the rate-limit message, waits until that pane's reset time, then injects `continue` to resume automatically. One daemon covers all your Claude sessions at once.
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
- Must be run inside a zellij session. The recommended way to run claude-retry is as a foreground daemon in a dedicated zellij pane:
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` rediscovers Claude panes on **every pass** (every 60s) via `zellij action list-clients`. This means:
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
- - You only ever need **one** daemon, no matter how many Claude sessions you run.
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
- 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. Logs stream to stderr so you always see signs of life.
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
- > **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 message can't be detected. Running `claude` → `/remote-control` keeps the session "live" and visible to the monitor.
34
+ ### Single-pane mode (legacy)
35
35
 
36
- To pin the daemon to a single pane instead of auto-discovery:
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** — `start` lists Claude panes via `zellij action list-clients`, matching panes whose command is the `claude` CLI (the daemon's own `claude-retry` pane is excluded). New panes are added, closed panes are pruned.
71
- 2. **Capture** — for each pane, grabs the screen with `zellij action dump-screen` (ANSI stripped).
72
- 3. **Match** — checks the text against the rate-limit patterns.
73
- 4. **Retry** — on detection, parses the reset time and marks the pane `waiting`; once the reset elapses, injects `continue` via `zellij action write-chars`.
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 inside the same zellij session as Claude — no transparent session wrapping, no external daemon. The zellij pane is the daemon.
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, listClaudePanes } from "./zellij.js";
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 (re-discovers each pass)
8
- claude-retry monitor <pane-id> Watch one specific zellij pane by ID
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
- Options:
12
- CLAUDE_PANE_ID=<id> Pin 'start' to a single pane instead of auto-discovery
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
- Run as a foreground daemon in a dedicated zellij pane. 'start' polls every
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 deps = {
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: () => Date.now(),
27
- sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
28
- listPanes: () => listClaudePanes(),
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, deps);
52
+ await runMonitor(paneId, singleDeps);
43
53
  break;
44
54
  }
45
55
  case 'start': {
46
- log('claude-retry daemon starting — discovering Claude panes (poll 60s)');
47
- await runMultiMonitor(deps);
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
- export interface MultiMonitorDeps extends MonitorDeps {
8
- listPanes: () => Promise<string[]>;
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 live Claude pane.
29
+ * One discovery+monitor pass over every Claude pane in every live session.
23
30
  *
24
- * Re-discovers panes each call so new Claude sessions are picked up and
25
- * closed panes are pruned. Per-pane state lives in `states`, keyed by pane ID,
26
- * and persists across calls. A failed discovery or a single pane's
27
- * capture/inject error is swallowed so one bad pane never stops the others.
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
- export async function tick(paneId, state, deps, marginSeconds, fallbackHours) {
7
- const screenText = await deps.capture(paneId);
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 (deps.now() < state.waitUntil) {
13
+ if (now < state.waitUntil) {
10
14
  return 'rate-limited';
11
15
  }
12
16
  // Wait period elapsed — inject continue
13
- await deps.inject(paneId, 'continue');
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(deps.now()));
24
- state.waitUntil = deps.now() + waitMs;
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 live Claude pane.
50
+ * One discovery+monitor pass over every Claude pane in every live session.
39
51
  *
40
- * Re-discovers panes each call so new Claude sessions are picked up and
41
- * closed panes are pruned. Per-pane state lives in `states`, keyed by pane ID,
42
- * and persists across calls. A failed discovery or a single pane's
43
- * capture/inject error is swallowed so one bad pane never stops the others.
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 panes;
60
+ let targets;
48
61
  try {
49
- panes = await deps.listPanes();
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(panes);
58
- for (const id of [...states.keys()]) {
59
- if (!live.has(id)) {
60
- states.delete(id);
61
- log(`pane ${id} gone — dropped from watch`);
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(panes.length === 0
77
+ log(targets.length === 0
65
78
  ? 'scan: no Claude panes found'
66
- : `scan: watching ${panes.length} Claude pane(s) [${panes.join(', ')}]`);
67
- for (const id of panes) {
68
- let state = states.get(id);
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(id, state);
72
- log(`pane ${id} — new Claude session, now watching`);
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 tick(id, state, deps, marginSeconds, fallbackHours);
77
- logPaneStatus(log, id, before, state, status);
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(`pane ${id} — capture/inject error (skipped this round)`);
94
+ log(`${target.label} — capture/inject error (skipped this round)`);
82
95
  }
83
96
  }
84
97
  }
85
- function logPaneStatus(log, id, before, state, status) {
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(`pane ${id} — RATE LIMITED, waiting until ${until}`);
101
+ log(`${label} — RATE LIMITED, waiting until ${until}`);
89
102
  }
90
103
  else if (status === 'rate-limited') {
91
- log(`pane ${id} — still waiting for reset`);
104
+ log(`${label} — still waiting for reset`);
92
105
  }
93
106
  else if (status === 'retried') {
94
- log(`pane ${id} — reset reached, injected 'continue'`);
107
+ log(`${label} — reset reached, cleared input + injected 'continue'`);
95
108
  }
96
109
  else {
97
- log(`pane ${id} — ok`);
110
+ log(`${label} — ok`);
98
111
  }
99
112
  }
100
113
  export async function runMultiMonitor(deps, pollIntervalMs, marginSeconds, fallbackHours) {
package/dist/patterns.js CHANGED
@@ -12,6 +12,7 @@ export function stripAnsi(text) {
12
12
  const LIMIT_PATTERNS = [
13
13
  /claude\.ai\/settings/i,
14
14
  /usage limit/i,
15
+ /session limit/i,
15
16
  /rate.?limit/i,
16
17
  /\blimit\b.*\breached\b/i,
17
18
  /\breached\b.*\blimit\b/i,
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tigorhutasuhut/claude-retry",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Monitor Claude CLI in a zellij pane, auto-inject continue on rate-limit",
5
5
  "type": "module",
6
6
  "license": "MIT",