acpreact 1.0.8 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # acpreact - ACP SDK
2
2
 
3
- A lightweight SDK for registering tools and running them via kilo CLI or opencode. Allows kilo and opencode to call registered tools via a custom JSON-RPC 2.0 protocol injected into the prompt.
3
+ A lightweight SDK for registering tools and running them via kilo CLI, opencode, or gemini. Supports multi-service fallback with automatic rate-limit detection, per-provider cooldowns, and transparent failover across a service stack.
4
4
 
5
5
  ## Features
6
6
 
@@ -8,7 +8,9 @@ A lightweight SDK for registering tools and running them via kilo CLI or opencod
8
8
  - **Tool Registration**: Register custom tools with descriptions and input schemas
9
9
  - **Tool Whitelist**: Built-in security model for controlling tool access
10
10
  - **Tool Execution**: Execute whitelisted tools with validation and logging
11
- - **CLI Integration**: Works with kilo CLI and opencode via `process()` method
11
+ - **CLI Integration**: Works with kilo CLI, opencode, and gemini via `process()` method
12
+ - **Multi-Service Fallback**: Automatically falls back through a service stack on rate limits
13
+ - **Rate-Limit Detection**: Detects 429, quota errors, and RESOURCE_EXHAUSTED per provider
12
14
  - **ES Module**: Pure ES modules, no build step required
13
15
 
14
16
  ## Prerequisites
@@ -61,6 +63,76 @@ console.log(result.logs); // tool call audit log
61
63
  const result = await acp.process('What is 15 + 27? Use the add tool.', { cli: 'opencode' });
62
64
  ```
63
65
 
66
+ ### Multi-Service Fallback
67
+
68
+ Pass a `services` array to the constructor to enable automatic fallback across providers. On rate limits, the engine marks the current service unavailable and continues to the next:
69
+
70
+ ```javascript
71
+ import { ACPProtocol } from 'acpreact';
72
+
73
+ const acp = new ACPProtocol('You are a calculator.', [
74
+ { cli: 'kilo' },
75
+ { cli: 'opencode' },
76
+ { cli: 'gemini' },
77
+ ]);
78
+
79
+ acp.registerTool('add', 'Add two numbers', { type: 'object', properties: { a: { type: 'number' }, b: { type: 'number' } }, required: ['a', 'b'] }, async ({ a, b }) => ({ sum: a + b }));
80
+
81
+ const result = await acp.process('What is 15 + 27? Use the add tool.');
82
+ ```
83
+
84
+ Override services per call:
85
+
86
+ ```javascript
87
+ const result = await acp.process('prompt', {
88
+ services: [{ cli: 'opencode' }, { cli: 'kilo' }],
89
+ });
90
+ ```
91
+
92
+ Multiple profiles per provider (useful for separate API key accounts):
93
+
94
+ ```javascript
95
+ const acp = new ACPProtocol('instruction', [
96
+ { cli: 'kilo', profile: 'account-a' },
97
+ { cli: 'kilo', profile: 'account-b' },
98
+ { cli: 'opencode' },
99
+ ]);
100
+ ```
101
+
102
+ ### createServiceStack
103
+
104
+ Build a typed service stack for use with `FallbackEngine` directly:
105
+
106
+ ```javascript
107
+ import { createServiceStack, FallbackEngine } from 'acpreact';
108
+
109
+ const stack = createServiceStack([
110
+ { cli: 'kilo', profile: 'work' },
111
+ { cli: 'opencode' },
112
+ { cli: 'gemini' },
113
+ ]);
114
+
115
+ const engine = new FallbackEngine(stack);
116
+ ```
117
+
118
+ ### Fallback Events
119
+
120
+ `ACPProtocol` (and `FallbackEngine`) emit events during fallback:
121
+
122
+ ```javascript
123
+ acp.on('rate-limited', ({ name, profileId, cooldownMs }) => {
124
+ console.log(`${name}(${profileId}) rate-limited for ${cooldownMs}ms`);
125
+ });
126
+
127
+ acp.on('fallback', ({ from, to }) => {
128
+ console.log(`Falling back from ${from.name} to ${to.name}`);
129
+ });
130
+
131
+ acp.on('success', ({ name, profileId, attempted }) => {
132
+ console.log(`Succeeded on ${name}(${profileId}) after ${attempted} attempt(s)`);
133
+ });
134
+ ```
135
+
64
136
  ### Using System Instructions
65
137
 
66
138
  ```javascript
