harnessly 1.0.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 Vomesh Atukuri
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,179 @@
1
+ # Harnessly
2
+
3
+ A harnessed AI coding agent CLI with guardrails, approval flow, observability, and error recovery. Harnessly can read files, write files, run commands, and search code — all driven by natural language, with safety guardrails wrapping every action.
4
+
5
+ ## Features
6
+
7
+ ### Agent
8
+ - **Multi-provider support** — OpenAI, Azure, Anthropic, or any custom OpenAI-compatible endpoint
9
+ - **Streaming responses** — Token-by-token output in real time
10
+ - **Conversation memory** — Maintains context across turns with auto-summarization
11
+ - **Up to 8 tool calls per turn** — Chained reasoning with tool feedback
12
+
13
+ ### Harness (Safety & Observability)
14
+ - **Guardrails**
15
+ - Command safety — 12 dangerous command patterns blocked (`rm -rf /`, `format`, `mkfs`, `dd`, `shutdown`, etc.)
16
+ - Directory scoping — All file operations restricted to working directory
17
+ - Path containment — Prevents path traversal (`../` attacks)
18
+ - **Approval flow** — Destructive tools (`writeFile`, `runCommand`) prompt for user approval before execution. Non-destructive tools auto-approve.
19
+ - **Observability**
20
+ - Step logging — Every tool call logged to `~/.harnessly/logs/` with timestamp, input, result, and duration
21
+ - Token tracking — Cumulative session token usage (input, output, total)
22
+ - **Error recovery** — Automatic retry with exponential backoff (1s, 2s, 4s) on transient API failures (timeout, rate limit, ECONNRESET, 429, 503)
23
+
24
+ ## Installation
25
+
26
+ ```bash
27
+ npm install -g harnessly
28
+ ```
29
+
30
+ Or run without installing:
31
+
32
+ ```bash
33
+ npx harnessly
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ ```bash
39
+ harnessly
40
+ ```
41
+
42
+ ### First Run Setup
43
+
44
+ On first run, Harnessly will prompt you to:
45
+ 1. Select your AI provider (OpenAI, Azure, Anthropic, or Custom)
46
+ 2. Enter your API key (masked input)
47
+ 3. Choose a model (or use the default)
48
+
49
+ Your config is saved at `~/.harnessly/config.json` so you only need to set it up once.
50
+
51
+ ### Environment Variables (Alternative to Setup)
52
+
53
+ Skip the setup wizard by setting environment variables:
54
+
55
+ ```bash
56
+ # OpenAI
57
+ export OPENAI_API_KEY=sk-...
58
+ harnessly
59
+
60
+ # Anthropic
61
+ export ANTHROPIC_API_KEY=sk-ant-...
62
+ harnessly
63
+
64
+ # Azure
65
+ export AZURE_API_KEY=...
66
+ export AZURE_RESOURCE_NAME=my-resource
67
+ harnessly
68
+ ```
69
+
70
+ ### Slash Commands
71
+
72
+ | Command | Description |
73
+ |---------|-------------|
74
+ | `/help` | Show available commands |
75
+ | `/tokens` | Display session token usage |
76
+ | `/logs` | Show log file path |
77
+ | `/clear` | Clear conversation history and reset tokens |
78
+ | `/config` | Switch provider or model |
79
+ | `/history` | Show recent prompt history |
80
+ | `/exit` | Exit Harnessly |
81
+
82
+ ## Examples
83
+
84
+ ```
85
+ >>> read the package.json and tell me what dependencies I have
86
+
87
+ >>> create a new file called utils.ts with a debounce function
88
+ ⚠ writeFile(utils.ts)
89
+ Allow? [y/N] y
90
+
91
+ >>> run the tests and fix any failures
92
+ ⚠ runCommand(npm test)
93
+ Allow? [y/N] y
94
+
95
+ >>> search for all TODO comments in the project
96
+ ```
97
+
98
+ ## Available Tools
99
+
100
+ | Tool | Description | Approval Required |
101
+ |------|-------------|-------------------|
102
+ | `readFile` | Read the contents of any file | No |
103
+ | `writeFile` | Create or overwrite a file | Yes |
104
+ | `listDirectory` | List files and folders in a directory | No |
105
+ | `runCommand` | Execute any shell command (30s timeout) | Yes |
106
+ | `searchFiles` | Search for text patterns across files | No |
107
+
108
+ ## How It Works
109
+
110
+ ```
111
+ ┌─────────────────────────────────────────┐
112
+ │ HARNESS LAYER │
113
+ │ ┌───────────────────────────────────┐ │
114
+ │ │ AGENT (LLM) │ │
115
+ │ │ ┌─────┐ ┌──────┐ ┌──────────┐ │ │
116
+ │ │ │Tools│ │Memory│ │Reasoning │ │ │
117
+ │ │ └─────┘ └──────┘ └──────────┘ │ │
118
+ │ └───────────────────────────────────┘ │
119
+ │ │
120
+ │ Guardrails ✅ Observability │
121
+ │ Approval ✅ Token Tracking │
122
+ │ Retry ✅ Step Logging │
123
+ └─────────────────────────────────────────┘
124
+ ```
125
+
126
+ 1. You type a message in the terminal
127
+ 2. Harnessly sends it to your chosen AI model along with tool definitions
128
+ 3. The AI model decides which tools to use and calls them
129
+ 4. **Harness layer checks**: Is this tool destructive? → Prompt user. Is this command dangerous? → Block. Is this path outside working dir? → Deny.
130
+ 5. Approved tool calls execute and results go back to the model
131
+ 6. The model can chain up to 8 tool calls per turn
132
+ 7. Final response is displayed with token usage
133
+
134
+ ## Configuration
135
+
136
+ Config is stored at `~/.harnessly/config.json`:
137
+
138
+ ```json
139
+ {
140
+ "provider": "OpenAI",
141
+ "apiKey": "sk-...",
142
+ "model": "gpt-4o"
143
+ }
144
+ ```
145
+
146
+ To reconfigure, delete the config file and restart Harnessly, or use `/config` to switch providers.
147
+
148
+ ## Development
149
+
150
+ ```bash
151
+ # Install dependencies
152
+ npm install
153
+
154
+ # Run in development mode
155
+ npm run dev
156
+
157
+ # Build
158
+ npm run build
159
+
160
+ # Start from build
161
+ npm start
162
+ ```
163
+
164
+ ## Tech Stack
165
+
166
+ - **AI SDK v7** (Vercel) — model integration, tool execution, and `toolApproval`
167
+ - **Zod v4** — tool input schema validation
168
+ - **clack/prompts** — terminal UI components
169
+ - **cac** — CLI framework
170
+ - **chalk** — terminal styling
171
+ - **ora** — loading spinners
172
+
173
+ ## License
174
+
175
+ MIT
176
+
177
+ ## Author
178
+
179
+ Vomesh ([@VomeshAtukuri](https://github.com/VomeshAtukuri))
@@ -0,0 +1,26 @@
1
+ import { type ModelMessage, type LanguageModel } from 'ai';
2
+ import type { ProviderConfig } from './ui';
3
+ export type ApprovalCallback = (toolName: string, args: any) => Promise<boolean>;
4
+ interface AskArgs {
5
+ userMessage: string;
6
+ onToolCall?: (toolName: string, args: unknown) => void;
7
+ signal?: AbortSignal;
8
+ }
9
+ interface AgentOptions {
10
+ approval?: ApprovalCallback;
11
+ }
12
+ export declare function createModel(config: ProviderConfig): LanguageModel;
13
+ export declare class Agent {
14
+ private messages;
15
+ private model;
16
+ private tools;
17
+ private approval?;
18
+ constructor(model: LanguageModel, options?: AgentOptions);
19
+ getHistory(): readonly ModelMessage[];
20
+ private summarize;
21
+ ask({ userMessage, onToolCall, signal }: AskArgs): Promise<{
22
+ textStream: import("ai").AsyncIterableStream<string>;
23
+ done: () => Promise<void>;
24
+ }>;
25
+ }
26
+ export {};
package/build/agent.js ADDED
@@ -0,0 +1,163 @@
1
+ import { createAzure } from '@ai-sdk/azure';
2
+ import { createOpenAI } from '@ai-sdk/openai';
3
+ import { createAnthropic } from '@ai-sdk/anthropic';
4
+ import { generateText, streamText, stepCountIs, } from 'ai';
5
+ import { createTools } from './tools';
6
+ import { trackTokens, withRetry } from './harness';
7
+ function getEnvKeyName(provider) {
8
+ switch (provider) {
9
+ case 'OpenAI': return 'OPENAI_API_KEY';
10
+ case 'Anthropic': return 'ANTHROPIC_API_KEY';
11
+ case 'Azure': return 'AZURE_API_KEY';
12
+ case 'Custom': return 'CUSTOM_API_KEY';
13
+ default: return `${provider.toUpperCase()}_API_KEY`;
14
+ }
15
+ }
16
+ function getEnvKey(provider) {
17
+ return process.env[getEnvKeyName(provider)];
18
+ }
19
+ const MAX_MESSAGES = 25;
20
+ // Keep the last N *complete* turns when summarizing, never a raw slice —
21
+ // see trimToCompleteTurns() for why a raw slice is unsafe.
22
+ const KEEP_RECENT_MESSAGES = 4;
23
+ const SYSTEM_PROMPT = `You are a powerful coding agent running in the user's terminal.
24
+ You have access to tools that let you read files, write files, run shell commands, list directories, and search code.
25
+
26
+ Working directory: ${process.cwd()}
27
+
28
+ Guidelines:
29
+ - When asked to do something, USE your tools to accomplish it. Don't just suggest code — actually make changes.
30
+ - Read files before modifying them to understand the current state.
31
+ - After making changes, verify them if possible (e.g. run tests, check output).
32
+ - Be concise in your responses. Show what you did, not lengthy explanations.
33
+ - If a task requires multiple steps, do them all in one turn.`;
34
+ export function createModel(config) {
35
+ const { provider, model, resourceName, baseURL } = config;
36
+ const apiKey = config.apiKey || getEnvKey(provider);
37
+ if (!apiKey) {
38
+ throw new Error(`No API key found. Set ${getEnvKeyName(provider)} env var or run setup.`);
39
+ }
40
+ switch (provider) {
41
+ case 'OpenAI': {
42
+ const openai = createOpenAI({ apiKey });
43
+ return openai.chat(model || 'gpt-4o');
44
+ }
45
+ case 'Azure': {
46
+ const azure = createAzure({ resourceName: resourceName, apiKey });
47
+ return azure.chat(model || 'gpt-4o');
48
+ }
49
+ case 'Anthropic': {
50
+ // Anthropic's API isn't OpenAI-compatible — use the dedicated provider,
51
+ // not createOpenAI (which would call OpenAI's endpoint with the wrong key/shape).
52
+ const anthropic = createAnthropic({ apiKey });
53
+ return anthropic(model || 'claude-sonnet-4-5');
54
+ }
55
+ case 'Custom': {
56
+ const custom = createOpenAI({ apiKey, baseURL });
57
+ return custom.chat(model || 'custom-model');
58
+ }
59
+ default:
60
+ throw new Error(`Unknown provider: ${provider}`);
61
+ }
62
+ }
63
+ /**
64
+ * A tool-result message must always immediately follow the assistant message
65
+ * that requested it. Slicing `messages.slice(-N)` blindly can land in the
66
+ * middle of such a pair and produce an invalid history. This walks backward
67
+ * from the end and only stops at a safe boundary (a user message, or an
68
+ * assistant message with no pending tool calls).
69
+ */
70
+ function trimToCompleteTurns(history, keep) {
71
+ if (history.length <= keep)
72
+ return history.slice();
73
+ let start = history.length - keep;
74
+ while (start > 0) {
75
+ const msg = history[start];
76
+ const prev = history[start - 1];
77
+ const prevIsToolCall = prev.role === 'assistant' &&
78
+ Array.isArray(prev.content) &&
79
+ prev.content.some((p) => p.type === 'tool-call');
80
+ const isDanglingToolResult = msg.role === 'tool' || prevIsToolCall;
81
+ if (!isDanglingToolResult)
82
+ break;
83
+ start--;
84
+ }
85
+ return history.slice(start);
86
+ }
87
+ export class Agent {
88
+ messages = [];
89
+ model;
90
+ tools;
91
+ approval;
92
+ constructor(model, options) {
93
+ this.model = model;
94
+ this.tools = createTools();
95
+ this.approval = options?.approval;
96
+ }
97
+ getHistory() {
98
+ return this.messages;
99
+ }
100
+ async summarize() {
101
+ const transcript = this.messages
102
+ .map((m) => `[${m.role}]: ${typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}`)
103
+ .join('\n');
104
+ const { text: summary } = await generateText({
105
+ model: this.model,
106
+ system: 'You are a summarizer. Condense the following conversation into a brief summary. ' +
107
+ 'Include: what the user asked, what tools were used, what files were changed, and the current state. ' +
108
+ 'Be concise but preserve all important context.',
109
+ prompt: transcript,
110
+ });
111
+ const recent = trimToCompleteTurns(this.messages, KEEP_RECENT_MESSAGES);
112
+ this.messages = [
113
+ { role: 'user', content: `[Previous conversation summary]: ${summary}` },
114
+ ...recent,
115
+ ];
116
+ }
117
+ async ask({ userMessage, onToolCall, signal }) {
118
+ this.messages.push({ role: 'user', content: userMessage });
119
+ try {
120
+ const result = await withRetry(() => Promise.resolve(streamText({
121
+ model: this.model,
122
+ system: SYSTEM_PROMPT,
123
+ messages: this.messages,
124
+ tools: this.tools,
125
+ stopWhen: stepCountIs(8),
126
+ abortSignal: signal,
127
+ toolApproval: this.approval ? async ({ toolCall }) => {
128
+ const approved = await this.approval(toolCall.toolName, toolCall.input);
129
+ return approved ? 'approved' : 'denied';
130
+ } : undefined,
131
+ onStepFinish: ({ toolCalls }) => {
132
+ for (const tc of toolCalls ?? []) {
133
+ onToolCall?.(tc.toolName, tc.input);
134
+ }
135
+ },
136
+ })));
137
+ return {
138
+ textStream: result.textStream,
139
+ done: async () => {
140
+ try {
141
+ const response = await result.response;
142
+ this.messages.push(...response.messages);
143
+ // Track token usage
144
+ const usage = await result.totalUsage;
145
+ trackTokens(usage);
146
+ if (this.messages.length > MAX_MESSAGES) {
147
+ await this.summarize();
148
+ }
149
+ }
150
+ catch (err) {
151
+ this.messages.pop();
152
+ throw err;
153
+ }
154
+ },
155
+ };
156
+ }
157
+ catch (err) {
158
+ // Roll back the optimistic push so a failed call doesn't corrupt history.
159
+ this.messages.pop();
160
+ throw err;
161
+ }
162
+ }
163
+ }
@@ -0,0 +1,8 @@
1
+ import type { ProviderConfig } from './ui';
2
+ export declare function getConfig(): ProviderConfig | null;
3
+ export declare function saveConfig(config: ProviderConfig): void;
4
+ export declare function deleteConfig(): void;
5
+ export declare function getHistory(): string[];
6
+ export declare function appendHistory(entry: string): void;
7
+ export declare function getConfigDir(): string;
8
+ export declare function clearHistory(): void;
@@ -0,0 +1,58 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ const CONFIG_DIR = path.join(os.homedir(), '.harnessly');
5
+ const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
6
+ const HISTORY_PATH = path.join(CONFIG_DIR, 'history.json');
7
+ function ensureDir() {
8
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
9
+ }
10
+ // ─── Config ──────────────────────────────────────────────
11
+ export function getConfig() {
12
+ try {
13
+ if (fs.existsSync(CONFIG_PATH)) {
14
+ return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
15
+ }
16
+ }
17
+ catch {
18
+ // corrupted config — treat as missing
19
+ }
20
+ return null;
21
+ }
22
+ export function saveConfig(config) {
23
+ ensureDir();
24
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
25
+ }
26
+ export function deleteConfig() {
27
+ try {
28
+ fs.unlinkSync(CONFIG_PATH);
29
+ }
30
+ catch { }
31
+ }
32
+ // ─── Session History (optional persistence) ──────────────
33
+ export function getHistory() {
34
+ try {
35
+ if (fs.existsSync(HISTORY_PATH)) {
36
+ return JSON.parse(fs.readFileSync(HISTORY_PATH, 'utf-8'));
37
+ }
38
+ }
39
+ catch { }
40
+ return [];
41
+ }
42
+ export function appendHistory(entry) {
43
+ ensureDir();
44
+ const history = getHistory();
45
+ history.push(entry);
46
+ // keep last 100 entries
47
+ const trimmed = history.slice(-100);
48
+ fs.writeFileSync(HISTORY_PATH, JSON.stringify(trimmed, null, 2));
49
+ }
50
+ export function getConfigDir() {
51
+ return CONFIG_DIR;
52
+ }
53
+ export function clearHistory() {
54
+ try {
55
+ fs.unlinkSync(HISTORY_PATH);
56
+ }
57
+ catch { }
58
+ }
@@ -0,0 +1,31 @@
1
+ export declare function checkPath(filePath: string): {
2
+ allowed: boolean;
3
+ reason?: string;
4
+ };
5
+ export declare function checkCommand(command: string): {
6
+ allowed: boolean;
7
+ reason?: string;
8
+ };
9
+ interface LogEntry {
10
+ timestamp: string;
11
+ tool: string;
12
+ input: any;
13
+ result: string;
14
+ durationMs: number;
15
+ }
16
+ export declare function logToolCall(entry: LogEntry): void;
17
+ export declare function getLogPath(): string;
18
+ export interface TokenUsage {
19
+ inputTokens: number;
20
+ outputTokens: number;
21
+ totalTokens: number;
22
+ }
23
+ export declare function trackTokens(usage: {
24
+ inputTokens?: number;
25
+ outputTokens?: number;
26
+ totalTokens?: number;
27
+ }): void;
28
+ export declare function getSessionTokens(): TokenUsage;
29
+ export declare function resetSessionTokens(): void;
30
+ export declare function withRetry<T>(fn: () => Promise<T>, maxRetries?: number, baseDelayMs?: number): Promise<T>;
31
+ export {};
@@ -0,0 +1,94 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ // ─── Directory Scoping ───────────────────────────────────
5
+ const WORKING_DIR = process.cwd();
6
+ function isWithinWorkingDir(targetPath) {
7
+ const resolved = path.resolve(targetPath);
8
+ const relative = path.relative(WORKING_DIR, resolved);
9
+ return !relative.startsWith('..') && !path.isAbsolute(relative);
10
+ }
11
+ export function checkPath(filePath) {
12
+ if (!isWithinWorkingDir(filePath)) {
13
+ return {
14
+ allowed: false,
15
+ reason: `Path "${filePath}" is outside the working directory (${WORKING_DIR}). Access denied.`,
16
+ };
17
+ }
18
+ return { allowed: true };
19
+ }
20
+ // ─── Command Safety ───────────────────────────────────────
21
+ const DANGEROUS_PATTERNS = [
22
+ { pattern: /\brm\s+-rf\s+\//i, reason: 'Recursive delete from root' },
23
+ { pattern: /\brm\s+-rf\s+~/i, reason: 'Recursive delete of home directory' },
24
+ { pattern: /\brm\s+-rf\s+\.\./i, reason: 'Recursive delete of parent directory' },
25
+ { pattern: /\bdel\s+\/s/i, reason: 'Windows recursive delete' },
26
+ { pattern: /\bformat\s+[a-z]:/i, reason: 'Disk format command' },
27
+ { pattern: /\bmkfs/i, reason: 'Filesystem format command' },
28
+ { pattern: /\bdd\s+if=/i, reason: 'Raw disk write command' },
29
+ { pattern: /\b:()\{.*\|.*&\};:/i, reason: 'Fork bomb' },
30
+ { pattern: /\bshutdown\b/i, reason: 'System shutdown command' },
31
+ { pattern: /\breboot\b/i, reason: 'System reboot command' },
32
+ { pattern: /\btaskkill\s+\/f/i, reason: 'Force kill all processes' },
33
+ { pattern: /\breg\s+delete/i, reason: 'Registry deletion' },
34
+ ];
35
+ export function checkCommand(command) {
36
+ for (const { pattern, reason } of DANGEROUS_PATTERNS) {
37
+ if (pattern.test(command)) {
38
+ return { allowed: false, reason: `Blocked: ${reason}` };
39
+ }
40
+ }
41
+ return { allowed: true };
42
+ }
43
+ // ─── Step Logging (Observability) ────────────────────────
44
+ const LOG_DIR = path.join(os.homedir(), '.harnessly', 'logs');
45
+ const LOG_PATH = path.join(LOG_DIR, `harnessly-${new Date().toISOString().slice(0, 10)}.log`);
46
+ function ensureLogDir() {
47
+ fs.mkdirSync(LOG_DIR, { recursive: true });
48
+ }
49
+ export function logToolCall(entry) {
50
+ ensureLogDir();
51
+ const line = `[${entry.timestamp}] ${entry.tool} (${entry.durationMs}ms)\n input: ${JSON.stringify(entry.input).slice(0, 200)}\n result: ${entry.result.slice(0, 200)}\n\n`;
52
+ fs.appendFileSync(LOG_PATH, line);
53
+ }
54
+ export function getLogPath() {
55
+ return LOG_PATH;
56
+ }
57
+ let sessionTokens = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
58
+ export function trackTokens(usage) {
59
+ sessionTokens.inputTokens += usage.inputTokens ?? 0;
60
+ sessionTokens.outputTokens += usage.outputTokens ?? 0;
61
+ sessionTokens.totalTokens += usage.totalTokens ?? 0;
62
+ }
63
+ export function getSessionTokens() {
64
+ return { ...sessionTokens };
65
+ }
66
+ export function resetSessionTokens() {
67
+ sessionTokens = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
68
+ }
69
+ // ─── Retry Logic (Error Recovery) ─────────────────────────
70
+ export async function withRetry(fn, maxRetries = 3, baseDelayMs = 1000) {
71
+ let lastError;
72
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
73
+ try {
74
+ return await fn();
75
+ }
76
+ catch (err) {
77
+ lastError = err;
78
+ if (attempt === maxRetries)
79
+ break;
80
+ // Only retry on transient errors (timeout, rate limit, network)
81
+ const isTransient = err.message?.includes('timeout') ||
82
+ err.message?.includes('rate limit') ||
83
+ err.message?.includes('ECONNRESET') ||
84
+ err.message?.includes('socket hang up') ||
85
+ err.statusCode === 429 ||
86
+ err.statusCode === 503;
87
+ if (!isTransient)
88
+ break;
89
+ const delay = baseDelayMs * Math.pow(2, attempt);
90
+ await new Promise(resolve => setTimeout(resolve, delay));
91
+ }
92
+ }
93
+ throw lastError;
94
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};