lynkr 9.5.0 → 9.7.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/README.md +39 -1
- package/bin/cli.js +2 -0
- package/bin/wrap.js +686 -0
- package/package.json +4 -3
- package/scripts/build-knn-index.js +1 -1
- package/scripts/convert-routellm.py +105 -0
- package/src/agents/decomposition/dispatcher.js +185 -0
- package/src/agents/decomposition/gate.js +136 -0
- package/src/agents/decomposition/index.js +183 -0
- package/src/agents/decomposition/model-call.js +75 -0
- package/src/agents/decomposition/planner.js +223 -0
- package/src/agents/decomposition/synthesizer.js +89 -0
- package/src/agents/decomposition/telemetry.js +55 -0
- package/src/api/router.js +694 -21
- package/src/auth-mode.js +116 -0
- package/src/clients/databricks.js +551 -68
- package/src/clients/openrouter-utils.js +6 -29
- package/src/clients/prompt-cache-injection.js +64 -0
- package/src/clients/responses-format.js +7 -0
- package/src/clients/tool-call-repair.js +130 -0
- package/src/config/index.js +23 -0
- package/src/context/output-format-guard.js +99 -0
- package/src/orchestrator/index.js +120 -60
- package/src/routing/index.js +31 -4
- package/src/routing/knn-router.js +9 -2
- package/src/routing/model-tiers.js +34 -0
- package/src/routing/tier-fallback.js +91 -0
- package/src/server.js +2 -0
- package/src/tools/decompose.js +91 -0
- package/src/tools/lazy-loader.js +8 -0
package/bin/wrap.js
ADDED
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Lynkr Wrap - Launch CLI tools through Lynkr proxy
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* lynkr wrap claude # launch Claude Code with defaults
|
|
7
|
+
* lynkr wrap copilot # wrap GitHub Copilot CLI
|
|
8
|
+
* lynkr wrap aider # wrap Aider AI assistant
|
|
9
|
+
* lynkr wrap cursor # wrap Cursor editor
|
|
10
|
+
* lynkr wrap codex # wrap OpenAI Codex CLI
|
|
11
|
+
* lynkr wrap claude --port 9000 # custom port
|
|
12
|
+
* lynkr wrap aider -- --help # pass args to aider
|
|
13
|
+
*
|
|
14
|
+
* This wraps official AI coding tool binaries and routes traffic through Lynkr,
|
|
15
|
+
* giving users access to tier routing, compression, and caching. For Claude Code,
|
|
16
|
+
* Pro/Max subscription users can leverage their OAuth tokens without separate API billing.
|
|
17
|
+
*
|
|
18
|
+
* @module bin/wrap
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const { spawn } = require('child_process');
|
|
22
|
+
const { existsSync } = require('fs');
|
|
23
|
+
const { execSync } = require('child_process');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
|
|
26
|
+
// Parse arguments
|
|
27
|
+
const args = process.argv.slice(2);
|
|
28
|
+
const target = args[0]; // 'claude', 'codex', etc.
|
|
29
|
+
|
|
30
|
+
if (!target) {
|
|
31
|
+
console.error('Usage: lynkr wrap <target> [options]');
|
|
32
|
+
console.error('');
|
|
33
|
+
console.error('Targets:');
|
|
34
|
+
console.error(' claude Wrap Claude Code CLI');
|
|
35
|
+
console.error(' copilot Wrap GitHub Copilot CLI');
|
|
36
|
+
console.error(' aider Wrap Aider AI coding assistant');
|
|
37
|
+
console.error(' cursor Wrap Cursor editor');
|
|
38
|
+
console.error(' codex Wrap OpenAI Codex CLI');
|
|
39
|
+
console.error('');
|
|
40
|
+
console.error('Options:');
|
|
41
|
+
console.error(' --port N Use port N for Lynkr proxy (default: 8081)');
|
|
42
|
+
console.error('');
|
|
43
|
+
console.error('Examples:');
|
|
44
|
+
console.error(' lynkr wrap claude');
|
|
45
|
+
console.error(' lynkr wrap copilot --port 9000');
|
|
46
|
+
console.error(' lynkr wrap aider -- --help');
|
|
47
|
+
console.error(' lynkr wrap cursor');
|
|
48
|
+
console.error(' lynkr wrap codex');
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (target === 'claude') {
|
|
53
|
+
wrapClaude();
|
|
54
|
+
} else if (target === 'copilot') {
|
|
55
|
+
wrapCopilot();
|
|
56
|
+
} else if (target === 'aider') {
|
|
57
|
+
wrapAider();
|
|
58
|
+
} else if (target === 'cursor') {
|
|
59
|
+
wrapCursor();
|
|
60
|
+
} else if (target === 'codex') {
|
|
61
|
+
wrapCodex();
|
|
62
|
+
} else {
|
|
63
|
+
console.error(`Error: 'lynkr wrap ${target}' is not supported yet.`);
|
|
64
|
+
console.error('');
|
|
65
|
+
console.error('Supported targets: claude, copilot, aider, cursor, codex');
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
70
|
+
// Claude Code wrapper
|
|
71
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
async function wrapClaude() {
|
|
74
|
+
console.log('╭─ Lynkr Wrap ─────────────────────────────────────────');
|
|
75
|
+
console.log('│ Starting Claude Code through Lynkr proxy...');
|
|
76
|
+
console.log('╰──────────────────────────────────────────────────────');
|
|
77
|
+
console.log('');
|
|
78
|
+
|
|
79
|
+
// Silence Lynkr logs in wrap mode so they don't bleed into Claude Code's
|
|
80
|
+
// TUI (the child inherits our stdio). Users who need Lynkr logs can set
|
|
81
|
+
// LOG_LEVEL=info|debug explicitly, or tail data/logs/lynkr.log.
|
|
82
|
+
if (!process.env.LOG_LEVEL || process.env.LOG_LEVEL === 'info' || process.env.LOG_LEVEL === 'error' || process.env.LOG_LEVEL === 'warn') {
|
|
83
|
+
process.env.LOG_LEVEL = 'silent';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Enable OAuth passthrough by default for wrap claude. Server reads this
|
|
87
|
+
// env before /v1/messages handlers are wired up, so set it before start().
|
|
88
|
+
if (process.env.LYNKR_OAUTH_PASSTHROUGH == null) {
|
|
89
|
+
process.env.LYNKR_OAUTH_PASSTHROUGH = 'true';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 1. Check for Claude Code binary
|
|
93
|
+
const claudePath = findClaudeBinary();
|
|
94
|
+
if (!claudePath) {
|
|
95
|
+
console.error('✗ Claude Code CLI not found in PATH');
|
|
96
|
+
console.error('');
|
|
97
|
+
console.error('Install it first:');
|
|
98
|
+
console.error(' • macOS: brew install --cask claude-code');
|
|
99
|
+
console.error(' • Or download from: https://claude.ai/code');
|
|
100
|
+
console.error('');
|
|
101
|
+
console.error('Then verify: claude --version');
|
|
102
|
+
process.exit(2);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
console.log(`✓ Found Claude Code at: ${claudePath}`);
|
|
106
|
+
|
|
107
|
+
// 2. Parse wrap-specific options
|
|
108
|
+
const wrapOpts = parseWrapOptions(args.slice(1));
|
|
109
|
+
const port = wrapOpts.port;
|
|
110
|
+
const claudeArgs = wrapOpts.passthrough;
|
|
111
|
+
|
|
112
|
+
// 3. Start Lynkr server
|
|
113
|
+
console.log(`✓ Starting Lynkr on port ${port}...`);
|
|
114
|
+
|
|
115
|
+
let server;
|
|
116
|
+
try {
|
|
117
|
+
const { start } = require('../src/server');
|
|
118
|
+
|
|
119
|
+
// Override port if specified
|
|
120
|
+
if (port !== 8081) {
|
|
121
|
+
process.env.PORT = String(port);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
server = await start();
|
|
125
|
+
|
|
126
|
+
// Wait for server to be ready
|
|
127
|
+
await waitForReady(port, 30000);
|
|
128
|
+
console.log(`✓ Lynkr ready on http://localhost:${port}`);
|
|
129
|
+
} catch (err) {
|
|
130
|
+
console.error('✗ Failed to start Lynkr:', err.message);
|
|
131
|
+
console.error('');
|
|
132
|
+
if (err.code === 'EADDRINUSE') {
|
|
133
|
+
console.error('Port already in use. Try:');
|
|
134
|
+
console.error(` lynkr wrap claude --port ${port + 1}`);
|
|
135
|
+
console.error('');
|
|
136
|
+
console.error('Or stop existing Lynkr:');
|
|
137
|
+
console.error(' lynkr stop');
|
|
138
|
+
} else {
|
|
139
|
+
console.error('Check your .env configuration:');
|
|
140
|
+
console.error(' DATABRICKS_API_KEY, OLLAMA_ENDPOINT, etc.');
|
|
141
|
+
console.error('');
|
|
142
|
+
console.error('Debug logs: tail -f data/logs/lynkr.log');
|
|
143
|
+
}
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
console.log('');
|
|
148
|
+
console.log('╭─ Claude Code ────────────────────────────────────────');
|
|
149
|
+
console.log('│ Launching with Lynkr routing enabled...');
|
|
150
|
+
console.log('│ • Tier routing: active');
|
|
151
|
+
console.log('│ • Compression: active');
|
|
152
|
+
console.log('│ • Caching: active');
|
|
153
|
+
if (claudeArgs.length > 0) {
|
|
154
|
+
console.log(`│ • Args: ${claudeArgs.join(' ')}`);
|
|
155
|
+
}
|
|
156
|
+
console.log('╰──────────────────────────────────────────────────────');
|
|
157
|
+
console.log('');
|
|
158
|
+
|
|
159
|
+
// 4. Launch Claude Code with Lynkr as base URL
|
|
160
|
+
// Force interactive mode if no args provided
|
|
161
|
+
const finalArgs = claudeArgs.length === 0 && !process.stdin.isTTY
|
|
162
|
+
? [] // Let Claude detect TTY and start interactive
|
|
163
|
+
: claudeArgs;
|
|
164
|
+
|
|
165
|
+
// NOTE: We deliberately do NOT set ENABLE_TOOL_SEARCH=true here.
|
|
166
|
+
//
|
|
167
|
+
// When ENABLE_TOOL_SEARCH=true, Claude Code defers MCP/system tool schemas
|
|
168
|
+
// behind a single `tool_search_tool` meta-tool that requires Anthropic's
|
|
169
|
+
// server-side dispatch to resolve. That worked when we sent everything to
|
|
170
|
+
// Anthropic, but it breaks tier routing: when "Can you read this repo" gets
|
|
171
|
+
// routed to Ollama (or any non-Anthropic provider), the model only sees the
|
|
172
|
+
// search meta-tool and has no way to discover Read/Write/Bash — it responds
|
|
173
|
+
// "no file system tools available."
|
|
174
|
+
//
|
|
175
|
+
// Without this env var, Claude Code materializes the full real tool list in
|
|
176
|
+
// every request. That's more tokens on the Anthropic side (passthrough
|
|
177
|
+
// forwards them verbatim, Anthropic accepts them because the UA matches),
|
|
178
|
+
// but Ollama/Moonshot/etc. now see the actual tools and can use them.
|
|
179
|
+
//
|
|
180
|
+
// The original 400 "Input tag does not match expected tags" error this
|
|
181
|
+
// workaround was fighting is no longer reachable — subscription requests
|
|
182
|
+
// now passthrough byte-for-byte, so Anthropic accepts whatever shape
|
|
183
|
+
// Claude Code sends.
|
|
184
|
+
const child = spawn(claudePath, finalArgs, {
|
|
185
|
+
env: {
|
|
186
|
+
...process.env,
|
|
187
|
+
ANTHROPIC_BASE_URL: `http://localhost:${port}`,
|
|
188
|
+
},
|
|
189
|
+
stdio: 'inherit',
|
|
190
|
+
shell: false,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Track start time for stats
|
|
194
|
+
const startTime = Date.now();
|
|
195
|
+
|
|
196
|
+
// 5. Handle signals - forward to child
|
|
197
|
+
const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
198
|
+
const forwardSignal = (signal) => {
|
|
199
|
+
if (!child.killed) {
|
|
200
|
+
child.kill(signal);
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
signals.forEach((signal) => {
|
|
205
|
+
process.on(signal, () => forwardSignal(signal));
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// 6. Wait for child to exit
|
|
209
|
+
child.on('exit', async (code, signal) => {
|
|
210
|
+
const duration = Date.now() - startTime;
|
|
211
|
+
|
|
212
|
+
console.log('');
|
|
213
|
+
console.log('╭─ Claude Code Exited ─────────────────────────────────');
|
|
214
|
+
|
|
215
|
+
if (signal) {
|
|
216
|
+
console.log(`│ Signal: ${signal}`);
|
|
217
|
+
} else {
|
|
218
|
+
console.log(`│ Exit code: ${code}`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
console.log(`│ Duration: ${formatDuration(duration)}`);
|
|
222
|
+
console.log('╰──────────────────────────────────────────────────────');
|
|
223
|
+
|
|
224
|
+
// Show stats if enabled and clean exit
|
|
225
|
+
if (process.env.LYNKR_WRAP_SHOW_STATS !== 'false' && code === 0) {
|
|
226
|
+
try {
|
|
227
|
+
await showSessionStats();
|
|
228
|
+
} catch (err) {
|
|
229
|
+
// Stats are nice-to-have, don't fail on error
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Shutdown Lynkr
|
|
234
|
+
console.log('');
|
|
235
|
+
console.log('Shutting down Lynkr...');
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
if (server && typeof server.close === 'function') {
|
|
239
|
+
await new Promise((resolve) => {
|
|
240
|
+
server.close(() => {
|
|
241
|
+
console.log('✓ Lynkr stopped');
|
|
242
|
+
resolve();
|
|
243
|
+
});
|
|
244
|
+
// Force close after 2s
|
|
245
|
+
setTimeout(() => {
|
|
246
|
+
console.log('✓ Lynkr stopped (forced)');
|
|
247
|
+
resolve();
|
|
248
|
+
}, 2000);
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
} catch (err) {
|
|
252
|
+
// Ignore shutdown errors
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
process.exit(code || 0);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// Handle child spawn errors
|
|
259
|
+
child.on('error', (err) => {
|
|
260
|
+
console.error('✗ Failed to launch Claude Code:', err.message);
|
|
261
|
+
process.exit(1);
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
266
|
+
// GitHub Copilot CLI wrapper
|
|
267
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
async function wrapCopilot() {
|
|
270
|
+
await wrapGeneric({
|
|
271
|
+
name: 'GitHub Copilot CLI',
|
|
272
|
+
binaryName: 'github-copilot-cli',
|
|
273
|
+
findBinary: findCopilotBinary,
|
|
274
|
+
envVar: 'OPENAI_API_BASE',
|
|
275
|
+
installInstructions: [
|
|
276
|
+
' • npm install -g @githubnext/github-copilot-cli',
|
|
277
|
+
' • Or: https://www.npmjs.com/package/@githubnext/github-copilot-cli',
|
|
278
|
+
],
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
283
|
+
// Aider wrapper
|
|
284
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
async function wrapAider() {
|
|
287
|
+
await wrapGeneric({
|
|
288
|
+
name: 'Aider',
|
|
289
|
+
binaryName: 'aider',
|
|
290
|
+
findBinary: findAiderBinary,
|
|
291
|
+
envVar: 'OPENAI_API_BASE',
|
|
292
|
+
installInstructions: [
|
|
293
|
+
' • pip install aider-chat',
|
|
294
|
+
' • Or: https://aider.chat/docs/install.html',
|
|
295
|
+
],
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
300
|
+
// Cursor wrapper
|
|
301
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
async function wrapCursor() {
|
|
304
|
+
await wrapGeneric({
|
|
305
|
+
name: 'Cursor',
|
|
306
|
+
binaryName: 'cursor',
|
|
307
|
+
findBinary: findCursorBinary,
|
|
308
|
+
envVar: 'ANTHROPIC_BASE_URL',
|
|
309
|
+
installInstructions: [
|
|
310
|
+
' • Download from: https://cursor.sh',
|
|
311
|
+
' • macOS: brew install --cask cursor',
|
|
312
|
+
],
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
317
|
+
// OpenAI Codex CLI wrapper
|
|
318
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
async function wrapCodex() {
|
|
321
|
+
await wrapGeneric({
|
|
322
|
+
name: 'OpenAI Codex CLI',
|
|
323
|
+
binaryName: 'codex',
|
|
324
|
+
findBinary: findCodexBinary,
|
|
325
|
+
envVar: 'OPENAI_API_BASE',
|
|
326
|
+
installInstructions: [
|
|
327
|
+
' • Install OpenAI CLI: pip install openai',
|
|
328
|
+
' • Or: npm install -g openai',
|
|
329
|
+
],
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
334
|
+
// Generic wrapper (used by copilot, aider, cursor, codex)
|
|
335
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
async function wrapGeneric(opts) {
|
|
338
|
+
console.log('╭─ Lynkr Wrap ─────────────────────────────────────────');
|
|
339
|
+
console.log(`│ Starting ${opts.name} through Lynkr proxy...`);
|
|
340
|
+
console.log('╰──────────────────────────────────────────────────────');
|
|
341
|
+
console.log('');
|
|
342
|
+
|
|
343
|
+
// Suppress verbose Lynkr logs in wrap mode
|
|
344
|
+
if (!process.env.LOG_LEVEL || process.env.LOG_LEVEL === 'info') {
|
|
345
|
+
process.env.LOG_LEVEL = 'error';
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// 1. Check for binary
|
|
349
|
+
const binaryPath = opts.findBinary();
|
|
350
|
+
if (!binaryPath) {
|
|
351
|
+
console.error(`✗ ${opts.name} not found in PATH`);
|
|
352
|
+
console.error('');
|
|
353
|
+
console.error('Install it first:');
|
|
354
|
+
opts.installInstructions.forEach((line) => console.error(line));
|
|
355
|
+
console.error('');
|
|
356
|
+
console.error(`Then verify: ${opts.binaryName} --version`);
|
|
357
|
+
process.exit(2);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
console.log(`✓ Found ${opts.name} at: ${binaryPath}`);
|
|
361
|
+
|
|
362
|
+
// 2. Parse wrap-specific options
|
|
363
|
+
const wrapOpts = parseWrapOptions(args.slice(1));
|
|
364
|
+
const port = wrapOpts.port;
|
|
365
|
+
const targetArgs = wrapOpts.passthrough;
|
|
366
|
+
|
|
367
|
+
// 3. Start Lynkr server
|
|
368
|
+
console.log(`✓ Starting Lynkr on port ${port}...`);
|
|
369
|
+
|
|
370
|
+
let server;
|
|
371
|
+
try {
|
|
372
|
+
const { start } = require('../src/server');
|
|
373
|
+
|
|
374
|
+
// Override port if specified
|
|
375
|
+
if (port !== 8081) {
|
|
376
|
+
process.env.PORT = String(port);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
server = await start();
|
|
380
|
+
|
|
381
|
+
// Wait for server to be ready
|
|
382
|
+
await waitForReady(port, 30000);
|
|
383
|
+
console.log(`✓ Lynkr ready on http://localhost:${port}`);
|
|
384
|
+
} catch (err) {
|
|
385
|
+
console.error('✗ Failed to start Lynkr:', err.message);
|
|
386
|
+
console.error('');
|
|
387
|
+
if (err.code === 'EADDRINUSE') {
|
|
388
|
+
console.error('Port already in use. Try:');
|
|
389
|
+
console.error(` lynkr wrap ${opts.binaryName} --port ${port + 1}`);
|
|
390
|
+
console.error('');
|
|
391
|
+
console.error('Or stop existing Lynkr:');
|
|
392
|
+
console.error(' lynkr stop');
|
|
393
|
+
} else {
|
|
394
|
+
console.error('Check your .env configuration:');
|
|
395
|
+
console.error(' TIER_SIMPLE, TIER_COMPLEX, etc.');
|
|
396
|
+
console.error('');
|
|
397
|
+
console.error('Debug logs: tail -f data/logs/lynkr.log');
|
|
398
|
+
}
|
|
399
|
+
process.exit(1);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
console.log('');
|
|
403
|
+
console.log(`╭─ ${opts.name} ────────────────────────────────────────`);
|
|
404
|
+
console.log('│ Launching with Lynkr routing enabled...');
|
|
405
|
+
console.log('│ • Tier routing: active');
|
|
406
|
+
console.log('│ • Compression: active');
|
|
407
|
+
console.log('│ • Caching: active');
|
|
408
|
+
console.log('╰──────────────────────────────────────────────────────');
|
|
409
|
+
console.log('');
|
|
410
|
+
|
|
411
|
+
// 4. Launch binary with Lynkr as base URL
|
|
412
|
+
const child = spawn(binaryPath, targetArgs, {
|
|
413
|
+
env: {
|
|
414
|
+
...process.env,
|
|
415
|
+
[opts.envVar]: `http://localhost:${port}`,
|
|
416
|
+
},
|
|
417
|
+
stdio: 'inherit',
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
// Track start time for stats
|
|
421
|
+
const startTime = Date.now();
|
|
422
|
+
|
|
423
|
+
// 5. Handle signals - forward to child
|
|
424
|
+
const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
425
|
+
const forwardSignal = (signal) => {
|
|
426
|
+
if (!child.killed) {
|
|
427
|
+
child.kill(signal);
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
signals.forEach((signal) => {
|
|
432
|
+
process.on(signal, () => forwardSignal(signal));
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
// 6. Wait for child to exit
|
|
436
|
+
child.on('exit', async (code, signal) => {
|
|
437
|
+
const duration = Date.now() - startTime;
|
|
438
|
+
|
|
439
|
+
console.log('');
|
|
440
|
+
console.log(`╭─ ${opts.name} Exited ─────────────────────────────────`);
|
|
441
|
+
|
|
442
|
+
if (signal) {
|
|
443
|
+
console.log(`│ Signal: ${signal}`);
|
|
444
|
+
} else {
|
|
445
|
+
console.log(`│ Exit code: ${code}`);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
console.log(`│ Duration: ${formatDuration(duration)}`);
|
|
449
|
+
console.log('╰──────────────────────────────────────────────────────');
|
|
450
|
+
|
|
451
|
+
// Show stats if enabled and clean exit
|
|
452
|
+
if (process.env.LYNKR_WRAP_SHOW_STATS !== 'false' && code === 0) {
|
|
453
|
+
try {
|
|
454
|
+
await showSessionStats();
|
|
455
|
+
} catch (err) {
|
|
456
|
+
// Stats are nice-to-have, don't fail on error
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Shutdown Lynkr
|
|
461
|
+
console.log('');
|
|
462
|
+
console.log('Shutting down Lynkr...');
|
|
463
|
+
|
|
464
|
+
try {
|
|
465
|
+
if (server && typeof server.close === 'function') {
|
|
466
|
+
await new Promise((resolve) => {
|
|
467
|
+
server.close(() => {
|
|
468
|
+
console.log('✓ Lynkr stopped');
|
|
469
|
+
resolve();
|
|
470
|
+
});
|
|
471
|
+
// Force close after 2s
|
|
472
|
+
setTimeout(() => {
|
|
473
|
+
console.log('✓ Lynkr stopped (forced)');
|
|
474
|
+
resolve();
|
|
475
|
+
}, 2000);
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
} catch (err) {
|
|
479
|
+
// Ignore shutdown errors
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
process.exit(code || 0);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
// Handle child spawn errors
|
|
486
|
+
child.on('error', (err) => {
|
|
487
|
+
console.error(`✗ Failed to launch ${opts.name}:`, err.message);
|
|
488
|
+
process.exit(1);
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
493
|
+
// Helper functions
|
|
494
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
495
|
+
|
|
496
|
+
function findClaudeBinary() {
|
|
497
|
+
return findBinaryHelper('claude', [
|
|
498
|
+
'/usr/local/bin/claude',
|
|
499
|
+
'/opt/homebrew/bin/claude',
|
|
500
|
+
path.join(process.env.HOME || '', '.local', 'bin', 'claude'),
|
|
501
|
+
]);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function findCopilotBinary() {
|
|
505
|
+
return findBinaryHelper('github-copilot-cli', [
|
|
506
|
+
'/usr/local/bin/github-copilot-cli',
|
|
507
|
+
'/opt/homebrew/bin/github-copilot-cli',
|
|
508
|
+
path.join(process.env.HOME || '', '.npm-global', 'bin', 'github-copilot-cli'),
|
|
509
|
+
path.join(process.env.HOME || '', '.local', 'bin', 'github-copilot-cli'),
|
|
510
|
+
]);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function findAiderBinary() {
|
|
514
|
+
return findBinaryHelper('aider', [
|
|
515
|
+
'/usr/local/bin/aider',
|
|
516
|
+
'/opt/homebrew/bin/aider',
|
|
517
|
+
path.join(process.env.HOME || '', '.local', 'bin', 'aider'),
|
|
518
|
+
path.join(process.env.HOME || '', 'Library', 'Python', '3.12', 'bin', 'aider'),
|
|
519
|
+
]);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function findCursorBinary() {
|
|
523
|
+
return findBinaryHelper('cursor', [
|
|
524
|
+
'/usr/local/bin/cursor',
|
|
525
|
+
'/opt/homebrew/bin/cursor',
|
|
526
|
+
'/Applications/Cursor.app/Contents/MacOS/Cursor',
|
|
527
|
+
path.join(process.env.HOME || '', '.local', 'bin', 'cursor'),
|
|
528
|
+
]);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function findCodexBinary() {
|
|
532
|
+
return findBinaryHelper('codex', [
|
|
533
|
+
'/usr/local/bin/codex',
|
|
534
|
+
'/opt/homebrew/bin/codex',
|
|
535
|
+
path.join(process.env.HOME || '', '.local', 'bin', 'codex'),
|
|
536
|
+
]);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function findBinaryHelper(binaryName, commonPaths) {
|
|
540
|
+
try {
|
|
541
|
+
// Try 'which <binary>'
|
|
542
|
+
const result = execSync(`which ${binaryName}`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] });
|
|
543
|
+
const binaryPath = result.trim();
|
|
544
|
+
if (binaryPath && existsSync(binaryPath)) {
|
|
545
|
+
return binaryPath;
|
|
546
|
+
}
|
|
547
|
+
} catch {
|
|
548
|
+
// Fall through to common paths
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Try common installation paths
|
|
552
|
+
for (const p of commonPaths) {
|
|
553
|
+
if (existsSync(p)) {
|
|
554
|
+
return p;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function parseWrapOptions(args) {
|
|
562
|
+
let port = 8081;
|
|
563
|
+
const passthrough = [];
|
|
564
|
+
let foundSeparator = false;
|
|
565
|
+
|
|
566
|
+
for (let i = 0; i < args.length; i++) {
|
|
567
|
+
const arg = args[i];
|
|
568
|
+
|
|
569
|
+
if (arg === '--') {
|
|
570
|
+
foundSeparator = true;
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
if (foundSeparator) {
|
|
575
|
+
// Everything after -- goes to Claude Code
|
|
576
|
+
passthrough.push(arg);
|
|
577
|
+
} else if (arg === '--port' && i + 1 < args.length) {
|
|
578
|
+
port = parseInt(args[i + 1], 10);
|
|
579
|
+
i++; // skip next arg
|
|
580
|
+
} else {
|
|
581
|
+
// Unknown lynkr flag or starts passthrough
|
|
582
|
+
passthrough.push(arg);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
return { port, passthrough };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
async function waitForReady(port, timeoutMs) {
|
|
590
|
+
const startTime = Date.now();
|
|
591
|
+
const http = require('http');
|
|
592
|
+
|
|
593
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
594
|
+
try {
|
|
595
|
+
await new Promise((resolve, reject) => {
|
|
596
|
+
const req = http.get(`http://localhost:${port}/health/ready`, (res) => {
|
|
597
|
+
if (res.statusCode === 200) {
|
|
598
|
+
resolve();
|
|
599
|
+
} else {
|
|
600
|
+
reject(new Error(`Health check returned ${res.statusCode}`));
|
|
601
|
+
}
|
|
602
|
+
res.resume(); // consume response
|
|
603
|
+
});
|
|
604
|
+
req.on('error', reject);
|
|
605
|
+
req.setTimeout(1000, () => {
|
|
606
|
+
req.destroy();
|
|
607
|
+
reject(new Error('Timeout'));
|
|
608
|
+
});
|
|
609
|
+
});
|
|
610
|
+
return; // Success
|
|
611
|
+
} catch {
|
|
612
|
+
// Not ready yet, wait and retry
|
|
613
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
throw new Error(`Lynkr did not become ready within ${timeoutMs}ms`);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function formatDuration(ms) {
|
|
621
|
+
const seconds = Math.floor(ms / 1000);
|
|
622
|
+
const minutes = Math.floor(seconds / 60);
|
|
623
|
+
const remainingSeconds = seconds % 60;
|
|
624
|
+
|
|
625
|
+
if (minutes > 0) {
|
|
626
|
+
return `${minutes}m ${remainingSeconds}s`;
|
|
627
|
+
}
|
|
628
|
+
return `${seconds}s`;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async function showSessionStats() {
|
|
632
|
+
try {
|
|
633
|
+
const { getMetricsCollector } = require('../src/observability/metrics');
|
|
634
|
+
const metricsCollector = getMetricsCollector();
|
|
635
|
+
const metrics = metricsCollector.getMetrics();
|
|
636
|
+
|
|
637
|
+
// Check if we have any data
|
|
638
|
+
const hasRequests = metrics && (
|
|
639
|
+
(typeof metrics.totalRequests === 'number' && metrics.totalRequests > 0) ||
|
|
640
|
+
(typeof metrics.requestCount === 'number' && metrics.requestCount > 0)
|
|
641
|
+
);
|
|
642
|
+
|
|
643
|
+
if (!hasRequests) {
|
|
644
|
+
console.log('');
|
|
645
|
+
console.log('╭─ Lynkr Session Stats ────────────────────────────────');
|
|
646
|
+
console.log('│ No requests tracked (check dashboard for details)');
|
|
647
|
+
console.log('╰──────────────────────────────────────────────────────');
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
console.log('');
|
|
652
|
+
console.log('╭─ Lynkr Session Stats ────────────────────────────────');
|
|
653
|
+
|
|
654
|
+
const requestCount = metrics.totalRequests || metrics.requestCount || 0;
|
|
655
|
+
console.log(`│ Requests ${requestCount}`);
|
|
656
|
+
|
|
657
|
+
if (metrics.tokensUsed || metrics.tokensSaved) {
|
|
658
|
+
const tokensUsed = metrics.tokensUsed || 0;
|
|
659
|
+
const tokensSaved = metrics.tokensSaved || 0;
|
|
660
|
+
const originalTokens = tokensUsed + tokensSaved;
|
|
661
|
+
if (originalTokens > 0) {
|
|
662
|
+
const savingsPercent = Math.round((tokensSaved / originalTokens) * 100);
|
|
663
|
+
console.log(`│ Tokens Original: ${originalTokens.toLocaleString()} → Routed: ${tokensUsed.toLocaleString()} (${savingsPercent}% saved)`);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
if (metrics.tierBreakdown && Object.keys(metrics.tierBreakdown).length > 0) {
|
|
668
|
+
const tiers = Object.entries(metrics.tierBreakdown)
|
|
669
|
+
.map(([tier, count]) => `${tier}: ${count}`)
|
|
670
|
+
.join(' ');
|
|
671
|
+
console.log(`│ Tier Mix ${tiers}`);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (metrics.cacheHits && metrics.cacheHits > 0) {
|
|
675
|
+
console.log(`│ Cache Hits ${metrics.cacheHits}`);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
console.log('╰──────────────────────────────────────────────────────');
|
|
679
|
+
} catch (err) {
|
|
680
|
+
// Stats are nice-to-have, silently ignore errors
|
|
681
|
+
console.log('');
|
|
682
|
+
console.log('╭─ Lynkr Session Stats ────────────────────────────────');
|
|
683
|
+
console.log('│ Stats unavailable (session data not found)');
|
|
684
|
+
console.log('╰──────────────────────────────────────────────────────');
|
|
685
|
+
}
|
|
686
|
+
}
|