@@ -98,8 +170,9 @@ Main class for ACP protocol communication.
98
170
 
99
171
  **Constructor:**
100
172
 
101
- - `new ACPProtocol(instruction?)`: Initialize the protocol
173
+ - `new ACPProtocol(instruction?, services?)`: Initialize the protocol
102
174
  - `instruction` (optional): String - system instruction prepended to every prompt sent to the CLI
175
+ - `services` (optional): Array of `{ cli, profile?, model? }` - instance-level default service stack for multi-service fallback
103
176
 
104
177
  **Methods:**
105
178
 
@@ -110,11 +183,14 @@ Main class for ACP protocol communication.
110
183
  - `handler`: Async function - receives params object, returns result
111
184
  - Returns: Tool definition object
112
185
 
113
- - `async process(text, options?)`: Send a prompt to kilo or opencode and execute any tool calls
186
+ - `async process(text, options?)`: Send a prompt to a CLI and execute any tool calls
114
187
  - `text`: String - the user prompt
115
- - `options.cli`: `'kilo'` (default) or `'opencode'`
188
+ - `options.cli`: `'kilo'` (default), `'opencode'`, or `'gemini'` — used when `options.services` not set
189
+ - `options.services`: Array of `{ cli, profile?, model? }` — enables multi-service fallback for this call; takes precedence over `options.cli`
116
190
  - `options.model`: String - model in `provider/model` format (uses CLI default if omitted)
191
+ - `options.timeout`: Number - per-attempt timeout in ms (default 120000)
117
192
  - Returns: `{ text, rawOutput, toolCalls, logs }` or `{ text, rawOutput, error, logs }` on parse failure
193
+ - Throws `AggregateError` when all services in the stack are exhausted
118
194
 
119
195
  - `createInitializeResponse()`: Generate ACP protocol initialization response with registered tools
120
196
 
package/core.js CHANGED
@@ -1,9 +1,44 @@
1
1
  import { spawn } from 'child_process';
2
2
  import { EventEmitter } from 'events';
3
3
  import { parseTextOutput, parseToolCalls } from './parser.js';
4
+ import { ServiceRegistry, createServiceStack, buildArgs } from './services.js';
5
+ import { FallbackEngine } from './fallback.js';
6
+
7
+ const DEFAULT_TIMEOUT_MS = 120_000;
8
+
9
+ function attachOutputs(err, output, errorOutput) {
10
+ err.output = output;
11
+ err.stderr = errorOutput;
12
+ return err;
13
+ }
14
+
15
+ function spawnService(entry, prompt, options, callbacks) {
16
+ return new Promise((resolve, reject) => {
17
+ const binary = entry.config?.binary || entry.name;
18
+ const args = entry.config?.buildArgs
19
+ ? entry.config.buildArgs(prompt, options)
20
+ : buildArgs(entry.name, prompt, options);
21
+ let output = '', errorOutput = '';
22
+ const child = spawn(binary, args, { stdio: ['pipe', 'pipe', 'pipe'], cwd: process.cwd(), env: { ...process.env } });
23
+ child.stdin.end();
24
+ child.stdout.on('data', (d) => { const c = d.toString(); output += c; callbacks?.onOutput?.(c); });
25
+ child.stderr.on('data', (d) => { const c = d.toString(); errorOutput += c; callbacks?.onStderr?.(c); });
26
+ const timer = setTimeout(() => {
27
+ child.kill();
28
+ reject(attachOutputs(new Error(`Timeout after ${DEFAULT_TIMEOUT_MS}ms`), output, errorOutput));
29
+ }, options?.timeout ?? DEFAULT_TIMEOUT_MS);
30
+ child.on('close', (code) => {
31
+ clearTimeout(timer);
32
+ if (code !== 0 && code !== null && !output)
33
+ return reject(attachOutputs(new Error(`${binary} exited with code ${code}: ${errorOutput}`), output, errorOutput));
34
+ resolve({ rawOutput: output, stderr: errorOutput, code });
35
+ });
36
+ child.on('error', (err) => { clearTimeout(timer); reject(attachOutputs(err, output, errorOutput)); });
37
+ });
38
+ }
4
39
 
