@tigorhutasuhut/herdr-claude-retry 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -82,9 +82,9 @@ npm run verify # typecheck + test + build (publish gate)
82
82
  npm run e2e # acceptance test — needs live herdr
83
83
  ```
84
84
 
85
- The e2e test (`test/e2e/blocked-pane.e2e.ts`) creates a temporary herdr workspace, sends a
86
- rate-limit banner to its root pane, runs the daemon against it, and asserts the daemon detects the
87
- banner. It skips gracefully if no herdr socket is found.
85
+ The e2e test (`test/e2e/blocked-pane.e2e.ts`) verifies live connectivity to the herdr socket:
86
+ connects, lists agents, reads all live panes (the core one-shot protocol fix), and runs a daemon
87
+ reconcile sweep asserting zero paneRead failures. Skips gracefully if no herdr socket is found.
88
88
 
89
89
  ## Publishing
90
90
 
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Account discovery and pane→account resolution.
3
+ *
4
+ * Primary resolution path: UUID→jsonl glob scan across discovered config dirs.
5
+ * Fallback: shell PID → /proc/<pid>/environ → CLAUDE_CONFIG_DIR.
6
+ */
7
+ export declare function parseConfigDirFromEnviron(buf: string): string | null;
8
+ export interface DiscoverDeps {
9
+ platform?: string;
10
+ readdir: (path: string) => Promise<string[]>;
11
+ readFile: (path: string) => Promise<string>;
12
+ defaultDir: () => string;
13
+ }
14
+ export interface ResolveDeps {
15
+ platform?: string;
16
+ /** List /proc pids */
17
+ listProcPids: () => Promise<string[]>;
18
+ /** Read /proc/<pid>/cmdline */
19
+ readCmdline: (pid: string) => Promise<string>;
20
+ /** Read /proc/<pid>/environ */
21
+ readEnviron: (pid: string) => Promise<string>;
22
+ /** List subdirs of a directory */
23
+ readdir: (path: string) => Promise<string[]>;
24
+ /** Check if a path exists (file or dir) */
25
+ exists: (path: string) => Promise<boolean>;
26
+ defaultDir: () => string;
27
+ }
28
+ /**
29
+ * Scan /proc for claude processes and collect their CLAUDE_CONFIG_DIR values.
30
+ * Returns deduplicated list including the default dir.
31
+ */
32
+ export declare function discoverAccountDirs(deps?: Partial<DiscoverDeps>): Promise<string[]>;
33
+ /**
34
+ * Resolve which config dir owns a pane, given:
35
+ * - sessionUuid: the Claude Code session UUID from agent_session.value
36
+ * - shellPid: the shell PID from pane.process_info (for fallback)
37
+ * - accountDirs: list of discovered config dirs
38
+ *
39
+ * Tier order:
40
+ * 1. Sole account - return it immediately
41
+ * 2. UUID-to-jsonl scan: glob <configDir>/projects/<star>/<uuid>.jsonl
42
+ * 3. PID-to-environ: read /proc/<shellPid>/environ, extract CLAUDE_CONFIG_DIR
43
+ * 4. null
44
+ */
45
+ export declare function resolveAccountDir(sessionUuid: string | null, shellPid: number | null, accountDirs: string[], deps?: Partial<ResolveDeps>): Promise<string | null>;
46
+ //# sourceMappingURL=accounts.d.ts.map
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Account discovery and pane→account resolution.
3
+ *
4
+ * Primary resolution path: UUID→jsonl glob scan across discovered config dirs.
5
+ * Fallback: shell PID → /proc/<pid>/environ → CLAUDE_CONFIG_DIR.
6
+ */
7
+ export function parseConfigDirFromEnviron(buf) {
8
+ const entries = buf.split('\0');
9
+ for (const entry of entries) {
10
+ if (entry.startsWith('CLAUDE_CONFIG_DIR=')) {
11
+ const val = entry.slice('CLAUDE_CONFIG_DIR='.length);
12
+ return val.length > 0 ? val : null;
13
+ }
14
+ }
15
+ return null;
16
+ }
17
+ function defaultConfigDir() {
18
+ return process.env['CLAUDE_CONFIG_DIR'] ?? `${process.env['HOME'] ?? '/root'}/.claude`;
19
+ }
20
+ function defaultDiscoverDeps() {
21
+ return {
22
+ platform: process.platform,
23
+ readdir: async (p) => {
24
+ const { readdir } = await import('node:fs/promises');
25
+ return readdir(p);
26
+ },
27
+ readFile: async (p) => {
28
+ const { readFile } = await import('node:fs/promises');
29
+ return readFile(p, 'utf8');
30
+ },
31
+ defaultDir: defaultConfigDir,
32
+ };
33
+ }
34
+ function defaultResolveDeps() {
35
+ return {
36
+ platform: process.platform,
37
+ listProcPids: async () => {
38
+ const { readdir } = await import('node:fs/promises');
39
+ const entries = await readdir('/proc');
40
+ return entries.filter(e => /^\d+$/.test(e));
41
+ },
42
+ readCmdline: async (pid) => {
43
+ const { readFile } = await import('node:fs/promises');
44
+ return readFile(`/proc/${pid}/cmdline`, 'utf8');
45
+ },
46
+ readEnviron: async (pid) => {
47
+ const { readFile } = await import('node:fs/promises');
48
+ return readFile(`/proc/${pid}/environ`, 'utf8');
49
+ },
50
+ readdir: async (p) => {
51
+ const { readdir } = await import('node:fs/promises');
52
+ return readdir(p);
53
+ },
54
+ exists: async (p) => {
55
+ const { access } = await import('node:fs/promises');
56
+ try {
57
+ await access(p);
58
+ return true;
59
+ }
60
+ catch {
61
+ return false;
62
+ }
63
+ },
64
+ defaultDir: defaultConfigDir,
65
+ };
66
+ }
67
+ /**
68
+ * Scan /proc for claude processes and collect their CLAUDE_CONFIG_DIR values.
69
+ * Returns deduplicated list including the default dir.
70
+ */
71
+ export async function discoverAccountDirs(deps) {
72
+ const merged = { ...defaultDiscoverDeps(), ...deps };
73
+ const platform = merged.platform ?? process.platform;
74
+ const base = merged.defaultDir();
75
+ const dirs = new Set([base]);
76
+ if (platform !== 'linux') {
77
+ return [base];
78
+ }
79
+ try {
80
+ const entries = await merged.readdir('/proc');
81
+ const pids = entries.filter(e => /^\d+$/.test(e));
82
+ await Promise.all(pids.map(async (pid) => {
83
+ try {
84
+ const cmdline = await merged.readFile(`/proc/${pid}/cmdline`);
85
+ if (!cmdline.includes('claude'))
86
+ return;
87
+ const environ = await merged.readFile(`/proc/${pid}/environ`);
88
+ const dir = parseConfigDirFromEnviron(environ) ?? base;
89
+ dirs.add(dir);
90
+ }
91
+ catch {
92
+ // skip unreadable pids
93
+ }
94
+ }));
95
+ }
96
+ catch {
97
+ return [base];
98
+ }
99
+ return Array.from(dirs);
100
+ }
101
+ /**
102
+ * Resolve which config dir owns a pane, given:
103
+ * - sessionUuid: the Claude Code session UUID from agent_session.value
104
+ * - shellPid: the shell PID from pane.process_info (for fallback)
105
+ * - accountDirs: list of discovered config dirs
106
+ *
107
+ * Tier order:
108
+ * 1. Sole account - return it immediately
109
+ * 2. UUID-to-jsonl scan: glob <configDir>/projects/<star>/<uuid>.jsonl
110
+ * 3. PID-to-environ: read /proc/<shellPid>/environ, extract CLAUDE_CONFIG_DIR
111
+ * 4. null
112
+ */
113
+ export async function resolveAccountDir(sessionUuid, shellPid, accountDirs, deps) {
114
+ const merged = { ...defaultResolveDeps(), ...deps };
115
+ // Tier 1: sole account
116
+ if (accountDirs.length === 1) {
117
+ return accountDirs[0];
118
+ }
119
+ if (accountDirs.length === 0) {
120
+ return null;
121
+ }
122
+ // Tier 2: UUID→jsonl scan
123
+ if (sessionUuid !== null) {
124
+ for (const configDir of accountDirs) {
125
+ try {
126
+ const projectsDir = `${configDir}/projects`;
127
+ let projects;
128
+ try {
129
+ projects = await merged.readdir(projectsDir);
130
+ }
131
+ catch {
132
+ continue;
133
+ }
134
+ let found = false;
135
+ for (const project of projects) {
136
+ const jsonlPath = `${projectsDir}/${project}/${sessionUuid}.jsonl`;
137
+ try {
138
+ const exists = await merged.exists(jsonlPath);
139
+ if (exists) {
140
+ found = true;
141
+ break;
142
+ }
143
+ }
144
+ catch {
145
+ // skip
146
+ }
147
+ }
148
+ if (found) {
149
+ return configDir;
150
+ }
151
+ }
152
+ catch {
153
+ // skip this dir
154
+ }
155
+ }
156
+ }
157
+ // Tier 3: PID→environ fallback
158
+ if (shellPid !== null) {
159
+ try {
160
+ const environ = await merged.readEnviron(String(shellPid));
161
+ const dir = parseConfigDirFromEnviron(environ);
162
+ if (dir !== null && accountDirs.includes(dir)) {
163
+ return dir;
164
+ }
165
+ }
166
+ catch {
167
+ // fallthrough
168
+ }
169
+ }
170
+ // Tier 4: unknown
171
+ return null;
172
+ }
173
+ //# sourceMappingURL=accounts.js.map
package/dist/cli.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * cli.ts — CLI entry point for herdr-claude-retry daemon.
3
+ *
4
+ * Usage:
5
+ * herdr-claude-retry start [options]
6
+ * --socket-path <path> override HERDR_SOCKET_PATH env
7
+ * --margin-seconds <n> default 60
8
+ * --sweep-interval <n> default 300 (seconds)
9
+ * --log-level <level> debug|info|warn|error; default info
10
+ * --help print usage and exit 0
11
+ */
12
+ export {};
13
+ //# sourceMappingURL=cli.d.ts.map
package/dist/cli.js ADDED
@@ -0,0 +1,137 @@
1
+ /**
2
+ * cli.ts — CLI entry point for herdr-claude-retry daemon.
3
+ *
4
+ * Usage:
5
+ * herdr-claude-retry start [options]
6
+ * --socket-path <path> override HERDR_SOCKET_PATH env
7
+ * --margin-seconds <n> default 60
8
+ * --sweep-interval <n> default 300 (seconds)
9
+ * --log-level <level> debug|info|warn|error; default info
10
+ * --help print usage and exit 0
11
+ */
12
+ import { parseArgs } from 'node:util';
13
+ import { HerdrClient } from "./herdr.js";
14
+ import { runDaemon } from "./daemon.js";
15
+ import { makeLogger } from "./log.js";
16
+ import { VERSION } from "./index.js";
17
+ // ---------------------------------------------------------------------------
18
+ // Help text
19
+ // ---------------------------------------------------------------------------
20
+ const HELP = `
21
+ herdr-claude-retry v${VERSION}
22
+
23
+ Usage:
24
+ herdr-claude-retry start [options]
25
+
26
+ Options:
27
+ --socket-path <path> Unix socket path (default: HERDR_SOCKET_PATH env or
28
+ $HOME/.config/herdr/herdr.sock)
29
+ --margin-seconds <n> Extra seconds after rate-limit resets before injecting
30
+ (default: 60)
31
+ --sweep-interval <n> Reconcile sweep interval in seconds (default: 300)
32
+ --log-level <level> Minimum log level: debug|info|warn|error (default: info)
33
+ --help Print this help and exit
34
+
35
+ Environment:
36
+ HERDR_SOCKET_PATH Default socket path if --socket-path not given
37
+ `.trim();
38
+ // ---------------------------------------------------------------------------
39
+ // Main
40
+ // ---------------------------------------------------------------------------
41
+ async function main() {
42
+ // Strip argv[0] (node) and argv[1] (script); grab subcommand if present
43
+ const rawArgs = process.argv.slice(2);
44
+ // Handle top-level --help before parseArgs (so it works without a subcommand)
45
+ if (rawArgs.includes('--help') || rawArgs.includes('-h')) {
46
+ process.stdout.write(HELP + '\n');
47
+ process.exit(0);
48
+ }
49
+ // Require 'start' subcommand
50
+ const [subcommand, ...rest] = rawArgs;
51
+ if (subcommand !== 'start') {
52
+ process.stderr.write(`error: unknown command '${subcommand ?? ''}'. Use 'start'.\n\n${HELP}\n`);
53
+ process.exit(1);
54
+ }
55
+ // Parse options
56
+ const { values } = parseArgs({
57
+ args: rest,
58
+ options: {
59
+ 'socket-path': { type: 'string' },
60
+ 'margin-seconds': { type: 'string' },
61
+ 'sweep-interval': { type: 'string' },
62
+ 'log-level': { type: 'string' },
63
+ 'help': { type: 'boolean' },
64
+ },
65
+ strict: true,
66
+ allowPositionals: false,
67
+ });
68
+ if (values['help']) {
69
+ process.stdout.write(HELP + '\n');
70
+ process.exit(0);
71
+ }
72
+ // Resolve log level
73
+ const rawLevel = values['log-level'] ?? 'info';
74
+ const validLevels = ['debug', 'info', 'warn', 'error'];
75
+ if (!validLevels.includes(rawLevel)) {
76
+ process.stderr.write(`error: invalid --log-level '${rawLevel}'. Must be one of: ${validLevels.join(', ')}\n`);
77
+ process.exit(1);
78
+ }
79
+ const logLevel = rawLevel;
80
+ // Construct logger
81
+ const logFn = makeLogger({ level: logLevel });
82
+ // Resolve socket path
83
+ const socketPath = values['socket-path'] ??
84
+ process.env['HERDR_SOCKET_PATH'] ??
85
+ `${process.env['HOME']}/.config/herdr/herdr.sock`;
86
+ // Parse numeric options
87
+ const marginSeconds = values['margin-seconds'] !== undefined
88
+ ? parseInt(values['margin-seconds'], 10)
89
+ : 60;
90
+ const sweepIntervalSeconds = values['sweep-interval'] !== undefined
91
+ ? parseInt(values['sweep-interval'], 10)
92
+ : 300;
93
+ if (isNaN(marginSeconds) || marginSeconds < 0) {
94
+ process.stderr.write(`error: invalid --margin-seconds '${values['margin-seconds']}'\n`);
95
+ process.exit(1);
96
+ }
97
+ if (isNaN(sweepIntervalSeconds) || sweepIntervalSeconds < 1) {
98
+ process.stderr.write(`error: invalid --sweep-interval '${values['sweep-interval']}'\n`);
99
+ process.exit(1);
100
+ }
101
+ // Signal handling
102
+ const ac = new AbortController();
103
+ process.on('SIGINT', () => ac.abort());
104
+ process.on('SIGTERM', () => ac.abort());
105
+ // Construct client
106
+ const client = new HerdrClient({ socketPath });
107
+ // Connect (connectivity check — open+close a socket)
108
+ logFn({ event: 'daemon.start', version: VERSION, socket_path: socketPath });
109
+ try {
110
+ await client.connect();
111
+ logFn({ event: 'socket.connected' });
112
+ }
113
+ catch (err) {
114
+ logFn({ level: 'error', event: 'socket.dead' });
115
+ process.stderr.write(`fatal: could not connect to ${socketPath}: ${err}\n`);
116
+ process.exit(1);
117
+ }
118
+ // Run daemon — map daemon's string logger to structured events
119
+ try {
120
+ await runDaemon({
121
+ client,
122
+ marginSeconds,
123
+ sweepIntervalMs: sweepIntervalSeconds * 1000,
124
+ signal: ac.signal,
125
+ log: (msg) => logFn({ event: 'daemon.internal', msg }),
126
+ });
127
+ }
128
+ finally {
129
+ client.destroy();
130
+ logFn({ event: 'daemon.stop', reason: ac.signal.aborted ? 'signal' : 'exit' });
131
+ }
132
+ }
133
+ main().catch((err) => {
134
+ process.stderr.write(`fatal: ${err}\n`);
135
+ process.exit(1);
136
+ });
137
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1,37 @@
1
+ /**
2
+ * daemon.ts — Event-driven daemon with reconcile sweep.
3
+ *
4
+ * Consumes HerdrClient event subscriptions and a periodic reconcile sweep
5
+ * to detect rate-limited Claude panes and inject 'continue' when limits reset.
6
+ */
7
+ import type { HerdrClient } from './herdr.ts';
8
+ import { discoverAccountDirs, resolveAccountDir } from './accounts.ts';
9
+ import { readAccessToken, fetchUsage } from './usage.ts';
10
+ import type { Logger } from './monitor.ts';
11
+ export interface DaemonOpts {
12
+ client: HerdrClient;
13
+ /** Override account dir discovery. */
14
+ accountDirs?: string[];
15
+ /** Extra seconds after resetsAt before injecting. Default 60. */
16
+ marginSeconds?: number;
17
+ /** Reconcile sweep interval in ms. Default 5 * 60 * 1000. */
18
+ sweepIntervalMs?: number;
19
+ /** Abort the daemon loop. */
20
+ signal?: AbortSignal;
21
+ /** Injectable logger. Defaults to stderr. */
22
+ log?: Logger;
23
+ /** Injectable clock. Defaults to Date.now. */
24
+ now?: () => number;
25
+ /** Injectable sleep. Defaults to setTimeout promise. */
26
+ sleep?: (ms: number) => Promise<void>;
27
+ /** Injectable fetchUsage for DI in tests. */
28
+ fetchUsageFn?: typeof fetchUsage;
29
+ /** Injectable readAccessToken for DI in tests. */
30
+ readTokenFn?: typeof readAccessToken;
31
+ /** Injectable discoverAccountDirs for DI in tests. */
32
+ discoverDirsFn?: typeof discoverAccountDirs;
33
+ /** Injectable resolveAccountDir for DI in tests. */
34
+ resolveAccountDirFn?: typeof resolveAccountDir;
35
+ }
36
+ export declare function runDaemon(opts: DaemonOpts): Promise<void>;
37
+ //# sourceMappingURL=daemon.d.ts.map