@tigorhutasuhut/claude-retry 0.1.1 → 0.1.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/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 on-screen rate-limit banner, then confirms against Anthropic's usage API — the same data the `/usage` command shows — to get the **exact** reset time and to discard stale banners. Once the reset elapses it 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,28 +53,30 @@ 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.
63
+ 4. **Resolve** — on a banner match, the reset time is determined via a three-tier cascade:
64
+ - **Tier 1 — usage API (primary).** Once per pass, the daemon discovers every Claude account in use by reading `CLAUDE_CONFIG_DIR` from each Claude process via `/proc` (Linux). For each account it calls `GET https://api.anthropic.com/api/oauth/usage` with the OAuth token from `<CLAUDE_CONFIG_DIR>/.credentials.json`. If the account is **not** limited the banner is stale — it is silently ignored, no wait issued. If the account **is** limited the daemon waits until the API's exact `resets_at` timestamp. Credentials are re-read every pass, so token refreshes are picked up automatically.
65
+ - **Tier 2 — /proc pane→account bridge (planned).** Needed only when two or more accounts are limited simultaneously, so the daemon must map a specific pane to its account. This is a phase-2 stub; until implemented, that case falls through to tier 3.
66
+ - **Tier 3 — text fallback.** Used when the API is unreachable, the account is unknown, or tier 2 is unresolved. Falls back to parsing the reset time from the on-screen banner text (the original behavior). A banner is never silently ignored when the account is unknown — this ensures a real limit is never missed.
67
+ 5. **Retry** — once the resolved reset time 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`.
68
+
69
+ 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.
74
70
 
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 Claudeno transparent session wrapping, no external daemon. The zellij pane is the daemon.
71
+ > **Multi-account note.** On Linux, account discovery reads `CLAUDE_CONFIG_DIR` from every live Claude process via `/proc`. This means the daemon polls usage for every account in usenot just the default one. On non-Linux systems it falls back to the default account (`~/.claude`) plus tier-3 text parsing.
76
72
 
77
73
  ## Requirements
78
74
 
79
- - Node.js >= 20
75
+ - Node.js >= 20 (required for global `fetch`, used by the usage API)
80
76
  - zellij >= 0.40
81
77
  - Must be inside a zellij session when running
78
+ - A logged-in Claude Code installation with a valid `<CLAUDE_CONFIG_DIR>/.credentials.json` (for usage-API tier 1 detection; without it the daemon degrades to text parsing)
79
+ - `/proc` account discovery is Linux-only; on other platforms the daemon uses the default account and text fallback
82
80
 
83
81
  ## Development
84
82
 
@@ -0,0 +1,35 @@
1
+ import { type AccountUsage } from './usage.ts';
2
+ import type { PaneTarget } from './zellij.ts';
3
+ export interface AccountSnapshot {
4
+ byDir: Map<string, AccountUsage>;
5
+ }
6
+ export declare function parseConfigDirFromEnviron(buf: string): string | null;
7
+ export interface DiscoverDeps {
8
+ platform: string;
9
+ readdir: (path: string) => Promise<string[]>;
10
+ readFile: (path: string) => Promise<string>;
11
+ defaultDir: () => string;
12
+ }
13
+ export interface ProcDeps {
14
+ platform?: string;
15
+ listProcPids: () => Promise<string[]>;
16
+ readCmdline: (pid: string) => Promise<string>;
17
+ readEnviron: (pid: string) => Promise<string>;
18
+ listFds: (pid: string) => Promise<string[]>;
19
+ readlink: (path: string) => Promise<string>;
20
+ }
21
+ interface ClaudeProc {
22
+ pid: string;
23
+ configDir: string;
24
+ pts: string | null;
25
+ }
26
+ interface ZellijServer {
27
+ session: string;
28
+ pts: Set<string>;
29
+ }
30
+ export declare function listClaudeProcs(deps: ProcDeps): Promise<ClaudeProc[]>;
31
+ export declare function listZellijServers(deps: ProcDeps): Promise<ZellijServer[]>;
32
+ export declare function discoverAccountDirs(deps?: Partial<DiscoverDeps>): Promise<string[]>;
33
+ export declare function resolvePaneConfigDir(target: PaneTarget, _snapshot: AccountSnapshot, deps?: ProcDeps): Promise<string | null>;
34
+ export {};
35
+ //# sourceMappingURL=accounts.d.ts.map
@@ -0,0 +1,197 @@
1
+ import { defaultConfigDir } from "./usage.js";
2
+ export function parseConfigDirFromEnviron(buf) {
3
+ const entries = buf.split('\0');
4
+ for (const entry of entries) {
5
+ if (entry.startsWith('CLAUDE_CONFIG_DIR=')) {
6
+ const val = entry.slice('CLAUDE_CONFIG_DIR='.length);
7
+ return val.length > 0 ? val : null;
8
+ }
9
+ }
10
+ return null;
11
+ }
12
+ function defaultProcDeps() {
13
+ return {
14
+ platform: process.platform,
15
+ listProcPids: async () => {
16
+ const { readdir } = await import('node:fs/promises');
17
+ const entries = await readdir('/proc');
18
+ return entries.filter(e => /^\d+$/.test(e));
19
+ },
20
+ readCmdline: async (pid) => {
21
+ const { readFile } = await import('node:fs/promises');
22
+ return readFile(`/proc/${pid}/cmdline`, 'utf8');
23
+ },
24
+ readEnviron: async (pid) => {
25
+ const { readFile } = await import('node:fs/promises');
26
+ return readFile(`/proc/${pid}/environ`, 'utf8');
27
+ },
28
+ listFds: async (pid) => {
29
+ const { readdir } = await import('node:fs/promises');
30
+ return readdir(`/proc/${pid}/fd`);
31
+ },
32
+ readlink: async (path) => {
33
+ const { readlink } = await import('node:fs/promises');
34
+ return readlink(path);
35
+ },
36
+ };
37
+ }
38
+ const CONTRACT_VERSION_SEG = '/contract_version_1/';
39
+ export async function listClaudeProcs(deps) {
40
+ const result = [];
41
+ let pids;
42
+ try {
43
+ pids = await deps.listProcPids();
44
+ }
45
+ catch {
46
+ return result;
47
+ }
48
+ await Promise.all(pids.map(async (pid) => {
49
+ try {
50
+ const cmdline = await deps.readCmdline(pid);
51
+ if (!cmdline.includes('claude'))
52
+ return;
53
+ let configDir;
54
+ try {
55
+ const environ = await deps.readEnviron(pid);
56
+ configDir = parseConfigDirFromEnviron(environ) ?? defaultConfigDir();
57
+ }
58
+ catch {
59
+ configDir = defaultConfigDir();
60
+ }
61
+ let pts = null;
62
+ try {
63
+ const target = await deps.readlink(`/proc/${pid}/fd/0`);
64
+ if (/^\/dev\/pts\/\d+$/.test(target)) {
65
+ pts = target;
66
+ }
67
+ }
68
+ catch {
69
+ // fd/0 unreadable — pts stays null
70
+ }
71
+ result.push({ pid, configDir, pts });
72
+ }
73
+ catch {
74
+ // skip unreadable pid
75
+ }
76
+ }));
77
+ return result;
78
+ }
79
+ export async function listZellijServers(deps) {
80
+ const result = [];
81
+ let pids;
82
+ try {
83
+ pids = await deps.listProcPids();
84
+ }
85
+ catch {
86
+ return result;
87
+ }
88
+ await Promise.all(pids.map(async (pid) => {
89
+ try {
90
+ const cmdline = await deps.readCmdline(pid);
91
+ const args = cmdline.split('\0');
92
+ const serverIdx = args.indexOf('--server');
93
+ if (serverIdx === -1)
94
+ return;
95
+ const serverPath = args[serverIdx + 1];
96
+ if (serverPath === undefined)
97
+ return;
98
+ const idx = serverPath.lastIndexOf(CONTRACT_VERSION_SEG);
99
+ if (idx === -1)
100
+ return;
101
+ const session = serverPath.slice(idx + CONTRACT_VERSION_SEG.length).trimEnd();
102
+ if (!session)
103
+ return;
104
+ const pts = new Set();
105
+ try {
106
+ const fds = await deps.listFds(pid);
107
+ await Promise.all(fds.map(async (fd) => {
108
+ try {
109
+ const target = await deps.readlink(`/proc/${pid}/fd/${fd}`);
110
+ if (/^\/dev\/pts\/\d+$/.test(target)) {
111
+ pts.add(target);
112
+ }
113
+ }
114
+ catch {
115
+ // skip unreadable fd
116
+ }
117
+ }));
118
+ }
119
+ catch {
120
+ // listFds failed — server with empty pts still recorded
121
+ }
122
+ result.push({ session, pts });
123
+ }
124
+ catch {
125
+ // skip unreadable pid
126
+ }
127
+ }));
128
+ return result;
129
+ }
130
+ export async function discoverAccountDirs(deps) {
131
+ const platform = deps?.platform ?? process.platform;
132
+ const readdirFn = deps?.readdir ?? (async (p) => {
133
+ const { readdir } = await import('node:fs/promises');
134
+ return readdir(p);
135
+ });
136
+ const readFileFn = deps?.readFile ?? (async (p) => {
137
+ const { readFile } = await import('node:fs/promises');
138
+ return readFile(p, 'utf8');
139
+ });
140
+ const defaultDir = deps?.defaultDir ?? (() => defaultConfigDir());
141
+ const base = defaultDir();
142
+ const dirs = new Set([base]);
143
+ if (platform !== 'linux') {
144
+ return [base];
145
+ }
146
+ try {
147
+ const entries = await readdirFn('/proc');
148
+ const pids = entries.filter(e => /^\d+$/.test(e));
149
+ await Promise.all(pids.map(async (pid) => {
150
+ try {
151
+ const cmdline = await readFileFn(`/proc/${pid}/cmdline`);
152
+ if (!cmdline.includes('claude'))
153
+ return;
154
+ const environ = await readFileFn(`/proc/${pid}/environ`);
155
+ const dir = parseConfigDirFromEnviron(environ) ?? base;
156
+ dirs.add(dir);
157
+ }
158
+ catch {
159
+ // skip unreadable pids
160
+ }
161
+ }));
162
+ }
163
+ catch {
164
+ // /proc unreadable or other failure → return default only
165
+ return [base];
166
+ }
167
+ return Array.from(dirs);
168
+ }
169
+ export async function resolvePaneConfigDir(target, _snapshot, deps) {
170
+ try {
171
+ const d = deps ?? defaultProcDeps();
172
+ const platform = d.platform ?? process.platform;
173
+ if (platform !== 'linux')
174
+ return null;
175
+ const [servers, claudeProcs] = await Promise.all([
176
+ listZellijServers(d),
177
+ listClaudeProcs(d),
178
+ ]);
179
+ const server = servers.find(s => s.session === target.session);
180
+ if (server === undefined)
181
+ return null;
182
+ const dirs = new Set();
183
+ for (const proc of claudeProcs) {
184
+ if (proc.pts !== null && server.pts.has(proc.pts)) {
185
+ dirs.add(proc.configDir);
186
+ }
187
+ }
188
+ if (dirs.size === 1) {
189
+ return [...dirs][0];
190
+ }
191
+ return null;
192
+ }
193
+ catch {
194
+ return null;
195
+ }
196
+ }
197
+ //# sourceMappingURL=accounts.js.map
package/dist/cli.js CHANGED
@@ -1,32 +1,63 @@
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
+ import { readAccessToken, fetchUsage } from "./usage.js";
5
+ import { discoverAccountDirs, resolvePaneConfigDir } from "./accounts.js";
4
6
  const USAGE = `claude-retry — Auto-inject 'continue' when Claude hits a rate limit in zellij