5
40
  class ACPProtocol extends EventEmitter {
6
- constructor(instruction) {
41
+ constructor(instruction, services) {
7
42
  super();
8
43
  this.messageId = 0;
9
44
  this.instruction = instruction;
@@ -13,11 +48,19 @@ class ACPProtocol extends EventEmitter {
13
48
  this.toolCallLog = [];
14
49
  this.rejectedCallLog = [];
15
50
  this.tools = {};
51
+ this.registry = new ServiceRegistry();
52
+ if (services) {
53
+ for (const svc of services) {
54
+ this.registry.registerService(svc.cli || svc.name, svc);
55
+ }
56
+ }
57
+ this.fallback = new FallbackEngine([]);
58
+ this.fallback.on('rate-limited', (e) => this.emit('rate-limited', e));
59
+ this.fallback.on('fallback', (e) => this.emit('fallback', e));
60
+ this.fallback.on('success', (e) => this.emit('success', e));
16
61
  }
17
62
 
18
- generateRequestId() {
19
- return ++this.messageId;
20
- }
63
+ generateRequestId() { return ++this.messageId; }
21
64
 
22
65
  registerTool(name, description, inputSchema, handler) {
23
66
  this.toolWhitelist.add(name);
@@ -51,8 +94,7 @@ class ACPProtocol extends EventEmitter {
51
94
 
52
95
  createInitializeResponse() {
53
96
  return {
54
- jsonrpc: '2.0',
55
- id: 1,
97
+ jsonrpc: '2.0', id: 1,
56
98
  result: {
57
99
  tools: this.getToolsList(),
58
100
  instruction: this.instruction,
@@ -65,30 +107,19 @@ class ACPProtocol extends EventEmitter {
65
107
  return { jsonrpc: '2.0', id: this.generateRequestId(), method, params };
66
108
  }
67
109
 
68
- createJsonRpcResponse(id, result) {
69
- return { jsonrpc: '2.0', id, result };
70
- }
110
+ createJsonRpcResponse(id, result) { return { jsonrpc: '2.0', id, result }; }
71
111
 
72
112
  createJsonRpcError(id, error) {
73
113
  return {
74
- jsonrpc: '2.0',
75
- id,
76
- error: {
77
- code: error?.code ?? -32000,
78
- message: error instanceof Error ? error.message : String(error),
79
- },
114
+ jsonrpc: '2.0', id,
115
+ error: { code: error?.code ?? -32000, message: error instanceof Error ? error.message : String(error) },
80
116
  };
81
117
  }
82
118
 
83
119
  validateToolCall(toolName) {
84
120
  if (!this.toolWhitelist.has(toolName)) {
85
121
  const availableTools = Array.from(this.toolWhitelist);
86
- this.rejectedCallLog.push({
87
- timestamp: new Date().toISOString(),
88
- attemptedTool: toolName,
89
- reason: 'Not in whitelist',
90
- availableTools,
91
- });
122
+ this.rejectedCallLog.push({ timestamp: new Date().toISOString(), attemptedTool: toolName, reason: 'Not in whitelist', availableTools });
92
123
  return { allowed: false, error: `Tool not available. Available: ${availableTools.join(', ')}` };
93
124
  }
94
125
  return { allowed: true };
@@ -97,10 +128,8 @@ class ACPProtocol extends EventEmitter {
97
128
  async callTool(toolName, params) {
98
129
  const validation = this.validateToolCall(toolName);
99
130
  if (!validation.allowed) throw new Error(validation.error);
100
-
101
131
  const entry = { timestamp: new Date().toISOString(), toolName, params, status: 'executing' };
102
132
  this.toolCallLog.push(entry);
103
-
104
133
  if (!this.tools[toolName]) throw new Error(`Unknown tool: ${toolName}`);
105
134
  const result = await this.tools[toolName](params);
106
135
  entry.status = 'completed';
@@ -108,67 +137,43 @@ class ACPProtocol extends EventEmitter {
108
137
  return result;
109
138
  }
110
139
 
111
- parseTextOutput(output) {
112
- return parseTextOutput(output);
113
- }
114
-
115
- parseToolCalls(output) {
116
- return parseToolCalls(output);
117
- }
140
+ parseTextOutput(output) { return parseTextOutput(output); }
141
+ parseToolCalls(output) { return parseToolCalls(output); }
118
142
 
119
143
  async process(text, options = {}) {
120
- const cli = options.cli || 'kilo';
121
- const model = options.model;
122
144
  const fullPrompt = this.instruction
123
145
  ? `${this.instruction}${this.getToolsPrompt()}\n\n---\n\n${text}`
124
146
  : `${this.getToolsPrompt()}\n\n---\n\n${text}`;
125
147
 
126
- const args = ['run', '--format', 'json'];
127
- if (cli === 'kilo') args.push('--auto');
128
- if (model) args.push('--model', model);
129
- args.push(fullPrompt);
130
-
131
- return new Promise((resolve, reject) => {
132
- let output = '';
133
- let errorOutput = '';
134
-
135
- const child = spawn(cli, args, {
136
- stdio: ['pipe', 'pipe', 'pipe'],
137
- cwd: process.cwd(),
138
- env: { ...process.env },
139
- });
140
-
141
- child.stdin.end();
142
- child.stdout.on('data', (data) => { output += data.toString(); });
143
- child.stderr.on('data', (data) => { errorOutput += data.toString(); });
144
-
145
- child.on('close', async (code) => {
146
- if (code !== 0 && code !== null && !output) {
147
- reject(new Error(`CLI exited with code ${code}: ${errorOutput}`));
148
- return;
149
- }
150
- try {
151
- const toolCalls = parseToolCalls(output);
152
- const results = [];
153
- for (const call of toolCalls) {
154
- const toolName = call.method.replace('tools/', '');
155
- if (this.toolWhitelist.has(toolName)) {
156
- const result = await this.callTool(toolName, call.params);
157
- results.push({ tool: toolName, result });
158
- }
159
- }
160
- resolve({ text: parseTextOutput(output), rawOutput: output, toolCalls: results, logs: this.toolCallLog });
161
- } catch (e) {
162
- resolve({ text: parseTextOutput(output), rawOutput: output, error: e.message, logs: this.toolCallLog });
163
- }
164
- });
148
+ let stack;
149
+ if (options.services) {
150
+ stack = createServiceStack(options.services);
151
+ } else if (options.cli) {
152
+ stack = [{ name: options.cli, profileId: '__default__', config: {} }];
153
+ } else {
154
+ stack = this.registry.getAll().length > 0
155
+ ? this.registry.getAvailable()
156
+ : [{ name: 'kilo', profileId: '__default__', config: {} }];
157
+ }
165
158
 
166
- child.on('error', (error) => {
167
- reject(new Error(`Failed to spawn ${cli}: ${error.message}`));
168
- });
159
+ this.fallback._stack = stack;
169
160
 
170
- setTimeout(() => { child.kill(); reject(new Error('Timeout')); }, 120000);
171
- });
161
+ const { rawOutput } = await this.fallback.run(spawnService, fullPrompt, options);
162
+
163
+ try {
164
+ const toolCalls = parseToolCalls(rawOutput);
165
+ const results = [];
166
+ for (const call of toolCalls) {
167
+ const toolName = call.method.replace('tools/', '');
168
+ if (this.toolWhitelist.has(toolName)) {
169
+ const result = await this.callTool(toolName, call.params);
170
+ results.push({ tool: toolName, result });
171
+ }
172
+ }
173
+ return { text: parseTextOutput(rawOutput), rawOutput, toolCalls: results, logs: this.toolCallLog };
174
+ } catch (e) {
175
+ return { text: parseTextOutput(rawOutput), rawOutput, error: e.message, logs: this.toolCallLog };
176
+ }
172
177
  }
173
178
 
174
179
  stop() {}
package/fallback.js ADDED
@@ -0,0 +1,66 @@
1
+ import { EventEmitter } from 'events';
2
+ import { isRateLimited, DEFAULT_COOLDOWN_MS } from './services.js';
3
+
4
+ class FallbackEngine extends EventEmitter {
5
+ constructor(serviceStack = []) {
6
+ super();
7
+ this._stack = serviceStack;
8
+ }
9
+
10
+ async run(spawnFn, text, options = {}) {
11
+ if (this._stack.length === 0) {
12
+ throw new Error('FallbackEngine: service stack is empty');
13
+ }
14
+
15
+ const errors = [];
16
+ let attempted = 0;
17
+
18
+ for (const entry of this._stack) {
19
+ const { name, profileId } = entry;
20
+ attempted++;
21
+
22
+ let result;
23
+ let spawnError;
24
+ let output = '';
25
+ let stderr = '';
26
+
27
+ try {
28
+ result = await spawnFn(entry, text, options, {
29
+ onOutput: (chunk) => { output += chunk; },
30
+ onStderr: (chunk) => { stderr += chunk; },
31
+ });
32
+ } catch (err) {
33
+ spawnError = err;
34
+ output = err.output || '';
35
+ stderr = err.stderr || '';
36
+ }
37
+
38
+ const rlCheck = isRateLimited(name, output, stderr);
39
+ const isMissing = spawnError?.code === 'ENOENT';
40
+
41
+ if (!spawnError && !rlCheck.rateLimited) {
42
+ this.emit('success', { name, profileId, attempted });
43
+ return result;
44
+ }
45
+
46
+ if (rlCheck.rateLimited || isMissing) {
47
+ const cooldownMs = rlCheck.retryAfterMs ?? DEFAULT_COOLDOWN_MS;
48
+ this.emit('rate-limited', { name, profileId, cooldownMs, error: spawnError?.message });
49
+ errors.push({ name, profileId, rateLimited: true, retryAfterMs: cooldownMs, error: spawnError?.message });
50
+ const remaining = this._stack.slice(attempted);
51
+ if (remaining.length > 0) this.emit('fallback', { from: { name, profileId }, to: remaining[0] });
52
+ continue;
53
+ }
54
+
55
+ throw spawnError || new Error(`Service ${name} failed: ${output}${stderr}`);
56
+ }
57
+
58
+ const summary = errors.map(e => `${e.name}(${e.profileId}): rate-limited`).join(', ');
59
+ throw new AggregateError(
60
+ errors.map(e => Object.assign(new Error(`${e.name} rate-limited`), e)),
61
+ `All services exhausted: ${summary}`
62
+ );
63
+ }
64
+ }
65
+
66
+ export { FallbackEngine };
package/index.js CHANGED
@@ -1,28 +1,49 @@
1
1
  import { ACPProtocol } from './core.js';
2
+ import { ServiceRegistry, isRateLimited, createServiceStack } from './services.js';
3
+ import { FallbackEngine } from './fallback.js';
2
4
 
3
- export { ACPProtocol };
5
+ export { ACPProtocol, ServiceRegistry, FallbackEngine, isRateLimited, createServiceStack };
4
6
 
5
7
  /*
6
- * acpreact - ACP SDK for registering tools
8
+ * acpreact - ACP SDK for registering tools with multi-service fallback
7
9
  *
8
- * Basic usage:
10
+ * Basic usage (single service, backward compatible):
9
11
  * import { ACPProtocol } from 'acpreact';
12
+ * const acp = new ACPProtocol('Your instruction');
13
+ * const result = await acp.process('prompt', { cli: 'kilo' });
10
14
  *
11
- * Create ACP Protocol instance:
12
- * const acp = new ACPProtocol();
15
+ * Multi-service fallback pass a services array to the constructor:
16
+ * const acp = new ACPProtocol('Your instruction', [
17
+ * { cli: 'kilo' },
18
+ * { cli: 'opencode' },
19
+ * { cli: 'gemini' },
20
+ * ]);
21
+ * const result = await acp.process('prompt');
13
22
  *
14
- * Register custom tools:
15
- * acp.registerTool('my_tool', 'Tool description', {
16
- * type: 'object',
17
- * properties: { query: { type: 'string' } },
18
- * required: ['query']
19
- * }, async (params) => {
20
- * return { result: 'processed' };
23
+ * Per-call override:
24
+ * const result = await acp.process('prompt', {
25
+ * services: [{ cli: 'opencode' }, { cli: 'kilo' }],
21
26
  * });
22
27
  *
23
- * Initialize protocol:
24
- * const response = acp.createInitializeResponse();
28
+ * Custom ACP-compatible provider:
29
+ * acp.registry.registerService('my-agent', {
30
+ * binary: 'my-agent',
31
+ * buildArgs: (prompt, opts) => ['run', prompt],
32
+ * });
33
+ *
34
+ * Multiple profiles per provider (different logins):
35
+ * const acp = new ACPProtocol('instruction', [
36
+ * { cli: 'kilo', profile: 'account-a' },
37
+ * { cli: 'kilo', profile: 'account-b' },
38
+ * { cli: 'opencode', profile: 'account-c' },
39
+ * ]);
40
+ *
41
+ * Listen to fallback events:
42
+ * acp.on('rate-limited', ({ name, profileId, cooldownMs }) => { ... });
43
+ * acp.on('fallback', ({ from, to }) => { ... });
44
+ * acp.on('success', ({ name, profileId, attempted }) => { ... });
25
45
  *
26
- * Execute tool:
27
- * const result = await acp.callTool('my_tool', { query: 'test' });
46
+ * Build a service stack manually:
47
+ * import { createServiceStack, ServiceRegistry, FallbackEngine } from 'acpreact';
48
+ * const stack = createServiceStack([{ cli: 'kilo' }, { cli: 'gemini' }]);
28
49
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acpreact",
3
- "version": "1.0.8",
3
+ "version": "1.1.1",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "description": "ACP SDK for monitoring and reacting to chat conversations",
package/services.js ADDED
@@ -0,0 +1,139 @@
1
+ import { EventEmitter } from 'events';
2
+
3
+ const DEFAULT_COOLDOWN_MS = 60_000;
4
+
5
+ const RATE_LIMIT_PATTERNS = {
6
+ common: [
7
+ /\b429\b/,
8
+ /rate.?limit/i,
9
+ /quota.?exceeded/i,
10
+ /too.?many.?requests/i,
11
+ ],
12
+ gemini: [/RESOURCE_EXHAUSTED/],
13
+ kilo: [],
14
+ opencode: [],
15
+ };
16
+
17
+ const RETRY_AFTER_PATTERN = /retry.?after[:\s]+(\d+)/i;
18
+
19
+ const BUILTIN_ARG_BUILDERS = {
20
+ kilo: (prompt, options) => {
21
+ const args = ['run', '--format', 'json', '--auto'];
22
+ if (options?.model) args.push('--model', options.model);
23
+ args.push(prompt);
24
+ return args;
25
+ },
26
+ opencode: (prompt, options) => {
27
+ const args = ['run', '--format', 'json'];
28
+ if (options?.model) args.push('--model', options.model);
29
+ args.push(prompt);
30
+ return args;
31
+ },
32
+ gemini: (prompt, options) => {
33
+ const args = ['run'];
34
+ if (options?.model) args.push('--model', options.model);
35
+ args.push(prompt);
36
+ return args;
37
+ },
38
+ };
39
+
40
+ function buildArgs(providerName, prompt, options) {
41
+ const builder = BUILTIN_ARG_BUILDERS[providerName];
42
+ if (builder) return builder(prompt, options);
43
+ const args = ['run'];
44
+ if (options?.model) args.push('--model', options.model);
45
+ args.push(prompt);
46
+ return args;
47
+ }
48
+
49
+ function isRateLimited(providerName, output = '', stderr = '') {
50
+ const combined = `${output}\n${stderr}`;
51
+ const patterns = [
52
+ ...RATE_LIMIT_PATTERNS.common,
53
+ ...(RATE_LIMIT_PATTERNS[providerName] || []),
54
+ ];
55
+
56
+ let rateLimited = false;
57
+ let retryAfterMs;
58
+
59
+ for (const pattern of patterns) {
60
+ if (pattern.test(combined)) {
61
+ rateLimited = true;
62
+ break;
63
+ }
64
+ }
65
+
66
+ if (rateLimited) {
67
+ const match = combined.match(RETRY_AFTER_PATTERN);
68
+ if (match) retryAfterMs = parseInt(match[1], 10) * 1000;
69
+ }
70
+
71
+ return retryAfterMs !== undefined ? { rateLimited, retryAfterMs } : { rateLimited };
72
+ }
73
+
74
+ function createServiceStack(configs) {
75
+ return configs.map(cfg => ({
76
+ name: cfg.cli || cfg.name,
77
+ profileId: cfg.profile ?? '__default__',
78
+ config: cfg,
79
+ }));
80
+ }
81
+
82
+ class ServiceRegistry extends EventEmitter {
83
+ constructor() {
84
+ super();
85
+ this._services = [];
86
+ this._cooldowns = new Map();
87
+ }
88
+
89
+ _cooldownKey(name, profileId) {
90
+ return `${name}::${profileId ?? '__default__'}`;
91
+ }
92
+
93
+ registerService(name, config = {}) {
94
+ const profileId = config.profile ?? '__default__';
95
+ const existing = this._services.findIndex(
96
+ s => s.name === name && s.profileId === profileId
97
+ );
98
+ const entry = { name, profileId, config };
99
+ if (existing >= 0) {
100
+ this._services[existing] = entry;
101
+ } else {
102
+ this._services.push(entry);
103
+ }
104
+ this._cooldowns.delete(this._cooldownKey(name, profileId));
105
+ return this;
106
+ }
107
+
108
+ markRateLimited(name, profileId, cooldownMs = DEFAULT_COOLDOWN_MS) {
109
+ const key = this._cooldownKey(name, profileId ?? '__default__');
110
+ const expiry = cooldownMs === 0 ? 0 : Date.now() + cooldownMs;
111
+ this._cooldowns.set(key, expiry);
112
+ this.emit('rate-limited', { name, profileId, cooldownMs, expiry });
113
+ }
114
+
115
+ clearCooldown(name, profileId) {
116
+ this._cooldowns.delete(this._cooldownKey(name, profileId ?? '__default__'));
117
+ }
118
+
119
+ isAvailable(name, profileId) {
120
+ const key = this._cooldownKey(name, profileId ?? '__default__');
121
+ if (!this._cooldowns.has(key)) return true;
122
+ const expiry = this._cooldowns.get(key);
123
+ if (expiry === 0 || Date.now() >= expiry) {
124
+ this._cooldowns.delete(key);
125
+ return true;
126
+ }
127
+ return false;
128
+ }
129
+
130
+ getAvailable() {
131
+ return this._services.filter(s => this.isAvailable(s.name, s.profileId));
132
+ }
133
+
134
+ getAll() {
135
+ return [...this._services];
136
+ }
137
+ }
138
+
139
+ export { ServiceRegistry, isRateLimited, createServiceStack, buildArgs, DEFAULT_COOLDOWN_MS };