@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 +28 -30
- package/dist/accounts.d.ts +35 -0
- package/dist/accounts.js +197 -0
- package/dist/cli.js +48 -16
- package/dist/monitor.d.ts +20 -7
- package/dist/monitor.js +110 -33
- package/dist/usage.d.ts +23 -0
- package/dist/usage.js +91 -0
- package/dist/zellij.d.ts +33 -0
- package/dist/zellij.js +86 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# claude-retry
|
|
2
2
|
|
|
3
|
-
Watches every
|
|
3
|
+
Watches every pane across **all** your [zellij](https://zellij.dev/) sessions. When a pane hits Anthropic's usage/session limit, it detects the 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
|
-
|
|
16
|
-
|
|
17
|
-
1. In your main pane, run `claude` as normal.
|
|
18
|
-
2. Open a second pane (e.g. `Ctrl+p` then `d` to split down).
|
|
19
|
-
3. In the new pane, start the daemon:
|
|
15
|
+
Run it as a foreground daemon, ideally in its **own dedicated zellij session** (the daemon skips its own session, so this keeps it from scanning itself):
|
|
20
16
|
|
|
21
17
|
```bash
|
|
18
|
+
# in a session you keep around, e.g. "Claude Retry Monitor":
|
|
22
19
|
claude-retry start
|
|
23
20
|
```
|
|
24
21
|
|
|
25
|
-
`start`
|
|
22
|
+
`start` re-scans **every pass** (60s): it walks all live zellij sessions and every pane in them, dumps each pane's screen, and acts on the ones showing a rate-limit banner. This means:
|
|
23
|
+
|
|
24
|
+
- **One** daemon covers every session — projects, branches, all of them.
|
|
25
|
+
- It works on **detached** sessions (uses zellij's global `--session` flag, not attached clients).
|
|
26
|
+
- Open a brand-new Claude session anywhere → picked up on the next pass. **No restart needed.**
|
|
27
|
+
- Close a pane → dropped from the watch list silently.
|
|
28
|
+
- Each pane keeps its own independent rate-limit state.
|
|
26
29
|
|
|
27
|
-
|
|
28
|
-
- Open a new Claude session in a new pane → it's picked up automatically on the next pass. **No restart needed.**
|
|
29
|
-
- Close a Claude pane → it's dropped from the watch list silently.
|
|
30
|
-
- Each pane gets its own independent rate-limit state.
|
|
30
|
+
Leave it running — the pane *is* the daemon. zellij keeps it alive across detach/attach, so you don't need systemd or any external supervisor. Chatty logs stream to stderr so you always see signs of life.
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
> **Start Claude the right way for detection.** Launch the session with the plain `claude` command, then run the `/remote-control` slash command *inside* it. Do **not** use the `claude remote-control` CLI subcommand directly — that mode silences the on-screen text, so `dump-screen` captures nothing and the rate-limit banner can't be detected. Running `claude` → `/remote-control` keeps the session "live" and visible to the monitor.
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
### Single-pane mode (legacy)
|
|
35
35
|
|
|
36
|
-
To
|
|
36
|
+
To watch just one pane in the **current** session, by ID:
|
|
37
37
|
|
|
38
38
|
```bash
|
|
39
|
-
# Watch one specific pane by ID:
|
|
40
39
|
claude-retry monitor 3
|
|
41
|
-
|
|
42
|
-
# Or restrict 'start' to one pane via env:
|
|
43
|
-
CLAUDE_PANE_ID=3 claude-retry start
|
|
44
40
|
```
|
|
45
41
|
|
|
46
42
|
### Optional: shell wrapper
|
|
@@ -57,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** — `
|
|
71
|
-
2. **Capture** —
|
|
72
|
-
3. **Match** —
|
|
73
|
-
4. **
|
|
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
|
-
|
|
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 use — not 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
|
package/dist/accounts.js
ADDED
|
@@ -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,
|
|
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
|
|
8
|
-
claude-retry monitor <pane-id> Watch one
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
27
|
-
sleep
|
|
28
|
-
|
|
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,
|
|
73
|
+
await runMonitor(paneId, singleDeps);
|
|
43
74
|
break;
|
|
44
75
|
}
|
|
45
76
|
case 'start': {
|
|
46
|
-
log('claude-retry daemon starting —
|
|
47
|
-
|
|
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
|
-
|
|
8
|
-
|
|
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
|
|
34
|
+
* One discovery+monitor pass over every Claude pane in every live session.
|
|
23
35
|
*
|
|
24
|
-
* Re-discovers
|
|
25
|
-
* closed
|
|
26
|
-
* and persists across calls. A failed
|
|
27
|
-
* capture/inject error is swallowed so one bad
|
|
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
|
-
|
|
7
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Core state transition for one pane, given its current screen text.
|
|
8
|
+
* `injectContinue` is called only when a wait period has elapsed. Shared by
|
|
9
|
+
* single-pane (tick) and multi-session (tickTarget) monitoring.
|
|
10
|
+
*
|
|
11
|
+
* 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 (
|
|
16
|
+
if (now < state.waitUntil) {
|
|
10
17
|
return 'rate-limited';
|
|
11
18
|
}
|
|
12
19
|
// Wait period elapsed — inject continue
|
|
13
|
-
await
|
|
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(
|
|
24
|
-
state.waitUntil =
|
|
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
|
|
104
|
+
* One discovery+monitor pass over every Claude pane in every live session.
|
|
39
105
|
*
|
|
40
|
-
* Re-discovers
|
|
41
|
-
* closed
|
|
42
|
-
* and persists across calls. A failed
|
|
43
|
-
* capture/inject error is swallowed so one bad
|
|
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
|
|
114
|
+
let targets;
|
|
48
115
|
try {
|
|
49
|
-
|
|
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(
|
|
58
|
-
for (const
|
|
59
|
-
if (!live.has(
|
|
60
|
-
states.delete(
|
|
61
|
-
log(
|
|
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(
|
|
141
|
+
log(targets.length === 0
|
|
65
142
|
? 'scan: no Claude panes found'
|
|
66
|
-
: `scan: watching ${
|
|
67
|
-
for (const
|
|
68
|
-
let state = states.get(
|
|
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(
|
|
72
|
-
log(
|
|
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
|
|
77
|
-
logPaneStatus(log,
|
|
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(
|
|
158
|
+
log(`${target.label} — capture/inject error (skipped this round)`);
|
|
82
159
|
}
|
|
83
160
|
}
|
|
84
161
|
}
|
|
85
|
-
function logPaneStatus(log,
|
|
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(
|
|
165
|
+
log(`${label} — RATE LIMITED, waiting until ${until}`);
|
|
89
166
|
}
|
|
90
167
|
else if (status === 'rate-limited') {
|
|
91
|
-
log(
|
|
168
|
+
log(`${label} — still waiting for reset`);
|
|
92
169
|
}
|
|
93
170
|
else if (status === 'retried') {
|
|
94
|
-
log(
|
|
171
|
+
log(`${label} — reset reached, cleared input + injected 'continue'`);
|
|
95
172
|
}
|
|
96
173
|
else {
|
|
97
|
-
log(
|
|
174
|
+
log(`${label} — ok`);
|
|
98
175
|
}
|
|
99
176
|
}
|
|
100
177
|
export async function runMultiMonitor(deps, pollIntervalMs, marginSeconds, fallbackHours) {
|
package/dist/usage.d.ts
ADDED
|
@@ -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) {
|