5
7
 
6
8
  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
9
+ claude-retry start Watch ALL Claude panes across ALL sessions
10
+ claude-retry monitor <pane-id> Watch one pane by ID in the current session
9
11
  claude-retry help Show this help
10
12
 
11
- Options:
12
- CLAUDE_PANE_ID=<id> Pin 'start' to a single pane instead of auto-discovery
13
+ Run as a foreground daemon in any zellij pane (a dedicated session is ideal).
14
+ 'start' polls every 60s, walks every live zellij session and every pane, and
15
+ auto-injects 'continue' after each Claude pane's rate-limit reset time. It works
16
+ on detached sessions and skips its own session. New Claude panes are picked up
17
+ automatically; closed ones are dropped. Logs go to stderr.
13
18
 
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.`;
19
+ 'monitor <pane-id>' is the legacy single-pane mode (current session only).`;
18
20
  /** Timestamped stderr logger — chatty so the daemon shows clear signs of life. */
19
21
  function log(msg) {
20
22
  const ts = new Date().toISOString().slice(11, 19); // HH:MM:SS
21
23
  process.stderr.write(`[${ts}] ${msg}\n`);
22
24
  }
23
- const deps = {
25
+ const now = () => Date.now();
26
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
27
+ // Single-pane (legacy 'monitor') deps — addressed by bare pane id.
28
+ const singleDeps = {
24
29
  capture: (id) => capturePane(id),
25
30
  inject: (id, text) => inject(id, text),
26
- now: () => Date.now(),
27
- sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
28
- listPanes: () => listClaudePanes(),
31
+ now,
32
+ sleep,
33
+ };
34
+ // Multi-session ('start') deps — addressed by PaneTarget across sessions.
35
+ const multiDeps = {
36
+ listTargets: () => listPaneTargets(),
37
+ capture: (t) => captureTarget(t),
38
+ inject: (t, text) => injectTarget(t, text),
39
+ now,
40
+ sleep,
29
41
  log,
42
+ getAccountSnapshot: async () => {
43
+ const dirs = await discoverAccountDirs();
44
+ const byDir = new Map();
45
+ await Promise.all(dirs.map(async (dir) => {
46
+ try {
47
+ const tok = await readAccessToken(dir);
48
+ if (!tok)
49
+ return;
50
+ const usage = await fetchUsage(tok.token);
51
+ if (usage)
52
+ byDir.set(dir, usage);
53
+ }
54
+ catch {
55
+ // swallow per-account errors — one bad account must not break the pass
56
+ }
57
+ }));
58
+ return { byDir };
59
+ },
60
+ resolvePaneAccount: (t, snapshot) => resolvePaneConfigDir(t, snapshot),
30
61
  };
31
62
  const [, , subcommand, ...rest] = process.argv;
32
63
  async function main() {
@@ -39,12 +70,13 @@ async function main() {
39
70
  process.exit(1);
40
71
  }
41
72
  log(`monitoring single pane ${paneId} (poll 5s)`);
42
- await runMonitor(paneId, deps);
73
+ await runMonitor(paneId, singleDeps);
43
74
  break;
44
75
  }
45
76
  case 'start': {
46
- log('claude-retry daemon starting — discovering Claude panes (poll 60s)');
47
- await runMultiMonitor(deps);
77
+ log('claude-retry daemon starting — walking all sessions/panes (poll 60s)');
78
+ log('usage-API detection enabled — account-aware rate-limit resolution active');
79
+ await runMultiMonitor(multiDeps);
48
80
  break;
49
81
  }
50
82
  case 'help': {
package/dist/monitor.d.ts CHANGED
@@ -1,13 +1,25 @@
1
+ import type { PaneTarget } from './zellij.ts';
2
+ import type { AccountSnapshot } from './accounts.ts';
1
3
  export interface MonitorDeps {
2
4
  capture: (paneId: string) => Promise<string>;
3
5
  inject: (paneId: string, text: string) => Promise<void>;
4
6
  now: () => number;
5
7
  sleep: (ms: number) => Promise<void>;
6
8
  }
7
- export interface MultiMonitorDeps extends MonitorDeps {
8
- listPanes: () => Promise<string[]>;
9
+ /** Deps for watching many Claude panes across sessions. Capture/inject are
10
+ * addressed by PaneTarget rather than a bare pane id. */
11
+ export interface MultiMonitorDeps {
12
+ listTargets: () => Promise<PaneTarget[]>;
13
+ capture: (target: PaneTarget) => Promise<string>;
14
+ inject: (target: PaneTarget, text: string) => Promise<void>;
15
+ now: () => number;
16
+ sleep: (ms: number) => Promise<void>;
9
17
  /** Optional sink for chatty progress logs (wired to stderr by the CLI). */
10
18
  log?: (msg: string) => void;
19
+ /** If provided, called ONCE per multiTick pass to get current account usage snapshot. */
20
+ getAccountSnapshot?: () => Promise<AccountSnapshot>;
21
+ /** If provided, used to resolve which account a pane belongs to when ambiguous. */
22
+ resolvePaneAccount?: (t: PaneTarget, s: AccountSnapshot) => Promise<string | null>;
11
23
  }
12
24
  export type PaneStates = Map<string, MonitorState>;
13
25
  export type MonitorStatus = 'monitoring' | 'rate-limited' | 'retried' | 'exited';
@@ -19,12 +31,13 @@ export declare function createState(): MonitorState;
19
31
  export declare function tick(paneId: string, state: MonitorState, deps: MonitorDeps, marginSeconds?: number, fallbackHours?: number): Promise<MonitorStatus>;
20
32
  export declare function runMonitor(paneId: string, deps: MonitorDeps, pollIntervalMs?: number, marginSeconds?: number, fallbackHours?: number): Promise<void>;
21
33
  /**
22
- * One discovery+monitor pass over every live Claude pane.
34
+ * One discovery+monitor pass over every Claude pane in every live session.
23
35
  *
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.
36
+ * Re-discovers targets each call so new Claude sessions/panes are picked up and
37
+ * closed ones are pruned. Per-pane state lives in `states`, keyed by the
38
+ * target's label (session:paneId), and persists across calls. A failed
39
+ * discovery or a single pane's capture/inject error is swallowed so one bad
40
+ * pane never stops the others.
28
41
  */
29
42
  export declare function multiTick(states: PaneStates, deps: MultiMonitorDeps, marginSeconds?: number, fallbackHours?: number): Promise<void>;
30
43
  export declare function runMultiMonitor(deps: MultiMonitorDeps, pollIntervalMs?: number, marginSeconds?: number, fallbackHours?: number): Promise<void>;
package/dist/monitor.js CHANGED
@@ -3,14 +3,21 @@ 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
+ * When snapshot/resolver/target/log are provided, applies the three-tier
12
+ * account-aware limit resolution. Single-pane callers omit these — text path only.
13
+ */
14
+ async function stepState(state, screenText, now, injectContinue, marginSeconds, fallbackHours, snapshot, resolvePaneAccount, target, log) {
8
15
  if (state.status === 'waiting') {
9
- if (deps.now() < state.waitUntil) {
16
+ if (now < state.waitUntil) {
10
17
  return 'rate-limited';
11
18
  }
12
19
  // Wait period elapsed — inject continue
13
- await deps.inject(paneId, 'continue');
20
+ await injectContinue();
14
21
  state.status = 'monitoring';
15
22
  state.waitUntil = 0;
16
23
  return 'retried';
@@ -18,15 +25,74 @@ export async function tick(paneId, state, deps, marginSeconds, fallbackHours) {
18
25
  // state.status === 'monitoring'
19
26
  const result = match(screenText);
20
27
  if (result.limited) {
28
+ const label = target?.label ?? 'pane';
29
+ const logger = log ?? (() => { });
30
+ // Tier 1: account-aware resolution when snapshot is available
31
+ if (snapshot !== undefined) {
32
+ let accountDir = null;
33
+ if (snapshot.byDir.size === 1) {
34
+ // Single account — always attributable, regardless of limited state
35
+ // (covers both fresh-limit and stale-banner cases without resolver)
36
+ accountDir = [...snapshot.byDir.keys()][0];
37
+ }
38
+ else {
39
+ // Find dirs that are limited in snapshot
40
+ const limitedDirs = [];
41
+ for (const [dir, usage] of snapshot.byDir) {
42
+ if (usage.limited)
43
+ limitedDirs.push(dir);
44
+ }
45
+ if (limitedDirs.length === 1) {
46
+ // Exactly one limited account — attribute banner to it
47
+ accountDir = limitedDirs[0];
48
+ }
49
+ else if (target !== undefined && resolvePaneAccount !== undefined) {
50
+ // Ambiguous (0 or 2+) — try proc bridge (phase 2 stub, returns null)
51
+ accountDir = await resolvePaneAccount(target, snapshot);
52
+ }
53
+ }
54
+ if (accountDir !== null) {
55
+ const usage = snapshot.byDir.get(accountDir);
56
+ if (usage !== undefined) {
57
+ if (!usage.limited) {
58
+ // Staleness gate: account is not limited → banner is stale → ignore
59
+ logger(`${label} stale banner ignored (account not limited)`);
60
+ return 'monitoring';
61
+ }
62
+ // Account confirmed limited — use resetsAtMs if available
63
+ const marginMs = (marginSeconds ?? 60) * 1000;
64
+ if (usage.resetsAtMs !== null) {
65
+ state.waitUntil = usage.resetsAtMs + marginMs;
66
+ state.status = 'waiting';
67
+ logger(`${label} account ${accountDir} limited, reset ${new Date(usage.resetsAtMs).toISOString()}`);
68
+ return 'rate-limited';
69
+ }
70
+ // resetsAtMs null — fall through to text parse for the time, but
71
+ // we know account is limited so we don't need to gate on text
72
+ // (fall through to tier 3 below)
73
+ }
74
+ // usage missing for this dir — fall through to tier 3
75
+ }
76
+ // accountDir null or usage missing — fall through to tier 3
77
+ }
78
+ // Tier 3: text fallback (current behavior, unchanged)
21
79
  const resetLine = result.resetLine ?? '';
22
80
  const parsed = parseResetTime(resetLine);
23
- const waitMs = calculateWaitMs(parsed, marginSeconds, fallbackHours, new Date(deps.now()));
24
- state.waitUntil = deps.now() + waitMs;
81
+ const waitMs = calculateWaitMs(parsed, marginSeconds, fallbackHours, new Date(now));
82
+ state.waitUntil = now + waitMs;
25
83
  state.status = 'waiting';
26
84
  return 'rate-limited';
27
85
  }
28
86
  return 'monitoring';
29
87
  }
88
+ export async function tick(paneId, state, deps, marginSeconds, fallbackHours) {
89
+ const screenText = await deps.capture(paneId);
90
+ return stepState(state, screenText, deps.now(), () => deps.inject(paneId, 'continue'), marginSeconds, fallbackHours);
91
+ }
92
+ async function tickTarget(target, state, deps, marginSeconds, fallbackHours, snapshot) {
93
+ const screenText = await deps.capture(target);
94
+ return stepState(state, screenText, deps.now(), () => deps.inject(target, 'continue'), marginSeconds, fallbackHours, snapshot, deps.resolvePaneAccount, target, deps.log);
95
+ }
30
96
  export async function runMonitor(paneId, deps, pollIntervalMs, marginSeconds, fallbackHours) {
31
97
  const state = createState();
32
98
  for (;;) {
@@ -35,66 +101,77 @@ export async function runMonitor(paneId, deps, pollIntervalMs, marginSeconds, fa
35
101
  }
36
102
  }
37
103
  /**
38
- * One discovery+monitor pass over every live Claude pane.
104
+ * One discovery+monitor pass over every Claude pane in every live session.
39
105
  *
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.
106
+ * Re-discovers targets each call so new Claude sessions/panes are picked up and
107
+ * closed ones are pruned. Per-pane state lives in `states`, keyed by the
108
+ * target's label (session:paneId), and persists across calls. A failed
109
+ * discovery or a single pane's capture/inject error is swallowed so one bad
110
+ * pane never stops the others.
44
111
  */
45
112
  export async function multiTick(states, deps, marginSeconds, fallbackHours) {
46
113
  const log = deps.log ?? (() => { });
47
- let panes;
114
+ let targets;
48
115
  try {
49
- panes = await deps.listPanes();
116
+ targets = await deps.listTargets();
50
117
  }
51
118
  catch {
52
119
  // Discovery failed this round — keep existing states, retry next tick.
53
- log('scan failed: could not list panes (will retry)');
120
+ log('scan failed: could not list sessions/panes (will retry)');
54
121
  return;
55
122
  }
123
+ // Fetch account snapshot once per pass (swallow errors → undefined).
124
+ let snapshot;
125
+ if (deps.getAccountSnapshot !== undefined) {
126
+ try {
127
+ snapshot = await deps.getAccountSnapshot();
128
+ }
129
+ catch {
130
+ snapshot = undefined;
131
+ }
132
+ }
56
133
  // 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`);
134
+ const live = new Set(targets.map((t) => t.label));
135
+ for (const key of [...states.keys()]) {
136
+ if (!live.has(key)) {
137
+ states.delete(key);
138
+ log(`${key} gone — dropped from watch`);
62
139
  }
63
140
  }
64
- log(panes.length === 0
141
+ log(targets.length === 0
65
142
  ? '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);
143
+ : `scan: watching ${targets.length} Claude pane(s) [${targets.map((t) => t.label).join(', ')}]`);
144
+ for (const target of targets) {
145
+ let state = states.get(target.label);
69
146
  if (!state) {
70
147
  state = createState();
71
- states.set(id, state);
72
- log(`pane ${id} — new Claude session, now watching`);
148
+ states.set(target.label, state);
149
+ log(`${target.label} — new Claude pane, now watching`);
73
150
  }
74
151
  const before = state.status;
75
152
  try {
76
- const status = await tick(id, state, deps, marginSeconds, fallbackHours);
77
- logPaneStatus(log, id, before, state, status);
153
+ const status = await tickTarget(target, state, deps, marginSeconds, fallbackHours, snapshot);
154
+ logPaneStatus(log, target.label, before, state, status);
78
155
  }
79
156
  catch {
80
157
  // This pane's capture/inject failed — leave its state, keep going.
81
- log(`pane ${id} — capture/inject error (skipped this round)`);
158
+ log(`${target.label} — capture/inject error (skipped this round)`);
82
159
  }
83
160
  }
84
161
  }
85
- function logPaneStatus(log, id, before, state, status) {
162
+ function logPaneStatus(log, label, before, state, status) {
86
163
  if (status === 'rate-limited' && before === 'monitoring') {
87
164
  const until = new Date(state.waitUntil).toISOString();
88
- log(`pane ${id} — RATE LIMITED, waiting until ${until}`);
165
+ log(`${label} — RATE LIMITED, waiting until ${until}`);
89
166
  }
90
167
  else if (status === 'rate-limited') {
91
- log(`pane ${id} — still waiting for reset`);
168
+ log(`${label} — still waiting for reset`);
92
169
  }
93
170
  else if (status === 'retried') {
94
- log(`pane ${id} — reset reached, injected 'continue'`);
171
+ log(`${label} — reset reached, cleared input + injected 'continue'`);
95
172
  }
96
173
  else {
97
- log(`pane ${id} — ok`);
174
+ log(`${label} — ok`);
98
175
  }
99
176
  }
100
177
  export async function runMultiMonitor(deps, pollIntervalMs, marginSeconds, fallbackHours) {
@@ -0,0 +1,23 @@
1
+ export interface WindowUsage {
2
+ utilization: number;
3
+ resetsAtMs: number | null;
4
+ }
5
+ export interface AccountUsage {
6
+ limited: boolean;
7
+ resetsAtMs: number | null;
8
+ }
9
+ export type FetchFn = (url: string, init: {
10
+ headers: Record<string, string>;
11
+ }) => Promise<{
12
+ status: number;
13
+ json: () => Promise<unknown>;
14
+ }>;
15
+ export type ReadFileFn = (path: string) => Promise<string>;
16
+ export declare const LIMIT_THRESHOLD: number;
17
+ export declare function defaultConfigDir(env?: NodeJS.ProcessEnv): string;
18
+ export declare function readAccessToken(configDir: string, readFile?: ReadFileFn): Promise<{
19
+ token: string;
20
+ expiresAtMs: number | null;
21
+ } | null>;
22
+ export declare function fetchUsage(token: string, fetchFn?: FetchFn, threshold?: number): Promise<AccountUsage | null>;
23
+ //# sourceMappingURL=usage.d.ts.map
package/dist/usage.js ADDED
@@ -0,0 +1,91 @@
1
+ import * as os from 'node:os';
2
+ import * as path from 'node:path';
3
+ import { readFile as fsReadFile } from 'node:fs/promises';
4
+ export const LIMIT_THRESHOLD = (() => {
5
+ const raw = process.env['CLAUDE_RETRY_LIMIT_THRESHOLD'];
6
+ if (raw !== undefined) {
7
+ const n = Number(raw);
8
+ if (!Number.isNaN(n))
9
+ return n;
10
+ }
11
+ return 90;
12
+ })();
13
+ export function defaultConfigDir(env) {
14
+ const e = env ?? process.env;
15
+ return e['CLAUDE_CONFIG_DIR'] || path.join(os.homedir(), '.claude');
16
+ }
17
+ export async function readAccessToken(configDir, readFile = (p) => fsReadFile(p, 'utf8')) {
18
+ try {
19
+ const credPath = path.join(configDir, '.credentials.json');
20
+ const raw = await readFile(credPath);
21
+ const parsed = JSON.parse(raw);
22
+ const oauth = parsed['claudeAiOauth'];
23
+ if (oauth === null || typeof oauth !== 'object')
24
+ return null;
25
+ const oauthObj = oauth;
26
+ const token = oauthObj['accessToken'];
27
+ if (typeof token !== 'string' || token === '')
28
+ return null;
29
+ const expiresAt = oauthObj['expiresAt'];
30
+ const expiresAtMs = typeof expiresAt === 'number' ? expiresAt : null;
31
+ return { token, expiresAtMs };
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
38
+ const WINDOW_KEYS = ['five_hour', 'seven_day', 'seven_day_opus', 'seven_day_sonnet'];
39
+ function defaultFetchFn(url, init) {
40
+ return fetch(url, init).then((r) => ({
41
+ status: r.status,
42
+ json: () => r.json(),
43
+ }));
44
+ }
45
+ export async function fetchUsage(token, fetchFn = defaultFetchFn, threshold = LIMIT_THRESHOLD) {
46
+ try {
47
+ const res = await fetchFn(USAGE_URL, {
48
+ headers: {
49
+ 'Authorization': `Bearer ${token}`,
50
+ 'anthropic-beta': 'oauth-2025-04-20',
51
+ 'anthropic-version': '2023-06-01',
52
+ },
53
+ });
54
+ if (res.status !== 200)
55
+ return null;
56
+ const body = await res.json();
57
+ if (body === null || typeof body !== 'object')
58
+ return null;
59
+ const data = body;
60
+ const windows = [];
61
+ for (const key of WINDOW_KEYS) {
62
+ const w = data[key];
63
+ if (w === null || w === undefined || typeof w !== 'object')
64
+ continue;
65
+ const wObj = w;
66
+ const utilization = wObj['utilization'];
67
+ const resetsAt = wObj['resets_at'];
68
+ if (typeof utilization !== 'number')
69
+ continue;
70
+ const resetsAtMs = typeof resetsAt === 'string' ? Date.parse(resetsAt) : null;
71
+ windows.push({ utilization, resetsAtMs: resetsAtMs !== null && !Number.isNaN(resetsAtMs) ? resetsAtMs : null });
72
+ }
73
+ const overThreshold = windows.filter((w) => w.utilization >= threshold);
74
+ const limited = overThreshold.length > 0;
75
+ let resetsAtMs = null;
76
+ if (limited) {
77
+ for (const w of overThreshold) {
78
+ if (w.resetsAtMs !== null) {
79
+ if (resetsAtMs === null || w.resetsAtMs > resetsAtMs) {
80
+ resetsAtMs = w.resetsAtMs;
81
+ }
82
+ }
83
+ }
84
+ }
85
+ return { limited, resetsAtMs };
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ }
91
+ //# sourceMappingURL=usage.js.map
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.1",
3
+ "version": "0.1.3",
4
4
  "description": "Monitor Claude CLI in a zellij pane, auto-inject continue on rate-limit",
5
5
  "type": "module",
6
6
  "license": "MIT",