@tigorhutasuhut/claude-retry 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tigor Hutasuhut
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # claude-retry
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.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g @tigorhutasuhut/claude-retry
9
+ ```
10
+
11
+ The package is scoped (`@tigorhutasuhut/claude-retry`); the installed command is still `claude-retry`.
12
+
13
+ ## Usage
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:
20
+
21
+ ```bash
22
+ claude-retry start
23
+ ```
24
+
25
+ `start` rediscovers Claude panes on **every pass** (every 60s) via `zellij action list-clients`. This means:
26
+
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.
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.
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.
35
+
36
+ To pin the daemon to a single pane instead of auto-discovery:
37
+
38
+ ```bash
39
+ # Watch one specific pane by ID:
40
+ claude-retry monitor 3
41
+
42
+ # Or restrict 'start' to one pane via env:
43
+ CLAUDE_PANE_ID=3 claude-retry start
44
+ ```
45
+
46
+ ### Optional: shell wrapper
47
+
48
+ A shell wrapper is included that launches Claude and a monitor pane together when you run `claude`. It is **optional** — the foreground daemon above is the simpler, recommended path. Source it only if you want the auto-spawn behavior:
49
+
50
+ **Fish** (`~/.config/fish/config.fish`):
51
+ ```fish
52
+ source (npm root -g)/claude-retry/shell/wrapper.fish
53
+ ```
54
+
55
+ **Bash** (`~/.bashrc`) / **Zsh** (`~/.zshrc`):
56
+ ```bash
57
+ source "$(npm root -g)/claude-retry/shell/wrapper.bash"
58
+ ```
59
+
60
+ ## Configuration
61
+
62
+ ```bash
63
+ CLAUDE_PANE_ID=3 claude-retry start # pin to one pane, skip auto-discovery
64
+ ```
65
+
66
+ ## How it works
67
+
68
+ Every pass (60s for `start`, 5s for single-pane `monitor`):
69
+
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`.
74
+
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.
76
+
77
+ ## Requirements
78
+
79
+ - Node.js >= 20
80
+ - zellij >= 0.40
81
+ - Must be inside a zellij session when running
82
+
83
+ ## Development
84
+
85
+ ```bash
86
+ npm install
87
+ npm run typecheck # tsc --noEmit
88
+ npm test # node --test
89
+ npm run build # tsc -> dist/
90
+ npm run verify # typecheck + test + build (the publish gate)
91
+ ```
92
+
93
+ ## Publishing
94
+
95
+ Releases are published to npm by GitHub Actions ([.github/workflows/publish.yml](.github/workflows/publish.yml)) when a GitHub Release is published. Auth uses npm [Trusted Publishing](https://docs.npmjs.com/trusted-publishers) (OIDC) — **no `NPM_TOKEN` secret** — and [provenance](https://docs.npmjs.com/generating-provenance-statements) is attached automatically.
96
+
97
+ ### One-time setup
98
+
99
+ 1. **Bootstrap the package** (trusted publishing can only be configured on a package that already exists). Publish `0.1.0` once from your machine:
100
+ ```bash
101
+ npm login
102
+ npm run verify
103
+ npm publish --provenance=false
104
+ ```
105
+ `--provenance=false` is required for this local bootstrap: provenance is only generated in CI via OIDC (`publishConfig.provenance` stays `true` for the Actions publish). Without the flag, a local publish fails with `Automatic provenance generation not supported for provider: null`.
106
+ 2. **Configure the trusted publisher** on npmjs.com: open the package → **Settings → Trusted Publisher → GitHub Actions**, and set:
107
+ - Organization or user: `tigorlazuardi`
108
+ - Repository: `claude-retry`
109
+ - Workflow filename: `publish.yml`
110
+ 3. (Recommended) In package **Settings**, set publishing access to **require two-factor or trusted publisher**, which disables token publishes entirely.
111
+
112
+ ### Cutting a release
113
+
114
+ ```bash
115
+ npm version patch # bump version + create git tag
116
+ git push --follow-tags
117
+ gh release create vX.Y.Z --generate-notes
118
+ ```
119
+
120
+ Publishing the GitHub Release triggers the workflow, which runs `npm ci` then `npm publish`. `prepublishOnly` (`npm run verify`) gates the publish on a clean typecheck, test, and build.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=cli.d.ts.map
package/dist/cli.js ADDED
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+ import { capturePane, inject, listClaudePanes } from "./zellij.js";
3
+ import { runMonitor, runMultiMonitor } from "./monitor.js";
4
+ const USAGE = `claude-retry — Auto-inject 'continue' when Claude hits a rate limit in zellij
5
+
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
9
+ claude-retry help Show this help
10
+
11
+ Options:
12
+ CLAUDE_PANE_ID=<id> Pin 'start' to a single pane instead of auto-discovery
13
+
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.`;
18
+ /** Timestamped stderr logger — chatty so the daemon shows clear signs of life. */
19
+ function log(msg) {
20
+ const ts = new Date().toISOString().slice(11, 19); // HH:MM:SS
21
+ process.stderr.write(`[${ts}] ${msg}\n`);
22
+ }
23
+ const deps = {
24
+ capture: (id) => capturePane(id),
25
+ inject: (id, text) => inject(id, text),
26
+ now: () => Date.now(),
27
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
28
+ listPanes: () => listClaudePanes(),
29
+ log,
30
+ };
31
+ const [, , subcommand, ...rest] = process.argv;
32
+ async function main() {
33
+ switch (subcommand) {
34
+ case 'monitor': {
35
+ const paneId = rest[0];
36
+ if (!paneId) {
37
+ console.error('Error: pane-id required\n');
38
+ console.error(USAGE);
39
+ process.exit(1);
40
+ }
41
+ log(`monitoring single pane ${paneId} (poll 5s)`);
42
+ await runMonitor(paneId, deps);
43
+ break;
44
+ }
45
+ case 'start': {
46
+ log('claude-retry daemon starting — discovering Claude panes (poll 60s)');
47
+ await runMultiMonitor(deps);
48
+ break;
49
+ }
50
+ case 'help': {
51
+ console.log(USAGE);
52
+ process.exit(0);
53
+ break;
54
+ }
55
+ default: {
56
+ console.error(USAGE);
57
+ process.exit(1);
58
+ }
59
+ }
60
+ }
61
+ main().catch((err) => {
62
+ console.error(err instanceof Error ? err.message : String(err));
63
+ process.exit(1);
64
+ });
65
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,3 @@
1
+ import type { MonitorDeps } from './monitor.ts';
2
+ export declare function buildDeps(): MonitorDeps;
3
+ //# sourceMappingURL=launcher.d.ts.map
@@ -0,0 +1,10 @@
1
+ import { capturePane, inject } from "./zellij.js";
2
+ export function buildDeps() {
3
+ return {
4
+ capture: (id) => capturePane(id),
5
+ inject: (id, text) => inject(id, text),
6
+ now: () => Date.now(),
7
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
8
+ };
9
+ }
10
+ //# sourceMappingURL=launcher.js.map
@@ -0,0 +1,31 @@
1
+ export interface MonitorDeps {
2
+ capture: (paneId: string) => Promise<string>;
3
+ inject: (paneId: string, text: string) => Promise<void>;
4
+ now: () => number;
5
+ sleep: (ms: number) => Promise<void>;
6
+ }
7
+ export interface MultiMonitorDeps extends MonitorDeps {
8
+ listPanes: () => Promise<string[]>;
9
+ /** Optional sink for chatty progress logs (wired to stderr by the CLI). */
10
+ log?: (msg: string) => void;
11
+ }
12
+ export type PaneStates = Map<string, MonitorState>;
13
+ export type MonitorStatus = 'monitoring' | 'rate-limited' | 'retried' | 'exited';
14
+ export interface MonitorState {
15
+ status: 'monitoring' | 'waiting';
16
+ waitUntil: number;
17
+ }
18
+ export declare function createState(): MonitorState;
19
+ export declare function tick(paneId: string, state: MonitorState, deps: MonitorDeps, marginSeconds?: number, fallbackHours?: number): Promise<MonitorStatus>;
20
+ export declare function runMonitor(paneId: string, deps: MonitorDeps, pollIntervalMs?: number, marginSeconds?: number, fallbackHours?: number): Promise<void>;
21
+ /**
22
+ * One discovery+monitor pass over every live Claude pane.
23
+ *
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.
28
+ */
29
+ export declare function multiTick(states: PaneStates, deps: MultiMonitorDeps, marginSeconds?: number, fallbackHours?: number): Promise<void>;
30
+ export declare function runMultiMonitor(deps: MultiMonitorDeps, pollIntervalMs?: number, marginSeconds?: number, fallbackHours?: number): Promise<void>;
31
+ //# sourceMappingURL=monitor.d.ts.map
@@ -0,0 +1,107 @@
1
+ import { match } from "./patterns.js";
2
+ import { parseResetTime, calculateWaitMs } from "./time-parser.js";
3
+ export function createState() {
4
+ return { status: 'monitoring', waitUntil: 0 };
5
+ }
6
+ export async function tick(paneId, state, deps, marginSeconds, fallbackHours) {
7
+ const screenText = await deps.capture(paneId);
8
+ if (state.status === 'waiting') {
9
+ if (deps.now() < state.waitUntil) {
10
+ return 'rate-limited';
11
+ }
12
+ // Wait period elapsed — inject continue
13
+ await deps.inject(paneId, 'continue');
14
+ state.status = 'monitoring';
15
+ state.waitUntil = 0;
16
+ return 'retried';
17
+ }
18
+ // state.status === 'monitoring'
19
+ const result = match(screenText);
20
+ if (result.limited) {
21
+ const resetLine = result.resetLine ?? '';
22
+ const parsed = parseResetTime(resetLine);
23
+ const waitMs = calculateWaitMs(parsed, marginSeconds, fallbackHours, new Date(deps.now()));
24
+ state.waitUntil = deps.now() + waitMs;
25
+ state.status = 'waiting';
26
+ return 'rate-limited';
27
+ }
28
+ return 'monitoring';
29
+ }
30
+ export async function runMonitor(paneId, deps, pollIntervalMs, marginSeconds, fallbackHours) {
31
+ const state = createState();
32
+ for (;;) {
33
+ await deps.sleep(pollIntervalMs ?? 5000);
34
+ await tick(paneId, state, deps, marginSeconds, fallbackHours);
35
+ }
36
+ }
37
+ /**
38
+ * One discovery+monitor pass over every live Claude pane.
39
+ *
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.
44
+ */
45
+ export async function multiTick(states, deps, marginSeconds, fallbackHours) {
46
+ const log = deps.log ?? (() => { });
47
+ let panes;
48
+ try {
49
+ panes = await deps.listPanes();
50
+ }
51
+ catch {
52
+ // Discovery failed this round — keep existing states, retry next tick.
53
+ log('scan failed: could not list panes (will retry)');
54
+ return;
55
+ }
56
+ // 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`);
62
+ }
63
+ }
64
+ log(panes.length === 0
65
+ ? '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);
69
+ if (!state) {
70
+ state = createState();
71
+ states.set(id, state);
72
+ log(`pane ${id} — new Claude session, now watching`);
73
+ }
74
+ const before = state.status;
75
+ try {
76
+ const status = await tick(id, state, deps, marginSeconds, fallbackHours);
77
+ logPaneStatus(log, id, before, state, status);
78
+ }
79
+ catch {
80
+ // This pane's capture/inject failed — leave its state, keep going.
81
+ log(`pane ${id} — capture/inject error (skipped this round)`);
82
+ }
83
+ }
84
+ }
85
+ function logPaneStatus(log, id, before, state, status) {
86
+ if (status === 'rate-limited' && before === 'monitoring') {
87
+ const until = new Date(state.waitUntil).toISOString();
88
+ log(`pane ${id} — RATE LIMITED, waiting until ${until}`);
89
+ }
90
+ else if (status === 'rate-limited') {
91
+ log(`pane ${id} — still waiting for reset`);
92
+ }
93
+ else if (status === 'retried') {
94
+ log(`pane ${id} — reset reached, injected 'continue'`);
95
+ }
96
+ else {
97
+ log(`pane ${id} — ok`);
98
+ }
99
+ }
100
+ export async function runMultiMonitor(deps, pollIntervalMs, marginSeconds, fallbackHours) {
101
+ const states = new Map();
102
+ for (;;) {
103
+ await deps.sleep(pollIntervalMs ?? 60000);
104
+ await multiTick(states, deps, marginSeconds, fallbackHours);
105
+ }
106
+ }
107
+ //# sourceMappingURL=monitor.js.map
@@ -0,0 +1,7 @@
1
+ export declare function stripAnsi(text: string): string;
2
+ export interface MatchResult {
3
+ limited: boolean;
4
+ resetLine: string | null;
5
+ }
6
+ export declare function match(text: string): MatchResult;
7
+ //# sourceMappingURL=patterns.d.ts.map
@@ -0,0 +1,49 @@
1
+ const CSI_REGEX = /\x1b\[[\x20-\x3f]*[\x40-\x7e]/g;
2
+ const OSC_REGEX = /\x1b\][\s\S]*?(?:\x07|\x1b\\)/g;
3
+ const DCS_REGEX = /\x1bP[\s\S]*?(?:\x07|\x1b\\)/g;
4
+ const OTHER_ESC_REGEX = /\x1b[_X^][\s\S]*?(?:\x07|\x1b\\)/g;
5
+ export function stripAnsi(text) {
6
+ return text
7
+ .replace(CSI_REGEX, '')
8
+ .replace(OSC_REGEX, '')
9
+ .replace(DCS_REGEX, '')
10
+ .replace(OTHER_ESC_REGEX, '');
11
+ }
12
+ const LIMIT_PATTERNS = [
13
+ /claude\.ai\/settings/i,
14
+ /usage limit/i,
15
+ /rate.?limit/i,
16
+ /\blimit\b.*\breached\b/i,
17
+ /\breached\b.*\blimit\b/i,
18
+ /\bhit\b.*\blimit\b/i,
19
+ /\blimit\b.*\bexceeded\b/i,
20
+ ];
21
+ const RESET_PATTERNS = [
22
+ /reset/i,
23
+ /try again/i,
24
+ /available/i,
25
+ ];
26
+ const WINDOW = 6;
27
+ export function match(text) {
28
+ const stripped = stripAnsi(text);
29
+ const lines = stripped.split('\n');
30
+ for (let i = 0; i < lines.length; i++) {
31
+ const line = lines[i];
32
+ const isLimitLine = LIMIT_PATTERNS.some((p) => p.test(line));
33
+ if (!isLimitLine)
34
+ continue;
35
+ // Search nearby lines (within WINDOW) for a reset line
36
+ const start = Math.max(0, i - WINDOW);
37
+ const end = Math.min(lines.length - 1, i + WINDOW);
38
+ for (let j = start; j <= end; j++) {
39
+ const nearby = lines[j];
40
+ if (RESET_PATTERNS.some((p) => p.test(nearby))) {
41
+ return { limited: true, resetLine: nearby.trim() };
42
+ }
43
+ }
44
+ // No reset line found nearby — return the limit line itself
45
+ return { limited: true, resetLine: line.trim() };
46
+ }
47
+ return { limited: false, resetLine: null };
48
+ }
49
+ //# sourceMappingURL=patterns.js.map
@@ -0,0 +1,14 @@
1
+ export interface AbsoluteTime {
2
+ hour: number;
3
+ minute: number;
4
+ timezone: string | null;
5
+ ambiguous: boolean;
6
+ }
7
+ export interface RelativeTime {
8
+ relative: true;
9
+ waitMs: number;
10
+ }
11
+ export type ParsedTime = AbsoluteTime | RelativeTime | null;
12
+ export declare function parseResetTime(text: string): ParsedTime;
13
+ export declare function calculateWaitMs(parsed: ParsedTime, marginSeconds?: number, fallbackHours?: number, now?: Date): number;
14
+ //# sourceMappingURL=time-parser.d.ts.map
@@ -0,0 +1,148 @@
1
+ const RESET_TIME_REGEX = /resets?\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*(?:\(([^)]+)\))?/i;
2
+ const RELATIVE_TIME_REGEX = /(?:try again|wait|resets?\s+in)[:\s]\s*(?:for\s+)?(?:in\s+)?(\d+)\s*(hours?|minutes?|mins?|h|m)\b/i;
3
+ export function parseResetTime(text) {
4
+ const relMatch = RELATIVE_TIME_REGEX.exec(text);
5
+ if (relMatch) {
6
+ const amount = parseInt(relMatch[1], 10);
7
+ const unit = relMatch[2].toLowerCase();
8
+ const isHours = unit.startsWith('h');
9
+ const waitMs = isHours ? amount * 3600000 : amount * 60000;
10
+ return { relative: true, waitMs };
11
+ }
12
+ const absMatch = RESET_TIME_REGEX.exec(text);
13
+ if (absMatch) {
14
+ let hour = parseInt(absMatch[1], 10);
15
+ const minute = absMatch[2] ? parseInt(absMatch[2], 10) : 0;
16
+ const meridiem = absMatch[3] ? absMatch[3].toLowerCase() : null;
17
+ const timezone = absMatch[4] ? absMatch[4].trim() : null;
18
+ const ambiguous = meridiem === null;
19
+ if (!ambiguous) {
20
+ if (meridiem === 'pm' && hour !== 12) {
21
+ hour += 12;
22
+ }
23
+ else if (meridiem === 'am' && hour === 12) {
24
+ hour = 0;
25
+ }
26
+ }
27
+ return { hour, minute, timezone, ambiguous };
28
+ }
29
+ return null;
30
+ }
31
+ function getOffsetMs(timezone, date) {
32
+ try {
33
+ // Format the date in the target timezone to get local time
34
+ const formatter = new Intl.DateTimeFormat('en-US', {
35
+ timeZone: timezone,
36
+ year: 'numeric',
37
+ month: '2-digit',
38
+ day: '2-digit',
39
+ hour: '2-digit',
40
+ minute: '2-digit',
41
+ second: '2-digit',
42
+ hour12: false,
43
+ });
44
+ const parts = formatter.formatToParts(date);
45
+ const get = (type) => parseInt(parts.find((p) => p.type === type)?.value ?? '0', 10);
46
+ const year = get('year');
47
+ const month = get('month') - 1;
48
+ const day = get('day');
49
+ const hour = get('hour') % 24; // hour12:false can give 24 for midnight
50
+ const minute = get('minute');
51
+ const second = get('second');
52
+ const localMs = Date.UTC(year, month, day, hour, minute, second);
53
+ return date.getTime() - localMs;
54
+ }
55
+ catch {
56
+ return 0; // invalid timezone — offset unknown
57
+ }
58
+ }
59
+ function isValidTimezone(tz) {
60
+ try {
61
+ Intl.DateTimeFormat(undefined, { timeZone: tz });
62
+ return true;
63
+ }
64
+ catch {
65
+ return false;
66
+ }
67
+ }
68
+ export function calculateWaitMs(parsed, marginSeconds = 60, fallbackHours = 5, now = new Date()) {
69
+ const fallbackMs = fallbackHours * 3600000 + marginSeconds * 1000;
70
+ if (parsed === null) {
71
+ return fallbackMs;
72
+ }
73
+ if ('relative' in parsed) {
74
+ return parsed.waitMs + marginSeconds * 1000;
75
+ }
76
+ // Absolute time
77
+ const { hour, minute, timezone, ambiguous } = parsed;
78
+ const tryCalculate = (h) => {
79
+ if (timezone && isValidTimezone(timezone)) {
80
+ // DST-safe: build a candidate time in the target timezone
81
+ // Start from "today" in that timezone and find next occurrence of h:minute
82
+ const nowMs = now.getTime();
83
+ const tzOffset = getOffsetMs(timezone, now);
84
+ // Local time in target tz
85
+ const localMs = nowMs - tzOffset;
86
+ const localDate = new Date(localMs);
87
+ const localMidnight = Date.UTC(localDate.getUTCFullYear(), localDate.getUTCMonth(), localDate.getUTCDate());
88
+ // Candidate: today at h:minute in that tz
89
+ let candidateLocalMs = localMidnight + h * 3600000 + minute * 60000;
90
+ let candidateUtcMs = candidateLocalMs + tzOffset;
91
+ // DST correction: the offset may differ at the candidate time
92
+ // Iteratively correct until stable
93
+ for (let i = 0; i < 3; i++) {
94
+ const candidateDate = new Date(candidateUtcMs);
95
+ const candidateTzOffset = getOffsetMs(timezone, candidateDate);
96
+ const corrected = candidateLocalMs + candidateTzOffset;
97
+ if (corrected === candidateUtcMs)
98
+ break;
99
+ candidateUtcMs = corrected;
100
+ }
101
+ if (candidateUtcMs <= nowMs) {
102
+ // Tomorrow rollover — advance local midnight by one day
103
+ candidateLocalMs += 86400000;
104
+ candidateUtcMs = candidateLocalMs + tzOffset;
105
+ for (let i = 0; i < 3; i++) {
106
+ const candidateDate = new Date(candidateUtcMs);
107
+ const candidateTzOffset = getOffsetMs(timezone, candidateDate);
108
+ const corrected = candidateLocalMs + candidateTzOffset;
109
+ if (corrected === candidateUtcMs)
110
+ break;
111
+ candidateUtcMs = corrected;
112
+ }
113
+ }
114
+ return candidateUtcMs - nowMs + marginSeconds * 1000;
115
+ }
116
+ else {
117
+ // No timezone or invalid — assume UTC
118
+ const nowMs = now.getTime();
119
+ const nowDate = now;
120
+ const midnight = Date.UTC(nowDate.getUTCFullYear(), nowDate.getUTCMonth(), nowDate.getUTCDate());
121
+ let candidateMs = midnight + h * 3600000 + minute * 60000;
122
+ if (candidateMs <= nowMs) {
123
+ candidateMs += 86400000;
124
+ }
125
+ return candidateMs - nowMs + marginSeconds * 1000;
126
+ }
127
+ };
128
+ if (ambiguous) {
129
+ // No am/pm: check both interpretations, pick the sooner future one
130
+ const pmHour = hour === 12 ? 12 : hour + 12;
131
+ const amHour = hour === 12 ? 0 : hour;
132
+ const waitAm = tryCalculate(amHour);
133
+ const waitPm = tryCalculate(pmHour);
134
+ // Return the sooner positive wait
135
+ if (waitAm > 0 && waitPm > 0)
136
+ return Math.min(waitAm, waitPm);
137
+ if (waitAm > 0)
138
+ return waitAm;
139
+ if (waitPm > 0)
140
+ return waitPm;
141
+ return fallbackMs;
142
+ }
143
+ const wait = tryCalculate(hour);
144
+ if (wait <= 0)
145
+ return fallbackMs;
146
+ return wait;
147
+ }
148
+ //# sourceMappingURL=time-parser.js.map
@@ -0,0 +1,16 @@
1
+ export type ExecFileFn = (cmd: string, args: string[]) => Promise<{
2
+ stdout: string;
3
+ stderr: string;
4
+ }>;
5
+ export declare function capturePane(paneId: string | number, execFileFn?: ExecFileFn): Promise<string>;
6
+ export declare function inject(paneId: string | number, text: string, execFileFn?: ExecFileFn): Promise<void>;
7
+ /**
8
+ * Discover every live Claude pane (deduped pane IDs).
9
+ *
10
+ * Honors CLAUDE_PANE_ID as an explicit single-pane override. Otherwise parses
11
+ * `zellij action list-clients` and returns every pane whose RUNNING_COMMAND is
12
+ * the `claude` CLI. Returns [] on failure so the caller can retry next tick.
13
+ */
14
+ export declare function listClaudePanes(execFileFn?: ExecFileFn): Promise<string[]>;
15
+ export declare function resolvePaneId(execFileFn?: ExecFileFn): Promise<string>;
16
+ //# sourceMappingURL=zellij.d.ts.map
package/dist/zellij.js ADDED
@@ -0,0 +1,101 @@
1
+ import { execFile as _execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ const _execFilePromise = promisify(_execFile);
4
+ const defaultExecFile = (cmd, args) => _execFilePromise(cmd, args, { cwd: process.cwd() });
5
+ export async function capturePane(paneId, execFileFn = defaultExecFile) {
6
+ const { stdout } = await execFileFn('zellij', [
7
+ 'action',
8
+ 'dump-screen',
9
+ '--pane-id',
10
+ String(paneId),
11
+ ]);
12
+ return stdout;
13
+ }
14
+ export async function inject(paneId, text, execFileFn = defaultExecFile) {
15
+ await execFileFn('zellij', ['action', 'write-chars', '--pane-id', String(paneId), text]);
16
+ await execFileFn('zellij', ['action', 'write', '--pane-id', String(paneId), '13']);
17
+ }
18
+ /** True when a list-clients RUNNING_COMMAND is the `claude` CLI itself.
19
+ * Excludes `claude-retry` (the monitor's own pane) and other claude-* tools. */
20
+ function paneCommandIsClaude(runningCommand) {
21
+ const first = runningCommand.trim().split(/\s+/)[0] ?? '';
22
+ const base = first.split('/').pop() ?? '';
23
+ return base === 'claude';
24
+ }
25
+ /**
26
+ * Discover every live Claude pane (deduped pane IDs).
27
+ *
28
+ * Honors CLAUDE_PANE_ID as an explicit single-pane override. Otherwise parses
29
+ * `zellij action list-clients` and returns every pane whose RUNNING_COMMAND is
30
+ * the `claude` CLI. Returns [] on failure so the caller can retry next tick.
31
+ */
32
+ export async function listClaudePanes(execFileFn = defaultExecFile) {
33
+ const envId = process.env['CLAUDE_PANE_ID'];
34
+ if (envId)
35
+ return [envId];
36
+ const ids = new Set();
37
+ try {
38
+ const { stdout } = await execFileFn('zellij', ['action', 'list-clients']);
39
+ for (const line of stdout.split('\n').slice(1)) {
40
+ const trimmed = line.trim();
41
+ if (!trimmed)
42
+ continue;
43
+ const parts = trimmed.split(/\s+/);
44
+ const paneId = parts[1];
45
+ const runningCommand = parts.slice(2).join(' ');
46
+ if (paneId !== undefined && paneCommandIsClaude(runningCommand)) {
47
+ ids.add(paneId);
48
+ }
49
+ }
50
+ }
51
+ catch {
52
+ // list-clients failed — return what we have; next tick retries.
53
+ }
54
+ return [...ids];
55
+ }
56
+ export async function resolvePaneId(execFileFn = defaultExecFile) {
57
+ // 1. Explicit env var
58
+ const envId = process.env['CLAUDE_PANE_ID'];
59
+ if (envId) {
60
+ return envId;
61
+ }
62
+ // 2. list-clients → parse tabular output → find row with "claude" in RUNNING_COMMAND
63
+ try {
64
+ const { stdout: clientsOut } = await execFileFn('zellij', ['action', 'list-clients']);
65
+ const clientsLines = clientsOut.split('\n');
66
+ // Header: CLIENT_ID ZELLIJ_PANE_ID RUNNING_COMMAND
67
+ // Skip header line (index 0), iterate remaining non-empty lines
68
+ for (const line of clientsLines.slice(1)) {
69
+ const trimmed = line.trim();
70
+ if (!trimmed)
71
+ continue;
72
+ const parts = trimmed.split(/\s+/);
73
+ // parts[0] = CLIENT_ID, parts[1] = ZELLIJ_PANE_ID, parts[2..] = RUNNING_COMMAND
74
+ const runningCommand = parts.slice(2).join(' ');
75
+ if (runningCommand.toLowerCase().includes('claude')) {
76
+ const paneId = parts[1];
77
+ if (paneId !== undefined) {
78
+ return paneId;
79
+ }
80
+ }
81
+ }
82
+ }
83
+ catch {
84
+ // list-clients failed — fall through to list-panes
85
+ }
86
+ // 3. list-panes -j → filter is_plugin=false → find title contains "claude"
87
+ try {
88
+ const { stdout: panesOut } = await execFileFn('zellij', ['action', 'list-panes', '-j']);
89
+ const panes = JSON.parse(panesOut);
90
+ const match = panes.find((p) => !p.is_plugin && p.title?.toLowerCase().includes('claude'));
91
+ if (match !== undefined) {
92
+ return String(match.id);
93
+ }
94
+ }
95
+ catch {
96
+ // list-panes failed — fall through to abort
97
+ }
98
+ // 4. Abort
99
+ throw new Error('Cannot resolve Claude pane ID. Set CLAUDE_PANE_ID env var or --pane-id.');
100
+ }
101
+ //# sourceMappingURL=zellij.js.map
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@tigorhutasuhut/claude-retry",
3
+ "version": "0.1.0",
4
+ "description": "Monitor Claude CLI in a zellij pane, auto-inject continue on rate-limit",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Tigor Hutasuhut <tigor.hutasuhut@gmail.com>",
8
+ "homepage": "https://github.com/tigorlazuardi/claude-retry#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/tigorlazuardi/claude-retry.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/tigorlazuardi/claude-retry/issues"
15
+ },
16
+ "keywords": [
17
+ "claude",
18
+ "zellij",
19
+ "rate-limit",
20
+ "cli",
21
+ "anthropic",
22
+ "automation"
23
+ ],
24
+ "engines": {
25
+ "node": ">=20"
26
+ },
27
+ "files": [
28
+ "dist/**/*.js",
29
+ "dist/**/*.d.ts",
30
+ "shell/"
31
+ ],
32
+ "bin": {
33
+ "claude-retry": "dist/cli.js"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public",
37
+ "provenance": true
38
+ },
39
+ "scripts": {
40
+ "build": "tsc",
41
+ "typecheck": "tsc --noEmit",
42
+ "test": "node --test 'test/**/*.test.ts'",
43
+ "verify": "npm run typecheck && npm test && npm run build",
44
+ "prepublishOnly": "npm run verify"
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "25.9.1",
48
+ "typescript": "^5.0.0"
49
+ }
50
+ }
@@ -0,0 +1,22 @@
1
+ # >>> claude-retry >>>
2
+ claude() {
3
+ if [ -z "${ZELLIJ:-}" ]; then
4
+ echo "[claude-retry] Must run inside a zellij session" >&2
5
+ return 1
6
+ fi
7
+
8
+ # Launch claude in a new pane, capture pane ID
9
+ local pane_result
10
+ pane_result=$(zellij run --close-on-exit -- claude "$@" 2>/dev/null)
11
+ local pane_id
12
+ pane_id=$(echo "$pane_result" | grep -oE '[0-9]+$')
13
+
14
+ if [ -z "$pane_id" ]; then
15
+ echo "[claude-retry] Failed to get pane ID from zellij run" >&2
16
+ return 1
17
+ fi
18
+
19
+ # Start claude-retry monitor in a background pane
20
+ CLAUDE_PANE_ID="$pane_id" zellij run --close-on-exit --name "claude-retry" -- claude-retry monitor "$pane_id"
21
+ }
22
+ # <<< claude-retry <<<
@@ -0,0 +1,23 @@
1
+ # >>> claude-retry >>>
2
+ function claude
3
+ if not set -q ZELLIJ
4
+ echo "[claude-retry] Must run inside a zellij session" >&2
5
+ return 1
6
+ end
7
+
8
+ # Launch claude in a new pane, capture pane ID
9
+ # zellij run returns "terminal_N" on stdout
10
+ set -l pane_result (zellij run --close-on-exit -- claude $argv 2>/dev/null)
11
+ # pane_result is like "terminal_3" — extract the numeric ID
12
+ set -l pane_id (echo $pane_result | grep -oE '[0-9]+$')
13
+
14
+ if test -z "$pane_id"
15
+ echo "[claude-retry] Failed to get pane ID from zellij run" >&2
16
+ return 1
17
+ end
18
+
19
+ # Start claude-retry monitor in a background pane
20
+ set -x CLAUDE_PANE_ID $pane_id
21
+ zellij run --close-on-exit --name "claude-retry" -- claude-retry monitor $pane_id
22
+ end
23
+ # <<< claude-retry <<<