@synkro-sh/cli 1.4.87 → 1.4.88
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/dist/bootstrap.js +1215 -2035
- package/dist/bootstrap.js.map +1 -1
- package/package.json +1 -1
package/dist/bootstrap.js
CHANGED
|
@@ -5243,1567 +5243,484 @@ var init_promptFetcher = __esm({
|
|
|
5243
5243
|
}
|
|
5244
5244
|
});
|
|
5245
5245
|
|
|
5246
|
-
// cli/local-cc/
|
|
5247
|
-
import { existsSync as existsSync7, readFileSync as readFileSync6 } from "fs";
|
|
5248
|
-
import { homedir as homedir6 } from "os";
|
|
5246
|
+
// cli/local-cc/macKeychain.ts
|
|
5247
|
+
import { existsSync as existsSync7, mkdirSync as mkdirSync6, writeFileSync as writeFileSync6, chmodSync, readFileSync as readFileSync6, statSync } from "fs";
|
|
5248
|
+
import { homedir as homedir6, platform as platform3 } from "os";
|
|
5249
5249
|
import { join as join6 } from "path";
|
|
5250
|
-
|
|
5251
|
-
|
|
5252
|
-
|
|
5253
|
-
const content = readFileSync6(CONFIG_PATH2, "utf-8");
|
|
5254
|
-
const match = content.match(/^SYNKRO_LOCAL_INFERENCE='([^']*)'/m);
|
|
5255
|
-
return match?.[1] === "yes";
|
|
5256
|
-
} catch {
|
|
5257
|
-
return false;
|
|
5258
|
-
}
|
|
5259
|
-
}
|
|
5260
|
-
var CONFIG_PATH2;
|
|
5261
|
-
var init_settings = __esm({
|
|
5262
|
-
"cli/local-cc/settings.ts"() {
|
|
5263
|
-
"use strict";
|
|
5264
|
-
CONFIG_PATH2 = join6(homedir6(), ".synkro", "config.env");
|
|
5265
|
-
}
|
|
5266
|
-
});
|
|
5267
|
-
|
|
5268
|
-
// cli/local-cc/channelSource.ts
|
|
5269
|
-
var CHANNEL_PLUGIN_SOURCE;
|
|
5270
|
-
var init_channelSource = __esm({
|
|
5271
|
-
"cli/local-cc/channelSource.ts"() {
|
|
5272
|
-
"use strict";
|
|
5273
|
-
CHANNEL_PLUGIN_SOURCE = `#!/usr/bin/env bun
|
|
5274
|
-
/**
|
|
5275
|
-
* Synkro local-CC channel plugin (auto-generated by \`synkro install\`).
|
|
5276
|
-
* DO NOT EDIT \u2014 your changes will be overwritten on next install.
|
|
5277
|
-
*/
|
|
5278
|
-
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
5279
|
-
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5280
|
-
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
5281
|
-
import { readFileSync } from 'node:fs';
|
|
5282
|
-
import { join } from 'node:path';
|
|
5283
|
-
import { homedir } from 'node:os';
|
|
5284
|
-
|
|
5285
|
-
const PORT = parseInt(process.env.SYNKRO_CHANNEL_PORT || '8929', 10);
|
|
5286
|
-
const HOSTNAME = '127.0.0.1';
|
|
5287
|
-
|
|
5288
|
-
const REQUEST_TIMEOUT_MS = parseInt(process.env.SYNKRO_CHANNEL_TIMEOUT_MS || '120000', 10);
|
|
5289
|
-
|
|
5290
|
-
// \u2500\u2500\u2500 Primer cache (in-memory, refreshed every 60s) \u2500\u2500\u2500
|
|
5291
|
-
const PRIMER_REFRESH_MS = 60_000;
|
|
5292
|
-
const PRIMER_KEY: Record<string, string> = {
|
|
5293
|
-
'grade-edit': 'grader_primer_edit',
|
|
5294
|
-
'grade-bash': 'grader_primer_bash',
|
|
5295
|
-
'grade-plan': 'grader_primer_plan',
|
|
5296
|
-
'grade-cwe': 'grader_primer_cwe',
|
|
5297
|
-
};
|
|
5298
|
-
const REPLY_INSTRUCTIONS = \`
|
|
5299
|
-
DELIVERY METHOD \u2014 MANDATORY, OVERRIDES ALL OTHER OUTPUT RULES:
|
|
5300
|
-
You are running inside a Synkro MCP channel. Do NOT output your verdict as text.
|
|
5301
|
-
Instead, after generating your verdict, call the \\\`reply\\\` tool EXACTLY ONCE with:
|
|
5302
|
-
- req_id: the req_id from this channel event's meta
|
|
5303
|
-
- result: your complete verdict block as a string (the <synkro-verdict>\u2026</synkro-verdict> XML)
|
|
5304
|
-
Any text output is silently discarded. Only the reply tool call is captured.\`;
|
|
5305
|
-
|
|
5306
|
-
let primerData: Record<string, string> = {};
|
|
5307
|
-
let primerFetchedAt = 0;
|
|
5308
|
-
let primerRefreshTimer: ReturnType<typeof setInterval> | null = null;
|
|
5309
|
-
|
|
5310
|
-
function readCreds(): { jwt: string; gatewayUrl: string } {
|
|
5311
|
-
try {
|
|
5312
|
-
const raw = JSON.parse(readFileSync(join(homedir(), '.synkro', 'credentials.json'), 'utf-8'));
|
|
5313
|
-
return { jwt: raw.access_token || '', gatewayUrl: raw.gateway_url || 'https://api.synkro.sh' };
|
|
5314
|
-
} catch { return { jwt: '', gatewayUrl: 'https://api.synkro.sh' }; }
|
|
5250
|
+
import { spawnSync } from "child_process";
|
|
5251
|
+
function needsKeychainBridge() {
|
|
5252
|
+
return platform3() === "darwin";
|
|
5315
5253
|
}
|
|
5316
|
-
|
|
5317
|
-
|
|
5318
|
-
const
|
|
5319
|
-
|
|
5320
|
-
|
|
5321
|
-
|
|
5322
|
-
|
|
5323
|
-
|
|
5324
|
-
|
|
5325
|
-
if (!resp.ok) return;
|
|
5326
|
-
const data = await resp.json() as Record<string, string>;
|
|
5327
|
-
primerData = data;
|
|
5328
|
-
primerFetchedAt = Date.now();
|
|
5329
|
-
} catch {}
|
|
5254
|
+
function readKeychainCreds() {
|
|
5255
|
+
if (platform3() !== "darwin") return null;
|
|
5256
|
+
const r = spawnSync("security", ["find-generic-password", "-s", KEYCHAIN_SERVICE, "-w"], {
|
|
5257
|
+
encoding: "utf-8",
|
|
5258
|
+
timeout: 5e3
|
|
5259
|
+
});
|
|
5260
|
+
if (r.status !== 0) return null;
|
|
5261
|
+
const blob = (r.stdout || "").trim();
|
|
5262
|
+
return blob || null;
|
|
5330
5263
|
}
|
|
5331
|
-
|
|
5332
|
-
|
|
5333
|
-
|
|
5334
|
-
|
|
5264
|
+
function exportKeychainCreds() {
|
|
5265
|
+
const blob = readKeychainCreds();
|
|
5266
|
+
if (!blob) return null;
|
|
5267
|
+
mkdirSync6(CLAUDE_CREDS_DIR, { recursive: true });
|
|
5268
|
+
chmodSync(CLAUDE_CREDS_DIR, 448);
|
|
5269
|
+
writeFileSync6(CLAUDE_CREDS_FILE, blob, "utf-8");
|
|
5270
|
+
chmodSync(CLAUDE_CREDS_FILE, 384);
|
|
5271
|
+
return CLAUDE_CREDS_FILE;
|
|
5335
5272
|
}
|
|
5336
|
-
|
|
5337
|
-
|
|
5338
|
-
|
|
5339
|
-
|
|
5273
|
+
function writeRefreshAgent(synkroBinPath) {
|
|
5274
|
+
if (platform3() !== "darwin") {
|
|
5275
|
+
throw new KeychainExportError("writeRefreshAgent is darwin-only");
|
|
5276
|
+
}
|
|
5277
|
+
mkdirSync6(join6(homedir6(), "Library", "LaunchAgents"), { recursive: true });
|
|
5278
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
5279
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
5280
|
+
<plist version="1.0">
|
|
5281
|
+
<dict>
|
|
5282
|
+
<key>Label</key>
|
|
5283
|
+
<string>${LAUNCHD_LABEL}</string>
|
|
5284
|
+
<key>ProgramArguments</key>
|
|
5285
|
+
<array>
|
|
5286
|
+
<string>${synkroBinPath}</string>
|
|
5287
|
+
<string>local-cc</string>
|
|
5288
|
+
<string>refresh-creds</string>
|
|
5289
|
+
</array>
|
|
5290
|
+
<key>StartInterval</key>
|
|
5291
|
+
<integer>${REFRESH_INTERVAL_SECONDS}</integer>
|
|
5292
|
+
<key>RunAtLoad</key>
|
|
5293
|
+
<true/>
|
|
5294
|
+
<key>StandardErrorPath</key>
|
|
5295
|
+
<string>${join6(SYNKRO_DIR2, "claude-creds-refresh.log")}</string>
|
|
5296
|
+
<key>StandardOutPath</key>
|
|
5297
|
+
<string>${join6(SYNKRO_DIR2, "claude-creds-refresh.log")}</string>
|
|
5298
|
+
</dict>
|
|
5299
|
+
</plist>
|
|
5300
|
+
`;
|
|
5301
|
+
writeFileSync6(LAUNCHD_PLIST, plist, "utf-8");
|
|
5302
|
+
return LAUNCHD_PLIST;
|
|
5340
5303
|
}
|
|
5341
|
-
|
|
5342
|
-
|
|
5343
|
-
|
|
5344
|
-
|
|
5345
|
-
|
|
5346
|
-
|
|
5347
|
-
|
|
5348
|
-
|
|
5349
|
-
|
|
5304
|
+
function loadRefreshAgent() {
|
|
5305
|
+
if (platform3() !== "darwin") return;
|
|
5306
|
+
spawnSync("launchctl", ["bootout", `gui/${process.getuid?.() ?? 501}`, LAUNCHD_PLIST], {
|
|
5307
|
+
encoding: "utf-8",
|
|
5308
|
+
timeout: 5e3
|
|
5309
|
+
});
|
|
5310
|
+
const r = spawnSync("launchctl", ["bootstrap", `gui/${process.getuid?.() ?? 501}`, LAUNCHD_PLIST], {
|
|
5311
|
+
encoding: "utf-8",
|
|
5312
|
+
timeout: 5e3
|
|
5313
|
+
});
|
|
5314
|
+
if (r.status !== 0) {
|
|
5315
|
+
throw new KeychainExportError(
|
|
5316
|
+
`launchctl bootstrap failed: ${r.stderr || r.stdout || "unknown"}`
|
|
5317
|
+
);
|
|
5318
|
+
}
|
|
5350
5319
|
}
|
|
5351
|
-
|
|
5352
|
-
|
|
5353
|
-
|
|
5354
|
-
|
|
5355
|
-
|
|
5356
|
-
|
|
5357
|
-
{
|
|
5358
|
-
|
|
5359
|
-
|
|
5360
|
-
tools: {},
|
|
5361
|
-
},
|
|
5362
|
-
instructions: [
|
|
5363
|
-
'Synkro local inference channel.',
|
|
5364
|
-
'Each <channel source="synkro-local" req_id="..." role="..."> event contains a',
|
|
5365
|
-
'self-contained instruction block followed by the payload to evaluate. Treat it',
|
|
5366
|
-
'as a fresh isolated request \u2014 IGNORE any prior conversation turns or context.',
|
|
5367
|
-
'Do not call Read, Edit, Write, Bash, or any other tool. Do exactly one thing:',
|
|
5368
|
-
'parse the request, produce the structured response described inside it, then',
|
|
5369
|
-
'call the \\\`reply\\\` tool exactly once with the same req_id and the response',
|
|
5370
|
-
'wrapped as the \\\`result\\\` argument (a string). Output no other text.',
|
|
5371
|
-
].join(' '),
|
|
5372
|
-
},
|
|
5373
|
-
);
|
|
5374
|
-
|
|
5375
|
-
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
5376
|
-
tools: [{
|
|
5377
|
-
name: 'reply',
|
|
5378
|
-
description: 'Return the response for a Synkro local-inference request',
|
|
5379
|
-
inputSchema: {
|
|
5380
|
-
type: 'object',
|
|
5381
|
-
properties: {
|
|
5382
|
-
req_id: { type: 'string', description: 'The req_id from the channel event being answered' },
|
|
5383
|
-
result: { type: 'string', description: 'The response text. For grading/classification roles, the JSON-tagged verdict the role requested.' },
|
|
5384
|
-
},
|
|
5385
|
-
required: ['req_id', 'result'],
|
|
5386
|
-
},
|
|
5387
|
-
}],
|
|
5388
|
-
}));
|
|
5389
|
-
|
|
5390
|
-
mcp.setRequestHandler(CallToolRequestSchema, async req => {
|
|
5391
|
-
if (req.params.name !== 'reply') {
|
|
5392
|
-
throw new Error('unknown tool: ' + req.params.name);
|
|
5393
|
-
}
|
|
5394
|
-
const args = req.params.arguments as { req_id?: string; result?: string };
|
|
5395
|
-
const reqId = String(args.req_id ?? '');
|
|
5396
|
-
const result = String(args.result ?? '');
|
|
5397
|
-
const p = pending.get(reqId);
|
|
5398
|
-
if (p) {
|
|
5399
|
-
clearTimeout(p.timer);
|
|
5400
|
-
pending.delete(reqId);
|
|
5401
|
-
p.resolve(result);
|
|
5402
|
-
return { content: [{ type: 'text', text: 'ok' }] };
|
|
5403
|
-
}
|
|
5404
|
-
return { content: [{ type: 'text', text: 'unknown req_id (likely already timed out)' }] };
|
|
5405
|
-
});
|
|
5406
|
-
|
|
5407
|
-
// Bind the listener BEFORE awaiting mcp.connect \u2014 Bun.serve is synchronous
|
|
5408
|
-
// and must run on the script's first tick, otherwise the stdio transport's
|
|
5409
|
-
// read loop can starve the serve setup.
|
|
5410
|
-
Bun.serve({
|
|
5411
|
-
port: PORT,
|
|
5412
|
-
hostname: HOSTNAME,
|
|
5413
|
-
idleTimeout: 0,
|
|
5414
|
-
async fetch(req) {
|
|
5415
|
-
const url = new URL(req.url);
|
|
5416
|
-
if (url.pathname === '/healthz') {
|
|
5417
|
-
return new Response(JSON.stringify({ ok: true, pending: pending.size }), {
|
|
5418
|
-
headers: { 'Content-Type': 'application/json' },
|
|
5419
|
-
});
|
|
5420
|
-
}
|
|
5421
|
-
if (req.method !== 'POST' || url.pathname !== '/submit') {
|
|
5422
|
-
return new Response('not found', { status: 404 });
|
|
5423
|
-
}
|
|
5424
|
-
let body: { role?: string; content?: string; payload?: string };
|
|
5425
|
-
try {
|
|
5426
|
-
body = await req.json() as typeof body;
|
|
5427
|
-
} catch {
|
|
5428
|
-
return new Response('invalid json', { status: 400 });
|
|
5429
|
-
}
|
|
5430
|
-
const role = String(body.role ?? '');
|
|
5431
|
-
const content = body.payload ? buildContent(role, body.payload) : String(body.content ?? '');
|
|
5432
|
-
if (!role || !content) {
|
|
5433
|
-
return new Response('missing role/content', { status: 400 });
|
|
5434
|
-
}
|
|
5435
|
-
const reqId = 'r' + (nextRequestId++) + Date.now().toString(36);
|
|
5436
|
-
const result = await new Promise<string>((resolve, reject) => {
|
|
5437
|
-
const timer = setTimeout(() => {
|
|
5438
|
-
pending.delete(reqId);
|
|
5439
|
-
reject(new Error('timeout waiting for reply (' + REQUEST_TIMEOUT_MS + 'ms)'));
|
|
5440
|
-
}, REQUEST_TIMEOUT_MS);
|
|
5441
|
-
pending.set(reqId, { resolve, reject, timer });
|
|
5442
|
-
mcp.notification({
|
|
5443
|
-
method: 'notifications/claude/channel',
|
|
5444
|
-
params: {
|
|
5445
|
-
content,
|
|
5446
|
-
meta: { req_id: reqId, role },
|
|
5447
|
-
},
|
|
5448
|
-
}).catch(err => {
|
|
5449
|
-
clearTimeout(timer);
|
|
5450
|
-
pending.delete(reqId);
|
|
5451
|
-
reject(err instanceof Error ? err : new Error(String(err)));
|
|
5452
|
-
});
|
|
5453
|
-
}).catch(err => {
|
|
5454
|
-
return JSON.stringify({ error: err instanceof Error ? err.message : String(err) });
|
|
5455
|
-
});
|
|
5456
|
-
if (typeof result === 'string' && result.startsWith('{"error":')) {
|
|
5457
|
-
return new Response(result, { status: 504, headers: { 'Content-Type': 'application/json' } });
|
|
5320
|
+
function uninstallRefreshAgent() {
|
|
5321
|
+
if (platform3() !== "darwin") return;
|
|
5322
|
+
spawnSync("launchctl", ["bootout", `gui/${process.getuid?.() ?? 501}`, LAUNCHD_PLIST], {
|
|
5323
|
+
encoding: "utf-8",
|
|
5324
|
+
timeout: 5e3
|
|
5325
|
+
});
|
|
5326
|
+
try {
|
|
5327
|
+
if (existsSync7(LAUNCHD_PLIST)) {
|
|
5328
|
+
__require("fs").unlinkSync(LAUNCHD_PLIST);
|
|
5458
5329
|
}
|
|
5459
|
-
|
|
5460
|
-
|
|
5461
|
-
|
|
5462
|
-
|
|
5463
|
-
|
|
5464
|
-
|
|
5465
|
-
|
|
5466
|
-
|
|
5467
|
-
|
|
5468
|
-
|
|
5469
|
-
|
|
5470
|
-
|
|
5471
|
-
|
|
5472
|
-
|
|
5330
|
+
} catch {
|
|
5331
|
+
}
|
|
5332
|
+
}
|
|
5333
|
+
var SYNKRO_DIR2, CLAUDE_CREDS_DIR, CLAUDE_CREDS_FILE, KEYCHAIN_SERVICE, LAUNCHD_LABEL, LAUNCHD_PLIST, REFRESH_INTERVAL_SECONDS, KeychainExportError;
|
|
5334
|
+
var init_macKeychain = __esm({
|
|
5335
|
+
"cli/local-cc/macKeychain.ts"() {
|
|
5336
|
+
"use strict";
|
|
5337
|
+
SYNKRO_DIR2 = join6(homedir6(), ".synkro");
|
|
5338
|
+
CLAUDE_CREDS_DIR = join6(SYNKRO_DIR2, "claude-creds");
|
|
5339
|
+
CLAUDE_CREDS_FILE = join6(CLAUDE_CREDS_DIR, ".credentials.json");
|
|
5340
|
+
KEYCHAIN_SERVICE = "Claude Code-credentials";
|
|
5341
|
+
LAUNCHD_LABEL = "com.synkro.cli.claude-creds-refresh";
|
|
5342
|
+
LAUNCHD_PLIST = join6(homedir6(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
|
|
5343
|
+
REFRESH_INTERVAL_SECONDS = 6 * 60 * 60;
|
|
5344
|
+
KeychainExportError = class extends Error {
|
|
5345
|
+
constructor(message, cause) {
|
|
5346
|
+
super(message);
|
|
5347
|
+
this.cause = cause;
|
|
5348
|
+
this.name = "KeychainExportError";
|
|
5349
|
+
}
|
|
5350
|
+
cause;
|
|
5351
|
+
};
|
|
5473
5352
|
}
|
|
5474
5353
|
});
|
|
5475
5354
|
|
|
5476
|
-
// cli/local-cc/
|
|
5477
|
-
import { existsSync as existsSync8, mkdirSync as
|
|
5478
|
-
import { join as join7 } from "path";
|
|
5355
|
+
// cli/local-cc/dockerInstall.ts
|
|
5356
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync7 } from "fs";
|
|
5479
5357
|
import { homedir as homedir7 } from "os";
|
|
5480
|
-
import {
|
|
5481
|
-
|
|
5482
|
-
|
|
5483
|
-
|
|
5484
|
-
|
|
5485
|
-
|
|
5486
|
-
|
|
5487
|
-
|
|
5488
|
-
|
|
5489
|
-
|
|
5490
|
-
|
|
5491
|
-
|
|
5492
|
-
|
|
5493
|
-
|
|
5494
|
-
"
|
|
5358
|
+
import { join as join7 } from "path";
|
|
5359
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
5360
|
+
function imageTag() {
|
|
5361
|
+
const registry = process.env.SYNKRO_IMAGE_REGISTRY || "";
|
|
5362
|
+
const tag = process.env.SYNKRO_IMAGE_TAG || DEFAULT_IMAGE;
|
|
5363
|
+
return registry ? `${registry.replace(/\/+$/, "")}/${tag.replace(/^.*\//, "")}` : tag;
|
|
5364
|
+
}
|
|
5365
|
+
function assertDockerAvailable() {
|
|
5366
|
+
const v = spawnSync2("docker", ["version", "--format", "{{.Server.Version}}"], {
|
|
5367
|
+
encoding: "utf-8",
|
|
5368
|
+
timeout: 5e3
|
|
5369
|
+
});
|
|
5370
|
+
if (v.status !== 0) {
|
|
5371
|
+
throw new DockerInstallError(
|
|
5372
|
+
"Docker CLI is not installed or the daemon is not reachable.\nInstall Docker Desktop (macOS) or docker-engine (Linux), start the daemon, then re-run."
|
|
5495
5373
|
);
|
|
5496
|
-
writeFileSync6(c.runScriptPath, c.runScriptSource, "utf-8");
|
|
5497
|
-
chmodSync(c.runScriptPath, 493);
|
|
5498
5374
|
}
|
|
5499
5375
|
}
|
|
5500
|
-
function
|
|
5501
|
-
|
|
5502
|
-
|
|
5503
|
-
cwd: c.sessionDir,
|
|
5504
|
-
encoding: "utf-8",
|
|
5505
|
-
timeout: 12e4
|
|
5506
|
-
});
|
|
5507
|
-
if (r.status !== 0) {
|
|
5508
|
-
throw new LocalCCInstallError(
|
|
5509
|
-
`bun install failed in ${c.sessionDir}: ${r.stderr || r.stdout || "unknown"}`
|
|
5510
|
-
);
|
|
5511
|
-
}
|
|
5512
|
-
}
|
|
5376
|
+
function claudeCredsHostDir() {
|
|
5377
|
+
if (needsKeychainBridge()) return CLAUDE_CREDS_DIR;
|
|
5378
|
+
return join7(homedir7(), ".claude");
|
|
5513
5379
|
}
|
|
5514
|
-
function
|
|
5515
|
-
|
|
5516
|
-
|
|
5517
|
-
|
|
5518
|
-
|
|
5519
|
-
|
|
5520
|
-
|
|
5521
|
-
|
|
5522
|
-
} catch (err) {
|
|
5523
|
-
throw new LocalCCInstallError(
|
|
5524
|
-
`refusing to modify malformed ${CLAUDE_JSON_PATH}: ${err.message}. Please fix the JSON manually before retrying.`,
|
|
5525
|
-
err
|
|
5380
|
+
async function dockerInstall(opts = {}) {
|
|
5381
|
+
assertDockerAvailable();
|
|
5382
|
+
const image = imageTag();
|
|
5383
|
+
const workers = String(opts.workersPerPool ?? 4);
|
|
5384
|
+
mkdirSync7(PGDATA_PATH, { recursive: true });
|
|
5385
|
+
if (!existsSync8(MCP_JWT_PATH)) {
|
|
5386
|
+
throw new DockerInstallError(
|
|
5387
|
+
`MCP JWT missing at ${MCP_JWT_PATH}. The installer should mint this before calling dockerInstall.`
|
|
5526
5388
|
);
|
|
5527
5389
|
}
|
|
5528
|
-
|
|
5529
|
-
|
|
5530
|
-
|
|
5531
|
-
|
|
5532
|
-
|
|
5533
|
-
throw new LocalCCInstallError(
|
|
5534
|
-
`refusing to write ${CLAUDE_JSON_PATH}: mutator dropped top-level key "${k}". This is a bug \u2014 please report.`
|
|
5390
|
+
if (needsKeychainBridge()) {
|
|
5391
|
+
const path = exportKeychainCreds();
|
|
5392
|
+
if (!path) {
|
|
5393
|
+
throw new DockerInstallError(
|
|
5394
|
+
"Claude Code keychain entry not found. Run `claude login` (or open Claude Code and sign in) before installing the container."
|
|
5535
5395
|
);
|
|
5536
5396
|
}
|
|
5537
|
-
|
|
5538
|
-
const newText = JSON.stringify(parsed, null, 2) + "\n";
|
|
5539
|
-
try {
|
|
5540
|
-
JSON.parse(newText);
|
|
5541
|
-
} catch (err) {
|
|
5542
|
-
throw new LocalCCInstallError(
|
|
5543
|
-
`refusing to write ${CLAUDE_JSON_PATH}: serialized result is not valid JSON. This is a bug \u2014 please report.`,
|
|
5544
|
-
err
|
|
5545
|
-
);
|
|
5546
|
-
}
|
|
5547
|
-
copyFileSync(CLAUDE_JSON_PATH, CLAUDE_JSON_BACKUP_PATH);
|
|
5548
|
-
const tmpPath = `${CLAUDE_JSON_PATH}.synkro-tmp.${process.pid}`;
|
|
5549
|
-
try {
|
|
5550
|
-
writeFileSync6(tmpPath, newText, "utf-8");
|
|
5551
|
-
const fd = openSync(tmpPath, "r");
|
|
5397
|
+
const plist = writeRefreshAgent("/usr/local/bin/synkro");
|
|
5552
5398
|
try {
|
|
5553
|
-
|
|
5554
|
-
}
|
|
5555
|
-
|
|
5399
|
+
loadRefreshAgent();
|
|
5400
|
+
} catch (err) {
|
|
5401
|
+
console.warn(` \u26A0 launchd refresh agent not loaded: ${err.message}`);
|
|
5402
|
+
console.warn(` Plist written to ${plist} \u2014 load manually with launchctl bootstrap when ready.`);
|
|
5556
5403
|
}
|
|
5557
|
-
|
|
5558
|
-
|
|
5404
|
+
} else {
|
|
5405
|
+
mkdirSync7(join7(homedir7(), ".claude"), { recursive: true });
|
|
5406
|
+
}
|
|
5407
|
+
console.log(` Pulling ${image}...`);
|
|
5408
|
+
const pull = spawnSync2("docker", ["pull", image], { encoding: "utf-8", stdio: "inherit", timeout: 6e5 });
|
|
5409
|
+
if (pull.status !== 0) {
|
|
5410
|
+
throw new DockerInstallError(`docker pull ${image} failed`);
|
|
5411
|
+
}
|
|
5412
|
+
spawnSync2("docker", ["rm", "-f", CONTAINER_NAME], { encoding: "utf-8", timeout: 3e4 });
|
|
5413
|
+
const credsDir = claudeCredsHostDir();
|
|
5414
|
+
const args2 = [
|
|
5415
|
+
"run",
|
|
5416
|
+
"--detach",
|
|
5417
|
+
"--name",
|
|
5418
|
+
CONTAINER_NAME,
|
|
5419
|
+
"--restart",
|
|
5420
|
+
"unless-stopped",
|
|
5421
|
+
"-p",
|
|
5422
|
+
`127.0.0.1:${HOST_MCP_PORT}:8931`,
|
|
5423
|
+
"-p",
|
|
5424
|
+
`127.0.0.1:${HOST_GRADER_PORT}:8929`,
|
|
5425
|
+
"-p",
|
|
5426
|
+
`127.0.0.1:${HOST_CWE_PORT}:8930`,
|
|
5427
|
+
"-p",
|
|
5428
|
+
`127.0.0.1:${HOST_PG_PORT}:5433`,
|
|
5429
|
+
"-v",
|
|
5430
|
+
`${PGDATA_PATH}:/data/pgdata`,
|
|
5431
|
+
"-v",
|
|
5432
|
+
`${MCP_JWT_PATH}:/data/.mcp-jwt:ro`,
|
|
5433
|
+
"-v",
|
|
5434
|
+
`${credsDir}:/home/synkro/.claude:rw`,
|
|
5435
|
+
"-e",
|
|
5436
|
+
`WORKERS_PER_POOL=${workers}`,
|
|
5437
|
+
image
|
|
5438
|
+
];
|
|
5439
|
+
const run = spawnSync2("docker", args2, { encoding: "utf-8", stdio: "inherit", timeout: 6e4 });
|
|
5440
|
+
if (run.status !== 0) {
|
|
5441
|
+
throw new DockerInstallError(`docker run failed (image ${image})`);
|
|
5442
|
+
}
|
|
5443
|
+
return { image, hostMcpPort: HOST_MCP_PORT, hostGraderPort: HOST_GRADER_PORT, hostCwePort: HOST_CWE_PORT };
|
|
5444
|
+
}
|
|
5445
|
+
async function waitForContainerReady(timeoutMs = 6e4) {
|
|
5446
|
+
const start = Date.now();
|
|
5447
|
+
const url = `http://127.0.0.1:${HOST_MCP_PORT}/health`;
|
|
5448
|
+
while (Date.now() - start < timeoutMs) {
|
|
5559
5449
|
try {
|
|
5560
|
-
|
|
5450
|
+
const r = await fetch(url, { signal: AbortSignal.timeout(2e3) });
|
|
5451
|
+
if (r.ok) return true;
|
|
5561
5452
|
} catch {
|
|
5562
5453
|
}
|
|
5563
|
-
|
|
5564
|
-
copyFileSync(CLAUDE_JSON_BACKUP_PATH, CLAUDE_JSON_PATH);
|
|
5565
|
-
} catch {
|
|
5566
|
-
}
|
|
5567
|
-
throw new LocalCCInstallError(
|
|
5568
|
-
`failed to write ${CLAUDE_JSON_PATH}: ${err.message}. Backup at ${CLAUDE_JSON_BACKUP_PATH} preserves the prior state.`,
|
|
5569
|
-
err
|
|
5570
|
-
);
|
|
5454
|
+
await new Promise((r) => setTimeout(r, 1e3));
|
|
5571
5455
|
}
|
|
5456
|
+
return false;
|
|
5572
5457
|
}
|
|
5573
|
-
function
|
|
5574
|
-
|
|
5575
|
-
|
|
5576
|
-
|
|
5577
|
-
|
|
5578
|
-
|
|
5579
|
-
|
|
5580
|
-
|
|
5458
|
+
function dockerStop() {
|
|
5459
|
+
spawnSync2("docker", ["stop", CONTAINER_NAME], { encoding: "utf-8", timeout: 3e4 });
|
|
5460
|
+
spawnSync2("docker", ["rm", "-f", CONTAINER_NAME], { encoding: "utf-8", timeout: 3e4 });
|
|
5461
|
+
}
|
|
5462
|
+
function dockerStatus() {
|
|
5463
|
+
const r = spawnSync2("docker", ["inspect", "--format", "{{.State.Status}}", CONTAINER_NAME], {
|
|
5464
|
+
encoding: "utf-8",
|
|
5465
|
+
timeout: 5e3
|
|
5466
|
+
});
|
|
5467
|
+
const status = (r.stdout || "").trim();
|
|
5468
|
+
if (status !== "running") return { running: false };
|
|
5469
|
+
return {
|
|
5470
|
+
running: true,
|
|
5471
|
+
image: imageTag(),
|
|
5472
|
+
healthz: `http://127.0.0.1:${HOST_MCP_PORT}/`
|
|
5473
|
+
};
|
|
5474
|
+
}
|
|
5475
|
+
var SYNKRO_DIR3, MCP_JWT_PATH, PGDATA_PATH, HOST_MCP_PORT, HOST_GRADER_PORT, HOST_CWE_PORT, HOST_PG_PORT, CONTAINER_NAME, DEFAULT_IMAGE, DockerInstallError;
|
|
5476
|
+
var init_dockerInstall = __esm({
|
|
5477
|
+
"cli/local-cc/dockerInstall.ts"() {
|
|
5478
|
+
"use strict";
|
|
5479
|
+
init_macKeychain();
|
|
5480
|
+
SYNKRO_DIR3 = join7(homedir7(), ".synkro");
|
|
5481
|
+
MCP_JWT_PATH = join7(SYNKRO_DIR3, ".mcp-jwt");
|
|
5482
|
+
PGDATA_PATH = join7(SYNKRO_DIR3, "pgdata");
|
|
5483
|
+
HOST_MCP_PORT = parseInt(process.env.SYNKRO_HOST_MCP_PORT || "18931", 10);
|
|
5484
|
+
HOST_GRADER_PORT = parseInt(process.env.SYNKRO_HOST_GRADER_PORT || "18929", 10);
|
|
5485
|
+
HOST_CWE_PORT = parseInt(process.env.SYNKRO_HOST_CWE_PORT || "18930", 10);
|
|
5486
|
+
HOST_PG_PORT = parseInt(process.env.SYNKRO_HOST_PG_PORT || "15433", 10);
|
|
5487
|
+
CONTAINER_NAME = "synkro-server";
|
|
5488
|
+
DEFAULT_IMAGE = "ghcr.io/synkro-sh/server:latest";
|
|
5489
|
+
DockerInstallError = class extends Error {
|
|
5490
|
+
constructor(message, cause) {
|
|
5491
|
+
super(message);
|
|
5492
|
+
this.cause = cause;
|
|
5493
|
+
this.name = "DockerInstallError";
|
|
5581
5494
|
}
|
|
5495
|
+
cause;
|
|
5582
5496
|
};
|
|
5583
|
-
writeFileSync6(c.projectMcpPath, JSON.stringify(mcp, null, 2) + "\n", "utf-8");
|
|
5584
5497
|
}
|
|
5498
|
+
});
|
|
5499
|
+
|
|
5500
|
+
// cli/commands/install.ts
|
|
5501
|
+
var install_exports = {};
|
|
5502
|
+
__export(install_exports, {
|
|
5503
|
+
installCommand: () => installCommand,
|
|
5504
|
+
parseArgs: () => parseArgs
|
|
5505
|
+
});
|
|
5506
|
+
import { existsSync as existsSync9, mkdirSync as mkdirSync8, writeFileSync as writeFileSync7, chmodSync as chmodSync2, readFileSync as readFileSync7, readdirSync } from "fs";
|
|
5507
|
+
import { homedir as homedir8 } from "os";
|
|
5508
|
+
import { join as join8 } from "path";
|
|
5509
|
+
import { execSync as execSync5 } from "child_process";
|
|
5510
|
+
import { createInterface as createInterface3 } from "readline";
|
|
5511
|
+
function sanitizeGatewayCandidate(raw) {
|
|
5512
|
+
if (!raw) return void 0;
|
|
5513
|
+
return /^https?:\/\//.test(raw) ? raw : void 0;
|
|
5585
5514
|
}
|
|
5586
|
-
function
|
|
5587
|
-
|
|
5588
|
-
|
|
5589
|
-
if (
|
|
5590
|
-
|
|
5591
|
-
|
|
5592
|
-
|
|
5593
|
-
if (
|
|
5594
|
-
|
|
5595
|
-
|
|
5596
|
-
|
|
5597
|
-
|
|
5598
|
-
|
|
5599
|
-
|
|
5600
|
-
|
|
5601
|
-
|
|
5602
|
-
|
|
5603
|
-
|
|
5604
|
-
|
|
5605
|
-
|
|
5606
|
-
|
|
5607
|
-
|
|
5608
|
-
|
|
5609
|
-
|
|
5610
|
-
|
|
5611
|
-
dirty = true;
|
|
5515
|
+
function parseArgs(argv) {
|
|
5516
|
+
const opts = {};
|
|
5517
|
+
for (const a of argv) {
|
|
5518
|
+
if (a.startsWith("--api-key=")) opts.apiKey = a.slice("--api-key=".length);
|
|
5519
|
+
else if (a.startsWith("--gateway=")) opts.gatewayUrl = a.slice("--gateway=".length);
|
|
5520
|
+
else if (a === "--skip-auth") opts.skipAuth = true;
|
|
5521
|
+
else if (a === "--no-mcp") opts.noMcp = true;
|
|
5522
|
+
else if (a === "--force" || a === "-f") opts.force = true;
|
|
5523
|
+
else if (a === "--link-repo") opts.linkRepo = true;
|
|
5524
|
+
}
|
|
5525
|
+
if (!opts.gatewayUrl) {
|
|
5526
|
+
const fromEnv = sanitizeGatewayCandidate(process.env.SYNKRO_GATEWAY_URL);
|
|
5527
|
+
if (fromEnv) opts.gatewayUrl = fromEnv;
|
|
5528
|
+
}
|
|
5529
|
+
return opts;
|
|
5530
|
+
}
|
|
5531
|
+
async function promptTranscriptConsent() {
|
|
5532
|
+
const rl = createInterface3({ input: process.stdin, output: process.stdout });
|
|
5533
|
+
return new Promise((resolve3) => {
|
|
5534
|
+
rl.question(
|
|
5535
|
+
"Would you like Synkro to use Claude Code session transcripts\nto generate guardrail rules and policies for your team? (Y/n) ",
|
|
5536
|
+
(answer) => {
|
|
5537
|
+
rl.close();
|
|
5538
|
+
const trimmed = answer.trim().toLowerCase();
|
|
5539
|
+
resolve3(trimmed === "" || trimmed === "y" || trimmed === "yes");
|
|
5612
5540
|
}
|
|
5613
|
-
|
|
5614
|
-
return dirty;
|
|
5541
|
+
);
|
|
5615
5542
|
});
|
|
5616
5543
|
}
|
|
5617
|
-
function
|
|
5618
|
-
|
|
5619
|
-
|
|
5620
|
-
|
|
5621
|
-
|
|
5622
|
-
|
|
5623
|
-
|
|
5624
|
-
|
|
5625
|
-
|
|
5626
|
-
|
|
5627
|
-
|
|
5628
|
-
|
|
5629
|
-
|
|
5630
|
-
|
|
5631
|
-
|
|
5632
|
-
|
|
5544
|
+
function ensureSynkroDir() {
|
|
5545
|
+
mkdirSync8(SYNKRO_DIR4, { recursive: true });
|
|
5546
|
+
mkdirSync8(HOOKS_DIR, { recursive: true });
|
|
5547
|
+
mkdirSync8(BIN_DIR, { recursive: true });
|
|
5548
|
+
mkdirSync8(OFFSETS_DIR, { recursive: true });
|
|
5549
|
+
mkdirSync8(join8(SYNKRO_DIR4, "sessions"), { recursive: true });
|
|
5550
|
+
}
|
|
5551
|
+
function writeHookScripts() {
|
|
5552
|
+
const bashScriptPath = join8(HOOKS_DIR, "cc-bash-judge.ts");
|
|
5553
|
+
const bashFollowupScriptPath = join8(HOOKS_DIR, "cc-bash-followup.ts");
|
|
5554
|
+
const editPrecheckScriptPath = join8(HOOKS_DIR, "cc-edit-precheck.ts");
|
|
5555
|
+
const cwePrecheckScriptPath = join8(HOOKS_DIR, "cc-cwe-precheck.ts");
|
|
5556
|
+
const cvePrecheckScriptPath = join8(HOOKS_DIR, "cc-cve-precheck.ts");
|
|
5557
|
+
const planJudgeScriptPath = join8(HOOKS_DIR, "cc-plan-judge.ts");
|
|
5558
|
+
const agentJudgeScriptPath = join8(HOOKS_DIR, "cc-agent-judge.ts");
|
|
5559
|
+
const stopSummaryScriptPath = join8(HOOKS_DIR, "cc-stop-summary.ts");
|
|
5560
|
+
const sessionStartScriptPath = join8(HOOKS_DIR, "cc-session-start.ts");
|
|
5561
|
+
const transcriptSyncScriptPath = join8(HOOKS_DIR, "cc-transcript-sync.ts");
|
|
5562
|
+
const userPromptSubmitScriptPath = join8(HOOKS_DIR, "cc-user-prompt-submit.ts");
|
|
5563
|
+
const commonScriptPath = join8(HOOKS_DIR, "_synkro-common.ts");
|
|
5564
|
+
const commonBashScriptPath = join8(HOOKS_DIR, "_synkro-common.sh");
|
|
5565
|
+
const cursorBashJudgePath = join8(HOOKS_DIR, "cursor-bash-judge.ts");
|
|
5566
|
+
const cursorEditCapturePath = join8(HOOKS_DIR, "cursor-edit-capture.ts");
|
|
5567
|
+
const mcpStdioProxyPath = join8(HOOKS_DIR, "mcp-stdio-proxy.ts");
|
|
5568
|
+
writeFileSync7(bashScriptPath, BASH_JUDGE_TS, "utf-8");
|
|
5569
|
+
writeFileSync7(bashFollowupScriptPath, BASH_FOLLOWUP_TS, "utf-8");
|
|
5570
|
+
writeFileSync7(editPrecheckScriptPath, EDIT_PRECHECK_TS, "utf-8");
|
|
5571
|
+
writeFileSync7(cwePrecheckScriptPath, CWE_PRECHECK_TS, "utf-8");
|
|
5572
|
+
writeFileSync7(cvePrecheckScriptPath, CVE_PRECHECK_TS, "utf-8");
|
|
5573
|
+
writeFileSync7(planJudgeScriptPath, PLAN_JUDGE_TS, "utf-8");
|
|
5574
|
+
writeFileSync7(agentJudgeScriptPath, AGENT_JUDGE_TS, "utf-8");
|
|
5575
|
+
writeFileSync7(stopSummaryScriptPath, STOP_SUMMARY_TS, "utf-8");
|
|
5576
|
+
writeFileSync7(sessionStartScriptPath, SESSION_START_TS, "utf-8");
|
|
5577
|
+
writeFileSync7(transcriptSyncScriptPath, TRANSCRIPT_SYNC_TS, "utf-8");
|
|
5578
|
+
writeFileSync7(userPromptSubmitScriptPath, USER_PROMPT_SUBMIT_TS, "utf-8");
|
|
5579
|
+
writeFileSync7(commonScriptPath, SYNKRO_COMMON_TS, "utf-8");
|
|
5580
|
+
writeFileSync7(commonBashScriptPath, SYNKRO_COMMON_SCRIPT, "utf-8");
|
|
5581
|
+
writeFileSync7(cursorBashJudgePath, CURSOR_BASH_JUDGE_TS, "utf-8");
|
|
5582
|
+
writeFileSync7(cursorEditCapturePath, CURSOR_EDIT_CAPTURE_TS, "utf-8");
|
|
5583
|
+
writeFileSync7(mcpStdioProxyPath, MCP_STDIO_PROXY_SRC, "utf-8");
|
|
5584
|
+
chmodSync2(bashScriptPath, 493);
|
|
5585
|
+
chmodSync2(bashFollowupScriptPath, 493);
|
|
5586
|
+
chmodSync2(editPrecheckScriptPath, 493);
|
|
5587
|
+
chmodSync2(cwePrecheckScriptPath, 493);
|
|
5588
|
+
chmodSync2(cvePrecheckScriptPath, 493);
|
|
5589
|
+
chmodSync2(planJudgeScriptPath, 493);
|
|
5590
|
+
chmodSync2(agentJudgeScriptPath, 493);
|
|
5591
|
+
chmodSync2(stopSummaryScriptPath, 493);
|
|
5592
|
+
chmodSync2(sessionStartScriptPath, 493);
|
|
5593
|
+
chmodSync2(transcriptSyncScriptPath, 493);
|
|
5594
|
+
chmodSync2(userPromptSubmitScriptPath, 493);
|
|
5595
|
+
chmodSync2(commonScriptPath, 493);
|
|
5596
|
+
chmodSync2(commonBashScriptPath, 493);
|
|
5597
|
+
chmodSync2(cursorBashJudgePath, 493);
|
|
5598
|
+
chmodSync2(cursorEditCapturePath, 493);
|
|
5599
|
+
chmodSync2(mcpStdioProxyPath, 493);
|
|
5600
|
+
return {
|
|
5601
|
+
bashScript: bashScriptPath,
|
|
5602
|
+
bashFollowupScript: bashFollowupScriptPath,
|
|
5603
|
+
editPrecheckScript: editPrecheckScriptPath,
|
|
5604
|
+
cwePrecheckScript: cwePrecheckScriptPath,
|
|
5605
|
+
cvePrecheckScript: cvePrecheckScriptPath,
|
|
5606
|
+
planJudgeScript: planJudgeScriptPath,
|
|
5607
|
+
agentJudgeScript: agentJudgeScriptPath,
|
|
5608
|
+
stopSummaryScript: stopSummaryScriptPath,
|
|
5609
|
+
sessionStartScript: sessionStartScriptPath,
|
|
5610
|
+
transcriptSyncScript: transcriptSyncScriptPath,
|
|
5611
|
+
userPromptSubmitScript: userPromptSubmitScriptPath,
|
|
5612
|
+
cursorBashJudgeScript: cursorBashJudgePath,
|
|
5613
|
+
cursorEditCaptureScript: cursorEditCapturePath
|
|
5614
|
+
};
|
|
5615
|
+
}
|
|
5616
|
+
function sanitizeConfigValue(raw, maxLen = 256) {
|
|
5617
|
+
if (!raw) return "";
|
|
5618
|
+
return raw.replace(/[^\x20-\x7E]/g, "").slice(0, maxLen);
|
|
5619
|
+
}
|
|
5620
|
+
function shellQuoteSingle(value) {
|
|
5621
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
5622
|
+
}
|
|
5623
|
+
function resolveSynkroBundle() {
|
|
5624
|
+
const scriptPath = process.argv[1];
|
|
5625
|
+
if (scriptPath && existsSync9(scriptPath)) return scriptPath;
|
|
5626
|
+
return null;
|
|
5627
|
+
}
|
|
5628
|
+
function writeConfigEnv(opts) {
|
|
5629
|
+
const credsPath = join8(SYNKRO_DIR4, "credentials.json");
|
|
5630
|
+
const safeGateway = sanitizeConfigValue(opts.gatewayUrl);
|
|
5631
|
+
const safeUserId = sanitizeConfigValue(opts.userId);
|
|
5632
|
+
const safeOrgId = sanitizeConfigValue(opts.orgId);
|
|
5633
|
+
const safeEmail = sanitizeConfigValue(opts.email);
|
|
5634
|
+
const safeTier = sanitizeConfigValue(opts.tier ?? "pro", 32);
|
|
5635
|
+
const safeInference = sanitizeConfigValue(opts.inference ?? "fast", 16);
|
|
5636
|
+
const safeSynkroBin = sanitizeConfigValue(opts.synkroBin ?? "", 1024);
|
|
5637
|
+
const lines = [
|
|
5638
|
+
"# Synkro CLI config (managed by synkro install)",
|
|
5639
|
+
"# JWT auth \u2014 the hook scripts read SYNKRO_CREDENTIALS_PATH at runtime",
|
|
5640
|
+
"# and send Authorization: Bearer <access_token> on every gateway call.",
|
|
5641
|
+
`SYNKRO_GATEWAY_URL=${shellQuoteSingle(safeGateway)}`,
|
|
5642
|
+
`SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
|
|
5643
|
+
`SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
|
|
5644
|
+
`SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
|
|
5645
|
+
`SYNKRO_VERSION=${shellQuoteSingle("1.4.88")}`
|
|
5646
|
+
];
|
|
5647
|
+
if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
|
|
5648
|
+
if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
|
|
5649
|
+
if (safeOrgId) lines.push(`SYNKRO_ORG_ID=${shellQuoteSingle(safeOrgId)}`);
|
|
5650
|
+
if (safeEmail) lines.push(`SYNKRO_EMAIL=${shellQuoteSingle(safeEmail)}`);
|
|
5651
|
+
if (opts.transcriptConsent !== void 0) {
|
|
5652
|
+
lines.push(`SYNKRO_TRANSCRIPT_CONSENT=${shellQuoteSingle(opts.transcriptConsent ? "yes" : "no")}`);
|
|
5633
5653
|
}
|
|
5634
|
-
|
|
5635
|
-
|
|
5636
|
-
|
|
5637
|
-
|
|
5638
|
-
|
|
5654
|
+
lines.push(`SYNKRO_LOCAL_INFERENCE=${shellQuoteSingle(opts.localInference ? "yes" : "no")}`);
|
|
5655
|
+
const safeMode = sanitizeConfigValue(opts.deploymentMode ?? "docker", 16);
|
|
5656
|
+
lines.push(`SYNKRO_DEPLOYMENT_MODE=${shellQuoteSingle(safeMode)}`);
|
|
5657
|
+
lines.push("");
|
|
5658
|
+
writeFileSync7(CONFIG_PATH2, lines.join("\n"), "utf-8");
|
|
5659
|
+
chmodSync2(CONFIG_PATH2, 384);
|
|
5639
5660
|
}
|
|
5640
|
-
function
|
|
5641
|
-
|
|
5642
|
-
|
|
5643
|
-
|
|
5644
|
-
|
|
5645
|
-
|
|
5661
|
+
function resolveDeploymentMode() {
|
|
5662
|
+
const envOverride = process.env.SYNKRO_DEPLOYMENT_MODE;
|
|
5663
|
+
if (envOverride) return envOverride.toLowerCase();
|
|
5664
|
+
try {
|
|
5665
|
+
if (existsSync9(CONFIG_PATH2)) {
|
|
5666
|
+
const m = readFileSync7(CONFIG_PATH2, "utf-8").match(/^SYNKRO_DEPLOYMENT_MODE='([^']*)'/m);
|
|
5667
|
+
if (m && m[1]) return m[1].toLowerCase();
|
|
5646
5668
|
}
|
|
5647
|
-
|
|
5648
|
-
|
|
5649
|
-
|
|
5650
|
-
|
|
5669
|
+
} catch {
|
|
5670
|
+
}
|
|
5671
|
+
return "docker";
|
|
5672
|
+
}
|
|
5673
|
+
function collectLocalMetadata() {
|
|
5674
|
+
const meta = { platform: process.platform };
|
|
5675
|
+
try {
|
|
5676
|
+
meta.display_name = execSync5("git config user.name", { encoding: "utf-8", timeout: 3e3 }).trim();
|
|
5677
|
+
} catch {
|
|
5678
|
+
}
|
|
5679
|
+
try {
|
|
5680
|
+
const remote = execSync5("git remote get-url origin", { encoding: "utf-8", timeout: 3e3 }).trim();
|
|
5681
|
+
const sshMatch = remote.match(/^git@[^:]+:(.+?)(?:\.git)?$/);
|
|
5682
|
+
const httpMatch = remote.match(/^https?:\/\/[^/]+\/(.+?)(?:\.git)?$/);
|
|
5683
|
+
const m = sshMatch || httpMatch;
|
|
5684
|
+
if (m) meta.active_repo = m[1];
|
|
5685
|
+
} catch {
|
|
5686
|
+
}
|
|
5687
|
+
try {
|
|
5688
|
+
meta.cc_version = execSync5("claude --version", { encoding: "utf-8", timeout: 5e3 }).trim().split("\n")[0];
|
|
5689
|
+
} catch {
|
|
5690
|
+
}
|
|
5691
|
+
const claudeDir = join8(homedir8(), ".claude");
|
|
5692
|
+
try {
|
|
5693
|
+
const settings = JSON.parse(readFileSync7(join8(claudeDir, "settings.json"), "utf-8"));
|
|
5694
|
+
const plugins = Object.keys(settings.enabledPlugins ?? {}).filter((k) => settings.enabledPlugins[k]);
|
|
5695
|
+
if (plugins.length) meta.enabled_plugins = plugins;
|
|
5696
|
+
if (settings.permissions?.defaultMode) meta.permissions_mode = settings.permissions.defaultMode;
|
|
5697
|
+
} catch {
|
|
5698
|
+
}
|
|
5699
|
+
try {
|
|
5700
|
+
const mcpCache = JSON.parse(readFileSync7(join8(claudeDir, "mcp-needs-auth-cache.json"), "utf-8"));
|
|
5701
|
+
const mcpNames = Object.keys(mcpCache);
|
|
5702
|
+
if (mcpNames.length) meta.mcp_servers = mcpNames;
|
|
5703
|
+
} catch {
|
|
5704
|
+
}
|
|
5705
|
+
try {
|
|
5706
|
+
const mcpList = execSync5("claude mcp list 2>/dev/null", { encoding: "utf-8", timeout: 1e4 });
|
|
5707
|
+
const connected = mcpList.split("\n").filter((l) => l.includes("Connected")).map((l) => l.split(":")[0].trim()).filter(Boolean);
|
|
5708
|
+
if (connected.length) meta.mcp_servers_connected = connected;
|
|
5709
|
+
} catch {
|
|
5710
|
+
}
|
|
5711
|
+
try {
|
|
5712
|
+
const sessionsDir = join8(claudeDir, "sessions");
|
|
5713
|
+
const files = readdirSync(sessionsDir).filter((f) => f.endsWith(".json")).slice(-5);
|
|
5714
|
+
for (const f of files) {
|
|
5715
|
+
const s = JSON.parse(readFileSync7(join8(sessionsDir, f), "utf-8"));
|
|
5716
|
+
if (s.version) {
|
|
5717
|
+
meta.cc_version = meta.cc_version || s.version;
|
|
5718
|
+
break;
|
|
5651
5719
|
}
|
|
5652
5720
|
}
|
|
5653
|
-
|
|
5654
|
-
}
|
|
5655
|
-
|
|
5656
|
-
var CLAUDE_JSON_BACKUP_PATH, SESSION_DIR, PLUGIN_PATH, PLUGIN_PKG_PATH, PLUGIN_SETTINGS_DIR, PLUGIN_SETTINGS_PATH, PROJECT_MCP_PATH, CLAUDE_JSON_PATH, RUN_SCRIPT_PATH, TMUX_SESSION_NAME, CHANNEL_1_PORT, SESSION_DIR_2, PLUGIN_PATH_2, PLUGIN_PKG_PATH_2, PLUGIN_SETTINGS_DIR_2, PLUGIN_SETTINGS_PATH_2, PROJECT_MCP_PATH_2, RUN_SCRIPT_PATH_2, TMUX_SESSION_NAME_2, CHANNEL_2_PORT, SESSION_DIR_3, PLUGIN_PATH_3, PLUGIN_PKG_PATH_3, PLUGIN_SETTINGS_DIR_3, PLUGIN_SETTINGS_PATH_3, PROJECT_MCP_PATH_3, RUN_SCRIPT_PATH_3, TMUX_SESSION_NAME_3, CHANNEL_3_PORT, SESSION_DIR_4, PLUGIN_PATH_4, PLUGIN_PKG_PATH_4, PLUGIN_SETTINGS_DIR_4, PLUGIN_SETTINGS_PATH_4, PROJECT_MCP_PATH_4, RUN_SCRIPT_PATH_4, TMUX_SESSION_NAME_4, CHANNEL_4_PORT, RUN_SCRIPT_SOURCE, RUN_SCRIPT_SOURCE_2, RUN_SCRIPT_SOURCE_3, RUN_SCRIPT_SOURCE_4, MCP_SERVER_NAME, PLUGIN_PACKAGE_JSON, LocalCCInstallError, CHANNELS;
|
|
5657
|
-
var init_install = __esm({
|
|
5658
|
-
"cli/local-cc/install.ts"() {
|
|
5659
|
-
"use strict";
|
|
5660
|
-
init_channelSource();
|
|
5661
|
-
CLAUDE_JSON_BACKUP_PATH = join7(homedir7(), ".claude.json.synkro-bak");
|
|
5662
|
-
SESSION_DIR = join7(homedir7(), ".synkro", "cc_sessions");
|
|
5663
|
-
PLUGIN_PATH = join7(SESSION_DIR, "synkro-channel.ts");
|
|
5664
|
-
PLUGIN_PKG_PATH = join7(SESSION_DIR, "package.json");
|
|
5665
|
-
PLUGIN_SETTINGS_DIR = join7(SESSION_DIR, ".claude");
|
|
5666
|
-
PLUGIN_SETTINGS_PATH = join7(PLUGIN_SETTINGS_DIR, "settings.json");
|
|
5667
|
-
PROJECT_MCP_PATH = join7(SESSION_DIR, ".mcp.json");
|
|
5668
|
-
CLAUDE_JSON_PATH = join7(homedir7(), ".claude.json");
|
|
5669
|
-
RUN_SCRIPT_PATH = join7(SESSION_DIR, "run-claude.sh");
|
|
5670
|
-
TMUX_SESSION_NAME = "synkro-local-cc";
|
|
5671
|
-
CHANNEL_1_PORT = 8941;
|
|
5672
|
-
SESSION_DIR_2 = join7(homedir7(), ".synkro", "cc_sessions_2");
|
|
5673
|
-
PLUGIN_PATH_2 = join7(SESSION_DIR_2, "synkro-channel.ts");
|
|
5674
|
-
PLUGIN_PKG_PATH_2 = join7(SESSION_DIR_2, "package.json");
|
|
5675
|
-
PLUGIN_SETTINGS_DIR_2 = join7(SESSION_DIR_2, ".claude");
|
|
5676
|
-
PLUGIN_SETTINGS_PATH_2 = join7(PLUGIN_SETTINGS_DIR_2, "settings.json");
|
|
5677
|
-
PROJECT_MCP_PATH_2 = join7(SESSION_DIR_2, ".mcp.json");
|
|
5678
|
-
RUN_SCRIPT_PATH_2 = join7(SESSION_DIR_2, "run-claude.sh");
|
|
5679
|
-
TMUX_SESSION_NAME_2 = "synkro-local-cc-2";
|
|
5680
|
-
CHANNEL_2_PORT = 8951;
|
|
5681
|
-
SESSION_DIR_3 = join7(homedir7(), ".synkro", "cc_sessions_3");
|
|
5682
|
-
PLUGIN_PATH_3 = join7(SESSION_DIR_3, "synkro-channel.ts");
|
|
5683
|
-
PLUGIN_PKG_PATH_3 = join7(SESSION_DIR_3, "package.json");
|
|
5684
|
-
PLUGIN_SETTINGS_DIR_3 = join7(SESSION_DIR_3, ".claude");
|
|
5685
|
-
PLUGIN_SETTINGS_PATH_3 = join7(PLUGIN_SETTINGS_DIR_3, "settings.json");
|
|
5686
|
-
PROJECT_MCP_PATH_3 = join7(SESSION_DIR_3, ".mcp.json");
|
|
5687
|
-
RUN_SCRIPT_PATH_3 = join7(SESSION_DIR_3, "run-claude.sh");
|
|
5688
|
-
TMUX_SESSION_NAME_3 = "synkro-local-cc-3";
|
|
5689
|
-
CHANNEL_3_PORT = 8942;
|
|
5690
|
-
SESSION_DIR_4 = join7(homedir7(), ".synkro", "cc_sessions_4");
|
|
5691
|
-
PLUGIN_PATH_4 = join7(SESSION_DIR_4, "synkro-channel.ts");
|
|
5692
|
-
PLUGIN_PKG_PATH_4 = join7(SESSION_DIR_4, "package.json");
|
|
5693
|
-
PLUGIN_SETTINGS_DIR_4 = join7(SESSION_DIR_4, ".claude");
|
|
5694
|
-
PLUGIN_SETTINGS_PATH_4 = join7(PLUGIN_SETTINGS_DIR_4, "settings.json");
|
|
5695
|
-
PROJECT_MCP_PATH_4 = join7(SESSION_DIR_4, ".mcp.json");
|
|
5696
|
-
RUN_SCRIPT_PATH_4 = join7(SESSION_DIR_4, "run-claude.sh");
|
|
5697
|
-
TMUX_SESSION_NAME_4 = "synkro-local-cc-4";
|
|
5698
|
-
CHANNEL_4_PORT = 8952;
|
|
5699
|
-
RUN_SCRIPT_SOURCE = `#!/usr/bin/env bash
|
|
5700
|
-
# Auto-generated by \`synkro install\`. Do not edit.
|
|
5701
|
-
set -uo pipefail
|
|
5702
|
-
|
|
5703
|
-
SESSION=${TMUX_SESSION_NAME}
|
|
5704
|
-
LOG="$HOME/.synkro/cc_sessions/run-claude.log"
|
|
5705
|
-
|
|
5706
|
-
log() { echo "[$(date '+%H:%M:%S')] $*" >> "$LOG"; echo "$*"; }
|
|
5707
|
-
|
|
5708
|
-
# Pre-flight checks
|
|
5709
|
-
if ! command -v claude >/dev/null 2>&1; then
|
|
5710
|
-
log "ERROR: claude CLI not found on PATH. Install Claude Code first."
|
|
5711
|
-
exit 1
|
|
5712
|
-
fi
|
|
5713
|
-
|
|
5714
|
-
if ! command -v tmux >/dev/null 2>&1; then
|
|
5715
|
-
log "ERROR: tmux not found on PATH."
|
|
5716
|
-
exit 1
|
|
5717
|
-
fi
|
|
5718
|
-
|
|
5719
|
-
# Check claude is authenticated
|
|
5720
|
-
if ! claude --version >/dev/null 2>&1; then
|
|
5721
|
-
log "ERROR: claude --version failed. Is Claude Code installed correctly?"
|
|
5722
|
-
exit 1
|
|
5723
|
-
fi
|
|
5724
|
-
|
|
5725
|
-
log "Starting local-CC session..."
|
|
5726
|
-
log "claude version: $(claude --version 2>&1 | head -1)"
|
|
5727
|
-
|
|
5728
|
-
# Kill any previous session so restarts come up clean.
|
|
5729
|
-
tmux kill-session -t "=$SESSION" 2>/dev/null || true
|
|
5730
|
-
|
|
5731
|
-
# Start claude inside a detached tmux session so it has a real pty.
|
|
5732
|
-
# Redirect stderr to the log so we can see why it dies.
|
|
5733
|
-
tmux new-session -d -s "$SESSION" \\
|
|
5734
|
-
"SYNKRO_CHANNEL_PORT=${CHANNEL_1_PORT} claude --dangerously-load-development-channels server:synkro-local --dangerously-skip-permissions --setting-sources project,local --model claude-sonnet-4-6 2>>$LOG; echo 'claude exited with code '$'?' >> $LOG"
|
|
5735
|
-
|
|
5736
|
-
# Claude's --dangerously-load-development-channels shows a confirmation
|
|
5737
|
-
# prompt: option 1 = "I am using this for local development" (accept),
|
|
5738
|
-
# option 2 = "Exit". Auto-accept by sending '1' + Enter.
|
|
5739
|
-
sleep 3
|
|
5740
|
-
if tmux has-session -t "=$SESSION" 2>/dev/null; then
|
|
5741
|
-
tmux send-keys -t "$SESSION" '1' 2>/dev/null || true
|
|
5742
|
-
sleep 1
|
|
5743
|
-
tmux send-keys -t "$SESSION" Enter 2>/dev/null || true
|
|
5744
|
-
sleep 1
|
|
5745
|
-
# Additional Enter for any follow-up prompts (workspace trust, MCP consent)
|
|
5746
|
-
tmux send-keys -t "$SESSION" Enter 2>/dev/null || true
|
|
5747
|
-
log "Sent auto-accept keys to claude session."
|
|
5748
|
-
fi
|
|
5749
|
-
|
|
5750
|
-
sleep 2
|
|
5751
|
-
if ! tmux has-session -t "$SESSION" 2>/dev/null; then
|
|
5752
|
-
log "ERROR: tmux session died immediately. Check $LOG for details."
|
|
5753
|
-
log "Try running claude manually to verify auth: claude --print 'say ok'"
|
|
5754
|
-
exit 1
|
|
5755
|
-
fi
|
|
5756
|
-
|
|
5757
|
-
log "tmux session started successfully."
|
|
5758
|
-
|
|
5759
|
-
# Block on the tmux session so pueue's task lifetime tracks claude's.
|
|
5760
|
-
while tmux has-session -t "=$SESSION" 2>/dev/null; do
|
|
5761
|
-
sleep 5
|
|
5762
|
-
done
|
|
5763
|
-
|
|
5764
|
-
log "tmux session ended."
|
|
5765
|
-
`;
|
|
5766
|
-
RUN_SCRIPT_SOURCE_2 = `#!/usr/bin/env bash
|
|
5767
|
-
# Auto-generated by \`synkro install\`. Channel 2 (CWE scan, port ${CHANNEL_2_PORT}).
|
|
5768
|
-
set -uo pipefail
|
|
5769
|
-
|
|
5770
|
-
SESSION=${TMUX_SESSION_NAME_2}
|
|
5771
|
-
LOG="$HOME/.synkro/cc_sessions_2/run-claude.log"
|
|
5772
|
-
|
|
5773
|
-
log() { echo "[$(date '+%H:%M:%S')] $*" >> "$LOG"; echo "$*"; }
|
|
5774
|
-
|
|
5775
|
-
if ! command -v claude >/dev/null 2>&1; then
|
|
5776
|
-
log "ERROR: claude CLI not found on PATH."
|
|
5777
|
-
exit 1
|
|
5778
|
-
fi
|
|
5779
|
-
|
|
5780
|
-
if ! command -v tmux >/dev/null 2>&1; then
|
|
5781
|
-
log "ERROR: tmux not found on PATH."
|
|
5782
|
-
exit 1
|
|
5783
|
-
fi
|
|
5784
|
-
|
|
5785
|
-
if ! claude --version >/dev/null 2>&1; then
|
|
5786
|
-
log "ERROR: claude --version failed."
|
|
5787
|
-
exit 1
|
|
5788
|
-
fi
|
|
5789
|
-
|
|
5790
|
-
log "Starting local-CC channel 2 (port ${CHANNEL_2_PORT})..."
|
|
5791
|
-
log "claude version: $(claude --version 2>&1 | head -1)"
|
|
5792
|
-
|
|
5793
|
-
tmux kill-session -t "=$SESSION" 2>/dev/null || true
|
|
5794
|
-
|
|
5795
|
-
tmux new-session -d -s "$SESSION" \\
|
|
5796
|
-
"SYNKRO_CHANNEL_PORT=${CHANNEL_2_PORT} claude --dangerously-load-development-channels server:synkro-local --dangerously-skip-permissions --setting-sources project,local --model claude-sonnet-4-6 2>>$LOG; echo 'claude exited with code '$'?' >> $LOG"
|
|
5797
|
-
|
|
5798
|
-
sleep 3
|
|
5799
|
-
if tmux has-session -t "=$SESSION" 2>/dev/null; then
|
|
5800
|
-
tmux send-keys -t "$SESSION" '1' 2>/dev/null || true
|
|
5801
|
-
sleep 1
|
|
5802
|
-
tmux send-keys -t "$SESSION" Enter 2>/dev/null || true
|
|
5803
|
-
sleep 1
|
|
5804
|
-
tmux send-keys -t "$SESSION" Enter 2>/dev/null || true
|
|
5805
|
-
log "Sent auto-accept keys to channel 2 session."
|
|
5806
|
-
fi
|
|
5807
|
-
|
|
5808
|
-
sleep 2
|
|
5809
|
-
if ! tmux has-session -t "$SESSION" 2>/dev/null; then
|
|
5810
|
-
log "ERROR: tmux session died immediately. Check $LOG for details."
|
|
5811
|
-
exit 1
|
|
5812
|
-
fi
|
|
5813
|
-
|
|
5814
|
-
log "tmux session started successfully (port ${CHANNEL_2_PORT})."
|
|
5815
|
-
|
|
5816
|
-
while tmux has-session -t "=$SESSION" 2>/dev/null; do
|
|
5817
|
-
sleep 5
|
|
5818
|
-
done
|
|
5819
|
-
|
|
5820
|
-
log "tmux session ended."
|
|
5821
|
-
`;
|
|
5822
|
-
RUN_SCRIPT_SOURCE_3 = `#!/usr/bin/env bash
|
|
5823
|
-
# Auto-generated by \`synkro install\`. General worker B (port ${CHANNEL_3_PORT}).
|
|
5824
|
-
set -uo pipefail
|
|
5825
|
-
|
|
5826
|
-
SESSION=${TMUX_SESSION_NAME_3}
|
|
5827
|
-
LOG="$HOME/.synkro/cc_sessions_3/run-claude.log"
|
|
5828
|
-
|
|
5829
|
-
log() { echo "[$(date '+%H:%M:%S')] $*" >> "$LOG"; echo "$*"; }
|
|
5830
|
-
|
|
5831
|
-
if ! command -v claude >/dev/null 2>&1; then
|
|
5832
|
-
log "ERROR: claude CLI not found on PATH."
|
|
5833
|
-
exit 1
|
|
5834
|
-
fi
|
|
5835
|
-
|
|
5836
|
-
if ! command -v tmux >/dev/null 2>&1; then
|
|
5837
|
-
log "ERROR: tmux not found on PATH."
|
|
5838
|
-
exit 1
|
|
5839
|
-
fi
|
|
5840
|
-
|
|
5841
|
-
if ! claude --version >/dev/null 2>&1; then
|
|
5842
|
-
log "ERROR: claude --version failed."
|
|
5843
|
-
exit 1
|
|
5844
|
-
fi
|
|
5845
|
-
|
|
5846
|
-
log "Starting local-CC general worker B (port ${CHANNEL_3_PORT})..."
|
|
5847
|
-
log "claude version: $(claude --version 2>&1 | head -1)"
|
|
5848
|
-
|
|
5849
|
-
tmux kill-session -t "=$SESSION" 2>/dev/null || true
|
|
5850
|
-
|
|
5851
|
-
tmux new-session -d -s "$SESSION" \\
|
|
5852
|
-
"SYNKRO_CHANNEL_PORT=${CHANNEL_3_PORT} claude --dangerously-load-development-channels server:synkro-local --dangerously-skip-permissions --setting-sources project,local --model claude-sonnet-4-6 2>>$LOG; echo 'claude exited with code '$'?' >> $LOG"
|
|
5853
|
-
|
|
5854
|
-
sleep 3
|
|
5855
|
-
if tmux has-session -t "=$SESSION" 2>/dev/null; then
|
|
5856
|
-
tmux send-keys -t "$SESSION" '1' 2>/dev/null || true
|
|
5857
|
-
sleep 1
|
|
5858
|
-
tmux send-keys -t "$SESSION" Enter 2>/dev/null || true
|
|
5859
|
-
sleep 1
|
|
5860
|
-
tmux send-keys -t "$SESSION" Enter 2>/dev/null || true
|
|
5861
|
-
log "Sent auto-accept keys to general worker B session."
|
|
5862
|
-
fi
|
|
5863
|
-
|
|
5864
|
-
sleep 2
|
|
5865
|
-
if ! tmux has-session -t "$SESSION" 2>/dev/null; then
|
|
5866
|
-
log "ERROR: tmux session died immediately. Check $LOG for details."
|
|
5867
|
-
exit 1
|
|
5868
|
-
fi
|
|
5869
|
-
|
|
5870
|
-
log "tmux session started successfully (port ${CHANNEL_3_PORT})."
|
|
5871
|
-
|
|
5872
|
-
while tmux has-session -t "=$SESSION" 2>/dev/null; do
|
|
5873
|
-
sleep 5
|
|
5874
|
-
done
|
|
5875
|
-
|
|
5876
|
-
log "tmux session ended."
|
|
5877
|
-
`;
|
|
5878
|
-
RUN_SCRIPT_SOURCE_4 = `#!/usr/bin/env bash
|
|
5879
|
-
# Auto-generated by \`synkro install\`. CWE worker B (port ${CHANNEL_4_PORT}).
|
|
5880
|
-
set -uo pipefail
|
|
5881
|
-
|
|
5882
|
-
SESSION=${TMUX_SESSION_NAME_4}
|
|
5883
|
-
LOG="$HOME/.synkro/cc_sessions_4/run-claude.log"
|
|
5884
|
-
|
|
5885
|
-
log() { echo "[$(date '+%H:%M:%S')] $*" >> "$LOG"; echo "$*"; }
|
|
5886
|
-
|
|
5887
|
-
if ! command -v claude >/dev/null 2>&1; then
|
|
5888
|
-
log "ERROR: claude CLI not found on PATH."
|
|
5889
|
-
exit 1
|
|
5890
|
-
fi
|
|
5891
|
-
|
|
5892
|
-
if ! command -v tmux >/dev/null 2>&1; then
|
|
5893
|
-
log "ERROR: tmux not found on PATH."
|
|
5894
|
-
exit 1
|
|
5895
|
-
fi
|
|
5896
|
-
|
|
5897
|
-
if ! claude --version >/dev/null 2>&1; then
|
|
5898
|
-
log "ERROR: claude --version failed."
|
|
5899
|
-
exit 1
|
|
5900
|
-
fi
|
|
5901
|
-
|
|
5902
|
-
log "Starting local-CC CWE worker B (port ${CHANNEL_4_PORT})..."
|
|
5903
|
-
log "claude version: $(claude --version 2>&1 | head -1)"
|
|
5904
|
-
|
|
5905
|
-
tmux kill-session -t "=$SESSION" 2>/dev/null || true
|
|
5906
|
-
|
|
5907
|
-
tmux new-session -d -s "$SESSION" \\
|
|
5908
|
-
"SYNKRO_CHANNEL_PORT=${CHANNEL_4_PORT} claude --dangerously-load-development-channels server:synkro-local --dangerously-skip-permissions --setting-sources project,local --model claude-sonnet-4-6 2>>$LOG; echo 'claude exited with code '$'?' >> $LOG"
|
|
5909
|
-
|
|
5910
|
-
sleep 3
|
|
5911
|
-
if tmux has-session -t "=$SESSION" 2>/dev/null; then
|
|
5912
|
-
tmux send-keys -t "$SESSION" '1' 2>/dev/null || true
|
|
5913
|
-
sleep 1
|
|
5914
|
-
tmux send-keys -t "$SESSION" Enter 2>/dev/null || true
|
|
5915
|
-
sleep 1
|
|
5916
|
-
tmux send-keys -t "$SESSION" Enter 2>/dev/null || true
|
|
5917
|
-
log "Sent auto-accept keys to CWE worker B session."
|
|
5918
|
-
fi
|
|
5919
|
-
|
|
5920
|
-
sleep 2
|
|
5921
|
-
if ! tmux has-session -t "$SESSION" 2>/dev/null; then
|
|
5922
|
-
log "ERROR: tmux session died immediately. Check $LOG for details."
|
|
5923
|
-
exit 1
|
|
5924
|
-
fi
|
|
5925
|
-
|
|
5926
|
-
log "tmux session started successfully (port ${CHANNEL_4_PORT})."
|
|
5927
|
-
|
|
5928
|
-
while tmux has-session -t "=$SESSION" 2>/dev/null; do
|
|
5929
|
-
sleep 5
|
|
5930
|
-
done
|
|
5931
|
-
|
|
5932
|
-
log "tmux session ended."
|
|
5933
|
-
`;
|
|
5934
|
-
MCP_SERVER_NAME = "synkro-local";
|
|
5935
|
-
PLUGIN_PACKAGE_JSON = JSON.stringify(
|
|
5936
|
-
{
|
|
5937
|
-
name: "synkro-local-channel",
|
|
5938
|
-
private: true,
|
|
5939
|
-
version: "0.1.0",
|
|
5940
|
-
type: "module",
|
|
5941
|
-
dependencies: {
|
|
5942
|
-
"@modelcontextprotocol/sdk": "^1.0.0"
|
|
5943
|
-
}
|
|
5944
|
-
},
|
|
5945
|
-
null,
|
|
5946
|
-
2
|
|
5947
|
-
) + "\n";
|
|
5948
|
-
LocalCCInstallError = class extends Error {
|
|
5949
|
-
constructor(message, cause) {
|
|
5950
|
-
super(message);
|
|
5951
|
-
this.cause = cause;
|
|
5952
|
-
this.name = "LocalCCInstallError";
|
|
5953
|
-
}
|
|
5954
|
-
cause;
|
|
5955
|
-
};
|
|
5956
|
-
CHANNELS = [
|
|
5957
|
-
{ sessionDir: SESSION_DIR, pluginPath: PLUGIN_PATH, pluginPkgPath: PLUGIN_PKG_PATH, pluginSettingsDir: PLUGIN_SETTINGS_DIR, pluginSettingsPath: PLUGIN_SETTINGS_PATH, projectMcpPath: PROJECT_MCP_PATH, runScriptPath: RUN_SCRIPT_PATH, runScriptSource: RUN_SCRIPT_SOURCE },
|
|
5958
|
-
{ sessionDir: SESSION_DIR_2, pluginPath: PLUGIN_PATH_2, pluginPkgPath: PLUGIN_PKG_PATH_2, pluginSettingsDir: PLUGIN_SETTINGS_DIR_2, pluginSettingsPath: PLUGIN_SETTINGS_PATH_2, projectMcpPath: PROJECT_MCP_PATH_2, runScriptPath: RUN_SCRIPT_PATH_2, runScriptSource: RUN_SCRIPT_SOURCE_2 },
|
|
5959
|
-
{ sessionDir: SESSION_DIR_3, pluginPath: PLUGIN_PATH_3, pluginPkgPath: PLUGIN_PKG_PATH_3, pluginSettingsDir: PLUGIN_SETTINGS_DIR_3, pluginSettingsPath: PLUGIN_SETTINGS_PATH_3, projectMcpPath: PROJECT_MCP_PATH_3, runScriptPath: RUN_SCRIPT_PATH_3, runScriptSource: RUN_SCRIPT_SOURCE_3 },
|
|
5960
|
-
{ sessionDir: SESSION_DIR_4, pluginPath: PLUGIN_PATH_4, pluginPkgPath: PLUGIN_PKG_PATH_4, pluginSettingsDir: PLUGIN_SETTINGS_DIR_4, pluginSettingsPath: PLUGIN_SETTINGS_PATH_4, projectMcpPath: PROJECT_MCP_PATH_4, runScriptPath: RUN_SCRIPT_PATH_4, runScriptSource: RUN_SCRIPT_SOURCE_4 }
|
|
5961
|
-
];
|
|
5962
|
-
}
|
|
5963
|
-
});
|
|
5964
|
-
|
|
5965
|
-
// cli/local-cc/macKeychain.ts
|
|
5966
|
-
import { existsSync as existsSync9, mkdirSync as mkdirSync7, writeFileSync as writeFileSync7, chmodSync as chmodSync2, readFileSync as readFileSync8, statSync } from "fs";
|
|
5967
|
-
import { homedir as homedir8, platform as platform3 } from "os";
|
|
5968
|
-
import { join as join8 } from "path";
|
|
5969
|
-
import { spawnSync as spawnSync2 } from "child_process";
|
|
5970
|
-
function needsKeychainBridge() {
|
|
5971
|
-
return platform3() === "darwin";
|
|
5972
|
-
}
|
|
5973
|
-
function readKeychainCreds() {
|
|
5974
|
-
if (platform3() !== "darwin") return null;
|
|
5975
|
-
const r = spawnSync2("security", ["find-generic-password", "-s", KEYCHAIN_SERVICE, "-w"], {
|
|
5976
|
-
encoding: "utf-8",
|
|
5977
|
-
timeout: 5e3
|
|
5978
|
-
});
|
|
5979
|
-
if (r.status !== 0) return null;
|
|
5980
|
-
const blob = (r.stdout || "").trim();
|
|
5981
|
-
return blob || null;
|
|
5982
|
-
}
|
|
5983
|
-
function exportKeychainCreds() {
|
|
5984
|
-
const blob = readKeychainCreds();
|
|
5985
|
-
if (!blob) return null;
|
|
5986
|
-
mkdirSync7(CLAUDE_CREDS_DIR, { recursive: true });
|
|
5987
|
-
chmodSync2(CLAUDE_CREDS_DIR, 448);
|
|
5988
|
-
writeFileSync7(CLAUDE_CREDS_FILE, blob, "utf-8");
|
|
5989
|
-
chmodSync2(CLAUDE_CREDS_FILE, 384);
|
|
5990
|
-
return CLAUDE_CREDS_FILE;
|
|
5991
|
-
}
|
|
5992
|
-
function writeRefreshAgent(synkroBinPath) {
|
|
5993
|
-
if (platform3() !== "darwin") {
|
|
5994
|
-
throw new KeychainExportError("writeRefreshAgent is darwin-only");
|
|
5995
|
-
}
|
|
5996
|
-
mkdirSync7(join8(homedir8(), "Library", "LaunchAgents"), { recursive: true });
|
|
5997
|
-
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
5998
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
5999
|
-
<plist version="1.0">
|
|
6000
|
-
<dict>
|
|
6001
|
-
<key>Label</key>
|
|
6002
|
-
<string>${LAUNCHD_LABEL}</string>
|
|
6003
|
-
<key>ProgramArguments</key>
|
|
6004
|
-
<array>
|
|
6005
|
-
<string>${synkroBinPath}</string>
|
|
6006
|
-
<string>local-cc</string>
|
|
6007
|
-
<string>refresh-creds</string>
|
|
6008
|
-
</array>
|
|
6009
|
-
<key>StartInterval</key>
|
|
6010
|
-
<integer>${REFRESH_INTERVAL_SECONDS}</integer>
|
|
6011
|
-
<key>RunAtLoad</key>
|
|
6012
|
-
<true/>
|
|
6013
|
-
<key>StandardErrorPath</key>
|
|
6014
|
-
<string>${join8(SYNKRO_DIR2, "claude-creds-refresh.log")}</string>
|
|
6015
|
-
<key>StandardOutPath</key>
|
|
6016
|
-
<string>${join8(SYNKRO_DIR2, "claude-creds-refresh.log")}</string>
|
|
6017
|
-
</dict>
|
|
6018
|
-
</plist>
|
|
6019
|
-
`;
|
|
6020
|
-
writeFileSync7(LAUNCHD_PLIST, plist, "utf-8");
|
|
6021
|
-
return LAUNCHD_PLIST;
|
|
6022
|
-
}
|
|
6023
|
-
function loadRefreshAgent() {
|
|
6024
|
-
if (platform3() !== "darwin") return;
|
|
6025
|
-
spawnSync2("launchctl", ["bootout", `gui/${process.getuid?.() ?? 501}`, LAUNCHD_PLIST], {
|
|
6026
|
-
encoding: "utf-8",
|
|
6027
|
-
timeout: 5e3
|
|
6028
|
-
});
|
|
6029
|
-
const r = spawnSync2("launchctl", ["bootstrap", `gui/${process.getuid?.() ?? 501}`, LAUNCHD_PLIST], {
|
|
6030
|
-
encoding: "utf-8",
|
|
6031
|
-
timeout: 5e3
|
|
6032
|
-
});
|
|
6033
|
-
if (r.status !== 0) {
|
|
6034
|
-
throw new KeychainExportError(
|
|
6035
|
-
`launchctl bootstrap failed: ${r.stderr || r.stdout || "unknown"}`
|
|
6036
|
-
);
|
|
6037
|
-
}
|
|
6038
|
-
}
|
|
6039
|
-
function uninstallRefreshAgent() {
|
|
6040
|
-
if (platform3() !== "darwin") return;
|
|
6041
|
-
spawnSync2("launchctl", ["bootout", `gui/${process.getuid?.() ?? 501}`, LAUNCHD_PLIST], {
|
|
6042
|
-
encoding: "utf-8",
|
|
6043
|
-
timeout: 5e3
|
|
6044
|
-
});
|
|
6045
|
-
try {
|
|
6046
|
-
if (existsSync9(LAUNCHD_PLIST)) {
|
|
6047
|
-
__require("fs").unlinkSync(LAUNCHD_PLIST);
|
|
6048
|
-
}
|
|
6049
|
-
} catch {
|
|
6050
|
-
}
|
|
6051
|
-
}
|
|
6052
|
-
var SYNKRO_DIR2, CLAUDE_CREDS_DIR, CLAUDE_CREDS_FILE, KEYCHAIN_SERVICE, LAUNCHD_LABEL, LAUNCHD_PLIST, REFRESH_INTERVAL_SECONDS, KeychainExportError;
|
|
6053
|
-
var init_macKeychain = __esm({
|
|
6054
|
-
"cli/local-cc/macKeychain.ts"() {
|
|
6055
|
-
"use strict";
|
|
6056
|
-
SYNKRO_DIR2 = join8(homedir8(), ".synkro");
|
|
6057
|
-
CLAUDE_CREDS_DIR = join8(SYNKRO_DIR2, "claude-creds");
|
|
6058
|
-
CLAUDE_CREDS_FILE = join8(CLAUDE_CREDS_DIR, ".credentials.json");
|
|
6059
|
-
KEYCHAIN_SERVICE = "Claude Code-credentials";
|
|
6060
|
-
LAUNCHD_LABEL = "com.synkro.cli.claude-creds-refresh";
|
|
6061
|
-
LAUNCHD_PLIST = join8(homedir8(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
|
|
6062
|
-
REFRESH_INTERVAL_SECONDS = 6 * 60 * 60;
|
|
6063
|
-
KeychainExportError = class extends Error {
|
|
6064
|
-
constructor(message, cause) {
|
|
6065
|
-
super(message);
|
|
6066
|
-
this.cause = cause;
|
|
6067
|
-
this.name = "KeychainExportError";
|
|
6068
|
-
}
|
|
6069
|
-
cause;
|
|
6070
|
-
};
|
|
6071
|
-
}
|
|
6072
|
-
});
|
|
6073
|
-
|
|
6074
|
-
// cli/local-cc/dockerInstall.ts
|
|
6075
|
-
import { existsSync as existsSync10, mkdirSync as mkdirSync8 } from "fs";
|
|
6076
|
-
import { homedir as homedir9 } from "os";
|
|
6077
|
-
import { join as join9 } from "path";
|
|
6078
|
-
import { spawnSync as spawnSync3 } from "child_process";
|
|
6079
|
-
function imageTag() {
|
|
6080
|
-
const registry = process.env.SYNKRO_IMAGE_REGISTRY || "";
|
|
6081
|
-
const tag = process.env.SYNKRO_IMAGE_TAG || DEFAULT_IMAGE;
|
|
6082
|
-
return registry ? `${registry.replace(/\/+$/, "")}/${tag.replace(/^.*\//, "")}` : tag;
|
|
6083
|
-
}
|
|
6084
|
-
function assertDockerAvailable() {
|
|
6085
|
-
const v = spawnSync3("docker", ["version", "--format", "{{.Server.Version}}"], {
|
|
6086
|
-
encoding: "utf-8",
|
|
6087
|
-
timeout: 5e3
|
|
6088
|
-
});
|
|
6089
|
-
if (v.status !== 0) {
|
|
6090
|
-
throw new DockerInstallError(
|
|
6091
|
-
"Docker CLI is not installed or the daemon is not reachable.\nInstall Docker Desktop (macOS) or docker-engine (Linux), start the daemon, then re-run."
|
|
6092
|
-
);
|
|
6093
|
-
}
|
|
6094
|
-
}
|
|
6095
|
-
function claudeCredsHostDir() {
|
|
6096
|
-
if (needsKeychainBridge()) return CLAUDE_CREDS_DIR;
|
|
6097
|
-
return join9(homedir9(), ".claude");
|
|
6098
|
-
}
|
|
6099
|
-
async function dockerInstall(opts = {}) {
|
|
6100
|
-
assertDockerAvailable();
|
|
6101
|
-
const image = imageTag();
|
|
6102
|
-
const workers = String(opts.workersPerPool ?? 4);
|
|
6103
|
-
mkdirSync8(PGDATA_PATH, { recursive: true });
|
|
6104
|
-
if (!existsSync10(MCP_JWT_PATH)) {
|
|
6105
|
-
throw new DockerInstallError(
|
|
6106
|
-
`MCP JWT missing at ${MCP_JWT_PATH}. The installer should mint this before calling dockerInstall.`
|
|
6107
|
-
);
|
|
6108
|
-
}
|
|
6109
|
-
if (needsKeychainBridge()) {
|
|
6110
|
-
const path = exportKeychainCreds();
|
|
6111
|
-
if (!path) {
|
|
6112
|
-
throw new DockerInstallError(
|
|
6113
|
-
"Claude Code keychain entry not found. Run `claude login` (or open Claude Code and sign in) before installing the container."
|
|
6114
|
-
);
|
|
6115
|
-
}
|
|
6116
|
-
const plist = writeRefreshAgent("/usr/local/bin/synkro");
|
|
6117
|
-
try {
|
|
6118
|
-
loadRefreshAgent();
|
|
6119
|
-
} catch (err) {
|
|
6120
|
-
console.warn(` \u26A0 launchd refresh agent not loaded: ${err.message}`);
|
|
6121
|
-
console.warn(` Plist written to ${plist} \u2014 load manually with launchctl bootstrap when ready.`);
|
|
6122
|
-
}
|
|
6123
|
-
} else {
|
|
6124
|
-
mkdirSync8(join9(homedir9(), ".claude"), { recursive: true });
|
|
6125
|
-
}
|
|
6126
|
-
console.log(` Pulling ${image}...`);
|
|
6127
|
-
const pull = spawnSync3("docker", ["pull", image], { encoding: "utf-8", stdio: "inherit", timeout: 6e5 });
|
|
6128
|
-
if (pull.status !== 0) {
|
|
6129
|
-
throw new DockerInstallError(`docker pull ${image} failed`);
|
|
6130
|
-
}
|
|
6131
|
-
spawnSync3("docker", ["rm", "-f", CONTAINER_NAME], { encoding: "utf-8", timeout: 3e4 });
|
|
6132
|
-
const credsDir = claudeCredsHostDir();
|
|
6133
|
-
const args2 = [
|
|
6134
|
-
"run",
|
|
6135
|
-
"--detach",
|
|
6136
|
-
"--name",
|
|
6137
|
-
CONTAINER_NAME,
|
|
6138
|
-
"--restart",
|
|
6139
|
-
"unless-stopped",
|
|
6140
|
-
"-p",
|
|
6141
|
-
`127.0.0.1:${HOST_MCP_PORT}:8931`,
|
|
6142
|
-
"-p",
|
|
6143
|
-
`127.0.0.1:${HOST_GRADER_PORT}:8929`,
|
|
6144
|
-
"-p",
|
|
6145
|
-
`127.0.0.1:${HOST_CWE_PORT}:8930`,
|
|
6146
|
-
"-p",
|
|
6147
|
-
`127.0.0.1:${HOST_PG_PORT}:5433`,
|
|
6148
|
-
"-v",
|
|
6149
|
-
`${PGDATA_PATH}:/data/pgdata`,
|
|
6150
|
-
"-v",
|
|
6151
|
-
`${MCP_JWT_PATH}:/data/.mcp-jwt:ro`,
|
|
6152
|
-
"-v",
|
|
6153
|
-
`${credsDir}:/home/synkro/.claude:rw`,
|
|
6154
|
-
"-e",
|
|
6155
|
-
`WORKERS_PER_POOL=${workers}`,
|
|
6156
|
-
image
|
|
6157
|
-
];
|
|
6158
|
-
const run = spawnSync3("docker", args2, { encoding: "utf-8", stdio: "inherit", timeout: 6e4 });
|
|
6159
|
-
if (run.status !== 0) {
|
|
6160
|
-
throw new DockerInstallError(`docker run failed (image ${image})`);
|
|
6161
|
-
}
|
|
6162
|
-
return { image, hostMcpPort: HOST_MCP_PORT, hostGraderPort: HOST_GRADER_PORT, hostCwePort: HOST_CWE_PORT };
|
|
6163
|
-
}
|
|
6164
|
-
async function waitForContainerReady(timeoutMs = 6e4) {
|
|
6165
|
-
const start = Date.now();
|
|
6166
|
-
const url = `http://127.0.0.1:${HOST_MCP_PORT}/health`;
|
|
6167
|
-
while (Date.now() - start < timeoutMs) {
|
|
6168
|
-
try {
|
|
6169
|
-
const r = await fetch(url, { signal: AbortSignal.timeout(2e3) });
|
|
6170
|
-
if (r.ok) return true;
|
|
6171
|
-
} catch {
|
|
6172
|
-
}
|
|
6173
|
-
await new Promise((r) => setTimeout(r, 1e3));
|
|
6174
|
-
}
|
|
6175
|
-
return false;
|
|
6176
|
-
}
|
|
6177
|
-
function dockerStop() {
|
|
6178
|
-
spawnSync3("docker", ["stop", CONTAINER_NAME], { encoding: "utf-8", timeout: 3e4 });
|
|
6179
|
-
spawnSync3("docker", ["rm", "-f", CONTAINER_NAME], { encoding: "utf-8", timeout: 3e4 });
|
|
6180
|
-
}
|
|
6181
|
-
function dockerStatus() {
|
|
6182
|
-
const r = spawnSync3("docker", ["inspect", "--format", "{{.State.Status}}", CONTAINER_NAME], {
|
|
6183
|
-
encoding: "utf-8",
|
|
6184
|
-
timeout: 5e3
|
|
6185
|
-
});
|
|
6186
|
-
const status = (r.stdout || "").trim();
|
|
6187
|
-
if (status !== "running") return { running: false };
|
|
6188
|
-
return {
|
|
6189
|
-
running: true,
|
|
6190
|
-
image: imageTag(),
|
|
6191
|
-
healthz: `http://127.0.0.1:${HOST_MCP_PORT}/`
|
|
6192
|
-
};
|
|
6193
|
-
}
|
|
6194
|
-
var SYNKRO_DIR3, MCP_JWT_PATH, PGDATA_PATH, HOST_MCP_PORT, HOST_GRADER_PORT, HOST_CWE_PORT, HOST_PG_PORT, CONTAINER_NAME, DEFAULT_IMAGE, DockerInstallError;
|
|
6195
|
-
var init_dockerInstall = __esm({
|
|
6196
|
-
"cli/local-cc/dockerInstall.ts"() {
|
|
6197
|
-
"use strict";
|
|
6198
|
-
init_macKeychain();
|
|
6199
|
-
SYNKRO_DIR3 = join9(homedir9(), ".synkro");
|
|
6200
|
-
MCP_JWT_PATH = join9(SYNKRO_DIR3, ".mcp-jwt");
|
|
6201
|
-
PGDATA_PATH = join9(SYNKRO_DIR3, "pgdata");
|
|
6202
|
-
HOST_MCP_PORT = parseInt(process.env.SYNKRO_HOST_MCP_PORT || "18931", 10);
|
|
6203
|
-
HOST_GRADER_PORT = parseInt(process.env.SYNKRO_HOST_GRADER_PORT || "18929", 10);
|
|
6204
|
-
HOST_CWE_PORT = parseInt(process.env.SYNKRO_HOST_CWE_PORT || "18930", 10);
|
|
6205
|
-
HOST_PG_PORT = parseInt(process.env.SYNKRO_HOST_PG_PORT || "15433", 10);
|
|
6206
|
-
CONTAINER_NAME = "synkro-server";
|
|
6207
|
-
DEFAULT_IMAGE = "ghcr.io/synkro-sh/server:latest";
|
|
6208
|
-
DockerInstallError = class extends Error {
|
|
6209
|
-
constructor(message, cause) {
|
|
6210
|
-
super(message);
|
|
6211
|
-
this.cause = cause;
|
|
6212
|
-
this.name = "DockerInstallError";
|
|
6213
|
-
}
|
|
6214
|
-
cause;
|
|
6215
|
-
};
|
|
6216
|
-
}
|
|
6217
|
-
});
|
|
6218
|
-
|
|
6219
|
-
// cli/local-cc/pueue.ts
|
|
6220
|
-
import { execFileSync, spawnSync as spawnSync4, spawn } from "child_process";
|
|
6221
|
-
import { homedir as homedir10 } from "os";
|
|
6222
|
-
import { join as join10 } from "path";
|
|
6223
|
-
import { connect } from "net";
|
|
6224
|
-
function pueueAvailable() {
|
|
6225
|
-
const r = spawnSync4("pueue", ["--version"], { encoding: "utf-8" });
|
|
6226
|
-
if (r.status !== 0) {
|
|
6227
|
-
throw new PueueError("pueue CLI not found on PATH. Install pueue (https://github.com/Nukesor/pueue) and start `pueued`.");
|
|
6228
|
-
}
|
|
6229
|
-
}
|
|
6230
|
-
function statusJson() {
|
|
6231
|
-
pueueAvailable();
|
|
6232
|
-
const r = spawnSync4("pueue", ["status", "--json"], { encoding: "utf-8" });
|
|
6233
|
-
if (r.status !== 0) {
|
|
6234
|
-
throw new PueueError(`pueue status failed: ${r.stderr || r.stdout || "unknown error"} \u2014 is pueued running?`);
|
|
6235
|
-
}
|
|
6236
|
-
try {
|
|
6237
|
-
return JSON.parse(r.stdout);
|
|
6238
|
-
} catch (err) {
|
|
6239
|
-
throw new PueueError(`pueue status returned non-JSON output: ${r.stdout.slice(0, 200)}`, err);
|
|
6240
|
-
}
|
|
6241
|
-
}
|
|
6242
|
-
function statusName(s) {
|
|
6243
|
-
if (typeof s === "string") return s;
|
|
6244
|
-
if (s && typeof s === "object") {
|
|
6245
|
-
if ("Running" in s) return "Running";
|
|
6246
|
-
if ("Done" in s) {
|
|
6247
|
-
const result = s.Done?.result;
|
|
6248
|
-
if (typeof result === "string") return `Done (${result})`;
|
|
6249
|
-
if (result && typeof result === "object") return `Done (${Object.keys(result)[0] ?? "unknown"})`;
|
|
6250
|
-
return "Done";
|
|
6251
|
-
}
|
|
6252
|
-
return Object.keys(s)[0] ?? "unknown";
|
|
6253
|
-
}
|
|
6254
|
-
return "unknown";
|
|
6255
|
-
}
|
|
6256
|
-
function findTask(channel = CHANNEL_PRIMARY) {
|
|
6257
|
-
const data = statusJson();
|
|
6258
|
-
for (const [id, t] of Object.entries(data.tasks)) {
|
|
6259
|
-
if (t.label === channel.taskLabel) {
|
|
6260
|
-
return {
|
|
6261
|
-
id: Number(id),
|
|
6262
|
-
label: t.label,
|
|
6263
|
-
status: statusName(t.status),
|
|
6264
|
-
command: t.command,
|
|
6265
|
-
cwd: t.path
|
|
6266
|
-
};
|
|
6267
|
-
}
|
|
6268
|
-
}
|
|
6269
|
-
return null;
|
|
6270
|
-
}
|
|
6271
|
-
function startTask(opts = {}) {
|
|
6272
|
-
const ch = opts.channel ?? CHANNEL_PRIMARY;
|
|
6273
|
-
const cwd = opts.cwd ?? ch.sessionDir;
|
|
6274
|
-
let existing = findTask(ch);
|
|
6275
|
-
while (existing) {
|
|
6276
|
-
if (existing.status === "Running" || existing.status === "Queued") {
|
|
6277
|
-
spawnSync4("tmux", ["kill-session", "-t", `=${ch.tmuxSession}`], { encoding: "utf-8" });
|
|
6278
|
-
spawnSync4("pueue", ["kill", String(existing.id)], { encoding: "utf-8" });
|
|
6279
|
-
for (let i = 0; i < 10; i++) {
|
|
6280
|
-
const check = findTask(ch);
|
|
6281
|
-
if (!check || check.id !== existing.id || check.status !== "Running" && check.status !== "Queued") break;
|
|
6282
|
-
spawnSync4("sleep", ["0.5"], { encoding: "utf-8" });
|
|
6283
|
-
}
|
|
6284
|
-
}
|
|
6285
|
-
spawnSync4("pueue", ["remove", String(existing.id)], { encoding: "utf-8" });
|
|
6286
|
-
existing = findTask(ch);
|
|
6287
|
-
}
|
|
6288
|
-
const runScript = join10(cwd, "run-claude.sh");
|
|
6289
|
-
const args2 = [
|
|
6290
|
-
"add",
|
|
6291
|
-
"--label",
|
|
6292
|
-
ch.taskLabel,
|
|
6293
|
-
"--working-directory",
|
|
6294
|
-
cwd,
|
|
6295
|
-
"--",
|
|
6296
|
-
"bash",
|
|
6297
|
-
runScript
|
|
6298
|
-
];
|
|
6299
|
-
const r = spawnSync4("pueue", args2, { encoding: "utf-8" });
|
|
6300
|
-
if (r.status !== 0) {
|
|
6301
|
-
throw new PueueError(`pueue add failed: ${r.stderr || r.stdout}`);
|
|
6302
|
-
}
|
|
6303
|
-
const created = findTask(ch);
|
|
6304
|
-
if (!created) {
|
|
6305
|
-
throw new PueueError(`pueue add succeeded but no task with label ${ch.taskLabel} found`);
|
|
6306
|
-
}
|
|
6307
|
-
return created;
|
|
6308
|
-
}
|
|
6309
|
-
function stopTask(channel = CHANNEL_PRIMARY) {
|
|
6310
|
-
spawnSync4("tmux", ["kill-session", "-t", `=${channel.tmuxSession}`], { encoding: "utf-8" });
|
|
6311
|
-
let t = findTask(channel);
|
|
6312
|
-
while (t) {
|
|
6313
|
-
if (t.status === "Running" || t.status === "Queued") {
|
|
6314
|
-
spawnSync4("pueue", ["kill", String(t.id)], { encoding: "utf-8" });
|
|
6315
|
-
for (let i = 0; i < 10; i++) {
|
|
6316
|
-
const check = findTask(channel);
|
|
6317
|
-
if (!check || check.id !== t.id || check.status !== "Running" && check.status !== "Queued") break;
|
|
6318
|
-
spawnSync4("sleep", ["0.5"], { encoding: "utf-8" });
|
|
6319
|
-
}
|
|
6320
|
-
}
|
|
6321
|
-
spawnSync4("pueue", ["remove", String(t.id)], { encoding: "utf-8" });
|
|
6322
|
-
t = findTask(channel);
|
|
6323
|
-
}
|
|
6324
|
-
}
|
|
6325
|
-
function tailLogs(lines = 80, channel = CHANNEL_PRIMARY) {
|
|
6326
|
-
const t = findTask(channel);
|
|
6327
|
-
if (!t) return `(no ${channel.taskLabel} task)`;
|
|
6328
|
-
const r = spawnSync4("pueue", ["log", "--lines", String(lines), String(t.id)], { encoding: "utf-8" });
|
|
6329
|
-
return r.stdout || r.stderr || "(no output)";
|
|
6330
|
-
}
|
|
6331
|
-
function probePort(host, port, timeoutMs = 500) {
|
|
6332
|
-
return new Promise((resolve3) => {
|
|
6333
|
-
const sock = connect(port, host);
|
|
6334
|
-
const done = (ok) => {
|
|
6335
|
-
try {
|
|
6336
|
-
sock.destroy();
|
|
6337
|
-
} catch {
|
|
6338
|
-
}
|
|
6339
|
-
resolve3(ok);
|
|
6340
|
-
};
|
|
6341
|
-
sock.once("connect", () => done(true));
|
|
6342
|
-
sock.once("error", () => done(false));
|
|
6343
|
-
sock.setTimeout(timeoutMs, () => done(false));
|
|
6344
|
-
});
|
|
6345
|
-
}
|
|
6346
|
-
function tmuxDismissPrompts(tmuxSession = TMUX_SESSION) {
|
|
6347
|
-
spawnSync4("tmux", ["send-keys", "-t", tmuxSession, "1"], { encoding: "utf-8" });
|
|
6348
|
-
spawnSync4("tmux", ["send-keys", "-t", tmuxSession, "Enter"], { encoding: "utf-8" });
|
|
6349
|
-
}
|
|
6350
|
-
async function waitForChannelReady(port, timeoutMs = 6e4, host = "127.0.0.1", tmuxSession = TMUX_SESSION) {
|
|
6351
|
-
const deadline = Date.now() + timeoutMs;
|
|
6352
|
-
while (Date.now() < deadline) {
|
|
6353
|
-
if (await probePort(host, port)) return true;
|
|
6354
|
-
tmuxDismissPrompts(tmuxSession);
|
|
6355
|
-
await new Promise((r) => setTimeout(r, 1e3));
|
|
6356
|
-
}
|
|
6357
|
-
return probePort(host, port);
|
|
6358
|
-
}
|
|
6359
|
-
function brewInstall(pkg) {
|
|
6360
|
-
const brew = spawnSync4("brew", ["--version"], { encoding: "utf-8" });
|
|
6361
|
-
if (brew.status !== 0) return false;
|
|
6362
|
-
console.log(` Installing ${pkg} via brew...`);
|
|
6363
|
-
const r = spawnSync4("brew", ["install", pkg], { encoding: "utf-8", stdio: "inherit", timeout: 12e4 });
|
|
6364
|
-
return r.status === 0;
|
|
6365
|
-
}
|
|
6366
|
-
function assertPueueInstalled() {
|
|
6367
|
-
let r = spawnSync4("pueue", ["--version"], { encoding: "utf-8" });
|
|
6368
|
-
if (r.status !== 0) {
|
|
6369
|
-
if (process.platform === "darwin" && brewInstall("pueue")) {
|
|
6370
|
-
r = spawnSync4("pueue", ["--version"], { encoding: "utf-8" });
|
|
6371
|
-
if (r.status !== 0) throw new PueueError("pueue install succeeded but binary not found on PATH.");
|
|
6372
|
-
} else {
|
|
6373
|
-
throw new PueueError("pueue not found. Install it: brew install pueue (macOS) or https://github.com/Nukesor/pueue");
|
|
6374
|
-
}
|
|
6375
|
-
}
|
|
6376
|
-
const status = spawnSync4("pueue", ["status", "--json"], { encoding: "utf-8", timeout: 5e3 });
|
|
6377
|
-
if (status.status !== 0) {
|
|
6378
|
-
console.log(" Starting pueued daemon...");
|
|
6379
|
-
const child = spawn("pueued", ["-d"], { stdio: "ignore", detached: true });
|
|
6380
|
-
child.unref();
|
|
6381
|
-
spawnSync4("sleep", ["1"]);
|
|
6382
|
-
const retry = spawnSync4("pueue", ["status", "--json"], { encoding: "utf-8", timeout: 5e3 });
|
|
6383
|
-
if (retry.status !== 0) {
|
|
6384
|
-
throw new PueueError("pueue daemon not reachable after starting pueued. Check `pueued` manually.");
|
|
6385
|
-
}
|
|
6386
|
-
}
|
|
6387
|
-
spawnSync4("pueue", ["parallel", "2"], { encoding: "utf-8" });
|
|
6388
|
-
}
|
|
6389
|
-
function assertClaudeInstalled() {
|
|
6390
|
-
const r = spawnSync4("claude", ["--version"], { encoding: "utf-8" });
|
|
6391
|
-
if (r.status !== 0) {
|
|
6392
|
-
throw new PueueError("claude CLI not found on PATH. Install Claude Code first: https://docs.claude.com/claude-code");
|
|
6393
|
-
}
|
|
6394
|
-
}
|
|
6395
|
-
function assertTmuxInstalled() {
|
|
6396
|
-
let r = spawnSync4("tmux", ["-V"], { encoding: "utf-8" });
|
|
6397
|
-
if (r.status !== 0) {
|
|
6398
|
-
if (process.platform === "darwin" && brewInstall("tmux")) {
|
|
6399
|
-
r = spawnSync4("tmux", ["-V"], { encoding: "utf-8" });
|
|
6400
|
-
if (r.status !== 0) throw new PueueError("tmux install succeeded but binary not found on PATH.");
|
|
6401
|
-
} else {
|
|
6402
|
-
throw new PueueError("tmux not found. Install it: brew install tmux (macOS) or apt install tmux (Linux)");
|
|
6403
|
-
}
|
|
6404
|
-
}
|
|
6405
|
-
}
|
|
6406
|
-
var TASK_LABEL, TMUX_SESSION, SESSION_DIR2, TASK_LABEL_2, TMUX_SESSION_2, SESSION_DIR_22, TASK_LABEL_3, TMUX_SESSION_3, SESSION_DIR_32, TASK_LABEL_4, TMUX_SESSION_4, SESSION_DIR_42, PueueError, CHANNEL_PRIMARY, CHANNEL_SECONDARY, CHANNEL_TERTIARY, CHANNEL_QUATERNARY;
|
|
6407
|
-
var init_pueue = __esm({
|
|
6408
|
-
"cli/local-cc/pueue.ts"() {
|
|
6409
|
-
"use strict";
|
|
6410
|
-
TASK_LABEL = "synkro-local-cc";
|
|
6411
|
-
TMUX_SESSION = "synkro-local-cc";
|
|
6412
|
-
SESSION_DIR2 = join10(homedir10(), ".synkro", "cc_sessions");
|
|
6413
|
-
TASK_LABEL_2 = "synkro-local-cc-2";
|
|
6414
|
-
TMUX_SESSION_2 = "synkro-local-cc-2";
|
|
6415
|
-
SESSION_DIR_22 = join10(homedir10(), ".synkro", "cc_sessions_2");
|
|
6416
|
-
TASK_LABEL_3 = "synkro-local-cc-3";
|
|
6417
|
-
TMUX_SESSION_3 = "synkro-local-cc-3";
|
|
6418
|
-
SESSION_DIR_32 = join10(homedir10(), ".synkro", "cc_sessions_3");
|
|
6419
|
-
TASK_LABEL_4 = "synkro-local-cc-4";
|
|
6420
|
-
TMUX_SESSION_4 = "synkro-local-cc-4";
|
|
6421
|
-
SESSION_DIR_42 = join10(homedir10(), ".synkro", "cc_sessions_4");
|
|
6422
|
-
PueueError = class extends Error {
|
|
6423
|
-
constructor(message, cause) {
|
|
6424
|
-
super(message);
|
|
6425
|
-
this.cause = cause;
|
|
6426
|
-
this.name = "PueueError";
|
|
6427
|
-
}
|
|
6428
|
-
cause;
|
|
6429
|
-
};
|
|
6430
|
-
CHANNEL_PRIMARY = { taskLabel: TASK_LABEL, tmuxSession: TMUX_SESSION, sessionDir: SESSION_DIR2 };
|
|
6431
|
-
CHANNEL_SECONDARY = { taskLabel: TASK_LABEL_2, tmuxSession: TMUX_SESSION_2, sessionDir: SESSION_DIR_22 };
|
|
6432
|
-
CHANNEL_TERTIARY = { taskLabel: TASK_LABEL_3, tmuxSession: TMUX_SESSION_3, sessionDir: SESSION_DIR_32 };
|
|
6433
|
-
CHANNEL_QUATERNARY = { taskLabel: TASK_LABEL_4, tmuxSession: TMUX_SESSION_4, sessionDir: SESSION_DIR_42 };
|
|
6434
|
-
}
|
|
6435
|
-
});
|
|
6436
|
-
|
|
6437
|
-
// cli/local-cc/turnLog.ts
|
|
6438
|
-
import { appendFileSync, existsSync as existsSync11, mkdirSync as mkdirSync9, openSync as openSync2, readFileSync as readFileSync9, readSync, closeSync as closeSync2, statSync as statSync2, watchFile, unwatchFile } from "fs";
|
|
6439
|
-
import { dirname as dirname5, join as join11 } from "path";
|
|
6440
|
-
import { homedir as homedir11 } from "os";
|
|
6441
|
-
function truncate(s, max = PREVIEW_MAX) {
|
|
6442
|
-
if (s.length <= max) return s;
|
|
6443
|
-
return s.slice(0, max) + "\u2026 [+" + (s.length - max) + " chars]";
|
|
6444
|
-
}
|
|
6445
|
-
function extractSeverity(result) {
|
|
6446
|
-
const m = result.match(/<synkro-(?:verdict|intent)>([\s\S]*?)<\/synkro-(?:verdict|intent)>/);
|
|
6447
|
-
if (!m) return void 0;
|
|
6448
|
-
try {
|
|
6449
|
-
const obj = JSON.parse(m[1]);
|
|
6450
|
-
if (obj.severity) return String(obj.severity);
|
|
6451
|
-
if (typeof obj.ok === "boolean") return obj.ok ? "ok" : "violations";
|
|
6452
|
-
if (obj.type) return String(obj.type);
|
|
6453
|
-
if (obj.verdict) return String(obj.verdict);
|
|
6454
|
-
} catch {
|
|
6455
|
-
}
|
|
6456
|
-
return void 0;
|
|
6457
|
-
}
|
|
6458
|
-
function appendTurn(args2) {
|
|
6459
|
-
try {
|
|
6460
|
-
mkdirSync9(dirname5(TURN_LOG_PATH), { recursive: true });
|
|
6461
|
-
const entry = {
|
|
6462
|
-
ts: new Date(args2.startedAt).toISOString(),
|
|
6463
|
-
role: args2.role,
|
|
6464
|
-
duration_ms: Date.now() - args2.startedAt,
|
|
6465
|
-
status: args2.status,
|
|
6466
|
-
request_preview: truncate(args2.request),
|
|
6467
|
-
response_preview: args2.result ? truncate(args2.result) : "",
|
|
6468
|
-
severity: args2.result ? extractSeverity(args2.result) : void 0,
|
|
6469
|
-
error: args2.error
|
|
6470
|
-
};
|
|
6471
|
-
appendFileSync(TURN_LOG_PATH, JSON.stringify(entry) + "\n", "utf-8");
|
|
6472
|
-
} catch {
|
|
6473
|
-
}
|
|
6474
|
-
}
|
|
6475
|
-
var TURN_LOG_PATH, PREVIEW_MAX;
|
|
6476
|
-
var init_turnLog = __esm({
|
|
6477
|
-
"cli/local-cc/turnLog.ts"() {
|
|
6478
|
-
"use strict";
|
|
6479
|
-
TURN_LOG_PATH = join11(homedir11(), ".synkro", "cc_sessions", "turns.log");
|
|
6480
|
-
PREVIEW_MAX = 400;
|
|
6481
|
-
}
|
|
6482
|
-
});
|
|
6483
|
-
|
|
6484
|
-
// cli/local-cc/client.ts
|
|
6485
|
-
import { request as httpRequest } from "http";
|
|
6486
|
-
import { connect as connect2 } from "net";
|
|
6487
|
-
async function submitToChannel(role, payload, opts = {}) {
|
|
6488
|
-
const body = JSON.stringify({ role, payload, content: payload });
|
|
6489
|
-
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
6490
|
-
const port = opts.port ?? CHANNEL_PORT;
|
|
6491
|
-
const startedAt = Date.now();
|
|
6492
|
-
try {
|
|
6493
|
-
const result = await new Promise((resolve3, reject) => {
|
|
6494
|
-
const req = httpRequest({
|
|
6495
|
-
host: CHANNEL_HOST,
|
|
6496
|
-
port,
|
|
6497
|
-
method: "POST",
|
|
6498
|
-
path: "/submit",
|
|
6499
|
-
headers: {
|
|
6500
|
-
"Content-Type": "application/json",
|
|
6501
|
-
"Content-Length": Buffer.byteLength(body)
|
|
6502
|
-
},
|
|
6503
|
-
timeout: timeoutMs
|
|
6504
|
-
}, (res) => {
|
|
6505
|
-
const chunks = [];
|
|
6506
|
-
res.on("data", (c) => chunks.push(c));
|
|
6507
|
-
res.on("end", () => {
|
|
6508
|
-
const text = Buffer.concat(chunks).toString("utf-8");
|
|
6509
|
-
if (res.statusCode !== 200) {
|
|
6510
|
-
reject(new LocalCCError(`channel returned ${res.statusCode}: ${text.slice(0, 500)}`));
|
|
6511
|
-
return;
|
|
6512
|
-
}
|
|
6513
|
-
try {
|
|
6514
|
-
const parsed = JSON.parse(text);
|
|
6515
|
-
if (parsed.error) {
|
|
6516
|
-
reject(new LocalCCError(parsed.error));
|
|
6517
|
-
return;
|
|
6518
|
-
}
|
|
6519
|
-
resolve3(String(parsed.result ?? ""));
|
|
6520
|
-
} catch (err) {
|
|
6521
|
-
reject(new LocalCCError(`malformed channel response: ${text.slice(0, 200)}`, err));
|
|
6522
|
-
}
|
|
6523
|
-
});
|
|
6524
|
-
});
|
|
6525
|
-
req.on("timeout", () => {
|
|
6526
|
-
req.destroy(new LocalCCError(`channel request timed out after ${timeoutMs}ms`));
|
|
6527
|
-
});
|
|
6528
|
-
req.on("error", (err) => {
|
|
6529
|
-
const msg = err.code === "ECONNREFUSED" ? `channel connection refused at ${CHANNEL_HOST}:${CHANNEL_PORT} (is the pueue task running?)` : `channel request failed: ${err.message}`;
|
|
6530
|
-
reject(new LocalCCError(msg, err));
|
|
6531
|
-
});
|
|
6532
|
-
req.write(body);
|
|
6533
|
-
req.end();
|
|
6534
|
-
});
|
|
6535
|
-
appendTurn({ startedAt, role, request: payload, result, status: "ok" });
|
|
6536
|
-
return result;
|
|
6537
|
-
} catch (err) {
|
|
6538
|
-
const message = err.message ?? String(err);
|
|
6539
|
-
const status = /timed out/i.test(message) ? "timeout" : "error";
|
|
6540
|
-
appendTurn({ startedAt, role, request: payload, status, error: message });
|
|
6541
|
-
throw err;
|
|
6542
|
-
}
|
|
6543
|
-
}
|
|
6544
|
-
var CHANNEL_HOST, CHANNEL_PORT, DEFAULT_TIMEOUT_MS, LocalCCError;
|
|
6545
|
-
var init_client = __esm({
|
|
6546
|
-
"cli/local-cc/client.ts"() {
|
|
6547
|
-
"use strict";
|
|
6548
|
-
init_turnLog();
|
|
6549
|
-
CHANNEL_HOST = "127.0.0.1";
|
|
6550
|
-
CHANNEL_PORT = parseInt(process.env.SYNKRO_CHANNEL_PORT || "8929", 10);
|
|
6551
|
-
DEFAULT_TIMEOUT_MS = 9e4;
|
|
6552
|
-
LocalCCError = class extends Error {
|
|
6553
|
-
constructor(message, cause) {
|
|
6554
|
-
super(message);
|
|
6555
|
-
this.cause = cause;
|
|
6556
|
-
this.name = "LocalCCError";
|
|
6557
|
-
}
|
|
6558
|
-
cause;
|
|
6559
|
-
};
|
|
6560
|
-
}
|
|
6561
|
-
});
|
|
6562
|
-
|
|
6563
|
-
// cli/commands/install.ts
|
|
6564
|
-
var install_exports = {};
|
|
6565
|
-
__export(install_exports, {
|
|
6566
|
-
installCommand: () => installCommand,
|
|
6567
|
-
parseArgs: () => parseArgs
|
|
6568
|
-
});
|
|
6569
|
-
import { existsSync as existsSync12, mkdirSync as mkdirSync10, writeFileSync as writeFileSync8, chmodSync as chmodSync3, readFileSync as readFileSync10, readdirSync, renameSync as renameSync5 } from "fs";
|
|
6570
|
-
import { homedir as homedir12 } from "os";
|
|
6571
|
-
import { join as join12 } from "path";
|
|
6572
|
-
import { execSync as execSync5, spawnSync as spawnSync5, spawn as spawn2 } from "child_process";
|
|
6573
|
-
import { createInterface as createInterface3 } from "readline";
|
|
6574
|
-
function sanitizeGatewayCandidate(raw) {
|
|
6575
|
-
if (!raw) return void 0;
|
|
6576
|
-
return /^https?:\/\//.test(raw) ? raw : void 0;
|
|
6577
|
-
}
|
|
6578
|
-
function parseArgs(argv) {
|
|
6579
|
-
const opts = {};
|
|
6580
|
-
for (const a of argv) {
|
|
6581
|
-
if (a.startsWith("--api-key=")) opts.apiKey = a.slice("--api-key=".length);
|
|
6582
|
-
else if (a.startsWith("--gateway=")) opts.gatewayUrl = a.slice("--gateway=".length);
|
|
6583
|
-
else if (a === "--skip-auth") opts.skipAuth = true;
|
|
6584
|
-
else if (a === "--no-mcp") opts.noMcp = true;
|
|
6585
|
-
else if (a === "--force" || a === "-f") opts.force = true;
|
|
6586
|
-
else if (a === "--link-repo") opts.linkRepo = true;
|
|
6587
|
-
}
|
|
6588
|
-
if (!opts.gatewayUrl) {
|
|
6589
|
-
const fromEnv = sanitizeGatewayCandidate(process.env.SYNKRO_GATEWAY_URL);
|
|
6590
|
-
if (fromEnv) opts.gatewayUrl = fromEnv;
|
|
6591
|
-
}
|
|
6592
|
-
return opts;
|
|
6593
|
-
}
|
|
6594
|
-
async function promptTranscriptConsent() {
|
|
6595
|
-
const rl = createInterface3({ input: process.stdin, output: process.stdout });
|
|
6596
|
-
return new Promise((resolve3) => {
|
|
6597
|
-
rl.question(
|
|
6598
|
-
"Would you like Synkro to use Claude Code session transcripts\nto generate guardrail rules and policies for your team? (Y/n) ",
|
|
6599
|
-
(answer) => {
|
|
6600
|
-
rl.close();
|
|
6601
|
-
const trimmed = answer.trim().toLowerCase();
|
|
6602
|
-
resolve3(trimmed === "" || trimmed === "y" || trimmed === "yes");
|
|
6603
|
-
}
|
|
6604
|
-
);
|
|
6605
|
-
});
|
|
6606
|
-
}
|
|
6607
|
-
function ensureSynkroDir() {
|
|
6608
|
-
mkdirSync10(SYNKRO_DIR4, { recursive: true });
|
|
6609
|
-
mkdirSync10(HOOKS_DIR, { recursive: true });
|
|
6610
|
-
mkdirSync10(BIN_DIR, { recursive: true });
|
|
6611
|
-
mkdirSync10(OFFSETS_DIR, { recursive: true });
|
|
6612
|
-
mkdirSync10(join12(SYNKRO_DIR4, "sessions"), { recursive: true });
|
|
6613
|
-
}
|
|
6614
|
-
function writeHookScripts() {
|
|
6615
|
-
const bashScriptPath = join12(HOOKS_DIR, "cc-bash-judge.ts");
|
|
6616
|
-
const bashFollowupScriptPath = join12(HOOKS_DIR, "cc-bash-followup.ts");
|
|
6617
|
-
const editPrecheckScriptPath = join12(HOOKS_DIR, "cc-edit-precheck.ts");
|
|
6618
|
-
const cwePrecheckScriptPath = join12(HOOKS_DIR, "cc-cwe-precheck.ts");
|
|
6619
|
-
const cvePrecheckScriptPath = join12(HOOKS_DIR, "cc-cve-precheck.ts");
|
|
6620
|
-
const planJudgeScriptPath = join12(HOOKS_DIR, "cc-plan-judge.ts");
|
|
6621
|
-
const agentJudgeScriptPath = join12(HOOKS_DIR, "cc-agent-judge.ts");
|
|
6622
|
-
const stopSummaryScriptPath = join12(HOOKS_DIR, "cc-stop-summary.ts");
|
|
6623
|
-
const sessionStartScriptPath = join12(HOOKS_DIR, "cc-session-start.ts");
|
|
6624
|
-
const transcriptSyncScriptPath = join12(HOOKS_DIR, "cc-transcript-sync.ts");
|
|
6625
|
-
const userPromptSubmitScriptPath = join12(HOOKS_DIR, "cc-user-prompt-submit.ts");
|
|
6626
|
-
const commonScriptPath = join12(HOOKS_DIR, "_synkro-common.ts");
|
|
6627
|
-
const commonBashScriptPath = join12(HOOKS_DIR, "_synkro-common.sh");
|
|
6628
|
-
const cursorBashJudgePath = join12(HOOKS_DIR, "cursor-bash-judge.ts");
|
|
6629
|
-
const cursorEditCapturePath = join12(HOOKS_DIR, "cursor-edit-capture.ts");
|
|
6630
|
-
const mcpLocalServerPath = join12(HOOKS_DIR, "mcp-local-server.ts");
|
|
6631
|
-
const pgliteDbPath = join12(HOOKS_DIR, "pglite-db.ts");
|
|
6632
|
-
const mcpStdioProxyPath = join12(HOOKS_DIR, "mcp-stdio-proxy.ts");
|
|
6633
|
-
writeFileSync8(bashScriptPath, BASH_JUDGE_TS, "utf-8");
|
|
6634
|
-
writeFileSync8(bashFollowupScriptPath, BASH_FOLLOWUP_TS, "utf-8");
|
|
6635
|
-
writeFileSync8(editPrecheckScriptPath, EDIT_PRECHECK_TS, "utf-8");
|
|
6636
|
-
writeFileSync8(cwePrecheckScriptPath, CWE_PRECHECK_TS, "utf-8");
|
|
6637
|
-
writeFileSync8(cvePrecheckScriptPath, CVE_PRECHECK_TS, "utf-8");
|
|
6638
|
-
writeFileSync8(planJudgeScriptPath, PLAN_JUDGE_TS, "utf-8");
|
|
6639
|
-
writeFileSync8(agentJudgeScriptPath, AGENT_JUDGE_TS, "utf-8");
|
|
6640
|
-
writeFileSync8(stopSummaryScriptPath, STOP_SUMMARY_TS, "utf-8");
|
|
6641
|
-
writeFileSync8(sessionStartScriptPath, SESSION_START_TS, "utf-8");
|
|
6642
|
-
writeFileSync8(transcriptSyncScriptPath, TRANSCRIPT_SYNC_TS, "utf-8");
|
|
6643
|
-
writeFileSync8(userPromptSubmitScriptPath, USER_PROMPT_SUBMIT_TS, "utf-8");
|
|
6644
|
-
writeFileSync8(commonScriptPath, SYNKRO_COMMON_TS, "utf-8");
|
|
6645
|
-
writeFileSync8(commonBashScriptPath, SYNKRO_COMMON_SCRIPT, "utf-8");
|
|
6646
|
-
writeFileSync8(cursorBashJudgePath, CURSOR_BASH_JUDGE_TS, "utf-8");
|
|
6647
|
-
writeFileSync8(cursorEditCapturePath, CURSOR_EDIT_CAPTURE_TS, "utf-8");
|
|
6648
|
-
writeFileSync8(mcpLocalServerPath, "#!/usr/bin/env bun\n/**\n * Local MCP guardrails server \u2014 runs on port 8931, stores rules in ~/.synkro/rules.json.\n * PGLite embedded database at ~/.synkro/pgdata.\n * JSON-RPC 2.0 + REST over HTTP, Bearer token auth, localhost only.\n */\nimport { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync, unlinkSync } from 'node:fs';\nimport { homedir } from 'node:os';\nimport { join } from 'node:path';\nimport pg from 'pg';\nimport { pipeline, type FeatureExtractionPipeline } from '@huggingface/transformers';\n\nconst PORT = parseInt(process.env.SYNKRO_MCP_PORT || '8931', 10);\nconst PG_SOCKET_PORT = parseInt(process.env.SYNKRO_PG_PORT || '5433', 10);\nconst BIND_HOST = process.env.SYNKRO_BIND_HOST || '127.0.0.1';\nconst HOME = homedir();\nconst RULES_PATH = join(HOME, '.synkro', 'rules.json');\nconst PGDATA_PATH = join(HOME, '.synkro', 'pgdata');\nconst JWT_TOKEN_PATH = join(HOME, '.synkro', '.mcp-jwt');\n\n// Synkro-signed long-lived JWT \u2014 minted during `synkro install`, required on all POST requests.\n// If missing, the server still starts (for GET health checks) but rejects all tool calls.\nlet SERVER_TOKEN = '';\ntry { SERVER_TOKEN = readFileSync(JWT_TOKEN_PATH, 'utf-8').trim(); } catch {}\nif (!SERVER_TOKEN) console.warn('[synkro] \u26A0 No MCP JWT found \u2014 run `synkro install` to authenticate.');\nconst MAX_BODY_BYTES = 1_048_576;\n\n// \u2500\u2500\u2500 OWASP Top 10 (2021) categories for violation classification \u2500\u2500\u2500\nconst OWASP_CATEGORIES: { id: string; label: string; description: string }[] = [\n { id: 'ASI01', label: 'Memory Poisoning', description: 'Malicious or corrupted data injected into agent memory, conversation history, vector stores, or retrieval contexts to alter future behavior; poisoned RAG sources, contaminated long-term memory, tampered embeddings used in retrieval' },\n { id: 'ASI02', label: 'Tool Misuse', description: 'Agent invokes tools, MCP servers, function calls, or APIs in unsafe ways; unsanitized arguments passed to shell, SQL, filesystem, or network tools; command injection via tool inputs; agents calling destructive tools without confirmation' },\n { id: 'ASI03', label: 'Privilege Compromise', description: 'Agent escalates or abuses permissions; running with excessive scopes, accessing resources outside its mandate, bypassing access control, using stolen credentials, exploiting overly broad IAM roles or API keys' },\n { id: 'ASI04', label: 'Resource Overload', description: 'Unbounded LLM token consumption, runaway tool loops, recursive agent calls, denial-of-wallet attacks, unrestricted context growth, missing rate limits on agent operations, cost-amplification via repeated inference' },\n { id: 'ASI05', label: 'Cascading Hallucination Attacks', description: 'Hallucinated outputs propagate downstream through multi-agent chains, tool calls, or generated code; fabricated package names, made-up API endpoints, invented function signatures, hallucinated dependencies causing supply-chain risks' },\n { id: 'ASI06', label: 'Intent Breaking and Goal Manipulation', description: 'Prompt injection, jailbreaks, indirect prompt injection via retrieved content, system prompt override, goal hijacking, instructions embedded in user data or tool output that redirect the agent away from its intended task' },\n { id: 'ASI07', label: 'Misaligned and Deceptive Behaviors', description: 'Agent provides misleading or deceptive responses, hides errors, fabricates success, refuses to follow legitimate instructions, exhibits reward hacking, deceives user about tool call outcomes or its own capabilities' },\n { id: 'ASI08', label: 'Repudiation and Untraceability', description: 'Insufficient audit logging of agent decisions, tool calls, or external actions; missing provenance, no replay capability, deleted or tampered logs, inability to attribute actions to specific agents, sessions, or users' },\n { id: 'ASI09', label: 'Identity Spoofing and Impersonation', description: 'Agent impersonates users, other agents, or trusted systems; missing authentication on agent-to-agent communication, weak identity binding, session hijacking, credential forwarding without verification' },\n { id: 'ASI10', label: 'Overwhelming Human-in-the-Loop', description: 'Approval fatigue from excessive HITL prompts, alarm flooding, low-signal confirmations that train users to rubber-stamp dangerous actions, missing escalation differentiation, no consolidation of low-risk approvals' },\n];\n\nlet embedder: FeatureExtractionPipeline | null = null;\nlet categoryEmbeddings: { id: string; label: string; vec: Float32Array }[] = [];\n\nasync function initEmbedder(): Promise<void> {\n try {\n embedder = await pipeline('feature-extraction', 'Xenova/gte-small', { dtype: 'fp32' });\n const texts = OWASP_CATEGORIES.map(c => `${c.label}: ${c.description}`);\n const results = await embedder(texts, { pooling: 'mean', normalize: true });\n categoryEmbeddings = OWASP_CATEGORIES.map((c, i) => ({\n id: c.id,\n label: c.label,\n vec: new Float32Array(results[i].data),\n }));\n console.log('[synkro] Embedding model loaded (gte-small), OWASP categories indexed');\n } catch (e) {\n console.error('[synkro] Embedding model failed to load:', String(e).slice(0, 200));\n }\n}\n\nfunction cosineSim(a: Float32Array, b: Float32Array): number {\n let dot = 0, na = 0, nb = 0;\n for (let i = 0; i < a.length; i++) {\n dot += a[i] * b[i];\n na += a[i] * a[i];\n nb += b[i] * b[i];\n }\n return dot / (Math.sqrt(na) * Math.sqrt(nb));\n}\n\nasync function classifyText(text: string): Promise<{ id: string; label: string; confidence: number } | null> {\n if (!embedder || categoryEmbeddings.length === 0) return null;\n const result = await embedder(text, { pooling: 'mean', normalize: true });\n const vec = new Float32Array(result.data);\n let best = { id: '', label: '', confidence: 0 };\n for (const cat of categoryEmbeddings) {\n const sim = cosineSim(vec, cat.vec);\n if (sim > best.confidence) best = { id: cat.id, label: cat.label, confidence: sim };\n }\n return best.confidence > 0.3 ? best : null;\n}\n\nasync function classifyViolationInline(violationId: string, text: string): Promise<void> {\n const cat = await classifyText(text.slice(0, 1000));\n if (cat) {\n await db.query(\n `UPDATE guard_violations SET mechanism_category = $1, classification_confidence = $2 WHERE id = $3`,\n [cat.id + ': ' + cat.label, cat.confidence, violationId]\n );\n }\n}\n\nlet _writeLock: Promise<void> = Promise.resolve();\nfunction serialized<T>(fn: () => T | Promise<T>): Promise<T> {\n let release: () => void;\n const next = new Promise<void>(r => { release = r; });\n const prev = _writeLock;\n _writeLock = next;\n return prev.then(() => fn()).finally(() => release!());\n}\n\n// \u2500\u2500\u2500 PGLite Database \u2500\u2500\u2500\n\nlet db: pg.Pool;\n\nconst SCHEMA_MIGRATIONS = [\n `CREATE EXTENSION IF NOT EXISTS vector`,\n `CREATE TABLE IF NOT EXISTS guard_checks (\n id TEXT PRIMARY KEY,\n project_id TEXT,\n org_id TEXT,\n policy_id TEXT,\n trace_id TEXT,\n passed SMALLINT,\n score REAL,\n rule_count INTEGER,\n rule_ids_checked TEXT[],\n model TEXT,\n interaction_type TEXT,\n skill_name TEXT,\n tool_names TEXT[],\n guard_mode TEXT,\n messages TEXT,\n verdicts TEXT,\n rule_similarities TEXT,\n sentiment_score REAL,\n sentiment_label TEXT,\n operation_type TEXT,\n conversation_id TEXT,\n trajectory_id UUID,\n end_user_id TEXT DEFAULT '',\n reasoning_content TEXT,\n cve_findings TEXT,\n judge_context TEXT,\n provider TEXT,\n input_tokens INTEGER,\n output_tokens INTEGER,\n total_tokens INTEGER,\n cost_usd REAL,\n content_redacted SMALLINT DEFAULT 0,\n updated_at TIMESTAMPTZ,\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n )`,\n `CREATE TABLE IF NOT EXISTS guard_violations (\n id TEXT PRIMARY KEY,\n user_id TEXT,\n org_id TEXT,\n policy_id TEXT,\n end_user_id TEXT,\n project_id TEXT,\n run_id TEXT,\n trajectory_id UUID,\n key TEXT,\n score REAL,\n value TEXT,\n comment TEXT,\n rules_violated TEXT[],\n rule_ids_violated TEXT[],\n issues TEXT[],\n severity TEXT,\n latency_ms INTEGER,\n messages TEXT,\n verdicts TEXT,\n model TEXT,\n guard_mode TEXT,\n interaction_type TEXT,\n tool_names TEXT[],\n skill_name TEXT,\n passed SMALLINT,\n conversation_id TEXT,\n mechanism_category TEXT,\n business_category TEXT,\n classification_confidence REAL,\n content_redacted SMALLINT DEFAULT 0,\n updated_at TIMESTAMPTZ,\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n )`,\n `CREATE TABLE IF NOT EXISTS trajectories (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n project_id TEXT NOT NULL,\n org_id TEXT NOT NULL DEFAULT '',\n conversation_id TEXT NOT NULL,\n check_ids TEXT[] NOT NULL DEFAULT '{}',\n check_count INTEGER NOT NULL DEFAULT 0,\n preamble TEXT,\n user_message TEXT,\n final_response TEXT,\n status TEXT NOT NULL DEFAULT 'pending_grade',\n passed SMALLINT,\n score REAL,\n verdicts TEXT,\n rule_ids_checked TEXT[],\n rule_ids_violated TEXT[],\n severity TEXT,\n model TEXT,\n provider TEXT,\n input_tokens INTEGER,\n output_tokens INTEGER,\n total_tokens INTEGER,\n cost_usd REAL,\n interaction_type TEXT,\n operation_type TEXT,\n end_user_id TEXT,\n policy_id TEXT,\n topic_label TEXT,\n started_at TIMESTAMPTZ,\n completed_at TIMESTAMPTZ,\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n )`,\n `CREATE TABLE IF NOT EXISTS usage_ticks (\n id TEXT PRIMARY KEY,\n session_id TEXT NOT NULL UNIQUE,\n model TEXT,\n input_tokens INTEGER NOT NULL DEFAULT 0,\n output_tokens INTEGER NOT NULL DEFAULT 0,\n cache_creation_tokens INTEGER DEFAULT 0,\n cache_read_tokens INTEGER DEFAULT 0,\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n )`,\n `CREATE TABLE IF NOT EXISTS scan_findings (\n id TEXT PRIMARY KEY,\n session_id TEXT NOT NULL,\n file_path TEXT NOT NULL,\n finding_type TEXT NOT NULL,\n finding_id TEXT NOT NULL,\n severity TEXT,\n status TEXT NOT NULL DEFAULT 'open',\n detail TEXT,\n description TEXT,\n package_name TEXT,\n package_version TEXT,\n fixed_version TEXT,\n aliases TEXT,\n \"references\" TEXT,\n cwe_name TEXT,\n repo TEXT,\n resolved_at TIMESTAMPTZ,\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n )`,\n `ALTER TABLE scan_findings ADD COLUMN IF NOT EXISTS repo TEXT`,\n `CREATE TABLE IF NOT EXISTS policies (\n id TEXT PRIMARY KEY,\n project_id UUID,\n org_id TEXT,\n name TEXT,\n rules JSONB NOT NULL DEFAULT '[]',\n rule_count INTEGER NOT NULL DEFAULT 0,\n scope TEXT DEFAULT 'agent_runtime',\n scope_owner TEXT NOT NULL DEFAULT 'org',\n is_active BOOLEAN NOT NULL DEFAULT TRUE,\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n )`,\n `CREATE TABLE IF NOT EXISTS precheck_corrections (\n id TEXT PRIMARY KEY,\n org_id TEXT NOT NULL,\n user_id TEXT,\n session_id TEXT,\n tool_use_id TEXT,\n surface_kind TEXT NOT NULL DEFAULT 'edit',\n file_path TEXT NOT NULL,\n file_after TEXT,\n user_intent TEXT,\n rule_id TEXT,\n rule_text TEXT,\n severity TEXT,\n category TEXT,\n reasoning TEXT,\n confidence REAL,\n decision TEXT NOT NULL,\n user_note TEXT,\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n resolved_at TIMESTAMPTZ\n )`,\n `CREATE TABLE IF NOT EXISTS guard_context (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n session_id TEXT NOT NULL UNIQUE,\n project_id UUID,\n org_id TEXT,\n summary TEXT NOT NULL DEFAULT '',\n compliance_summary TEXT,\n check_counter INTEGER NOT NULL DEFAULT 0,\n violated_rule_ids TEXT[] DEFAULT '{}',\n updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n )`,\n `CREATE TABLE IF NOT EXISTS user_profiles (\n id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n org_id TEXT NOT NULL,\n end_user_id TEXT NOT NULL,\n email TEXT,\n full_name TEXT,\n display_name TEXT,\n platform TEXT,\n active_repo TEXT,\n silent_mode BOOLEAN NOT NULL DEFAULT FALSE,\n last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n )`,\n `ALTER TABLE trajectories ADD COLUMN IF NOT EXISTS operation_type TEXT`,\n `CREATE UNIQUE INDEX IF NOT EXISTS usage_ticks_session_id_key ON usage_ticks (session_id)`,\n `CREATE TABLE IF NOT EXISTS synkro_local_config (\n key TEXT PRIMARY KEY,\n value TEXT,\n updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n )`,\n `CREATE TABLE IF NOT EXISTS scan_exemptions (\n id SERIAL PRIMARY KEY,\n path TEXT NOT NULL,\n cwe_id TEXT NOT NULL,\n reason TEXT,\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n UNIQUE (path, cwe_id)\n )`,\n `ALTER TABLE policies ADD COLUMN IF NOT EXISTS active_in_session BOOLEAN NOT NULL DEFAULT TRUE`,\n];\n\nasync function initDb(): Promise<void> {\n mkdirSync(join(HOME, '.synkro'), { recursive: true });\n\n db = new pg.Pool({\n host: process.env.SYNKRO_PG_HOST || '127.0.0.1',\n port: PG_SOCKET_PORT,\n user: 'postgres',\n database: 'postgres',\n ssl: false,\n // pglite-socket is effectively single-connection (the docs warn that\n // concurrent clients \"are not guaranteed to work\"). Keep our connection\n // warm for 30s so request bursts don't have to reconnect every time, but\n // still release eventually so external clients (psql, debug tools) can\n // grab the socket during quiet windows.\n max: 1,\n idleTimeoutMillis: 30000,\n });\n db.on('error', (err) => {\n // Pool emits 'error' when an idle connection dies; pg auto-replaces it on\n // the next query, so just log and keep going.\n console.error('[synkro] pg pool error (auto-recovering):', String(err).slice(0, 200));\n });\n\n // Wait for the standalone pglite-db daemon to accept connections.\n let lastErr: unknown = null;\n for (let i = 0; i < 60; i++) {\n try {\n await db.query('SELECT 1');\n lastErr = null;\n break;\n } catch (err) {\n lastErr = err;\n await new Promise(r => setTimeout(r, 500));\n }\n }\n if (lastErr) throw new Error('pglite-db daemon unreachable on port ' + PG_SOCKET_PORT + ': ' + String(lastErr).slice(0, 200));\n\n for (const migration of SCHEMA_MIGRATIONS) {\n try { await db.query(migration); } catch (e) {\n console.error('[synkro] migration error:', String(e).slice(0, 200));\n }\n }\n\n console.log('[synkro] PGLite database ready at ' + PGDATA_PATH);\n await migrateRulesJsonIfPresent();\n}\n\n// \u2500\u2500\u2500 Ingest Functions \u2500\u2500\u2500\n\nasync function ingestEvent(event: any): Promise<void> {\n switch (event.capture_type) {\n case 'local_verdict': await ingestVerdict(event); break;\n case 'usage_tick': await ingestUsageTick(event); break;\n case 'scan_finding': await ingestScanFinding(event); break;\n case 'rule_sync': await ingestRuleSync(event); break;\n }\n}\n\nasync function ingestVerdict(event: any): Promise<void> {\n const id = event.event_id || `evt_${Date.now()}_${Math.random().toString(36).slice(2)}`;\n const passed = event.verdict === 'pass' || event.verdict === 'allow' ? 1 : 0;\n const sessionId = event.session_id || '';\n const operationType = event.tool_name\n ? `${event.hook_type || 'tool'}:${event.tool_name}`\n : event.hook_type || null;\n const messages = event.command\n ? JSON.stringify([{ role: 'assistant', content: `[${event.tool_name || event.hook_type}] ${event.command}` }])\n : event.recent_user_messages?.length\n ? JSON.stringify([{ role: 'user', content: event.recent_user_messages[0] }])\n : null;\n const verdicts = event.reasoning ? JSON.stringify({ reasoning: event.reasoning }) : null;\n const ruleIdsChecked = event.rules_checked?.map((r: any) => r.rule_id || r) || null;\n const ts = event._ts ? new Date(event._ts).toISOString() : new Date().toISOString();\n\n await db.query(\n `INSERT INTO guard_checks (id, project_id, passed, model, interaction_type, tool_names, guard_mode, operation_type, messages, verdicts, rule_ids_checked, conversation_id, end_user_id, judge_context, created_at)\n VALUES ($1, $2, $3, $4, $5, $6, 'local', $7, $8, $9, $10, $11, 'local-user', $12, $13)\n ON CONFLICT (id) DO NOTHING`,\n [id, event.repo || 'local', passed, event.cc_model || event.model || null,\n event.hook_type || null, event.tool_name ? [event.tool_name] : null,\n operationType, messages, verdicts, ruleIdsChecked, sessionId,\n event.category || null, ts]\n );\n\n if (!passed && event.verdict !== 'allow') {\n const violationId = `viol_${id}`;\n await db.query(\n `INSERT INTO guard_violations (id, project_id, run_id, severity, model, interaction_type, tool_names, guard_mode, passed, rule_ids_violated, rules_violated, messages, verdicts, mechanism_category, conversation_id, created_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, 'local', 0, $8, $9, $10, $11, $12, $13, $14)\n ON CONFLICT (id) DO NOTHING`,\n [violationId, event.repo || 'local', id, event.severity || null,\n event.cc_model || event.model || null, event.hook_type || null,\n event.tool_name ? [event.tool_name] : null,\n event.violated_rules || null, event.violated_rules || null,\n event.command ? JSON.stringify({ command: event.command }) : null, verdicts,\n event.category || null, sessionId, ts]\n );\n const classifyText = [event.command, event.reasoning].filter(Boolean).join(' ');\n if (classifyText && embedder) {\n classifyText.length > 0 && classifyViolationInline(violationId, classifyText).catch(() => {});\n }\n }\n\n const existing = await db.query<{ id: string }>(\n `SELECT id FROM trajectories WHERE check_ids[1] = $1 LIMIT 1`, [id]\n );\n if (existing.rows.length) return;\n\n const model = event.cc_model || event.model || null;\n await db.query(\n `INSERT INTO trajectories (project_id, org_id, conversation_id, check_ids, check_count, passed, severity, end_user_id, status, interaction_type, model, provider, operation_type, user_message, final_response, started_at, completed_at, created_at)\n VALUES ($1, 'local', $2, $3, 1, $4, $5, 'local-user', 'graded', $6, $7, $8, $9, $10, $11, $12, $13, $14)`,\n [event.repo || 'local', sessionId, [id], passed,\n !passed ? (event.severity || 'medium') : null,\n event.hook_type || null, model,\n model?.startsWith('claude-') ? 'anthropic' : 'unknown',\n operationType,\n event.command || event.recent_user_messages?.[0] || null,\n event.reasoning || null, ts, ts, ts]\n );\n}\n\nasync function ingestUsageTick(event: any): Promise<void> {\n if (!event.cc_usage && !event.session_id) return;\n const sid = event.session_id || '';\n const id = event.event_id || `usage_${Date.now()}_${Math.random().toString(36).slice(2)}`;\n const ts = event._ts ? new Date(event._ts).toISOString() : new Date().toISOString();\n const usage = event.cc_usage || {};\n\n await db.query(\n `INSERT INTO usage_ticks (id, session_id, model, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, created_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n ON CONFLICT (session_id) DO UPDATE SET\n input_tokens = EXCLUDED.input_tokens,\n output_tokens = EXCLUDED.output_tokens,\n cache_creation_tokens = EXCLUDED.cache_creation_tokens,\n cache_read_tokens = EXCLUDED.cache_read_tokens,\n model = COALESCE(EXCLUDED.model, usage_ticks.model),\n created_at = EXCLUDED.created_at`,\n [id, sid, event.cc_model || event.model || null,\n usage.input_tokens || 0, usage.output_tokens || 0,\n usage.cache_creation_input_tokens || 0, usage.cache_read_input_tokens || 0, ts]\n );\n}\n\nasync function ingestScanFinding(event: any): Promise<void> {\n if (!event.finding_type || !event.finding_id) return;\n const sessionId = event.session_id || '';\n const filePath = event.file_path || '';\n\n if (event.finding_id === 'pass') {\n await db.query(\n `UPDATE scan_findings SET status = 'resolved', resolved_at = NOW() WHERE file_path = $1 AND status = 'open'`,\n [filePath]\n );\n return;\n }\n\n if (event.status === 'resolved') {\n await db.query(\n `UPDATE scan_findings SET status = 'resolved', resolved_at = NOW() WHERE session_id = $1 AND file_path = $2 AND status = 'open'`,\n [sessionId, filePath]\n );\n return;\n }\n\n const isBlocked = event.finding_type === 'cve';\n const ts = event._ts ? new Date(event._ts).toISOString() : new Date().toISOString();\n const tsMs = event._ts ? new Date(event._ts).getTime() : Date.now();\n const id = `sf_${sessionId}_${event.finding_id}_${tsMs}`;\n\n await db.query(\n `INSERT INTO scan_findings (id, session_id, file_path, finding_type, finding_id, severity, status, detail, description, package_name, package_version, fixed_version, aliases, \"references\", cwe_name, repo, resolved_at, created_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)\n ON CONFLICT (id) DO NOTHING`,\n [id, sessionId, filePath, event.finding_type, event.finding_id,\n event.severity || null, isBlocked ? 'resolved' : (event.status || 'open'),\n event.detail || null, event.description || null,\n event.package_name || null, event.package_version || null, event.fixed_version || null,\n event.aliases ? JSON.stringify(event.aliases) : null,\n event.references ? JSON.stringify(event.references) : null,\n event.cwe_name || null, event.repo || null,\n isBlocked ? ts : null, ts]\n );\n}\n\nasync function ingestRuleSync(event: any): Promise<void> {\n if (!event.policy_id) return;\n const ts = event._ts ? new Date(event._ts).toISOString() : new Date().toISOString();\n const rules = Array.isArray(event.rules) ? event.rules : [];\n\n await db.query(\n `INSERT INTO policies (id, name, rules, rule_count, scope, scope_owner, is_active, created_at, updated_at)\n VALUES ($1, $2, $3, $4, 'agent_runtime', 'user', true, $5, $6)\n ON CONFLICT (id) DO UPDATE SET name = $2, rules = $3, rule_count = $4, updated_at = $6`,\n [event.policy_id, event.policy_name || 'My Rules', JSON.stringify(rules), rules.length, ts, ts]\n );\n}\n\n// \u2500\u2500\u2500 Storage \u2500\u2500\u2500\n\ninterface Rule {\n rule_id: string;\n text: string;\n category: string;\n severity: string;\n mode: string;\n hook_stage: string;\n scope: string;\n}\n\ninterface Policy {\n id: string;\n name: string;\n rules: Rule[];\n ruleCount: number;\n scopeOwner: string;\n isActive: boolean;\n}\n\ninterface ScanExemption {\n path: string;\n cwe_id: string;\n reason?: string;\n}\n\ninterface RulesFile {\n policies: Policy[];\n config: { silent: boolean; activePolicyId: string };\n scanExemptions: ScanExemption[];\n}\n\nconst DEFAULT_POLICY: Policy = {\n id: 'local-policy',\n name: 'My Rules',\n rules: [],\n ruleCount: 0,\n scopeOwner: 'user',\n isActive: true,\n};\n\nfunction parseRulesField(raw: any): Rule[] {\n if (Array.isArray(raw)) return raw;\n if (typeof raw === 'string') {\n try { return JSON.parse(raw); } catch { return []; }\n }\n return [];\n}\n\nasync function readRules(): Promise<RulesFile> {\n if (!db) {\n return {\n policies: [{ ...DEFAULT_POLICY }],\n config: { silent: false, activePolicyId: 'local-policy' },\n scanExemptions: [],\n };\n }\n\n const policiesRes = await db.query<{\n id: string; name: string; rules: any; rule_count: number; scope_owner: string; is_active: boolean;\n }>(`SELECT id, name, rules, rule_count, scope_owner, is_active FROM policies ORDER BY created_at ASC, id ASC`);\n\n const policies: Policy[] = policiesRes.rows.map(r => ({\n id: r.id,\n name: r.name || 'My Rules',\n rules: parseRulesField(r.rules),\n ruleCount: r.rule_count || 0,\n scopeOwner: r.scope_owner || 'user',\n isActive: r.is_active !== false,\n }));\n\n if (policies.length === 0) policies.push({ ...DEFAULT_POLICY });\n\n const cfgRes = await db.query<{ key: string; value: string }>(`SELECT key, value FROM synkro_local_config`);\n const cfgMap: Record<string, string> = {};\n for (const r of cfgRes.rows) cfgMap[r.key] = r.value;\n const config = {\n silent: cfgMap.silent === 'true',\n activePolicyId: cfgMap.activePolicyId || policies[0].id,\n };\n\n const exRes = await db.query<{ path: string; cwe_id: string; reason: string | null }>(\n `SELECT path, cwe_id, reason FROM scan_exemptions ORDER BY created_at ASC, id ASC`);\n const scanExemptions: ScanExemption[] = exRes.rows.map(r => ({\n path: r.path, cwe_id: r.cwe_id, ...(r.reason ? { reason: r.reason } : {}),\n }));\n\n return { policies, config, scanExemptions };\n}\n\nasync function writeRules(data: RulesFile): Promise<void> {\n if (!db) return;\n for (const p of data.policies) p.ruleCount = p.rules.length;\n\n const keepIds: string[] = [];\n for (const p of data.policies) {\n keepIds.push(p.id);\n await db.query(\n `INSERT INTO policies (id, name, rules, rule_count, scope, scope_owner, is_active, created_at, updated_at)\n VALUES ($1, $2, $3, $4, 'agent_runtime', $5, $6, NOW(), NOW())\n ON CONFLICT (id) DO UPDATE SET name = $2, rules = $3, rule_count = $4, scope_owner = $5, is_active = $6, updated_at = NOW()`,\n [p.id, p.name, JSON.stringify(p.rules), p.ruleCount, p.scopeOwner, p.isActive]\n );\n }\n if (keepIds.length > 0) {\n const placeholders = keepIds.map((_, i) => `$${i + 1}`).join(',');\n await db.query(`DELETE FROM policies WHERE id NOT IN (${placeholders})`, keepIds);\n } else {\n await db.query(`DELETE FROM policies`);\n }\n\n await db.query(\n `INSERT INTO synkro_local_config (key, value, updated_at) VALUES ('silent', $1, NOW())\n ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`,\n [String(data.config.silent)]\n );\n await db.query(\n `INSERT INTO synkro_local_config (key, value, updated_at) VALUES ('activePolicyId', $1, NOW())\n ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`,\n [data.config.activePolicyId]\n );\n\n await db.query(`DELETE FROM scan_exemptions`);\n for (const e of data.scanExemptions) {\n await db.query(\n `INSERT INTO scan_exemptions (path, cwe_id, reason) VALUES ($1, $2, $3) ON CONFLICT (path, cwe_id) DO NOTHING`,\n [e.path, e.cwe_id, e.reason || null]\n );\n }\n}\n\nasync function migrateRulesJsonIfPresent(): Promise<void> {\n if (!db || !existsSync(RULES_PATH)) return;\n try {\n const data = JSON.parse(readFileSync(RULES_PATH, 'utf-8')) as RulesFile;\n if (!data || !Array.isArray(data.policies)) return;\n // writeRules upserts policies (ON CONFLICT DO UPDATE) and replaces config + exemptions.\n // Safe to run even when old ingestRuleSync had already inserted the active policy.\n await writeRules({\n policies: data.policies.length > 0 ? data.policies : [{ ...DEFAULT_POLICY }],\n config: data.config || { silent: false, activePolicyId: 'local-policy' },\n scanExemptions: Array.isArray(data.scanExemptions) ? data.scanExemptions : [],\n });\n unlinkSync(RULES_PATH);\n console.log('[synkro] migrated rules.json into PGLite, removed file');\n } catch (err) {\n console.error('[synkro] rules.json migration failed:', err);\n }\n}\n\nfunction emitRuleSync(data: RulesFile): void {\n const active = data.policies.find(p => p.id === data.config.activePolicyId) || data.policies[0];\n const event = {\n capture_type: 'rule_sync',\n policy_id: active?.id || 'local-policy',\n policy_name: active?.name || 'My Rules',\n rules: active?.rules || [],\n rule_count: active?.ruleCount || 0,\n scan_exemptions: data.scanExemptions,\n silent: data.config.silent,\n _ts: new Date().toISOString(),\n };\n if (db) ingestRuleSync(event).catch(() => {});\n}\n\nfunction highestRuleNumber(data: RulesFile): number {\n let max = 0;\n for (const p of data.policies) {\n for (const r of p.rules) {\n const m = /^R(\\d+)$/.exec(r.rule_id);\n if (m) {\n const n = parseInt(m[1], 10);\n if (n > max) max = n;\n }\n }\n }\n return max;\n}\n\nfunction nextRuleId(data: RulesFile): string {\n return 'R' + String(highestRuleNumber(data) + 1).padStart(3, '0');\n}\n\nfunction ruleIdAllocator(data: RulesFile): () => string {\n let n = highestRuleNumber(data);\n return () => 'R' + String(++n).padStart(3, '0');\n}\n\nfunction getActivePolicy(data: RulesFile): Policy {\n return data.policies.find(p => p.id === data.config.activePolicyId) || data.policies[0];\n}\n\nfunction findOrCreatePolicy(data: RulesFile, name: string): Policy {\n const existing = data.policies.find(p => p.name.toLowerCase() === name.toLowerCase());\n if (existing) return existing;\n const p: Policy = {\n id: `policy_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,\n name,\n rules: [],\n ruleCount: 0,\n scopeOwner: 'user',\n isActive: true,\n };\n data.policies.push(p);\n return p;\n}\n\nfunction getAllRules(data: RulesFile): Array<Rule & { policyName: string; policyId: string }> {\n const all: Array<Rule & { policyName: string; policyId: string }> = [];\n for (const p of data.policies) {\n if (!p.isActive) continue;\n for (const r of p.rules) {\n all.push({ ...r, policyName: p.name, policyId: p.id });\n }\n }\n return all;\n}\n\n// \u2500\u2500\u2500 Keyword Search \u2500\u2500\u2500\n\nconst STOPWORDS = new Set(['the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should', 'may', 'might', 'must', 'shall', 'can', 'need', 'dare', 'ought', 'used', 'to', 'of', 'in', 'for', 'on', 'with', 'at', 'by', 'from', 'as', 'into', 'through', 'during', 'before', 'after', 'above', 'below', 'between', 'out', 'off', 'over', 'under', 'again', 'further', 'then', 'once', 'here', 'there', 'when', 'where', 'why', 'how', 'all', 'each', 'every', 'both', 'few', 'more', 'most', 'other', 'some', 'such', 'no', 'nor', 'not', 'only', 'own', 'same', 'so', 'than', 'too', 'very', 'just', 'because', 'but', 'and', 'or', 'if', 'while', 'about', 'up', 'it', 'its', 'this', 'that', 'these', 'those', 'i', 'me', 'my', 'we', 'our', 'you', 'your', 'he', 'she', 'they', 'them', 'what', 'which', 'who', 'whom']);\n\nfunction tokenize(text: string): string[] {\n return text.toLowerCase().replace(/[^a-z0-9_-]/g, ' ').split(/\\s+/).filter(t => t.length > 1 && !STOPWORDS.has(t));\n}\n\nfunction keywordSearch(query: string, rules: Array<Rule & { policyName: string; policyId: string }>, topK: number): any[] {\n const qTokens = tokenize(query);\n if (qTokens.length === 0) return rules.slice(0, topK);\n\n const scored = rules.map(r => {\n const rTokens = new Set(tokenize(`${r.text} ${r.category} ${r.severity}`));\n const overlap = qTokens.filter(t => rTokens.has(t) || [...rTokens].some(rt => rt.includes(t) || t.includes(rt))).length;\n return { rule: r, score: overlap / qTokens.length };\n });\n\n scored.sort((a, b) => b.score - a.score);\n const results = scored.filter(s => s.score > 0).slice(0, topK);\n if (results.length === 0) return rules.slice(0, topK);\n\n return results.map(s => ({\n rule_id: s.rule.rule_id,\n text: s.rule.text,\n category: s.rule.category,\n severity: s.rule.severity,\n mode: s.rule.mode,\n hook_stage: s.rule.hook_stage,\n scope: s.rule.scope,\n pack_name: s.rule.policyName,\n score: Math.round(s.score * 100) / 100,\n }));\n}\n\n// \u2500\u2500\u2500 Tool Handlers \u2500\u2500\u2500\n\nasync function handleGetGuardrails(args: any): Promise<any> {\n const data = await readRules();\n const all = getAllRules(data);\n const topK = Math.min(args.top_k || 8, 25);\n let filtered = all;\n if (args.category) filtered = filtered.filter(r => r.category === args.category);\n const results = keywordSearch(args.query || '', filtered, topK);\n return { rules: results, total: results.length, query: args.query };\n}\n\nasync function handleCreateGuardrail(args: any): Promise<any> {\n const data = await readRules();\n const policy = args.ruleset ? findOrCreatePolicy(data, args.ruleset) : getActivePolicy(data);\n const rule: Rule = {\n rule_id: nextRuleId(data),\n text: args.text,\n category: args.category || 'custom',\n severity: args.severity || 'medium',\n mode: args.mode || 'audit',\n hook_stage: args.hook_stage || 'both',\n scope: args.scope || 'user',\n };\n policy.rules.push(rule);\n await writeRules(data);\n emitRuleSync(data);\n return { created: true, rule_id: rule.rule_id, text: rule.text, pack_name: policy.name, total_rules: policy.rules.length };\n}\n\nasync function handleBulkCreateGuardrails(args: any): Promise<any> {\n const data = await readRules();\n const policy = args.ruleset ? findOrCreatePolicy(data, args.ruleset) : getActivePolicy(data);\n const created: any[] = [];\n const allocId = ruleIdAllocator(data);\n for (const r of args.rules || []) {\n const rule: Rule = {\n rule_id: allocId(),\n text: r.text,\n category: r.category || 'custom',\n severity: r.severity || 'medium',\n mode: r.mode || 'audit',\n hook_stage: r.hook_stage || 'both',\n scope: args.scope || 'user',\n };\n policy.rules.push(rule);\n created.push({ rule_id: rule.rule_id, text: rule.text });\n }\n await writeRules(data);\n emitRuleSync(data);\n return { created: created.length, rules: created, pack_name: policy.name, total_rules: policy.rules.length };\n}\n\nasync function handleUpdateGuardrail(args: any): Promise<any> {\n const data = await readRules();\n const needle = (args.rule_text || '').toLowerCase();\n for (const p of data.policies) {\n for (const r of p.rules) {\n if (r.text.toLowerCase().includes(needle)) {\n if (args.text) r.text = args.text;\n if (args.category) r.category = args.category;\n if (args.severity) r.severity = args.severity;\n if (args.mode) r.mode = args.mode;\n if (args.hook_stage) r.hook_stage = args.hook_stage;\n await writeRules(data);\n emitRuleSync(data);\n return { updated: true, rule_id: r.rule_id, text: r.text };\n }\n }\n }\n return { updated: false, error: `No rule found matching \"${args.rule_text}\"` };\n}\n\nasync function handleDeleteGuardrail(args: any): Promise<any> {\n const data = await readRules();\n const needle = (args.rule_text || '').toLowerCase();\n for (const p of data.policies) {\n const idx = p.rules.findIndex(r => r.text.toLowerCase().includes(needle));\n if (idx !== -1) {\n const removed = p.rules.splice(idx, 1)[0];\n await writeRules(data);\n emitRuleSync(data);\n return { deleted: true, rule_id: removed.rule_id, text: removed.text };\n }\n }\n return { deleted: false, error: `No rule found matching \"${args.rule_text}\"` };\n}\n\nasync function handleListGuardrails(args: any): Promise<any> {\n const data = await readRules();\n let all = getAllRules(data);\n if (args.category) all = all.filter(r => r.category === args.category);\n if (args.severity) all = all.filter(r => r.severity === args.severity);\n if (args.mode) all = all.filter(r => r.mode === args.mode);\n if (args.hook_stage) all = all.filter(r => r.hook_stage === args.hook_stage);\n if (args.pack_name) {\n const pn = args.pack_name.toLowerCase();\n all = all.filter(r => r.policyName.toLowerCase().includes(pn));\n }\n return {\n rules: all.map(r => ({\n rule_id: r.rule_id,\n text: r.text,\n category: r.category,\n severity: r.severity,\n mode: r.mode,\n hook_stage: r.hook_stage,\n scope: r.scope,\n pack_name: r.policyName,\n })),\n total: all.length,\n };\n}\n\nasync function handleSwapRuleset(args: any): Promise<any> {\n const data = await readRules();\n const name = args.policy_name || '';\n if (name.toLowerCase() === 'all') {\n data.config.activePolicyId = data.policies[0]?.id || 'local-policy';\n await writeRules(data);\n return { swapped: true, active: 'all' };\n }\n const match = data.policies.find(p => p.name.toLowerCase().includes(name.toLowerCase()));\n if (!match) return { swapped: false, error: `No ruleset found matching \"${name}\"` };\n data.config.activePolicyId = match.id;\n await writeRules(data);\n return { swapped: true, active: match.name };\n}\n\nasync function handleToggleSilentMode(args: any): Promise<any> {\n const data = await readRules();\n data.config.silent = args.enabled === true;\n await writeRules(data);\n emitRuleSync(data);\n return { silent: data.config.silent };\n}\n\nasync function handleScanDependencies(args: any): Promise<any> {\n const manifests = args.manifests || [];\n if (manifests.length === 0) return { findings: [], summary: null };\n\n const packages: Array<{ name: string; version: string; ecosystem: string }> = [];\n for (const m of manifests) {\n const fp: string = m.file_path || '';\n const content: string = m.content || '';\n try {\n if (fp.endsWith('package.json')) {\n const pkg = JSON.parse(content);\n for (const [name, ver] of Object.entries({ ...pkg.dependencies, ...pkg.devDependencies })) {\n packages.push({ name, version: String(ver).replace(/^[\\^~>=<]*/g, ''), ecosystem: 'npm' });\n }\n } else if (fp.endsWith('requirements.txt') || fp.match(/requirements.*\\.txt$/)) {\n for (const line of content.split('\\n')) {\n const m = line.trim().match(/^([a-zA-Z0-9_-]+)==(.+)/);\n if (m) packages.push({ name: m[1], version: m[2], ecosystem: 'PyPI' });\n }\n } else if (fp.endsWith('go.mod')) {\n for (const line of content.split('\\n')) {\n const m = line.trim().match(/^\\t?([^\\s]+)\\s+v([^\\s]+)/);\n if (m) packages.push({ name: m[1], version: m[2], ecosystem: 'Go' });\n }\n } else if (fp.endsWith('Cargo.toml')) {\n for (const line of content.split('\\n')) {\n const m = line.trim().match(/^([a-zA-Z0-9_-]+)\\s*=\\s*\"([^\"]+)\"/);\n if (m && !['name', 'version', 'edition', 'authors', 'description', 'license', 'repository'].includes(m[1])) {\n packages.push({ name: m[1], version: m[2], ecosystem: 'crates.io' });\n }\n }\n }\n } catch {}\n }\n\n if (packages.length === 0) return { findings: [], summary: null };\n\n const capped = packages.slice(0, 50);\n const queries = capped.map(p => ({ package: { name: p.name, ecosystem: p.ecosystem }, version: p.version }));\n\n try {\n const resp = await fetch('https://api.osv.dev/v1/querybatch', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ queries }),\n signal: AbortSignal.timeout(10000),\n });\n if (!resp.ok) return { findings: [], summary: 'OSV query failed' };\n const data = await resp.json() as { results: Array<{ vulns?: any[] }> };\n\n const findings: any[] = [];\n for (let i = 0; i < data.results.length; i++) {\n for (const vuln of data.results[i].vulns || []) {\n findings.push({\n id: vuln.id,\n package: capped[i].name,\n version: capped[i].version,\n ecosystem: capped[i].ecosystem,\n summary: vuln.summary || 'No description',\n aliases: vuln.aliases || [],\n severity: vuln.database_specific?.severity || 'unknown',\n });\n }\n }\n return { findings, summary: findings.length > 0 ? `${findings.length} vulnerabilities found` : null };\n } catch {\n return { findings: [], summary: 'OSV query timed out' };\n }\n}\n\nconst CWE_ID_RE = /^CWE-\\d{1,6}$/i;\nconst PATH_TRAVERSAL_RE = /\\.\\.[/\\\\]/;\nconst MAX_REASON_LEN = 500;\n\nfunction validateExemptionArgs(args: any): { path: string; cwe_id: string; reason?: string } | string {\n const p = typeof args.path === 'string' ? args.path.trim() : '';\n if (!p || PATH_TRAVERSAL_RE.test(p)) return 'Invalid path';\n const cwe = typeof args.cwe_id === 'string' ? args.cwe_id.trim().toUpperCase() : '';\n if (!CWE_ID_RE.test(cwe)) return 'Invalid cwe_id (expected CWE-NNN)';\n const reason = typeof args.reason === 'string' ? args.reason.slice(0, MAX_REASON_LEN) : undefined;\n return { path: p, cwe_id: cwe, reason };\n}\n\nasync function handleExemptPath(args: any): Promise<any> {\n const v = validateExemptionArgs(args);\n if (typeof v === 'string') return { exempted: false, error: v };\n const data = await readRules();\n const existing = data.scanExemptions.find(e => e.path === v.path && e.cwe_id.toUpperCase() === v.cwe_id);\n if (existing) return { exempted: true, already_existed: true, path: v.path, cwe_id: v.cwe_id };\n\n data.scanExemptions.push({ path: v.path, cwe_id: v.cwe_id, reason: v.reason });\n await writeRules(data);\n emitRuleSync(data);\n return { exempted: true, path: v.path, cwe_id: v.cwe_id, total_exemptions: data.scanExemptions.length };\n}\n\nasync function handleRemoveExemption(args: any): Promise<any> {\n const v = validateExemptionArgs(args);\n if (typeof v === 'string') return { removed: false, error: v };\n const data = await readRules();\n const idx = data.scanExemptions.findIndex(e => e.path === v.path && e.cwe_id.toUpperCase() === v.cwe_id);\n if (idx === -1) return { removed: false, error: `No exemption found for path=\"${v.path}\" cwe_id=\"${v.cwe_id}\"` };\n data.scanExemptions.splice(idx, 1);\n await writeRules(data);\n emitRuleSync(data);\n return { removed: true, path: v.path, cwe_id: v.cwe_id };\n}\n\nasync function handleListExemptions(): Promise<any> {\n const data = await readRules();\n return { exemptions: data.scanExemptions, total: data.scanExemptions.length };\n}\n\n// \u2500\u2500\u2500 Findings \u2500\u2500\u2500\n\nconst CONFIG_PATH = join(HOME, '.synkro', 'config.json');\nconst FINDINGS_JWT_PATH = join(HOME, '.synkro', '.mcp-jwt');\n\nconst ALLOWED_API_HOSTS = new Set(['api.synkro.sh', 'localhost', '127.0.0.1']);\nfunction validateApiUrl(raw: string): string | null {\n try {\n const u = new URL(raw);\n if (!['http:', 'https:'].includes(u.protocol)) return null;\n if (!ALLOWED_API_HOSTS.has(u.hostname)) return null;\n return u.origin;\n } catch { return null; }\n}\n\ninterface Finding {\n id: string;\n session_id: string;\n file_path: string;\n finding_type: string;\n finding_id: string;\n severity: string;\n status: string;\n detail?: string;\n package_name?: string;\n package_version?: string;\n fixed_version?: string;\n created_at: string;\n resolved_at?: string;\n}\n\nconst CREDENTIALS_PATH = join(HOME, '.synkro', 'credentials.json');\n\nfunction getCloudConfig(): { apiUrl: string; jwt: string } | null {\n try {\n let jwt = '';\n try {\n const creds = JSON.parse(readFileSync(CREDENTIALS_PATH, 'utf-8'));\n jwt = creds.access_token || '';\n } catch {}\n if (!jwt) {\n try { jwt = readFileSync(FINDINGS_JWT_PATH, 'utf-8').trim(); } catch {}\n }\n if (!jwt) return null;\n let raw = process.env.SYNKRO_API_URL || '';\n if (!raw) raw = 'https://api.synkro.sh';\n const apiUrl = validateApiUrl(raw);\n if (!apiUrl) return null;\n return { apiUrl, jwt };\n } catch {\n return null;\n }\n}\n\nasync function readLocalFindings(): Promise<Finding[]> {\n const result = await db.query<Finding>(\n `SELECT id, session_id, file_path, finding_type, finding_id, severity, status, detail,\n description, cwe_name, package_name, package_version, fixed_version,\n created_at::text as created_at, resolved_at::text as resolved_at\n FROM scan_findings ORDER BY created_at DESC`\n );\n return result.rows;\n}\n\nasync function proxyToCloudMcp(toolName: string, args: Record<string, unknown>): Promise<any | null> {\n const cloud = getCloudConfig();\n if (!cloud) return null;\n try {\n const resp = await fetch(`${cloud.apiUrl}/api/v1/mcp/guardrails`, {\n method: 'POST',\n headers: { Authorization: `Bearer ${cloud.jwt}`, 'Content-Type': 'application/json' },\n body: JSON.stringify({\n jsonrpc: '2.0',\n id: `local_${Date.now()}`,\n method: 'tools/call',\n params: { name: toolName, arguments: args },\n }),\n signal: AbortSignal.timeout(10000),\n });\n if (!resp.ok) return null;\n const data = await resp.json() as any;\n if (data.error) return null;\n return data.result;\n } catch {\n return null;\n }\n}\n\nasync function handleListFindings(args: any): Promise<any> {\n let findings = await readLocalFindings();\n if (args.status) findings = findings.filter(f => f.status === args.status);\n if (args.finding_type) findings = findings.filter(f => f.finding_type === args.finding_type);\n if (args.severity) findings = findings.filter(f => f.severity === args.severity);\n\n const limit = Math.min(args.limit || 50, 200);\n const open = findings.filter(f => f.status === 'open').length;\n const resolved = findings.filter(f => f.status === 'resolved').length;\n\n if (findings.length === 0) {\n return { content: [{ type: 'text', text: args.status ? `No ${args.status} findings found.` : 'No scan findings found.' }] };\n }\n\n const lines = findings.slice(0, limit).map(f => {\n const badge = f.finding_type === 'cve' ? '\u{1F534} CVE' : '\u{1F7E1} CWE';\n const name = (f as any).cwe_name ? ` (${(f as any).cwe_name})` : '';\n const pkg = f.package_name ? ` in \\`${f.package_name}@${f.package_version || '?'}\\`` : '';\n const file = f.file_path ? ` \u2014 \\`${f.file_path}\\`` : '';\n return `- **${badge} ${f.finding_id}**${name}${pkg}${file}\\n Status: ${f.status} | Severity: ${f.severity || 'unknown'} | ID: \\`${f.id}\\``;\n });\n\n return {\n content: [{ type: 'text', text: `**${findings.length} finding${findings.length === 1 ? '' : 's'}** (${open} open, ${resolved} resolved)\\n\\n${lines.join('\\n\\n')}` }],\n };\n}\n\nasync function handleGetFindingDetail(args: any): Promise<any> {\n const id = typeof args.id === 'string' ? args.id.trim() : '';\n if (!id) return { content: [{ type: 'text', text: 'id is required' }], isError: true };\n\n const findings = await readLocalFindings();\n const match = findings.find(f => f.id === id);\n if (!match) return { content: [{ type: 'text', text: 'Finding not found' }], isError: true };\n\n const parts: string[] = [];\n const m = match as any;\n parts.push(`# ${m.finding_type.toUpperCase()} ${m.finding_id}${m.cwe_name ? ` \u2014 ${m.cwe_name}` : ''}`);\n parts.push(`**Status:** ${m.status} | **Severity:** ${m.severity || 'unknown'}`);\n if (m.file_path) parts.push(`**File:** \\`${m.file_path}\\``);\n if (m.package_name) parts.push(`**Package:** \\`${m.package_name}@${m.package_version || '?'}\\``);\n if (m.fixed_version) parts.push(`**Fix available:** ${m.fixed_version}`);\n parts.push(`**Detected:** ${m.created_at}`);\n if (m.resolved_at) parts.push(`**Resolved:** ${m.resolved_at}`);\n if (m.description) parts.push(`\\n## Description\\n${m.description}`);\n if (m.detail) parts.push(`\\n## Detail\\n${m.detail}`);\n\n return { content: [{ type: 'text', text: parts.join('\\n') }] };\n}\n\nasync function handleResolveFinding(args: any): Promise<any> {\n const id = typeof args.id === 'string' ? args.id.trim() : '';\n const filePath = typeof args.file_path === 'string' ? args.file_path.trim() : '';\n const findingId = typeof args.finding_id === 'string' ? args.finding_id.trim() : '';\n\n if (!id && !filePath) return { content: [{ type: 'text', text: 'id or file_path is required' }], isError: true };\n\n if (id) {\n const result = await db.query<Finding>(\n `SELECT * FROM scan_findings WHERE id = $1 AND status = 'open' LIMIT 1`, [id]\n );\n const match = result.rows[0];\n if (!match) return { content: [{ type: 'text', text: 'No matching open finding' }], isError: true };\n await db.query(`UPDATE scan_findings SET status = 'resolved', resolved_at = NOW() WHERE id = $1`, [id]);\n proxyToCloudMcp('resolve_finding', { id }).catch(() => {});\n return { content: [{ type: 'text', text: `Finding \\`${match.finding_id}\\` on \\`${match.file_path || '(unknown)'}\\` marked as **resolved**.` }] };\n }\n\n let where = `file_path = $1 AND status = 'open'`;\n const params: string[] = [filePath];\n if (findingId) { params.push(findingId); where += ` AND finding_id = $2`; }\n\n const before = await db.query<{ cnt: string }>(`SELECT count(*) as cnt FROM scan_findings WHERE ${where}`, params);\n const cnt = Number(before.rows[0]?.cnt || 0);\n if (cnt === 0) return { content: [{ type: 'text', text: `No open findings for \\`${filePath}\\`` }], isError: true };\n\n await db.query(`UPDATE scan_findings SET status = 'resolved', resolved_at = NOW() WHERE ${where}`, params);\n return { content: [{ type: 'text', text: `Resolved ${cnt} finding${cnt === 1 ? '' : 's'} on \\`${filePath}\\`.` }] };\n}\n\n// \u2500\u2500\u2500 Tool Descriptors \u2500\u2500\u2500\n\nconst TOOL_DESCRIPTORS = [\n {\n name: 'get_guardrails',\n description:\n \"Retrieve rules by keyword similarity. Call BEFORE writing security-sensitive code \" +\n \"AND before create_guardrail to check for existing rules.\",\n inputSchema: {\n type: 'object',\n properties: {\n query: { type: 'string', description: \"Plain-language description of what you're looking up.\" },\n category: { type: 'string', enum: ['security', 'architecture', 'style', 'testing', 'compliance', 'custom'] },\n top_k: { type: 'integer', default: 8, description: 'Max rules to return (default 8, max 25).' },\n },\n required: ['query'],\n },\n },\n {\n name: 'create_guardrail',\n description: \"Persist a new rule. Call get_guardrails first to avoid duplicates.\",\n inputSchema: {\n type: 'object',\n properties: {\n text: { type: 'string', description: 'The rule in plain language.' },\n category: { type: 'string', enum: ['security', 'architecture', 'style', 'testing', 'compliance', 'custom'] },\n severity: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] },\n mode: { type: 'string', enum: ['blocking', 'audit'], description: '\"blocking\" = halt on violation, \"audit\" = log only.' },\n scope: { type: 'string', enum: ['user', 'org'], default: 'user' },\n hook_stage: { type: 'string', enum: ['pre', 'post', 'both'], default: 'both' },\n ruleset: { type: 'string', description: 'Optional: name of ruleset to add to (created if missing).' },\n },\n required: ['text', 'category'],\n },\n },\n {\n name: 'bulk_create_guardrails',\n description: \"Create multiple rules at once. Preferable to looping create_guardrail.\",\n inputSchema: {\n type: 'object',\n properties: {\n rules: {\n type: 'array', minItems: 1, maxItems: 50,\n items: {\n type: 'object',\n properties: {\n text: { type: 'string' }, category: { type: 'string', enum: ['security', 'architecture', 'style', 'testing', 'compliance', 'custom'] },\n severity: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] },\n mode: { type: 'string', enum: ['blocking', 'audit'] },\n hook_stage: { type: 'string', enum: ['pre', 'post', 'both'] },\n },\n required: ['text', 'category'],\n },\n },\n scope: { type: 'string', enum: ['user', 'org'], default: 'user' },\n ruleset: { type: 'string' },\n },\n required: ['rules'],\n },\n },\n {\n name: 'update_guardrail',\n description: \"Refine an existing rule. Pass a substring of the rule text to identify it.\",\n inputSchema: {\n type: 'object',\n properties: {\n rule_text: { type: 'string', description: 'Substring of rule text to find.' },\n text: { type: 'string' }, category: { type: 'string', enum: ['security', 'architecture', 'style', 'testing', 'compliance', 'custom'] },\n severity: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] },\n mode: { type: 'string', enum: ['blocking', 'audit'] },\n hook_stage: { type: 'string', enum: ['pre', 'post', 'both'] },\n },\n required: ['rule_text'],\n },\n },\n {\n name: 'delete_guardrail',\n description: \"Permanently remove a rule. Pass a substring of the rule text to identify it.\",\n inputSchema: {\n type: 'object',\n properties: { rule_text: { type: 'string', description: 'Substring of rule text to find.' } },\n required: ['rule_text'],\n },\n },\n {\n name: 'list_guardrails',\n description: \"Enumerate all rules. Use for listings, not similarity search.\",\n inputSchema: {\n type: 'object',\n properties: {\n category: { type: 'string', enum: ['security', 'architecture', 'style', 'testing', 'compliance', 'custom'] },\n severity: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] },\n mode: { type: 'string', enum: ['blocking', 'audit', 'literal_match'] },\n pack_name: { type: 'string' },\n hook_stage: { type: 'string', enum: ['pre', 'post', 'both'] },\n },\n required: [],\n },\n },\n {\n name: 'swap_ruleset',\n description: 'Switch which ruleset is active. Pass \"all\" to use all rulesets.',\n inputSchema: {\n type: 'object',\n properties: { policy_name: { type: 'string' } },\n required: ['policy_name'],\n },\n },\n {\n name: 'toggle_silent_mode',\n description: 'Toggle grading on/off. NEVER call autonomously \u2014 this is a USER decision.',\n inputSchema: {\n type: 'object',\n properties: {\n enabled: { type: 'boolean' },\n user_confirmation: { type: 'string', description: \"Copy-paste the user's exact request.\" },\n },\n required: ['enabled', 'user_confirmation'],\n },\n },\n {\n name: 'scan_dependencies',\n description: \"Scan manifests against OSV for known vulnerabilities. Read ALL manifest files first.\",\n inputSchema: {\n type: 'object',\n properties: {\n manifests: {\n type: 'array', minItems: 1,\n items: {\n type: 'object',\n properties: { file_path: { type: 'string' }, content: { type: 'string' } },\n required: ['file_path', 'content'],\n },\n },\n },\n required: ['manifests'],\n },\n },\n {\n name: 'exempt_path',\n description: \"Exempt a CWE from firing on a specific file/directory.\",\n inputSchema: {\n type: 'object',\n properties: {\n path: { type: 'string' }, cwe_id: { type: 'string' }, reason: { type: 'string' },\n },\n required: ['path', 'cwe_id'],\n },\n },\n {\n name: 'remove_exemption',\n description: \"Remove a scan exemption.\",\n inputSchema: {\n type: 'object',\n properties: { path: { type: 'string' }, cwe_id: { type: 'string' } },\n required: ['path', 'cwe_id'],\n },\n },\n {\n name: 'list_exemptions',\n description: \"List all scan exemptions.\",\n inputSchema: { type: 'object', properties: {}, required: [] },\n },\n {\n name: 'list_findings',\n description: \"List CWE/CVE scan findings. Shows security issues found by Synkro hooks. Use to review what needs fixing.\",\n inputSchema: {\n type: 'object',\n properties: {\n status: { type: 'string', enum: ['open', 'resolved', 'exempted'], description: 'Filter by status (default: all).' },\n finding_type: { type: 'string', enum: ['cwe', 'cve'], description: 'Filter by finding type.' },\n severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] },\n file_path: { type: 'string', description: 'Filter by file path substring.' },\n limit: { type: 'integer', default: 25, description: 'Max results (default 25, max 50).' },\n },\n required: [],\n },\n },\n {\n name: 'get_finding_detail',\n description: \"Get full detail of a specific finding including remediation context.\",\n inputSchema: {\n type: 'object',\n properties: {\n id: { type: 'string', description: 'Finding ID (e.g. sf_...).' },\n file_path: { type: 'string', description: 'File path (used with finding_id).' },\n finding_id: { type: 'string', description: 'CWE/CVE ID like CWE-89 or CVE-2024-1234.' },\n },\n required: [],\n },\n },\n {\n name: 'resolve_finding',\n description: \"Mark finding(s) as resolved after the underlying issue is fixed. Can target by ID, file+finding_id, or all findings for a file.\",\n inputSchema: {\n type: 'object',\n properties: {\n id: { type: 'string', description: 'Specific finding ID to resolve.' },\n file_path: { type: 'string', description: 'Resolve all open findings matching this file path.' },\n finding_id: { type: 'string', description: 'CWE/CVE ID (used with file_path for targeted resolution).' },\n },\n required: [],\n },\n },\n];\n\nconst MCP_INSTRUCTIONS =\n \"Synkro Guardrails MCP server (local mode).\\n\\n\" +\n \"Whenever the user mentions: rule, guardrail, policy, standard, \" +\n \"make/create/add/set up a rule, never let X, always require X, \" +\n \"block X, enforce X, delete/remove a rule, consolidate duplicates, \" +\n \"'we need a rule about\u2026' \u2014 route to THIS server's tools.\\n\\n\" +\n \"TOOL ROUTING:\\n\" +\n \" \u2022 get_guardrails(query) \u2014 keyword search. Use to check if a rule exists.\\n\" +\n \" \u2022 list_guardrails \u2014 full enumeration. Use for listings.\\n\" +\n \" \u2022 list_findings \u2014 show CWE/CVE scan findings (open, resolved, all).\\n\" +\n \" \u2022 get_finding_detail \u2014 get full detail + remediation context for a finding.\\n\" +\n \" \u2022 resolve_finding \u2014 mark findings resolved after fixing the code.\\n\\n\" +\n \"When the user asks about security issues, vulnerabilities, or scan results, \" +\n \"use list_findings first. After fixing code, call resolve_finding to update status.\\n\\n\" +\n \"Do NOT use Claude Code's `update-config` skill for these requests.\\n\\n\" +\n \"Rules are stored locally in ~/.synkro/rules.json and enforced by hooks.\";\n\n// \u2500\u2500\u2500 JSON-RPC Dispatcher \u2500\u2500\u2500\n\nfunction jsonRpcOk(id: any, result: any): any {\n return { jsonrpc: '2.0', id, result };\n}\n\nfunction jsonRpcError(id: any, code: number, message: string): any {\n return { jsonrpc: '2.0', id, error: { code, message } };\n}\n\nasync function handleRpc(body: any): Promise<any> {\n const { id, method, params } = body;\n\n if (method === 'initialize') {\n return jsonRpcOk(id, {\n protocolVersion: '2024-11-05',\n capabilities: { tools: {} },\n serverInfo: { name: 'synkro-guardrails-local', version: '1.0.0' },\n instructions: MCP_INSTRUCTIONS,\n });\n }\n\n if (method === 'notifications/initialized') {\n return null;\n }\n\n if (method === 'tools/list') {\n return jsonRpcOk(id, { tools: TOOL_DESCRIPTORS });\n }\n\n if (method === 'tools/call') {\n const toolName = params?.name;\n const args = params?.arguments || {};\n\n try {\n let result: any;\n switch (toolName) {\n case 'get_guardrails': result = await handleGetGuardrails(args); break;\n case 'create_guardrail': result = await handleCreateGuardrail(args); break;\n case 'bulk_create_guardrails': result = await handleBulkCreateGuardrails(args); break;\n case 'update_guardrail': result = await handleUpdateGuardrail(args); break;\n case 'delete_guardrail': result = await handleDeleteGuardrail(args); break;\n case 'list_guardrails': result = await handleListGuardrails(args); break;\n case 'swap_ruleset': result = await handleSwapRuleset(args); break;\n case 'toggle_silent_mode': result = await handleToggleSilentMode(args); break;\n case 'scan_dependencies': result = await handleScanDependencies(args); break;\n case 'exempt_path': result = await handleExemptPath(args); break;\n case 'remove_exemption': result = await handleRemoveExemption(args); break;\n case 'list_exemptions': result = await handleListExemptions(); break;\n case 'list_findings': result = await handleListFindings(args); break;\n case 'get_finding_detail': result = await handleGetFindingDetail(args); break;\n case 'resolve_finding': result = await handleResolveFinding(args); break;\n default: return jsonRpcError(id, -32601, `Unknown tool: ${toolName}`);\n }\n if (result?.content && Array.isArray(result.content)) {\n return jsonRpcOk(id, result);\n }\n return jsonRpcOk(id, { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] });\n } catch (err) {\n console.error('[synkro] tool error:', err);\n return jsonRpcOk(id, { content: [{ type: 'text', text: 'Internal error processing tool call' }], isError: true });\n }\n }\n\n // \u2500\u2500\u2500 Dashboard REST-bridge methods \u2500\u2500\u2500\n // Called by the local dashboard (not AI agents) to mutate rules.json directly.\n\n if (method === 'dashboard.patch_policy') {\n try {\n const data = await readRules();\n const policyId = params?.policy_id as string | undefined;\n const policy = policyId\n ? data.policies.find(p => p.id === policyId)\n : getActivePolicy(data);\n if (!policy) return jsonRpcError(id, -32602, `Policy not found: ${policyId}`);\n\n if (params?.name !== undefined) {\n policy.name = params.name;\n }\n if (params?.is_active !== undefined) {\n policy.isActive = params.is_active;\n }\n // Bulk replace\n if (Array.isArray(params?.rules)) {\n policy.rules = params.rules;\n policy.ruleCount = policy.rules.length;\n }\n // Individual updates by rule_id\n if (Array.isArray(params?.rule_updates)) {\n for (const upd of params.rule_updates) {\n const rule = policy.rules.find(r => r.rule_id === upd.rule_id);\n if (!rule) continue;\n if (upd.text !== undefined) rule.text = upd.text;\n if (upd.category !== undefined) rule.category = upd.category;\n if (upd.severity !== undefined) rule.severity = upd.severity;\n if (upd.mode !== undefined) rule.mode = upd.mode;\n if (upd.hook_stage !== undefined) rule.hook_stage = upd.hook_stage;\n }\n policy.ruleCount = policy.rules.length;\n }\n\n await writeRules(data);\n emitRuleSync(data);\n return jsonRpcOk(id, { ok: true, policy_id: policy.id, rule_count: policy.ruleCount });\n } catch (err) {\n console.error('[synkro] dashboard.patch_policy error:', err);\n return jsonRpcError(id, -32603, 'Internal error');\n }\n }\n\n if (method === 'dashboard.create_policy') {\n try {\n const data = await readRules();\n const name = (params?.name as string) || 'New Rule Set';\n const allocId = ruleIdAllocator(data);\n const rules: Rule[] = (params?.rules || []).map((r: any) => ({\n rule_id: r.rule_id || allocId(),\n text: r.text || '',\n category: r.category || 'custom',\n severity: r.severity || 'medium',\n mode: r.mode || 'audit',\n hook_stage: r.hook_stage || 'both',\n scope: r.scope || 'user',\n }));\n const policy: Policy = {\n id: `policy_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,\n name,\n rules,\n ruleCount: rules.length,\n scopeOwner: 'user',\n isActive: true,\n };\n data.policies.push(policy);\n await writeRules(data);\n emitRuleSync(data);\n return jsonRpcOk(id, { ok: true, policy_id: policy.id, name: policy.name });\n } catch (err) {\n console.error('[synkro] dashboard.create_policy error:', err);\n return jsonRpcError(id, -32603, 'Internal error');\n }\n }\n\n if (method === 'dashboard.delete_policy') {\n try {\n const data = await readRules();\n const policyId = params?.policy_id as string | undefined;\n const idx = policyId ? data.policies.findIndex(p => p.id === policyId) : -1;\n if (idx === -1) return jsonRpcError(id, -32602, `Policy not found: ${policyId}`);\n\n if (params?.hard === true) {\n data.policies.splice(idx, 1);\n } else {\n data.policies[idx].isActive = false;\n }\n await writeRules(data);\n emitRuleSync(data);\n return jsonRpcOk(id, { ok: true, policy_id: policyId });\n } catch (err) {\n console.error('[synkro] dashboard.delete_policy error:', err);\n return jsonRpcError(id, -32603, 'Internal error');\n }\n }\n\n if (method === 'dashboard.list_policies') {\n try {\n const data = await readRules();\n return jsonRpcOk(id, {\n policies: data.policies.map(p => ({\n id: p.id,\n name: p.name,\n rules: p.rules,\n ruleCount: p.ruleCount,\n isActive: p.isActive,\n scopeOwner: p.scopeOwner,\n })),\n active_policy_id: data.config.activePolicyId,\n });\n } catch (err) {\n console.error('[synkro] dashboard.list_policies error:', err);\n return jsonRpcError(id, -32603, 'Internal error');\n }\n }\n\n return jsonRpcError(id, -32601, `Unknown method: ${method}`);\n}\n\n// \u2500\u2500\u2500 REST Query Handlers (for dashboard) \u2500\u2500\u2500\n\nconst MODEL_PRICING: Record<string, { input: number; output: number }> = {\n 'claude-opus-4-6': { input: 15, output: 75 },\n 'claude-opus-4-7': { input: 15, output: 75 },\n 'claude-sonnet-4-6': { input: 3, output: 15 },\n 'claude-haiku-4-5-20251001': { input: 0.8, output: 4 },\n 'gpt-4o': { input: 2.5, output: 10 },\n 'gpt-4o-mini': { input: 0.15, output: 0.6 },\n 'gemini-2.5-flash': { input: 0.15, output: 0.6 },\n 'gemini-2.5-pro': { input: 1.25, output: 10 },\n};\n\nfunction estimateCost(model: string | null, inputTokens: number, outputTokens: number): number {\n const pricing = MODEL_PRICING[model || ''] || MODEL_PRICING['claude-sonnet-4-6'];\n return (inputTokens * pricing.input + outputTokens * pricing.output) / 1_000_000;\n}\n\nfunction camelify(row: any): any {\n const out: any = {};\n for (const [k, v] of Object.entries(row)) {\n const camel = k.replace(/_([a-z])/g, (_, c) => c.toUpperCase());\n out[k] = v;\n if (camel !== k) out[camel] = v;\n }\n return out;\n}\n\nasync function restQueryChecks(params: URLSearchParams): Promise<any> {\n const days = parseInt(params.get('days') || '7', 10);\n const limit = Math.min(parseInt(params.get('limit') || '25', 10), 200);\n const offset = parseInt(params.get('offset') || '0', 10);\n const cutoff = new Date(Date.now() - days * 86400000).toISOString();\n\n const [rows, totalResult] = await Promise.all([\n db.query(\n `SELECT * FROM guard_checks WHERE created_at >= $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3`,\n [cutoff, limit, offset]\n ),\n db.query<{ cnt: string }>(\n `SELECT count(*) as cnt FROM guard_checks WHERE created_at >= $1`, [cutoff]\n ),\n ]);\n return { checks: rows.rows.map(camelify), total: Number(totalResult.rows[0]?.cnt || 0) };\n}\n\nasync function restQueryViolations(params: URLSearchParams): Promise<any> {\n const days = parseInt(params.get('days') || '7', 10);\n const limit = Math.min(parseInt(params.get('limit') || '25', 10), 200);\n const offset = parseInt(params.get('offset') || '0', 10);\n const cutoff = new Date(Date.now() - days * 86400000).toISOString();\n\n const [rows, totalResult] = await Promise.all([\n db.query(\n `SELECT * FROM guard_violations WHERE created_at >= $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3`,\n [cutoff, limit, offset]\n ),\n db.query<{ cnt: string }>(\n `SELECT count(*) as cnt FROM guard_violations WHERE created_at >= $1`, [cutoff]\n ),\n ]);\n return { violations: rows.rows.map(camelify), total: Number(totalResult.rows[0]?.cnt || 0) };\n}\n\nasync function restQueryTrajectories(params: URLSearchParams): Promise<any> {\n const days = parseInt(params.get('days') || '7', 10);\n const limit = Math.min(parseInt(params.get('limit') || '25', 10), 200);\n const offset = parseInt(params.get('offset') || '0', 10);\n const search = params.get('search') || '';\n const cutoff = new Date(Date.now() - days * 86400000).toISOString();\n\n let whereClause = `WHERE created_at >= $1`;\n const queryParams: any[] = [cutoff];\n if (search) {\n queryParams.push(`%${search}%`);\n whereClause += ` AND (conversation_id ILIKE $${queryParams.length} OR user_message ILIKE $${queryParams.length})`;\n }\n\n queryParams.push(limit, offset);\n const limitIdx = queryParams.length - 1;\n const offsetIdx = queryParams.length;\n\n const [rows, totalResult] = await Promise.all([\n db.query(\n `SELECT * FROM trajectories ${whereClause} ORDER BY created_at DESC LIMIT $${limitIdx} OFFSET $${offsetIdx}`,\n queryParams\n ),\n db.query<{ cnt: string }>(\n `SELECT count(*) as cnt FROM trajectories ${whereClause}`,\n search ? [cutoff, `%${search}%`] : [cutoff]\n ),\n ]);\n\n const mapped = rows.rows.map((r: any) => ({\n ...camelify(r),\n endUserName: r.end_user_id === 'local-user' ? 'local' : r.end_user_id || null,\n hasViolation: r.passed !== 1,\n passedCount: r.passed === 1 ? 1 : 0,\n failedCount: r.passed === 1 ? 0 : 1,\n stepCount: r.check_count,\n }));\n return { trajectories: mapped, total: Number(totalResult.rows[0]?.cnt || 0) };\n}\n\nasync function restQueryTrajectoryDetail(id: string): Promise<any> {\n const trajResult = await db.query(\n `SELECT * FROM trajectories WHERE id::text = $1 OR conversation_id = $1 LIMIT 1`,\n [id]\n );\n const row = trajResult.rows[0];\n if (!row) return null;\n\n const trajectory = {\n ...camelify(row),\n endUserName: row.end_user_id === 'local-user' ? 'local' : row.end_user_id || null,\n hasViolation: row.passed !== 1,\n };\n\n const checkIds: string[] = Array.isArray(row.check_ids) ? row.check_ids : [];\n let checks: any[] = [];\n if (checkIds.length > 0) {\n const placeholders = checkIds.map((_, i) => `$${i + 1}`).join(', ');\n const checksResult = await db.query(\n `SELECT * FROM guard_checks WHERE id IN (${placeholders}) ORDER BY created_at ASC`,\n checkIds\n );\n checks = checksResult.rows.map((r: any) => camelify(r));\n }\n\n return { trajectory, checks, external_traces: [], session_messages: null, session_usage: null };\n}\n\nasync function restQueryMetrics(params: URLSearchParams): Promise<any> {\n const days = parseInt(params.get('days') || '7', 10);\n const cutoff = new Date(Date.now() - days * 86400000).toISOString();\n\n const [checks, violations, usage, mechCategories] = await Promise.all([\n db.query<{ cnt: string; passed_cnt: string }>(\n `SELECT count(*) as cnt, count(*) FILTER (WHERE passed = 1) as passed_cnt FROM guard_checks WHERE created_at >= $1`, [cutoff]\n ),\n db.query<{ cnt: string }>(\n `SELECT count(*) as cnt FROM guard_violations WHERE created_at >= $1`, [cutoff]\n ),\n db.query<{ total_input: string; total_output: string }>(\n `SELECT COALESCE(sum(input_tokens), 0) as total_input, COALESCE(sum(output_tokens), 0) as total_output FROM usage_ticks WHERE created_at >= $1`, [cutoff]\n ),\n db.query<{ mechanism_category: string; cnt: string }>(\n `SELECT mechanism_category, count(*) as cnt FROM guard_violations WHERE created_at >= $1 AND mechanism_category IS NOT NULL GROUP BY mechanism_category ORDER BY cnt DESC`, [cutoff]\n ),\n ]);\n\n const totalChecks = Number(checks.rows[0]?.cnt || 0);\n const passedChecks = Number(checks.rows[0]?.passed_cnt || 0);\n const totalViolations = Number(violations.rows[0]?.cnt || 0);\n const inputTokens = Number(usage.rows[0]?.total_input || 0);\n const outputTokens = Number(usage.rows[0]?.total_output || 0);\n const byMechanismCategory: Record<string, number> = {};\n for (const row of mechCategories.rows) {\n byMechanismCategory[row.mechanism_category] = Number(row.cnt);\n }\n\n return {\n totalChecks,\n passedChecks,\n failedChecks: totalChecks - passedChecks,\n totalViolations,\n violationRate: totalChecks > 0 ? ((totalChecks - passedChecks) / totalChecks) * 100 : 0,\n passRate: totalChecks > 0 ? passedChecks / totalChecks : 1,\n byMechanismCategory,\n inputTokens,\n outputTokens,\n estimatedCost: estimateCost(null, inputTokens, outputTokens),\n };\n}\n\nasync function restQueryTrends(params: URLSearchParams): Promise<any> {\n const days = parseInt(params.get('days') || '7', 10);\n const cutoff = new Date(Date.now() - days * 86400000).toISOString();\n\n const result = await db.query<{ day: string; checks: string; violations: string; passed: string }>(\n `SELECT date_trunc('day', created_at)::date::text as day,\n count(*) as checks,\n count(*) FILTER (WHERE passed = 0) as violations,\n count(*) FILTER (WHERE passed = 1) as passed\n FROM guard_checks WHERE created_at >= $1\n GROUP BY date_trunc('day', created_at)\n ORDER BY day`, [cutoff]\n );\n\n return { trends: result.rows.map(r => ({ date: r.day, checks: Number(r.checks), violations: Number(r.violations), passed: Number(r.passed) })) };\n}\n\nasync function restQueryFindings(params: URLSearchParams): Promise<any> {\n const status = params.get('status') || '';\n const findingType = params.get('finding_type') || '';\n const severity = params.get('severity') || '';\n const limit = Math.min(parseInt(params.get('limit') || '50', 10), 200);\n\n const result = await db.query(\n `SELECT * FROM (\n SELECT DISTINCT ON (sf.finding_id, sf.file_path) sf.*, t.project_id AS repo\n FROM scan_findings sf\n LEFT JOIN trajectories t ON t.conversation_id = sf.session_id\n WHERE ($1::text = '' OR sf.status = $1)\n AND ($2::text = '' OR sf.finding_type = $2)\n AND ($3::text = '' OR sf.severity = $3)\n ORDER BY sf.finding_id, sf.file_path, sf.created_at DESC\n ) d\n ORDER BY created_at DESC NULLS LAST\n LIMIT $4`,\n [status, findingType, severity, limit]\n );\n const deduped = result.rows;\n\n const summary = await db.query<{ status: string; cnt: string }>(\n `SELECT status, count(*) as cnt FROM (\n SELECT DISTINCT ON (finding_id, file_path) status FROM scan_findings ORDER BY finding_id, file_path, created_at DESC\n ) d GROUP BY status`\n );\n const counts: Record<string, number> = {};\n for (const r of summary.rows) counts[r.status] = Number(r.cnt);\n\n return { findings: deduped.map(camelify), open: counts.open || 0, resolved: counts.resolved || 0, total: (counts.open || 0) + (counts.resolved || 0) };\n}\n\nasync function restQueryFindingsSummary(): Promise<any> {\n const result = await db.query<{ status: string; severity: string; cnt: string }>(\n `SELECT status, severity, count(*) as cnt FROM (\n SELECT DISTINCT ON (finding_id, file_path) status, severity FROM scan_findings ORDER BY finding_id, file_path, created_at DESC\n ) d GROUP BY status, severity`\n );\n const open = result.rows.filter(r => r.status === 'open');\n const resolved = result.rows.filter(r => r.status === 'resolved');\n const openCount = open.reduce((n, r) => n + Number(r.cnt), 0);\n const resolvedCount = resolved.reduce((n, r) => n + Number(r.cnt), 0);\n const total = openCount + resolvedCount;\n\n const topResult = await db.query<{ finding_id: string; finding_type: string; cnt: string }>(\n `SELECT finding_id, finding_type, count(*) as cnt FROM (\n SELECT DISTINCT ON (finding_id, file_path) finding_id, finding_type FROM scan_findings WHERE status = 'open' ORDER BY finding_id, file_path, created_at DESC\n ) d GROUP BY finding_id, finding_type ORDER BY cnt DESC LIMIT 10`\n );\n\n return {\n total,\n open: openCount,\n resolved: resolvedCount,\n exempted: 0,\n resolution_rate: total > 0 ? Math.round((resolvedCount / total) * 100) : 0,\n top_findings: topResult.rows.map(r => ({ finding_id: r.finding_id, finding_type: r.finding_type, count: Number(r.cnt) })),\n by_severity: open.reduce((acc: Record<string, number>, r) => { acc[r.severity || 'unknown'] = (acc[r.severity || 'unknown'] || 0) + Number(r.cnt); return acc; }, {}),\n };\n}\n\nasync function restQueryUsers(params: URLSearchParams): Promise<any> {\n const days = parseInt(params.get('days') || '30', 10);\n const cutoff = new Date(Date.now() - days * 86400000).toISOString();\n\n const result = await db.query<{ end_user_id: string; checks: string; violations: string; last_seen: string }>(\n `SELECT end_user_id, count(*) as checks,\n count(*) FILTER (WHERE passed = 0) as violations,\n max(created_at)::text as last_seen\n FROM guard_checks WHERE created_at >= $1 AND end_user_id IS NOT NULL AND end_user_id != ''\n GROUP BY end_user_id ORDER BY checks DESC`, [cutoff]\n );\n\n return { users: result.rows.map(r => ({ endUserId: r.end_user_id, checks: Number(r.checks), violations: Number(r.violations), lastSeen: r.last_seen })) };\n}\n\nasync function restQuerySearch(params: URLSearchParams): Promise<any> {\n const query = params.get('query') || '';\n const limit = Math.min(parseInt(params.get('limit') || '20', 10), 100);\n if (!query) return { results: [] };\n\n const pattern = `%${query}%`;\n const result = await db.query(\n `SELECT id::text, 'check' as type, messages as content, created_at FROM guard_checks WHERE messages ILIKE $1\n UNION ALL\n SELECT id::text, 'trajectory' as type, user_message as content, created_at FROM trajectories WHERE user_message ILIKE $1\n ORDER BY created_at DESC LIMIT $2`,\n [pattern, limit]\n );\n return { results: result.rows.map(camelify) };\n}\n\nasync function restQueryProjects(): Promise<any> {\n const result = await db.query<{ project_id: string; checks: string; last_seen: string }>(\n `SELECT project_id, count(*) as checks, max(created_at)::text as last_seen\n FROM guard_checks WHERE project_id IS NOT NULL\n GROUP BY project_id ORDER BY last_seen DESC`\n );\n return result.rows.map(r => ({ id: r.project_id, name: r.project_id, checks: Number(r.checks), lastSeen: r.last_seen }));\n}\n\n// \u2500\u2500\u2500 HTTP Server \u2500\u2500\u2500\n\nasync function reclassifyViolations(): Promise<{ total: number; classified: number }> {\n if (!embedder) return { total: 0, classified: 0 };\n const result = await db.query(`SELECT id, messages, verdicts, key, severity, interaction_type FROM guard_violations`);\n let classified = 0;\n for (const row of result.rows as any[]) {\n let text = '';\n try {\n const msgs = typeof row.messages === 'string' ? JSON.parse(row.messages) : row.messages;\n if (msgs?.command) text = msgs.command;\n else if (msgs?.content) text = msgs.content;\n else if (typeof msgs === 'string') text = msgs;\n } catch {}\n try {\n const v = typeof row.verdicts === 'string' ? JSON.parse(row.verdicts) : row.verdicts;\n if (v?.reasoning) text += ' ' + v.reasoning;\n } catch {}\n if (!text.trim()) continue;\n const cat = await classifyText(text.slice(0, 1000));\n if (cat) {\n await db.query(\n `UPDATE guard_violations SET mechanism_category = $1, classification_confidence = $2 WHERE id = $3`,\n [cat.id + ': ' + cat.label, cat.confidence, row.id]\n );\n classified++;\n }\n }\n return { total: result.rows.length, classified };\n}\n\n// \u2500\u2500\u2500 Grader Pool Dispatcher \u2500\u2500\u2500\n// Hooks POST to 8929 (general: bash/edit/plan) or 8930 (CWE). We round-robin\n// across N persistent claude workers per pool. Workers are managed by pueue \u2014\n// the dispatcher just picks the least-busy one and forwards the request.\n\ninterface Worker {\n url: string;\n inFlight: number;\n sickUntil: number;\n}\n\n// Pool size is read from WORKERS_PER_POOL at boot. Defaults to 2 so the bare-\n// host install (which still ships exactly 2 cc_sessions per pool) keeps working\n// unchanged; the container's entrypoint overrides it via env var to match\n// whatever pool-config.sh just queued up.\nconst WORKERS_PER_POOL = Math.max(1, parseInt(process.env.WORKERS_PER_POOL || '2', 10));\nconst GENERAL_BASE_PORT = 8940;\nconst CWE_BASE_PORT = 8950;\n\nfunction buildPool(base: number, n: number): Worker[] {\n const pool: Worker[] = [];\n for (let i = 1; i <= n; i++) {\n pool.push({ url: `http://127.0.0.1:${base + i}`, inFlight: 0, sickUntil: 0 });\n }\n return pool;\n}\n\nconst POOLS: Record<string, Worker[]> = {\n general: buildPool(GENERAL_BASE_PORT, WORKERS_PER_POOL),\n cwe: buildPool(CWE_BASE_PORT, WORKERS_PER_POOL),\n};\n\nfunction pickWorker(pool: Worker[]): Worker | null {\n const now = Date.now();\n const healthy = pool.filter(w => w.sickUntil <= now);\n if (healthy.length === 0) return null;\n return healthy.reduce((best, w) => (w.inFlight < best.inFlight ? w : best), healthy[0]);\n}\n\nasync function dispatchGrade(req: Request, pool: Worker[], path: string): Promise<Response> {\n const body = req.method === 'POST' ? await req.text() : '';\n const tried = new Set<string>();\n const headers: Record<string, string> = {};\n req.headers.forEach((v, k) => {\n const lk = k.toLowerCase();\n if (lk !== 'host' && lk !== 'connection' && lk !== 'content-length') headers[lk] = v;\n });\n\n for (let attempt = 0; attempt < pool.length; attempt++) {\n const worker = pickWorker(pool);\n if (!worker || tried.has(worker.url)) break;\n tried.add(worker.url);\n worker.inFlight++;\n try {\n const resp = await fetch(worker.url + path, {\n method: req.method,\n headers,\n body: body || undefined,\n signal: AbortSignal.timeout(60000),\n });\n worker.inFlight--;\n if (resp.status >= 500) {\n worker.sickUntil = Date.now() + 30000;\n continue;\n }\n const respBody = await resp.text();\n return new Response(respBody, {\n status: resp.status,\n headers: { 'Content-Type': resp.headers.get('content-type') || 'application/json' },\n });\n } catch {\n worker.inFlight = Math.max(0, worker.inFlight - 1);\n worker.sickUntil = Date.now() + 30000;\n }\n }\n return new Response(JSON.stringify({ error: 'no healthy grader workers' }), {\n status: 503,\n headers: { 'Content-Type': 'application/json' },\n });\n}\n\nfunction poolHealthSnapshot(name: string, pool: Worker[]) {\n const now = Date.now();\n return {\n pool: name,\n workers: pool.length,\n healthy: pool.filter(w => w.sickUntil <= now).length,\n pending: pool.reduce((s, w) => s + w.inFlight, 0),\n detail: pool.map(w => ({\n url: w.url,\n inFlight: w.inFlight,\n sickFor: w.sickUntil > now ? Math.round((w.sickUntil - now) / 1000) : 0,\n })),\n };\n}\n\nfunction startDispatcher(pool: Worker[], port: number, label: string): void {\n Bun.serve({\n port,\n hostname: BIND_HOST,\n async fetch(req) {\n const url = new URL(req.url);\n if (url.pathname === '/healthz' && req.method === 'GET') {\n return Response.json(poolHealthSnapshot(label, pool));\n }\n if (url.pathname === '/submit' && req.method === 'POST') {\n return dispatchGrade(req, pool, '/submit');\n }\n return dispatchGrade(req, pool, url.pathname + url.search);\n },\n });\n console.log(`[synkro] grader dispatcher (${label}) on http://127.0.0.1:${port} \u2192 ${pool.map(w => w.url).join(', ')}`);\n}\n\nasync function startServer(): Promise<void> {\n await initDb();\n startDispatcher(POOLS.general, 8929, 'general');\n startDispatcher(POOLS.cwe, 8930, 'cwe');\n console.log(`[synkro] Postgres wire-protocol on postgresql://postgres@127.0.0.1:${PG_SOCKET_PORT}/postgres?sslmode=disable`);\n initEmbedder().then(async () => {\n if (!embedder) return;\n const stats = await reclassifyViolations().catch(() => null);\n if (stats) console.log(`[synkro] Reclassified ${stats.classified}/${stats.total} violations with ASI categories`);\n });\n\n const server = Bun.serve({\n port: PORT,\n hostname: BIND_HOST,\n async fetch(req) {\n const origin = req.headers.get('origin') || '';\n const allowedOrigin = /^https?:\\/\\/(localhost|127\\.0\\.0\\.1)(:\\d+)?$/.test(origin) ? origin : 'http://localhost:4322';\n const cors = { 'Access-Control-Allow-Origin': allowedOrigin, 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization' };\n const url = new URL(req.url);\n const path = url.pathname;\n\n if (req.method === 'OPTIONS') {\n return new Response('', { status: 204, headers: cors });\n }\n\n // Health check (unauthenticated)\n if (req.method === 'GET' && (path === '/' || path === '/health')) {\n return Response.json({ name: 'synkro-guardrails-local', version: '2.0.0', status: 'ok' }, { headers: cors });\n }\n\n // POST /api/local/reclassify \u2014 reclassify all violations using OWASP embeddings\n if (req.method === 'POST' && path === '/api/local/reclassify') {\n if (!embedder) return Response.json({ error: 'Embedding model not loaded yet' }, { status: 503, headers: cors });\n const result = await reclassifyViolations();\n return Response.json(result, { headers: cors });\n }\n\n // GET /api/local/* \u2014 no Bearer required (server bound to 127.0.0.1 only)\n if (req.method === 'GET' && path.startsWith('/api/local/')) {\n if (path === '/api/local/checks') return Response.json(await restQueryChecks(url.searchParams), { headers: cors });\n if (path === '/api/local/violations') return Response.json(await restQueryViolations(url.searchParams), { headers: cors });\n if (path === '/api/local/trajectories') return Response.json(await restQueryTrajectories(url.searchParams), { headers: cors });\n const trajDetailMatch = path.match(/^\\/api\\/local\\/trajectories\\/([^/]+)$/);\n if (trajDetailMatch) {\n const detail = await restQueryTrajectoryDetail(decodeURIComponent(trajDetailMatch[1]));\n if (detail) return Response.json(detail, { headers: cors });\n return Response.json({ error: 'Not found' }, { status: 404, headers: cors });\n }\n if (path === '/api/local/metrics') return Response.json(await restQueryMetrics(url.searchParams), { headers: cors });\n if (path === '/api/local/trends') return Response.json(await restQueryTrends(url.searchParams), { headers: cors });\n if (path === '/api/local/findings/summary') return Response.json(await restQueryFindingsSummary(), { headers: cors });\n if (path === '/api/local/findings') return Response.json(await restQueryFindings(url.searchParams), { headers: cors });\n if (path === '/api/local/users') return Response.json(await restQueryUsers(url.searchParams), { headers: cors });\n if (path === '/api/local/search') return Response.json(await restQuerySearch(url.searchParams), { headers: cors });\n if (path === '/api/local/projects') return Response.json(await restQueryProjects(), { headers: cors });\n if (path === '/api/local/hook-config') {\n const data = await readRules();\n const policy = data.policies.find(p => p.id === data.config.activePolicyId) || data.policies[0] || null;\n return Response.json({\n policy: policy ? { id: policy.id, name: policy.name, rules: policy.rules } : null,\n silent: data.config.silent,\n scan_exemptions: data.scanExemptions,\n }, { headers: cors });\n }\n return Response.json({ error: 'Not found' }, { status: 404, headers: cors });\n }\n\n // \u2500\u2500\u2500 REST: Reclassify violations with OWASP LLM categories \u2500\u2500\u2500\n if (req.method === 'POST' && path === '/api/local/reclassify') {\n try {\n const stats = await reclassifyViolations();\n return Response.json({ ok: true, ...stats }, { headers: cors });\n } catch (e) {\n return Response.json({ error: String(e) }, { status: 500, headers: cors });\n }\n }\n\n // \u2500\u2500\u2500 REST: Migrate rows from IndexedDB (no auth \u2014 localhost-only, one-time migration) \u2500\u2500\u2500\n if (req.method === 'POST' && path === '/api/migrate/rows') {\n const ALLOWED_COLS: Record<string, Set<string>> = {\n guard_checks: new Set(['id','project_id','org_id','policy_id','trace_id','passed','score','rule_count','rule_ids_checked','model','interaction_type','skill_name','tool_names','guard_mode','messages','verdicts','rule_similarities','sentiment_score','sentiment_label','operation_type','conversation_id','trajectory_id','end_user_id','reasoning_content','cve_findings','judge_context','provider','input_tokens','output_tokens','total_tokens','cost_usd','content_redacted','updated_at','created_at']),\n guard_violations: new Set(['id','user_id','org_id','policy_id','end_user_id','project_id','run_id','trajectory_id','key','score','value','comment','rules_violated','rule_ids_violated','issues','severity','latency_ms','messages','verdicts','model','guard_mode','interaction_type','tool_names','skill_name','passed','conversation_id','mechanism_category','business_category','classification_confidence','content_redacted','updated_at','created_at']),\n trajectories: new Set(['id','project_id','org_id','conversation_id','check_ids','check_count','preamble','user_message','final_response','status','passed','score','verdicts','rule_ids_checked','rule_ids_violated','severity','model','provider','input_tokens','output_tokens','total_tokens','cost_usd','interaction_type','operation_type','end_user_id','policy_id','topic_label','started_at','completed_at','created_at']),\n usage_ticks: new Set(['id','session_id','model','input_tokens','output_tokens','cache_creation_tokens','cache_read_tokens','created_at']),\n scan_findings: new Set(['id','session_id','file_path','finding_type','finding_id','severity','status','detail','description','package_name','package_version','fixed_version','aliases','references','cwe_name','resolved_at','created_at']),\n };\n try {\n const body = await req.json() as any;\n const table = body.table;\n const rows = Array.isArray(body.rows) ? body.rows : [];\n const allowedCols = ALLOWED_COLS[table];\n if (!allowedCols) {\n return Response.json({ error: 'invalid table' }, { status: 400, headers: cors });\n }\n let inserted = 0;\n for (const row of rows.slice(0, 2000)) {\n try {\n const cols = Object.keys(row).filter(k => allowedCols.has(k) && row[k] !== undefined && row[k] !== null);\n if (!cols.length) continue;\n const placeholders = cols.map((_, i) => `$${i + 1}`).join(', ');\n const colNames = cols.map(c => c === 'references' ? '\"references\"' : c).join(', ');\n const values = cols.map(k => {\n const v = row[k];\n if (Array.isArray(v)) return v;\n if (typeof v === 'object' && v !== null) return JSON.stringify(v);\n return v;\n });\n await db.query(`INSERT INTO ${table} (${colNames}) VALUES (${placeholders}) ON CONFLICT DO NOTHING`, values);\n inserted++;\n } catch {}\n }\n return Response.json({ ok: true, inserted }, { headers: cors });\n } catch (e) {\n return Response.json({ error: String(e) }, { status: 400, headers: cors });\n }\n }\n\n // Auth check for POST routes (ingest, JSON-RPC)\n const authHeader = req.headers.get('authorization') || '';\n const bearer = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';\n if (bearer !== SERVER_TOKEN) {\n return Response.json({ error: 'Unauthorized' }, { status: 401, headers: cors });\n }\n\n // \u2500\u2500\u2500 REST: Ingest \u2500\u2500\u2500\n if (req.method === 'POST' && path === '/api/ingest') {\n try {\n const body = await req.json() as any;\n await ingestEvent(body.data || body);\n return Response.json({ ok: true }, { headers: cors });\n } catch (e) {\n return Response.json({ error: String(e) }, { status: 400, headers: cors });\n }\n }\n\n if (req.method === 'POST' && path === '/api/ingest/batch') {\n try {\n const body = await req.json() as any;\n const events = Array.isArray(body) ? body : (body.events || []);\n let ingested = 0;\n for (const evt of events.slice(0, 500)) {\n try { await ingestEvent(evt.data || evt); ingested++; } catch {}\n }\n return Response.json({ ok: true, ingested }, { headers: cors });\n } catch (e) {\n return Response.json({ error: String(e) }, { status: 400, headers: cors });\n }\n }\n\n // \u2500\u2500\u2500 JSON-RPC (MCP protocol) \u2500\u2500\u2500\n if (req.method === 'POST') {\n const raw = await req.arrayBuffer();\n if (raw.byteLength > MAX_BODY_BYTES) {\n return Response.json(jsonRpcError(null, -32600, 'Request too large'), { status: 413, headers: cors });\n }\n return serialized(async () => {\n try {\n const body = JSON.parse(new TextDecoder().decode(raw));\n const result = await handleRpc(body);\n if (result === null) return new Response('', { status: 204, headers: cors });\n return Response.json(result, { headers: cors });\n } catch {\n return Response.json(jsonRpcError(null, -32700, 'Parse error'), { status: 400, headers: cors });\n }\n });\n }\n\n return new Response('Not found', { status: 404, headers: cors });\n },\n });\n\n console.log(`[synkro] local MCP server listening on http://127.0.0.1:${server.port}`);\n}\n\nasync function shutdown() {\n try { await db?.end(); } catch {}\n process.exit(0);\n}\nprocess.on('SIGINT', shutdown);\nprocess.on('SIGTERM', shutdown);\n\nstartServer().catch(err => {\n console.error('[synkro] Failed to start server:', err);\n process.exit(1);\n});\n", "utf-8");
|
|
6649
|
-
writeFileSync8(pgliteDbPath, "#!/usr/bin/env bun\n/**\n * Standalone PGlite data server.\n *\n * Owns the local Postgres datadir at ~/.synkro/pgdata and exposes the\n * Postgres wire protocol on 127.0.0.1:5433. The MCP server connects as\n * a normal pg client. This is the only writer process for pgdata.\n *\n * Lifecycle: starts PGlite, loads vector extension, then prints \"READY\"\n * to stdout so the parent can wait for it before opening a pool.\n */\n\nimport { homedir } from 'node:os';\nimport { join } from 'node:path';\nimport { mkdirSync } from 'node:fs';\nimport { PGlite } from '@electric-sql/pglite';\nimport { PGLiteSocketServer } from '@electric-sql/pglite-socket';\nimport { vector } from '@electric-sql/pglite/vector';\n\nconst HOME = homedir();\nconst PGDATA_PATH = process.env.SYNKRO_PGDATA_PATH || join(HOME, '.synkro', 'pgdata');\nconst PORT = parseInt(process.env.SYNKRO_PG_PORT || '5433', 10);\nconst BIND_HOST = process.env.SYNKRO_BIND_HOST || '127.0.0.1';\n\nlet db: PGlite | null = null;\nlet socketServer: PGLiteSocketServer | null = null;\n\nasync function main(): Promise<void> {\n mkdirSync(join(HOME, '.synkro'), { recursive: true });\n\n db = new PGlite(PGDATA_PATH, { extensions: { vector } });\n await db.waitReady;\n\n socketServer = new PGLiteSocketServer({ db, port: PORT, host: BIND_HOST });\n (socketServer as any).on?.('error', (err: unknown) => {\n process.stderr.write(`[pglite-db] socket error: ${String(err).slice(0, 200)}\\n`);\n });\n await socketServer.start();\n\n process.stdout.write(`READY postgresql://postgres@127.0.0.1:${PORT}/postgres?sslmode=disable\\n`);\n}\n\nasync function shutdown(): Promise<void> {\n try { await socketServer?.stop(); } catch {}\n try { await db?.close(); } catch {}\n process.exit(0);\n}\n\nprocess.on('SIGINT', shutdown);\nprocess.on('SIGTERM', shutdown);\n\nmain().catch(err => {\n process.stderr.write(`[pglite-db] failed to start: ${err instanceof Error ? err.stack || err.message : JSON.stringify(err)}\\n`);\n process.exit(1);\n});\n", "utf-8");
|
|
6650
|
-
writeFileSync8(mcpStdioProxyPath, MCP_STDIO_PROXY_SRC, "utf-8");
|
|
6651
|
-
chmodSync3(bashScriptPath, 493);
|
|
6652
|
-
chmodSync3(bashFollowupScriptPath, 493);
|
|
6653
|
-
chmodSync3(editPrecheckScriptPath, 493);
|
|
6654
|
-
chmodSync3(cwePrecheckScriptPath, 493);
|
|
6655
|
-
chmodSync3(cvePrecheckScriptPath, 493);
|
|
6656
|
-
chmodSync3(planJudgeScriptPath, 493);
|
|
6657
|
-
chmodSync3(agentJudgeScriptPath, 493);
|
|
6658
|
-
chmodSync3(stopSummaryScriptPath, 493);
|
|
6659
|
-
chmodSync3(sessionStartScriptPath, 493);
|
|
6660
|
-
chmodSync3(transcriptSyncScriptPath, 493);
|
|
6661
|
-
chmodSync3(userPromptSubmitScriptPath, 493);
|
|
6662
|
-
chmodSync3(commonScriptPath, 493);
|
|
6663
|
-
chmodSync3(commonBashScriptPath, 493);
|
|
6664
|
-
chmodSync3(cursorBashJudgePath, 493);
|
|
6665
|
-
chmodSync3(cursorEditCapturePath, 493);
|
|
6666
|
-
chmodSync3(mcpLocalServerPath, 493);
|
|
6667
|
-
chmodSync3(pgliteDbPath, 493);
|
|
6668
|
-
chmodSync3(mcpStdioProxyPath, 493);
|
|
6669
|
-
return {
|
|
6670
|
-
bashScript: bashScriptPath,
|
|
6671
|
-
bashFollowupScript: bashFollowupScriptPath,
|
|
6672
|
-
editPrecheckScript: editPrecheckScriptPath,
|
|
6673
|
-
cwePrecheckScript: cwePrecheckScriptPath,
|
|
6674
|
-
cvePrecheckScript: cvePrecheckScriptPath,
|
|
6675
|
-
planJudgeScript: planJudgeScriptPath,
|
|
6676
|
-
agentJudgeScript: agentJudgeScriptPath,
|
|
6677
|
-
stopSummaryScript: stopSummaryScriptPath,
|
|
6678
|
-
sessionStartScript: sessionStartScriptPath,
|
|
6679
|
-
transcriptSyncScript: transcriptSyncScriptPath,
|
|
6680
|
-
userPromptSubmitScript: userPromptSubmitScriptPath,
|
|
6681
|
-
cursorBashJudgeScript: cursorBashJudgePath,
|
|
6682
|
-
cursorEditCaptureScript: cursorEditCapturePath,
|
|
6683
|
-
mcpLocalServerScript: mcpLocalServerPath
|
|
6684
|
-
};
|
|
6685
|
-
}
|
|
6686
|
-
function sanitizeConfigValue(raw, maxLen = 256) {
|
|
6687
|
-
if (!raw) return "";
|
|
6688
|
-
return raw.replace(/[^\x20-\x7E]/g, "").slice(0, maxLen);
|
|
6689
|
-
}
|
|
6690
|
-
function shellQuoteSingle(value) {
|
|
6691
|
-
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
6692
|
-
}
|
|
6693
|
-
function resolveSynkroBundle() {
|
|
6694
|
-
const scriptPath = process.argv[1];
|
|
6695
|
-
if (scriptPath && existsSync12(scriptPath)) return scriptPath;
|
|
6696
|
-
return null;
|
|
6697
|
-
}
|
|
6698
|
-
function writeConfigEnv(opts) {
|
|
6699
|
-
const credsPath = join12(SYNKRO_DIR4, "credentials.json");
|
|
6700
|
-
const safeGateway = sanitizeConfigValue(opts.gatewayUrl);
|
|
6701
|
-
const safeUserId = sanitizeConfigValue(opts.userId);
|
|
6702
|
-
const safeOrgId = sanitizeConfigValue(opts.orgId);
|
|
6703
|
-
const safeEmail = sanitizeConfigValue(opts.email);
|
|
6704
|
-
const safeTier = sanitizeConfigValue(opts.tier ?? "pro", 32);
|
|
6705
|
-
const safeInference = sanitizeConfigValue(opts.inference ?? "fast", 16);
|
|
6706
|
-
const safeSynkroBin = sanitizeConfigValue(opts.synkroBin ?? "", 1024);
|
|
6707
|
-
const lines = [
|
|
6708
|
-
"# Synkro CLI config (managed by synkro install)",
|
|
6709
|
-
"# JWT auth \u2014 the hook scripts read SYNKRO_CREDENTIALS_PATH at runtime",
|
|
6710
|
-
"# and send Authorization: Bearer <access_token> on every gateway call.",
|
|
6711
|
-
`SYNKRO_GATEWAY_URL=${shellQuoteSingle(safeGateway)}`,
|
|
6712
|
-
`SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
|
|
6713
|
-
`SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
|
|
6714
|
-
`SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
|
|
6715
|
-
`SYNKRO_VERSION=${shellQuoteSingle("1.4.87")}`
|
|
6716
|
-
];
|
|
6717
|
-
if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
|
|
6718
|
-
if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
|
|
6719
|
-
if (safeOrgId) lines.push(`SYNKRO_ORG_ID=${shellQuoteSingle(safeOrgId)}`);
|
|
6720
|
-
if (safeEmail) lines.push(`SYNKRO_EMAIL=${shellQuoteSingle(safeEmail)}`);
|
|
6721
|
-
if (opts.transcriptConsent !== void 0) {
|
|
6722
|
-
lines.push(`SYNKRO_TRANSCRIPT_CONSENT=${shellQuoteSingle(opts.transcriptConsent ? "yes" : "no")}`);
|
|
6723
|
-
}
|
|
6724
|
-
lines.push(`SYNKRO_LOCAL_INFERENCE=${shellQuoteSingle(opts.localInference ? "yes" : "no")}`);
|
|
6725
|
-
const safeMode = sanitizeConfigValue(opts.deploymentMode ?? "bare-host", 16);
|
|
6726
|
-
lines.push(`SYNKRO_DEPLOYMENT_MODE=${shellQuoteSingle(safeMode)}`);
|
|
6727
|
-
lines.push("");
|
|
6728
|
-
writeFileSync8(CONFIG_PATH3, lines.join("\n"), "utf-8");
|
|
6729
|
-
chmodSync3(CONFIG_PATH3, 384);
|
|
6730
|
-
}
|
|
6731
|
-
function resolveDeploymentMode() {
|
|
6732
|
-
const envOverride = process.env.SYNKRO_DEPLOYMENT_MODE;
|
|
6733
|
-
if (envOverride) return envOverride.toLowerCase();
|
|
6734
|
-
try {
|
|
6735
|
-
if (existsSync12(CONFIG_PATH3)) {
|
|
6736
|
-
const m = readFileSync10(CONFIG_PATH3, "utf-8").match(/^SYNKRO_DEPLOYMENT_MODE='([^']*)'/m);
|
|
6737
|
-
if (m && m[1]) return m[1].toLowerCase();
|
|
6738
|
-
}
|
|
6739
|
-
} catch {
|
|
6740
|
-
}
|
|
6741
|
-
return "bare-host";
|
|
6742
|
-
}
|
|
6743
|
-
function updateLocalInferenceFlag(enabled) {
|
|
6744
|
-
if (!existsSync12(CONFIG_PATH3)) return;
|
|
6745
|
-
let content = readFileSync10(CONFIG_PATH3, "utf-8");
|
|
6746
|
-
const flag = enabled ? "yes" : "no";
|
|
6747
|
-
if (content.includes("SYNKRO_LOCAL_INFERENCE=")) {
|
|
6748
|
-
content = content.replace(/^SYNKRO_LOCAL_INFERENCE='[^']*'/m, `SYNKRO_LOCAL_INFERENCE='${flag}'`);
|
|
6749
|
-
} else {
|
|
6750
|
-
content = content.trimEnd() + `
|
|
6751
|
-
SYNKRO_LOCAL_INFERENCE='${flag}'
|
|
6752
|
-
`;
|
|
6753
|
-
}
|
|
6754
|
-
writeFileSync8(CONFIG_PATH3, content, "utf-8");
|
|
6755
|
-
}
|
|
6756
|
-
function collectLocalMetadata() {
|
|
6757
|
-
const meta = { platform: process.platform };
|
|
6758
|
-
try {
|
|
6759
|
-
meta.display_name = execSync5("git config user.name", { encoding: "utf-8", timeout: 3e3 }).trim();
|
|
6760
|
-
} catch {
|
|
6761
|
-
}
|
|
6762
|
-
try {
|
|
6763
|
-
const remote = execSync5("git remote get-url origin", { encoding: "utf-8", timeout: 3e3 }).trim();
|
|
6764
|
-
const sshMatch = remote.match(/^git@[^:]+:(.+?)(?:\.git)?$/);
|
|
6765
|
-
const httpMatch = remote.match(/^https?:\/\/[^/]+\/(.+?)(?:\.git)?$/);
|
|
6766
|
-
const m = sshMatch || httpMatch;
|
|
6767
|
-
if (m) meta.active_repo = m[1];
|
|
6768
|
-
} catch {
|
|
6769
|
-
}
|
|
6770
|
-
try {
|
|
6771
|
-
meta.cc_version = execSync5("claude --version", { encoding: "utf-8", timeout: 5e3 }).trim().split("\n")[0];
|
|
6772
|
-
} catch {
|
|
6773
|
-
}
|
|
6774
|
-
const claudeDir = join12(homedir12(), ".claude");
|
|
6775
|
-
try {
|
|
6776
|
-
const settings = JSON.parse(readFileSync10(join12(claudeDir, "settings.json"), "utf-8"));
|
|
6777
|
-
const plugins = Object.keys(settings.enabledPlugins ?? {}).filter((k) => settings.enabledPlugins[k]);
|
|
6778
|
-
if (plugins.length) meta.enabled_plugins = plugins;
|
|
6779
|
-
if (settings.permissions?.defaultMode) meta.permissions_mode = settings.permissions.defaultMode;
|
|
6780
|
-
} catch {
|
|
6781
|
-
}
|
|
6782
|
-
try {
|
|
6783
|
-
const mcpCache = JSON.parse(readFileSync10(join12(claudeDir, "mcp-needs-auth-cache.json"), "utf-8"));
|
|
6784
|
-
const mcpNames = Object.keys(mcpCache);
|
|
6785
|
-
if (mcpNames.length) meta.mcp_servers = mcpNames;
|
|
6786
|
-
} catch {
|
|
6787
|
-
}
|
|
6788
|
-
try {
|
|
6789
|
-
const mcpList = execSync5("claude mcp list 2>/dev/null", { encoding: "utf-8", timeout: 1e4 });
|
|
6790
|
-
const connected = mcpList.split("\n").filter((l) => l.includes("Connected")).map((l) => l.split(":")[0].trim()).filter(Boolean);
|
|
6791
|
-
if (connected.length) meta.mcp_servers_connected = connected;
|
|
6792
|
-
} catch {
|
|
6793
|
-
}
|
|
6794
|
-
try {
|
|
6795
|
-
const sessionsDir = join12(claudeDir, "sessions");
|
|
6796
|
-
const files = readdirSync(sessionsDir).filter((f) => f.endsWith(".json")).slice(-5);
|
|
6797
|
-
for (const f of files) {
|
|
6798
|
-
const s = JSON.parse(readFileSync10(join12(sessionsDir, f), "utf-8"));
|
|
6799
|
-
if (s.version) {
|
|
6800
|
-
meta.cc_version = meta.cc_version || s.version;
|
|
6801
|
-
break;
|
|
6802
|
-
}
|
|
6803
|
-
}
|
|
6804
|
-
} catch {
|
|
6805
|
-
}
|
|
6806
|
-
return meta;
|
|
5721
|
+
} catch {
|
|
5722
|
+
}
|
|
5723
|
+
return meta;
|
|
6807
5724
|
}
|
|
6808
5725
|
async function fetchUserProfile(gatewayUrl, token) {
|
|
6809
5726
|
try {
|
|
@@ -6852,20 +5769,20 @@ function assertGatewayAllowed(gatewayUrl) {
|
|
|
6852
5769
|
}
|
|
6853
5770
|
function isAlreadyInstalled() {
|
|
6854
5771
|
const requiredScripts = [
|
|
6855
|
-
|
|
6856
|
-
|
|
6857
|
-
|
|
6858
|
-
|
|
6859
|
-
|
|
6860
|
-
|
|
6861
|
-
|
|
5772
|
+
join8(HOOKS_DIR, "cc-bash-judge.ts"),
|
|
5773
|
+
join8(HOOKS_DIR, "cc-bash-followup.ts"),
|
|
5774
|
+
join8(HOOKS_DIR, "cc-edit-precheck.ts"),
|
|
5775
|
+
join8(HOOKS_DIR, "cc-cve-precheck.ts"),
|
|
5776
|
+
join8(HOOKS_DIR, "cc-plan-judge.ts"),
|
|
5777
|
+
join8(HOOKS_DIR, "cc-stop-summary.ts"),
|
|
5778
|
+
join8(HOOKS_DIR, "cc-session-start.ts")
|
|
6862
5779
|
];
|
|
6863
|
-
if (!requiredScripts.every((p) =>
|
|
6864
|
-
if (!
|
|
6865
|
-
const settingsPath =
|
|
6866
|
-
if (!
|
|
5780
|
+
if (!requiredScripts.every((p) => existsSync9(p))) return false;
|
|
5781
|
+
if (!existsSync9(CONFIG_PATH2)) return false;
|
|
5782
|
+
const settingsPath = join8(homedir8(), ".claude", "settings.json");
|
|
5783
|
+
if (!existsSync9(settingsPath)) return false;
|
|
6867
5784
|
try {
|
|
6868
|
-
const settings = JSON.parse(
|
|
5785
|
+
const settings = JSON.parse(readFileSync7(settingsPath, "utf-8"));
|
|
6869
5786
|
const hooks = settings?.hooks;
|
|
6870
5787
|
if (!hooks || typeof hooks !== "object") return false;
|
|
6871
5788
|
const hasManaged = (kind) => Array.isArray(hooks[kind]) && hooks[kind].some((entry) => entry?.__synkro_managed__ === true);
|
|
@@ -6878,226 +5795,6 @@ function isAlreadyInstalled() {
|
|
|
6878
5795
|
}
|
|
6879
5796
|
return true;
|
|
6880
5797
|
}
|
|
6881
|
-
function printChannelDiagnostics() {
|
|
6882
|
-
try {
|
|
6883
|
-
const tmuxCheck = spawnSync5("tmux", ["has-session", "-t", "synkro-local-cc"], { encoding: "utf-8" });
|
|
6884
|
-
console.warn(` tmux session: ${tmuxCheck.status === 0 ? "running" : "not running"}`);
|
|
6885
|
-
const pueueTask = findTask();
|
|
6886
|
-
console.warn(` pueue task: ${pueueTask ? `id=${pueueTask.id} status=${pueueTask.status}` : "not found"}`);
|
|
6887
|
-
const bunCheck = spawnSync5("bun", ["--version"], { encoding: "utf-8" });
|
|
6888
|
-
console.warn(` bun: ${bunCheck.status === 0 ? bunCheck.stdout.trim() : "not found"}`);
|
|
6889
|
-
const claudeCheck = spawnSync5("claude", ["--version"], { encoding: "utf-8" });
|
|
6890
|
-
console.warn(` claude: ${claudeCheck.status === 0 ? claudeCheck.stdout.trim().split("\n")[0] : "not found"}`);
|
|
6891
|
-
if (pueueTask) {
|
|
6892
|
-
const logs = tailLogs(15);
|
|
6893
|
-
if (logs && logs !== "(no output)") {
|
|
6894
|
-
console.warn(` pueue logs (last 15 lines):`);
|
|
6895
|
-
for (const line of logs.split("\n").slice(0, 15)) {
|
|
6896
|
-
console.warn(` ${line}`);
|
|
6897
|
-
}
|
|
6898
|
-
}
|
|
6899
|
-
}
|
|
6900
|
-
const logPath = join12(homedir12(), ".synkro", "cc_sessions", "run-claude.log");
|
|
6901
|
-
if (existsSync12(logPath)) {
|
|
6902
|
-
const logContent = readFileSync10(logPath, "utf-8").trim().split("\n").slice(-10);
|
|
6903
|
-
console.warn(` run-claude.log:`);
|
|
6904
|
-
for (const line of logContent) console.warn(` ${line}`);
|
|
6905
|
-
}
|
|
6906
|
-
} catch {
|
|
6907
|
-
}
|
|
6908
|
-
console.warn(` Run \`synkro local-cc status\` and \`synkro local-cc logs --tmux\` to debug.`);
|
|
6909
|
-
}
|
|
6910
|
-
async function backfillLocalRules(gatewayUrl, token) {
|
|
6911
|
-
if (existsSync12(RULES_PATH)) {
|
|
6912
|
-
console.log(" Local rules already exist \u2014 skipping cloud backfill.");
|
|
6913
|
-
return;
|
|
6914
|
-
}
|
|
6915
|
-
try {
|
|
6916
|
-
const resp = await fetch(`${gatewayUrl}/api/v1/hook/config`, {
|
|
6917
|
-
headers: { "Authorization": `Bearer ${token}` },
|
|
6918
|
-
signal: AbortSignal.timeout(8e3)
|
|
6919
|
-
});
|
|
6920
|
-
if (!resp.ok) {
|
|
6921
|
-
console.log(" No cloud rules to backfill.");
|
|
6922
|
-
return;
|
|
6923
|
-
}
|
|
6924
|
-
const data = await resp.json();
|
|
6925
|
-
const rules = (data.rules || []).map((r) => ({
|
|
6926
|
-
rule_id: r.rule_id || `r_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
6927
|
-
text: r.text || "",
|
|
6928
|
-
category: r.category || "custom",
|
|
6929
|
-
severity: r.severity || "medium",
|
|
6930
|
-
mode: r.mode || "blocking",
|
|
6931
|
-
hook_stage: r.hook_stage || "both",
|
|
6932
|
-
scope: r.scope || "user"
|
|
6933
|
-
}));
|
|
6934
|
-
const policyName = data.active_policy_name || "My Rules";
|
|
6935
|
-
const policyId = data.active_policy_id || "local-policy";
|
|
6936
|
-
const scanExemptions = (data.scan_exemptions || []).filter((e) => e && typeof e.path === "string").map((e) => ({ path: e.path, cwe_id: e.cwe_id || "", reason: e.reason }));
|
|
6937
|
-
const silent = data.silent_mode === true || data.silent_mode === "true";
|
|
6938
|
-
const rulesFile = {
|
|
6939
|
-
policies: [{
|
|
6940
|
-
id: policyId,
|
|
6941
|
-
name: policyName,
|
|
6942
|
-
rules,
|
|
6943
|
-
ruleCount: rules.length,
|
|
6944
|
-
scopeOwner: "user",
|
|
6945
|
-
isActive: true
|
|
6946
|
-
}],
|
|
6947
|
-
config: { silent, activePolicyId: policyId },
|
|
6948
|
-
scanExemptions
|
|
6949
|
-
};
|
|
6950
|
-
const tmp = RULES_PATH + ".tmp";
|
|
6951
|
-
writeFileSync8(tmp, JSON.stringify(rulesFile, null, 2) + "\n", "utf-8");
|
|
6952
|
-
renameSync5(tmp, RULES_PATH);
|
|
6953
|
-
console.log(` Backfilled ${rules.length} rules from cloud to ~/.synkro/rules.json`);
|
|
6954
|
-
} catch (err) {
|
|
6955
|
-
console.warn(` \u26A0 Cloud backfill failed: ${err.message}`);
|
|
6956
|
-
}
|
|
6957
|
-
}
|
|
6958
|
-
async function installMcpDependencies() {
|
|
6959
|
-
const pkgJsonPath = join12(SYNKRO_DIR4, "package.json");
|
|
6960
|
-
const requiredDeps = {
|
|
6961
|
-
"@electric-sql/pglite": "^0.4.0",
|
|
6962
|
-
"@electric-sql/pglite-socket": "^0.1.0",
|
|
6963
|
-
"pg": "^8.13.1"
|
|
6964
|
-
};
|
|
6965
|
-
let needsInstall = false;
|
|
6966
|
-
if (existsSync12(pkgJsonPath)) {
|
|
6967
|
-
try {
|
|
6968
|
-
const existing = JSON.parse(readFileSync10(pkgJsonPath, "utf-8"));
|
|
6969
|
-
const deps = existing.dependencies || {};
|
|
6970
|
-
for (const [name] of Object.entries(requiredDeps)) {
|
|
6971
|
-
if (!deps[name]) {
|
|
6972
|
-
needsInstall = true;
|
|
6973
|
-
break;
|
|
6974
|
-
}
|
|
6975
|
-
}
|
|
6976
|
-
} catch {
|
|
6977
|
-
needsInstall = true;
|
|
6978
|
-
}
|
|
6979
|
-
} else {
|
|
6980
|
-
needsInstall = true;
|
|
6981
|
-
}
|
|
6982
|
-
if (needsInstall) {
|
|
6983
|
-
const pkg = { name: "synkro-local", private: true, dependencies: requiredDeps };
|
|
6984
|
-
writeFileSync8(pkgJsonPath, JSON.stringify(pkg, null, 2) + "\n", "utf-8");
|
|
6985
|
-
console.log(" Installing PGLite dependencies...");
|
|
6986
|
-
const { execSync: execSync6 } = await import("child_process");
|
|
6987
|
-
try {
|
|
6988
|
-
execSync6("bun install --no-save", { cwd: SYNKRO_DIR4, stdio: "pipe", timeout: 3e4 });
|
|
6989
|
-
console.log(" PGLite dependencies installed.");
|
|
6990
|
-
} catch (e) {
|
|
6991
|
-
console.warn(" \u26A0 Failed to install PGLite deps:", String(e).slice(0, 100));
|
|
6992
|
-
}
|
|
6993
|
-
}
|
|
6994
|
-
}
|
|
6995
|
-
async function startLocalPgliteDb() {
|
|
6996
|
-
const script = join12(HOOKS_DIR, "pglite-db.ts");
|
|
6997
|
-
if (!existsSync12(script)) {
|
|
6998
|
-
console.warn(" \u26A0 pglite-db script not found \u2014 skipping.");
|
|
6999
|
-
return;
|
|
7000
|
-
}
|
|
7001
|
-
try {
|
|
7002
|
-
const sock = await new Promise((resolve3) => {
|
|
7003
|
-
const s = __require("net").connect(PGLITE_PORT, "127.0.0.1");
|
|
7004
|
-
s.once("connect", () => {
|
|
7005
|
-
s.destroy();
|
|
7006
|
-
resolve3(true);
|
|
7007
|
-
});
|
|
7008
|
-
s.once("error", () => {
|
|
7009
|
-
s.destroy();
|
|
7010
|
-
resolve3(false);
|
|
7011
|
-
});
|
|
7012
|
-
s.setTimeout(500, () => {
|
|
7013
|
-
s.destroy();
|
|
7014
|
-
resolve3(false);
|
|
7015
|
-
});
|
|
7016
|
-
});
|
|
7017
|
-
if (sock) {
|
|
7018
|
-
console.log(` pglite-db already running on port ${PGLITE_PORT}`);
|
|
7019
|
-
return;
|
|
7020
|
-
}
|
|
7021
|
-
} catch {
|
|
7022
|
-
}
|
|
7023
|
-
const proc = spawn2("bun", ["run", script], {
|
|
7024
|
-
stdio: "ignore",
|
|
7025
|
-
detached: true,
|
|
7026
|
-
env: { ...process.env, SYNKRO_PG_PORT: String(PGLITE_PORT) }
|
|
7027
|
-
});
|
|
7028
|
-
proc.unref();
|
|
7029
|
-
for (let i = 0; i < 50; i++) {
|
|
7030
|
-
await new Promise((r) => setTimeout(r, 200));
|
|
7031
|
-
const up = await new Promise((resolve3) => {
|
|
7032
|
-
const s = __require("net").connect(PGLITE_PORT, "127.0.0.1");
|
|
7033
|
-
s.once("connect", () => {
|
|
7034
|
-
s.destroy();
|
|
7035
|
-
resolve3(true);
|
|
7036
|
-
});
|
|
7037
|
-
s.once("error", () => {
|
|
7038
|
-
s.destroy();
|
|
7039
|
-
resolve3(false);
|
|
7040
|
-
});
|
|
7041
|
-
s.setTimeout(300, () => {
|
|
7042
|
-
s.destroy();
|
|
7043
|
-
resolve3(false);
|
|
7044
|
-
});
|
|
7045
|
-
});
|
|
7046
|
-
if (up) {
|
|
7047
|
-
console.log(` pglite-db started on port ${PGLITE_PORT}`);
|
|
7048
|
-
return;
|
|
7049
|
-
}
|
|
7050
|
-
}
|
|
7051
|
-
console.warn(` \u26A0 pglite-db did not start within 10s \u2014 MCP server will retry.`);
|
|
7052
|
-
}
|
|
7053
|
-
async function startLocalMcpServer() {
|
|
7054
|
-
const serverScript = join12(HOOKS_DIR, "mcp-local-server.ts");
|
|
7055
|
-
if (!existsSync12(serverScript)) {
|
|
7056
|
-
console.warn(" \u26A0 Local MCP server script not found \u2014 skipping.");
|
|
7057
|
-
return;
|
|
7058
|
-
}
|
|
7059
|
-
await installMcpDependencies();
|
|
7060
|
-
await startLocalPgliteDb();
|
|
7061
|
-
try {
|
|
7062
|
-
const probe = await fetch(`http://127.0.0.1:${MCP_LOCAL_PORT}/`, { signal: AbortSignal.timeout(1e3) });
|
|
7063
|
-
if (probe.ok) {
|
|
7064
|
-
console.log(" Restarting local MCP server to pick up new credentials...");
|
|
7065
|
-
try {
|
|
7066
|
-
const { execSync: execSync6 } = await import("child_process");
|
|
7067
|
-
const selfPid = process.pid;
|
|
7068
|
-
const pids = execSync6(`lsof -ti :${MCP_LOCAL_PORT}`, { encoding: "utf-8" }).trim();
|
|
7069
|
-
for (const pid of pids.split("\n").filter(Boolean)) {
|
|
7070
|
-
const n = parseInt(pid, 10);
|
|
7071
|
-
if (n === selfPid) continue;
|
|
7072
|
-
try {
|
|
7073
|
-
process.kill(n, "SIGTERM");
|
|
7074
|
-
} catch {
|
|
7075
|
-
}
|
|
7076
|
-
}
|
|
7077
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
7078
|
-
} catch {
|
|
7079
|
-
}
|
|
7080
|
-
}
|
|
7081
|
-
} catch {
|
|
7082
|
-
}
|
|
7083
|
-
const proc = spawn2("bun", ["run", serverScript], {
|
|
7084
|
-
stdio: "ignore",
|
|
7085
|
-
detached: true
|
|
7086
|
-
});
|
|
7087
|
-
proc.unref();
|
|
7088
|
-
for (let i = 0; i < 25; i++) {
|
|
7089
|
-
await new Promise((r) => setTimeout(r, 200));
|
|
7090
|
-
try {
|
|
7091
|
-
const probe = await fetch(`http://127.0.0.1:${MCP_LOCAL_PORT}/`, { signal: AbortSignal.timeout(500) });
|
|
7092
|
-
if (probe.ok) {
|
|
7093
|
-
console.log(` Local MCP server started on port ${MCP_LOCAL_PORT}`);
|
|
7094
|
-
return;
|
|
7095
|
-
}
|
|
7096
|
-
} catch {
|
|
7097
|
-
}
|
|
7098
|
-
}
|
|
7099
|
-
console.warn(` \u26A0 Local MCP server did not start within 5s \u2014 it may need to be started manually.`);
|
|
7100
|
-
}
|
|
7101
5798
|
async function installCommand(opts = {}) {
|
|
7102
5799
|
const gatewayUrl = opts.gatewayUrl || sanitizeGatewayCandidate(process.env.SYNKRO_GATEWAY_URL) || "https://api.synkro.sh";
|
|
7103
5800
|
try {
|
|
@@ -7125,50 +5822,8 @@ async function installCommand(opts = {}) {
|
|
|
7125
5822
|
} catch {
|
|
7126
5823
|
}
|
|
7127
5824
|
}
|
|
7128
|
-
const token2 = getAccessToken();
|
|
7129
|
-
if (token2) {
|
|
7130
|
-
const profile2 = await fetchUserProfile(gatewayUrl, token2);
|
|
7131
|
-
if (profile2.localInference && !isLocalCCEnabled()) {
|
|
7132
|
-
console.log("Local inference enabled \u2014 setting up local-CC channels...");
|
|
7133
|
-
try {
|
|
7134
|
-
assertClaudeInstalled();
|
|
7135
|
-
assertPueueInstalled();
|
|
7136
|
-
assertTmuxInstalled();
|
|
7137
|
-
stopTask();
|
|
7138
|
-
stopTask(CHANNEL_SECONDARY);
|
|
7139
|
-
stopTask(CHANNEL_TERTIARY);
|
|
7140
|
-
stopTask(CHANNEL_QUATERNARY);
|
|
7141
|
-
installLocalCC();
|
|
7142
|
-
const t1 = startTask();
|
|
7143
|
-
const t2 = startTask({ channel: CHANNEL_SECONDARY });
|
|
7144
|
-
const t3 = startTask({ channel: CHANNEL_TERTIARY });
|
|
7145
|
-
const t4 = startTask({ channel: CHANNEL_QUATERNARY });
|
|
7146
|
-
console.log(` general pool: pueue id=${t1.id},${t3.id} CWE pool: pueue id=${t2.id},${t4.id}`);
|
|
7147
|
-
console.log(" Waiting for all 4 workers...");
|
|
7148
|
-
const [r1, r2, r3, r4] = await Promise.all([
|
|
7149
|
-
waitForChannelReady(CHANNEL_1_PORT, 6e4, CHANNEL_HOST),
|
|
7150
|
-
waitForChannelReady(CHANNEL_2_PORT, 6e4, CHANNEL_HOST, CHANNEL_SECONDARY.tmuxSession),
|
|
7151
|
-
waitForChannelReady(CHANNEL_3_PORT, 6e4, CHANNEL_HOST, CHANNEL_TERTIARY.tmuxSession),
|
|
7152
|
-
waitForChannelReady(CHANNEL_4_PORT, 6e4, CHANNEL_HOST, CHANNEL_QUATERNARY.tmuxSession)
|
|
7153
|
-
]);
|
|
7154
|
-
if (r1) console.log(` general worker A ready (${CHANNEL_HOST}:${CHANNEL_1_PORT})`);
|
|
7155
|
-
else console.warn(" \u26A0 general worker A did not come up within 60s \u2014 check `synkro local-cc logs`");
|
|
7156
|
-
if (r3) console.log(` general worker B ready (${CHANNEL_HOST}:${CHANNEL_3_PORT})`);
|
|
7157
|
-
else console.warn(" \u26A0 general worker B did not come up within 60s");
|
|
7158
|
-
if (r2) console.log(` CWE worker A ready (${CHANNEL_HOST}:${CHANNEL_2_PORT})`);
|
|
7159
|
-
else console.warn(" \u26A0 CWE worker A did not come up within 60s");
|
|
7160
|
-
if (r4) console.log(` CWE worker B ready (${CHANNEL_HOST}:${CHANNEL_4_PORT})`);
|
|
7161
|
-
else console.warn(" \u26A0 CWE worker B did not come up within 60s");
|
|
7162
|
-
updateLocalInferenceFlag(true);
|
|
7163
|
-
} catch (err) {
|
|
7164
|
-
console.warn(` \u26A0 Local-CC setup skipped: ${err.message}`);
|
|
7165
|
-
console.warn(" Install pueue, tmux, and claude, then re-run install.");
|
|
7166
|
-
}
|
|
7167
|
-
}
|
|
7168
|
-
}
|
|
7169
5825
|
console.log("\u2713 Synkro is already installed and configured.");
|
|
7170
|
-
console.log(" Run `synkro
|
|
7171
|
-
console.log(" Run `synkro-cli install --force` to reinstall from scratch.");
|
|
5826
|
+
console.log(" Run `synkro install --force` to reinstall from scratch.");
|
|
7172
5827
|
return;
|
|
7173
5828
|
}
|
|
7174
5829
|
console.log("Synkro install starting...\n");
|
|
@@ -7241,9 +5896,9 @@ async function installCommand(opts = {}) {
|
|
|
7241
5896
|
console.log(` ${scripts.transcriptSyncScript}
|
|
7242
5897
|
`);
|
|
7243
5898
|
for (const mode of ["edit", "bash"]) {
|
|
7244
|
-
const pidFile =
|
|
5899
|
+
const pidFile = join8(SYNKRO_DIR4, "daemon", mode, "daemon.pid");
|
|
7245
5900
|
try {
|
|
7246
|
-
const pid = parseInt(
|
|
5901
|
+
const pid = parseInt(readFileSync7(pidFile, "utf-8").trim(), 10);
|
|
7247
5902
|
if (pid > 0) {
|
|
7248
5903
|
process.kill(pid, "SIGTERM");
|
|
7249
5904
|
console.log(`Stopped stale ${mode} grader daemon (pid ${pid})`);
|
|
@@ -7324,20 +5979,17 @@ async function installCommand(opts = {}) {
|
|
|
7324
5979
|
if (mintResp.ok) {
|
|
7325
5980
|
const minted = await mintResp.json();
|
|
7326
5981
|
mcpJwt = minted.token;
|
|
7327
|
-
|
|
5982
|
+
writeFileSync7(join8(SYNKRO_DIR4, ".mcp-jwt"), mcpJwt + "\n", { mode: 384 });
|
|
7328
5983
|
} else {
|
|
7329
5984
|
console.warn(" \u26A0 Could not mint MCP token \u2014 local server will reject requests until re-installed.");
|
|
7330
5985
|
}
|
|
7331
5986
|
const mcp = installMcpConfig({ gatewayUrl, bearerToken: mcpJwt, local: true });
|
|
7332
5987
|
console.log(`Registered local MCP guardrails server in ${mcp.path}`);
|
|
7333
5988
|
console.log(` url: ${mcp.url}`);
|
|
7334
|
-
console.log(" (rules stored in ~/.synkro/rules.json)");
|
|
7335
5989
|
console.log();
|
|
7336
|
-
await backfillLocalRules(gatewayUrl, token);
|
|
7337
|
-
await startLocalMcpServer();
|
|
7338
5990
|
} catch (err) {
|
|
7339
5991
|
console.warn(` \u26A0 Local MCP setup failed: ${err.message}`);
|
|
7340
|
-
console.warn(" Hooks are still installed. Re-run `synkro
|
|
5992
|
+
console.warn(" Hooks are still installed. Re-run `synkro install` to retry.");
|
|
7341
5993
|
console.log();
|
|
7342
5994
|
}
|
|
7343
5995
|
} else {
|
|
@@ -7355,7 +6007,7 @@ async function installCommand(opts = {}) {
|
|
|
7355
6007
|
throw new Error(`mcp-token mint failed (${mintResp.status}): ${errText.slice(0, 200)}`);
|
|
7356
6008
|
}
|
|
7357
6009
|
const minted = await mintResp.json();
|
|
7358
|
-
|
|
6010
|
+
writeFileSync7(join8(SYNKRO_DIR4, ".mcp-jwt"), minted.token + "\n", { mode: 384 });
|
|
7359
6011
|
const mcp = installMcpConfig({ gatewayUrl, bearerToken: minted.token });
|
|
7360
6012
|
console.log(`Registered Synkro guardrails MCP server in ${mcp.path}`);
|
|
7361
6013
|
console.log(` url: ${mcp.url}`);
|
|
@@ -7364,7 +6016,7 @@ async function installCommand(opts = {}) {
|
|
|
7364
6016
|
console.log();
|
|
7365
6017
|
} catch (err) {
|
|
7366
6018
|
console.warn(` \u26A0 MCP registration failed: ${err.message}`);
|
|
7367
|
-
console.warn(" Hooks are still installed. Re-run `synkro
|
|
6019
|
+
console.warn(" Hooks are still installed. Re-run `synkro install` to retry MCP setup.");
|
|
7368
6020
|
console.log();
|
|
7369
6021
|
}
|
|
7370
6022
|
}
|
|
@@ -7372,8 +6024,8 @@ async function installCommand(opts = {}) {
|
|
|
7372
6024
|
if (hasCursor && !opts.noMcp) {
|
|
7373
6025
|
try {
|
|
7374
6026
|
if (useLocalMcp) {
|
|
7375
|
-
const jwtPath =
|
|
7376
|
-
if (!
|
|
6027
|
+
const jwtPath = join8(SYNKRO_DIR4, ".mcp-jwt");
|
|
6028
|
+
if (!existsSync9(jwtPath)) {
|
|
7377
6029
|
const mintResp = await fetch(`${gatewayUrl}/api/v1/cli/mcp-token`, {
|
|
7378
6030
|
method: "POST",
|
|
7379
6031
|
headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json" },
|
|
@@ -7381,7 +6033,7 @@ async function installCommand(opts = {}) {
|
|
|
7381
6033
|
});
|
|
7382
6034
|
if (mintResp.ok) {
|
|
7383
6035
|
const minted = await mintResp.json();
|
|
7384
|
-
|
|
6036
|
+
writeFileSync7(jwtPath, minted.token + "\n", { mode: 384 });
|
|
7385
6037
|
}
|
|
7386
6038
|
}
|
|
7387
6039
|
const mcp = installCursorMcpConfig({ gatewayUrl, bearerToken: "", local: true });
|
|
@@ -7401,7 +6053,7 @@ async function installCommand(opts = {}) {
|
|
|
7401
6053
|
throw new Error(`mcp-token mint failed (${mintResp.status}): ${errText.slice(0, 200)}`);
|
|
7402
6054
|
}
|
|
7403
6055
|
const minted = await mintResp.json();
|
|
7404
|
-
|
|
6056
|
+
writeFileSync7(join8(SYNKRO_DIR4, ".mcp-jwt"), minted.token + "\n", { mode: 384 });
|
|
7405
6057
|
const mcp = installCursorMcpConfig({ gatewayUrl, bearerToken: minted.token });
|
|
7406
6058
|
console.log(`Registered Synkro guardrails MCP server in ${mcp.path}`);
|
|
7407
6059
|
console.log(` url: ${mcp.url}`);
|
|
@@ -7412,18 +6064,10 @@ async function installCommand(opts = {}) {
|
|
|
7412
6064
|
console.log();
|
|
7413
6065
|
}
|
|
7414
6066
|
}
|
|
7415
|
-
const priorLocalFlag = (() => {
|
|
7416
|
-
try {
|
|
7417
|
-
const content = readFileSync10(CONFIG_PATH3, "utf-8");
|
|
7418
|
-
return content.includes("SYNKRO_LOCAL_INFERENCE='yes'");
|
|
7419
|
-
} catch {
|
|
7420
|
-
return false;
|
|
7421
|
-
}
|
|
7422
|
-
})();
|
|
7423
6067
|
const synkroBundle = resolveSynkroBundle();
|
|
7424
|
-
const persistedMode = resolveDeploymentMode()
|
|
6068
|
+
const persistedMode = resolveDeploymentMode();
|
|
7425
6069
|
writeConfigEnv({ gatewayUrl, userId, orgId, email, tier: profile.tier, inference: profile.inference, synkroBin: synkroBundle, transcriptConsent, localInference: profile.localInference, deploymentMode: persistedMode });
|
|
7426
|
-
console.log(`Wrote config to ${
|
|
6070
|
+
console.log(`Wrote config to ${CONFIG_PATH2}`);
|
|
7427
6071
|
console.log(` inference: ${profile.inference} (server-side grading)`);
|
|
7428
6072
|
if (profile.localInference) console.log(` local inference: enabled (gradingProvider=claude-code)`);
|
|
7429
6073
|
if (synkroBundle) console.log(` SYNKRO_CLI_BIN=${synkroBundle}`);
|
|
@@ -7435,34 +6079,7 @@ async function installCommand(opts = {}) {
|
|
|
7435
6079
|
console.warn(` \u26A0 Could not cache judge prompts: ${err.message}`);
|
|
7436
6080
|
}
|
|
7437
6081
|
console.log();
|
|
7438
|
-
|
|
7439
|
-
const localCcDeps = [];
|
|
7440
|
-
try {
|
|
7441
|
-
assertClaudeInstalled();
|
|
7442
|
-
localCcDeps.push("claude");
|
|
7443
|
-
} catch (err) {
|
|
7444
|
-
console.warn(` \u26A0 claude: ${err.message}`);
|
|
7445
|
-
}
|
|
7446
|
-
try {
|
|
7447
|
-
assertPueueInstalled();
|
|
7448
|
-
localCcDeps.push("pueue");
|
|
7449
|
-
} catch (err) {
|
|
7450
|
-
console.warn(` \u26A0 pueue: ${err.message}`);
|
|
7451
|
-
}
|
|
7452
|
-
try {
|
|
7453
|
-
assertTmuxInstalled();
|
|
7454
|
-
localCcDeps.push("tmux");
|
|
7455
|
-
} catch (err) {
|
|
7456
|
-
console.warn(` \u26A0 tmux: ${err.message}`);
|
|
7457
|
-
}
|
|
7458
|
-
if (localCcDeps.length === 3) {
|
|
7459
|
-
console.log(` \u2713 All local-CC dependencies ready (${localCcDeps.join(", ")})`);
|
|
7460
|
-
} else {
|
|
7461
|
-
console.warn(` \u26A0 Some dependencies missing \u2014 \`synkro local-cc enable\` may not work until they're installed.`);
|
|
7462
|
-
}
|
|
7463
|
-
console.log();
|
|
7464
|
-
const deploymentMode = resolveDeploymentMode();
|
|
7465
|
-
if (profile.localInference && deploymentMode === "docker") {
|
|
6082
|
+
if (profile.localInference) {
|
|
7466
6083
|
try {
|
|
7467
6084
|
console.log("Installing Synkro server container...");
|
|
7468
6085
|
const workersPerPool = parseInt(process.env.SYNKRO_WORKERS_PER_POOL || "4", 10);
|
|
@@ -7472,64 +6089,13 @@ async function installCommand(opts = {}) {
|
|
|
7472
6089
|
console.log(" waiting for container /healthz...");
|
|
7473
6090
|
const ready = await waitForContainerReady(6e4);
|
|
7474
6091
|
if (ready) {
|
|
7475
|
-
console.log(" container ready");
|
|
7476
|
-
console.log("");
|
|
7477
|
-
console.log(` Set SYNKRO_MCP_PORT=${hostMcpPort} and SYNKRO_CHANNEL_PORT=${hostGraderPort}`);
|
|
7478
|
-
console.log(" in your shell so hooks point at the container.");
|
|
6092
|
+
console.log(" \u2713 container ready");
|
|
7479
6093
|
} else {
|
|
7480
6094
|
console.warn(" \u26A0 container did not become healthy within 60s \u2014 check `docker logs synkro-server`");
|
|
7481
6095
|
}
|
|
7482
6096
|
console.log();
|
|
7483
6097
|
} catch (err) {
|
|
7484
6098
|
console.warn(` \u26A0 Docker install failed: ${err.message}
|
|
7485
|
-
`);
|
|
7486
|
-
}
|
|
7487
|
-
} else if (profile.localInference && localCcDeps.length === 3) {
|
|
7488
|
-
try {
|
|
7489
|
-
stopTask();
|
|
7490
|
-
stopTask(CHANNEL_SECONDARY);
|
|
7491
|
-
stopTask(CHANNEL_TERTIARY);
|
|
7492
|
-
stopTask(CHANNEL_QUATERNARY);
|
|
7493
|
-
const r = installLocalCC();
|
|
7494
|
-
console.log(`Installed local-CC channel plugin at ${r.pluginPath}`);
|
|
7495
|
-
const t1 = startTask();
|
|
7496
|
-
const t2 = startTask({ channel: CHANNEL_SECONDARY });
|
|
7497
|
-
const t3 = startTask({ channel: CHANNEL_TERTIARY });
|
|
7498
|
-
const t4 = startTask({ channel: CHANNEL_QUATERNARY });
|
|
7499
|
-
console.log(`General pool: pueue id=${t1.id},${t3.id} CWE pool: pueue id=${t2.id},${t4.id}`);
|
|
7500
|
-
console.log("Waiting for all 4 workers (up to 60s)...");
|
|
7501
|
-
const [ready1, ready2, ready3, ready4] = await Promise.all([
|
|
7502
|
-
waitForChannelReady(CHANNEL_1_PORT, 6e4, CHANNEL_HOST),
|
|
7503
|
-
waitForChannelReady(CHANNEL_2_PORT, 6e4, CHANNEL_HOST, CHANNEL_SECONDARY.tmuxSession),
|
|
7504
|
-
waitForChannelReady(CHANNEL_3_PORT, 6e4, CHANNEL_HOST, CHANNEL_TERTIARY.tmuxSession),
|
|
7505
|
-
waitForChannelReady(CHANNEL_4_PORT, 6e4, CHANNEL_HOST, CHANNEL_QUATERNARY.tmuxSession)
|
|
7506
|
-
]);
|
|
7507
|
-
if (ready1) console.log(` general worker A ready (${CHANNEL_HOST}:${CHANNEL_1_PORT})`);
|
|
7508
|
-
else {
|
|
7509
|
-
console.warn(" \u26A0 general worker A did not come up within 60s.");
|
|
7510
|
-
printChannelDiagnostics();
|
|
7511
|
-
}
|
|
7512
|
-
if (ready3) console.log(` general worker B ready (${CHANNEL_HOST}:${CHANNEL_3_PORT})`);
|
|
7513
|
-
else console.warn(" \u26A0 general worker B did not come up within 60s.");
|
|
7514
|
-
if (ready2) console.log(` CWE worker A ready (${CHANNEL_HOST}:${CHANNEL_2_PORT})`);
|
|
7515
|
-
else console.warn(" \u26A0 CWE worker A did not come up within 60s.");
|
|
7516
|
-
if (ready4) console.log(` CWE worker B ready (${CHANNEL_HOST}:${CHANNEL_4_PORT})`);
|
|
7517
|
-
else console.warn(" \u26A0 CWE worker B did not come up within 60s.");
|
|
7518
|
-
const anyReady = ready1 || ready2 || ready3 || ready4;
|
|
7519
|
-
if (anyReady) {
|
|
7520
|
-
console.log("Warming up inference...");
|
|
7521
|
-
const warmups = [];
|
|
7522
|
-
const bashWarmup = "Proposed command: echo hello\nUser intent: warmup\nRecent user messages: []\nRecent actions: []\nOrg rules: []\n";
|
|
7523
|
-
const cweWarmup = 'File: /tmp/warmup.ts\nContent (first 4000 chars):\nconsole.log("hello");\n\nCWE rules to check against:\n[]\n';
|
|
7524
|
-
if (ready1) warmups.push(submitToChannel("grade-bash", bashWarmup, { timeoutMs: 3e4, port: CHANNEL_1_PORT }).then(() => console.log(" general A warm")).catch(() => console.log(" general A warmup skipped")));
|
|
7525
|
-
if (ready3) warmups.push(submitToChannel("grade-bash", bashWarmup, { timeoutMs: 3e4, port: CHANNEL_3_PORT }).then(() => console.log(" general B warm")).catch(() => console.log(" general B warmup skipped")));
|
|
7526
|
-
if (ready2) warmups.push(submitToChannel("grade-cwe", cweWarmup, { timeoutMs: 3e4, port: CHANNEL_2_PORT }).then(() => console.log(" CWE A warm")).catch(() => console.log(" CWE A warmup skipped")));
|
|
7527
|
-
if (ready4) warmups.push(submitToChannel("grade-cwe", cweWarmup, { timeoutMs: 3e4, port: CHANNEL_4_PORT }).then(() => console.log(" CWE B warm")).catch(() => console.log(" CWE B warmup skipped")));
|
|
7528
|
-
await Promise.all(warmups);
|
|
7529
|
-
console.log();
|
|
7530
|
-
}
|
|
7531
|
-
} catch (err) {
|
|
7532
|
-
console.warn(` \u26A0 Local-CC setup failed: ${err.message}
|
|
7533
6099
|
`);
|
|
7534
6100
|
}
|
|
7535
6101
|
}
|
|
@@ -7581,17 +6147,17 @@ function detectGitRepo2() {
|
|
|
7581
6147
|
function getClaudeProjectsFolder() {
|
|
7582
6148
|
const cwd = process.cwd();
|
|
7583
6149
|
const sanitized = "-" + cwd.replace(/\//g, "-");
|
|
7584
|
-
const projectsDir =
|
|
7585
|
-
return
|
|
6150
|
+
const projectsDir = join8(homedir8(), ".claude", "projects", sanitized);
|
|
6151
|
+
return existsSync9(projectsDir) ? projectsDir : null;
|
|
7586
6152
|
}
|
|
7587
6153
|
function extractSessionInsights(projectsDir) {
|
|
7588
6154
|
const insights = [];
|
|
7589
6155
|
const files = readdirSync(projectsDir).filter((f) => f.endsWith(".jsonl"));
|
|
7590
6156
|
for (const file of files) {
|
|
7591
6157
|
const sessionId = file.replace(".jsonl", "");
|
|
7592
|
-
const filePath =
|
|
6158
|
+
const filePath = join8(projectsDir, file);
|
|
7593
6159
|
try {
|
|
7594
|
-
const content =
|
|
6160
|
+
const content = readFileSync7(filePath, "utf-8");
|
|
7595
6161
|
const lines = content.split("\n").filter(Boolean);
|
|
7596
6162
|
for (let i = 0; i < lines.length; i++) {
|
|
7597
6163
|
try {
|
|
@@ -7667,7 +6233,7 @@ function extractTextContent(content) {
|
|
|
7667
6233
|
return "";
|
|
7668
6234
|
}
|
|
7669
6235
|
function parseTranscriptFile(filePath) {
|
|
7670
|
-
const content =
|
|
6236
|
+
const content = readFileSync7(filePath, "utf-8");
|
|
7671
6237
|
const lines = content.split("\n").filter(Boolean);
|
|
7672
6238
|
const messages = [];
|
|
7673
6239
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -7701,137 +6267,625 @@ function parseTranscriptFile(filePath) {
|
|
|
7701
6267
|
if (msg.content.length > 0) messages.push(msg);
|
|
7702
6268
|
} catch {
|
|
7703
6269
|
}
|
|
7704
|
-
}
|
|
7705
|
-
return messages;
|
|
7706
|
-
}
|
|
7707
|
-
async function syncTranscriptsBulk(gatewayUrl, token, repo) {
|
|
7708
|
-
const projectsDir = getClaudeProjectsFolder();
|
|
7709
|
-
if (!projectsDir) return { sessions: 0, messages: 0 };
|
|
7710
|
-
const files = readdirSync(projectsDir).filter((f) => f.endsWith(".jsonl"));
|
|
7711
|
-
if (files.length === 0) return { sessions: 0, messages: 0 };
|
|
7712
|
-
console.log(`Found ${files.length} CC session transcripts, syncing...`);
|
|
7713
|
-
const maxMessagesPerSession = 500;
|
|
7714
|
-
let totalSessions = 0;
|
|
7715
|
-
let totalMessages = 0;
|
|
7716
|
-
for (let i = 0; i < files.length; i += 5) {
|
|
7717
|
-
const batch = files.slice(i, i + 5);
|
|
7718
|
-
const sessions = [];
|
|
7719
|
-
for (const file of batch) {
|
|
7720
|
-
const sessionId = file.replace(".jsonl", "");
|
|
7721
|
-
const filePath =
|
|
7722
|
-
try {
|
|
7723
|
-
const allMessages = parseTranscriptFile(filePath);
|
|
7724
|
-
const messages = allMessages.length > maxMessagesPerSession ? allMessages.slice(-maxMessagesPerSession) : allMessages;
|
|
7725
|
-
if (messages.length > 0) {
|
|
7726
|
-
sessions.push({ cc_session_id: sessionId, messages });
|
|
7727
|
-
}
|
|
7728
|
-
} catch {
|
|
7729
|
-
}
|
|
7730
|
-
}
|
|
7731
|
-
if (sessions.length === 0) continue;
|
|
6270
|
+
}
|
|
6271
|
+
return messages;
|
|
6272
|
+
}
|
|
6273
|
+
async function syncTranscriptsBulk(gatewayUrl, token, repo) {
|
|
6274
|
+
const projectsDir = getClaudeProjectsFolder();
|
|
6275
|
+
if (!projectsDir) return { sessions: 0, messages: 0 };
|
|
6276
|
+
const files = readdirSync(projectsDir).filter((f) => f.endsWith(".jsonl"));
|
|
6277
|
+
if (files.length === 0) return { sessions: 0, messages: 0 };
|
|
6278
|
+
console.log(`Found ${files.length} CC session transcripts, syncing...`);
|
|
6279
|
+
const maxMessagesPerSession = 500;
|
|
6280
|
+
let totalSessions = 0;
|
|
6281
|
+
let totalMessages = 0;
|
|
6282
|
+
for (let i = 0; i < files.length; i += 5) {
|
|
6283
|
+
const batch = files.slice(i, i + 5);
|
|
6284
|
+
const sessions = [];
|
|
6285
|
+
for (const file of batch) {
|
|
6286
|
+
const sessionId = file.replace(".jsonl", "");
|
|
6287
|
+
const filePath = join8(projectsDir, file);
|
|
6288
|
+
try {
|
|
6289
|
+
const allMessages = parseTranscriptFile(filePath);
|
|
6290
|
+
const messages = allMessages.length > maxMessagesPerSession ? allMessages.slice(-maxMessagesPerSession) : allMessages;
|
|
6291
|
+
if (messages.length > 0) {
|
|
6292
|
+
sessions.push({ cc_session_id: sessionId, messages });
|
|
6293
|
+
}
|
|
6294
|
+
} catch {
|
|
6295
|
+
}
|
|
6296
|
+
}
|
|
6297
|
+
if (sessions.length === 0) continue;
|
|
6298
|
+
try {
|
|
6299
|
+
const resp = await fetch(`${gatewayUrl}/api/v1/cli/sync-transcripts`, {
|
|
6300
|
+
method: "POST",
|
|
6301
|
+
headers: {
|
|
6302
|
+
"Authorization": `Bearer ${token}`,
|
|
6303
|
+
"Content-Type": "application/json"
|
|
6304
|
+
},
|
|
6305
|
+
body: JSON.stringify({ repo, sessions })
|
|
6306
|
+
});
|
|
6307
|
+
if (resp.ok) {
|
|
6308
|
+
const result = await resp.json();
|
|
6309
|
+
totalMessages += result.accepted;
|
|
6310
|
+
totalSessions += result.sessions;
|
|
6311
|
+
}
|
|
6312
|
+
} catch {
|
|
6313
|
+
}
|
|
6314
|
+
for (const file of batch) {
|
|
6315
|
+
const sessionId = file.replace(".jsonl", "");
|
|
6316
|
+
const filePath = join8(projectsDir, file);
|
|
6317
|
+
try {
|
|
6318
|
+
const content = readFileSync7(filePath, "utf-8");
|
|
6319
|
+
const lineCount = content.split("\n").filter(Boolean).length;
|
|
6320
|
+
writeFileSync7(join8(OFFSETS_DIR, sessionId), String(lineCount), "utf-8");
|
|
6321
|
+
} catch {
|
|
6322
|
+
}
|
|
6323
|
+
}
|
|
6324
|
+
}
|
|
6325
|
+
return { sessions: totalSessions, messages: totalMessages };
|
|
6326
|
+
}
|
|
6327
|
+
var SYNKRO_DIR4, HOOKS_DIR, BIN_DIR, CONFIG_PATH2, MCP_STDIO_PROXY_SRC, OFFSETS_DIR;
|
|
6328
|
+
var init_install = __esm({
|
|
6329
|
+
"cli/commands/install.ts"() {
|
|
6330
|
+
"use strict";
|
|
6331
|
+
init_agentDetect();
|
|
6332
|
+
init_ccHookConfig();
|
|
6333
|
+
init_cursorHookConfig();
|
|
6334
|
+
init_mcpConfig();
|
|
6335
|
+
init_hookScripts();
|
|
6336
|
+
init_hookScriptsTs();
|
|
6337
|
+
init_stub();
|
|
6338
|
+
init_repoConnect();
|
|
6339
|
+
init_projects();
|
|
6340
|
+
init_setupGithub();
|
|
6341
|
+
init_promptFetcher();
|
|
6342
|
+
init_dockerInstall();
|
|
6343
|
+
SYNKRO_DIR4 = join8(homedir8(), ".synkro");
|
|
6344
|
+
HOOKS_DIR = join8(SYNKRO_DIR4, "hooks");
|
|
6345
|
+
BIN_DIR = join8(SYNKRO_DIR4, "bin");
|
|
6346
|
+
CONFIG_PATH2 = join8(SYNKRO_DIR4, "config.env");
|
|
6347
|
+
MCP_STDIO_PROXY_SRC = `#!/usr/bin/env bun
|
|
6348
|
+
import { readFileSync } from 'node:fs';
|
|
6349
|
+
import { homedir } from 'node:os';
|
|
6350
|
+
import { join } from 'node:path';
|
|
6351
|
+
import { createInterface } from 'node:readline';
|
|
6352
|
+
|
|
6353
|
+
const HOME = homedir();
|
|
6354
|
+
const TOKEN_PATH = join(HOME, '.synkro', '.mcp-jwt');
|
|
6355
|
+
const PORT = parseInt(process.env.SYNKRO_MCP_PORT || '8931', 10);
|
|
6356
|
+
const URL = \`http://127.0.0.1:\${PORT}\`;
|
|
6357
|
+
|
|
6358
|
+
let token = '';
|
|
6359
|
+
try { token = readFileSync(TOKEN_PATH, 'utf-8').trim(); } catch {}
|
|
6360
|
+
|
|
6361
|
+
const rl = createInterface({ input: process.stdin, terminal: false });
|
|
6362
|
+
|
|
6363
|
+
rl.on('line', async (line) => {
|
|
6364
|
+
if (!line.trim()) return;
|
|
6365
|
+
let msg;
|
|
6366
|
+
try { msg = JSON.parse(line); } catch { return; }
|
|
6367
|
+
if (!msg.id && msg.method?.startsWith('notifications/')) return;
|
|
6368
|
+
|
|
6369
|
+
try {
|
|
6370
|
+
const resp = await fetch(URL, {
|
|
6371
|
+
method: 'POST',
|
|
6372
|
+
headers: {
|
|
6373
|
+
'Content-Type': 'application/json',
|
|
6374
|
+
'Authorization': \`Bearer \${token}\`,
|
|
6375
|
+
},
|
|
6376
|
+
body: line,
|
|
6377
|
+
signal: AbortSignal.timeout(30000),
|
|
6378
|
+
});
|
|
6379
|
+
if (resp.status === 204) return;
|
|
6380
|
+
const body = await resp.text();
|
|
6381
|
+
process.stdout.write(body + '\\n');
|
|
6382
|
+
} catch (err) {
|
|
6383
|
+
if (msg.id != null) {
|
|
6384
|
+
process.stdout.write(JSON.stringify({
|
|
6385
|
+
jsonrpc: '2.0',
|
|
6386
|
+
id: msg.id,
|
|
6387
|
+
error: { code: -32603, message: 'MCP proxy: HTTP server unreachable' },
|
|
6388
|
+
}) + '\\n');
|
|
6389
|
+
}
|
|
6390
|
+
}
|
|
6391
|
+
});
|
|
6392
|
+
`;
|
|
6393
|
+
OFFSETS_DIR = join8(SYNKRO_DIR4, ".transcript-offsets");
|
|
6394
|
+
}
|
|
6395
|
+
});
|
|
6396
|
+
|
|
6397
|
+
// cli/local-cc/pueue.ts
|
|
6398
|
+
import { execFileSync, spawnSync as spawnSync3, spawn } from "child_process";
|
|
6399
|
+
import { homedir as homedir9 } from "os";
|
|
6400
|
+
import { join as join9 } from "path";
|
|
6401
|
+
import { connect } from "net";
|
|
6402
|
+
function pueueAvailable() {
|
|
6403
|
+
const r = spawnSync3("pueue", ["--version"], { encoding: "utf-8" });
|
|
6404
|
+
if (r.status !== 0) {
|
|
6405
|
+
throw new PueueError("pueue CLI not found on PATH. Install pueue (https://github.com/Nukesor/pueue) and start `pueued`.");
|
|
6406
|
+
}
|
|
6407
|
+
}
|
|
6408
|
+
function statusJson() {
|
|
6409
|
+
pueueAvailable();
|
|
6410
|
+
const r = spawnSync3("pueue", ["status", "--json"], { encoding: "utf-8" });
|
|
6411
|
+
if (r.status !== 0) {
|
|
6412
|
+
throw new PueueError(`pueue status failed: ${r.stderr || r.stdout || "unknown error"} \u2014 is pueued running?`);
|
|
6413
|
+
}
|
|
6414
|
+
try {
|
|
6415
|
+
return JSON.parse(r.stdout);
|
|
6416
|
+
} catch (err) {
|
|
6417
|
+
throw new PueueError(`pueue status returned non-JSON output: ${r.stdout.slice(0, 200)}`, err);
|
|
6418
|
+
}
|
|
6419
|
+
}
|
|
6420
|
+
function statusName(s) {
|
|
6421
|
+
if (typeof s === "string") return s;
|
|
6422
|
+
if (s && typeof s === "object") {
|
|
6423
|
+
if ("Running" in s) return "Running";
|
|
6424
|
+
if ("Done" in s) {
|
|
6425
|
+
const result = s.Done?.result;
|
|
6426
|
+
if (typeof result === "string") return `Done (${result})`;
|
|
6427
|
+
if (result && typeof result === "object") return `Done (${Object.keys(result)[0] ?? "unknown"})`;
|
|
6428
|
+
return "Done";
|
|
6429
|
+
}
|
|
6430
|
+
return Object.keys(s)[0] ?? "unknown";
|
|
6431
|
+
}
|
|
6432
|
+
return "unknown";
|
|
6433
|
+
}
|
|
6434
|
+
function findTask(channel = CHANNEL_PRIMARY) {
|
|
6435
|
+
const data = statusJson();
|
|
6436
|
+
for (const [id, t] of Object.entries(data.tasks)) {
|
|
6437
|
+
if (t.label === channel.taskLabel) {
|
|
6438
|
+
return {
|
|
6439
|
+
id: Number(id),
|
|
6440
|
+
label: t.label,
|
|
6441
|
+
status: statusName(t.status),
|
|
6442
|
+
command: t.command,
|
|
6443
|
+
cwd: t.path
|
|
6444
|
+
};
|
|
6445
|
+
}
|
|
6446
|
+
}
|
|
6447
|
+
return null;
|
|
6448
|
+
}
|
|
6449
|
+
function stopTask(channel = CHANNEL_PRIMARY) {
|
|
6450
|
+
spawnSync3("tmux", ["kill-session", "-t", `=${channel.tmuxSession}`], { encoding: "utf-8" });
|
|
6451
|
+
let t = findTask(channel);
|
|
6452
|
+
while (t) {
|
|
6453
|
+
if (t.status === "Running" || t.status === "Queued") {
|
|
6454
|
+
spawnSync3("pueue", ["kill", String(t.id)], { encoding: "utf-8" });
|
|
6455
|
+
for (let i = 0; i < 10; i++) {
|
|
6456
|
+
const check = findTask(channel);
|
|
6457
|
+
if (!check || check.id !== t.id || check.status !== "Running" && check.status !== "Queued") break;
|
|
6458
|
+
spawnSync3("sleep", ["0.5"], { encoding: "utf-8" });
|
|
6459
|
+
}
|
|
6460
|
+
}
|
|
6461
|
+
spawnSync3("pueue", ["remove", String(t.id)], { encoding: "utf-8" });
|
|
6462
|
+
t = findTask(channel);
|
|
6463
|
+
}
|
|
6464
|
+
}
|
|
6465
|
+
var TASK_LABEL, TMUX_SESSION, SESSION_DIR, TASK_LABEL_2, TMUX_SESSION_2, SESSION_DIR_2, TASK_LABEL_3, TMUX_SESSION_3, SESSION_DIR_3, TASK_LABEL_4, TMUX_SESSION_4, SESSION_DIR_4, PueueError, CHANNEL_PRIMARY, CHANNEL_SECONDARY, CHANNEL_TERTIARY, CHANNEL_QUATERNARY;
|
|
6466
|
+
var init_pueue = __esm({
|
|
6467
|
+
"cli/local-cc/pueue.ts"() {
|
|
6468
|
+
"use strict";
|
|
6469
|
+
TASK_LABEL = "synkro-local-cc";
|
|
6470
|
+
TMUX_SESSION = "synkro-local-cc";
|
|
6471
|
+
SESSION_DIR = join9(homedir9(), ".synkro", "cc_sessions");
|
|
6472
|
+
TASK_LABEL_2 = "synkro-local-cc-2";
|
|
6473
|
+
TMUX_SESSION_2 = "synkro-local-cc-2";
|
|
6474
|
+
SESSION_DIR_2 = join9(homedir9(), ".synkro", "cc_sessions_2");
|
|
6475
|
+
TASK_LABEL_3 = "synkro-local-cc-3";
|
|
6476
|
+
TMUX_SESSION_3 = "synkro-local-cc-3";
|
|
6477
|
+
SESSION_DIR_3 = join9(homedir9(), ".synkro", "cc_sessions_3");
|
|
6478
|
+
TASK_LABEL_4 = "synkro-local-cc-4";
|
|
6479
|
+
TMUX_SESSION_4 = "synkro-local-cc-4";
|
|
6480
|
+
SESSION_DIR_4 = join9(homedir9(), ".synkro", "cc_sessions_4");
|
|
6481
|
+
PueueError = class extends Error {
|
|
6482
|
+
constructor(message, cause) {
|
|
6483
|
+
super(message);
|
|
6484
|
+
this.cause = cause;
|
|
6485
|
+
this.name = "PueueError";
|
|
6486
|
+
}
|
|
6487
|
+
cause;
|
|
6488
|
+
};
|
|
6489
|
+
CHANNEL_PRIMARY = { taskLabel: TASK_LABEL, tmuxSession: TMUX_SESSION, sessionDir: SESSION_DIR };
|
|
6490
|
+
CHANNEL_SECONDARY = { taskLabel: TASK_LABEL_2, tmuxSession: TMUX_SESSION_2, sessionDir: SESSION_DIR_2 };
|
|
6491
|
+
CHANNEL_TERTIARY = { taskLabel: TASK_LABEL_3, tmuxSession: TMUX_SESSION_3, sessionDir: SESSION_DIR_3 };
|
|
6492
|
+
CHANNEL_QUATERNARY = { taskLabel: TASK_LABEL_4, tmuxSession: TMUX_SESSION_4, sessionDir: SESSION_DIR_4 };
|
|
6493
|
+
}
|
|
6494
|
+
});
|
|
6495
|
+
|
|
6496
|
+
// cli/local-cc/channelSource.ts
|
|
6497
|
+
var init_channelSource = __esm({
|
|
6498
|
+
"cli/local-cc/channelSource.ts"() {
|
|
6499
|
+
"use strict";
|
|
6500
|
+
}
|
|
6501
|
+
});
|
|
6502
|
+
|
|
6503
|
+
// cli/local-cc/install.ts
|
|
6504
|
+
import { existsSync as existsSync10, mkdirSync as mkdirSync9, writeFileSync as writeFileSync8, readFileSync as readFileSync8, chmodSync as chmodSync3, copyFileSync, renameSync as renameSync4, unlinkSync as unlinkSync4, openSync, fsyncSync, closeSync } from "fs";
|
|
6505
|
+
import { join as join10 } from "path";
|
|
6506
|
+
import { homedir as homedir10 } from "os";
|
|
6507
|
+
import { spawnSync as spawnSync4 } from "child_process";
|
|
6508
|
+
function safelyMutateClaudeJson(mutator) {
|
|
6509
|
+
if (!existsSync10(CLAUDE_JSON_PATH)) {
|
|
6510
|
+
return;
|
|
6511
|
+
}
|
|
6512
|
+
const originalText = readFileSync8(CLAUDE_JSON_PATH, "utf-8");
|
|
6513
|
+
let parsed;
|
|
6514
|
+
try {
|
|
6515
|
+
parsed = JSON.parse(originalText);
|
|
6516
|
+
} catch (err) {
|
|
6517
|
+
throw new LocalCCInstallError(
|
|
6518
|
+
`refusing to modify malformed ${CLAUDE_JSON_PATH}: ${err.message}. Please fix the JSON manually before retrying.`,
|
|
6519
|
+
err
|
|
6520
|
+
);
|
|
6521
|
+
}
|
|
6522
|
+
const originalKeys = new Set(Object.keys(parsed));
|
|
6523
|
+
const dirty = mutator(parsed);
|
|
6524
|
+
if (!dirty) return;
|
|
6525
|
+
for (const k of originalKeys) {
|
|
6526
|
+
if (!(k in parsed)) {
|
|
6527
|
+
throw new LocalCCInstallError(
|
|
6528
|
+
`refusing to write ${CLAUDE_JSON_PATH}: mutator dropped top-level key "${k}". This is a bug \u2014 please report.`
|
|
6529
|
+
);
|
|
6530
|
+
}
|
|
6531
|
+
}
|
|
6532
|
+
const newText = JSON.stringify(parsed, null, 2) + "\n";
|
|
6533
|
+
try {
|
|
6534
|
+
JSON.parse(newText);
|
|
6535
|
+
} catch (err) {
|
|
6536
|
+
throw new LocalCCInstallError(
|
|
6537
|
+
`refusing to write ${CLAUDE_JSON_PATH}: serialized result is not valid JSON. This is a bug \u2014 please report.`,
|
|
6538
|
+
err
|
|
6539
|
+
);
|
|
6540
|
+
}
|
|
6541
|
+
copyFileSync(CLAUDE_JSON_PATH, CLAUDE_JSON_BACKUP_PATH);
|
|
6542
|
+
const tmpPath = `${CLAUDE_JSON_PATH}.synkro-tmp.${process.pid}`;
|
|
6543
|
+
try {
|
|
6544
|
+
writeFileSync8(tmpPath, newText, "utf-8");
|
|
6545
|
+
const fd = openSync(tmpPath, "r");
|
|
6546
|
+
try {
|
|
6547
|
+
fsyncSync(fd);
|
|
6548
|
+
} finally {
|
|
6549
|
+
closeSync(fd);
|
|
6550
|
+
}
|
|
6551
|
+
renameSync4(tmpPath, CLAUDE_JSON_PATH);
|
|
6552
|
+
} catch (err) {
|
|
6553
|
+
try {
|
|
6554
|
+
unlinkSync4(tmpPath);
|
|
6555
|
+
} catch {
|
|
6556
|
+
}
|
|
7732
6557
|
try {
|
|
7733
|
-
|
|
7734
|
-
method: "POST",
|
|
7735
|
-
headers: {
|
|
7736
|
-
"Authorization": `Bearer ${token}`,
|
|
7737
|
-
"Content-Type": "application/json"
|
|
7738
|
-
},
|
|
7739
|
-
body: JSON.stringify({ repo, sessions })
|
|
7740
|
-
});
|
|
7741
|
-
if (resp.ok) {
|
|
7742
|
-
const result = await resp.json();
|
|
7743
|
-
totalMessages += result.accepted;
|
|
7744
|
-
totalSessions += result.sessions;
|
|
7745
|
-
}
|
|
6558
|
+
copyFileSync(CLAUDE_JSON_BACKUP_PATH, CLAUDE_JSON_PATH);
|
|
7746
6559
|
} catch {
|
|
7747
6560
|
}
|
|
7748
|
-
|
|
7749
|
-
|
|
7750
|
-
|
|
7751
|
-
|
|
7752
|
-
|
|
7753
|
-
|
|
7754
|
-
|
|
7755
|
-
|
|
6561
|
+
throw new LocalCCInstallError(
|
|
6562
|
+
`failed to write ${CLAUDE_JSON_PATH}: ${err.message}. Backup at ${CLAUDE_JSON_BACKUP_PATH} preserves the prior state.`,
|
|
6563
|
+
err
|
|
6564
|
+
);
|
|
6565
|
+
}
|
|
6566
|
+
}
|
|
6567
|
+
function uninstallLocalCC() {
|
|
6568
|
+
safelyMutateClaudeJson((parsed) => {
|
|
6569
|
+
let dirty = false;
|
|
6570
|
+
if (parsed.mcpServers && parsed.mcpServers[MCP_SERVER_NAME]) {
|
|
6571
|
+
delete parsed.mcpServers[MCP_SERVER_NAME];
|
|
6572
|
+
dirty = true;
|
|
6573
|
+
}
|
|
6574
|
+
for (const dir of CHANNELS.map((c) => c.sessionDir)) {
|
|
6575
|
+
if (parsed.projects && typeof parsed.projects === "object" && parsed.projects[dir]) {
|
|
6576
|
+
delete parsed.projects[dir];
|
|
6577
|
+
dirty = true;
|
|
7756
6578
|
}
|
|
7757
6579
|
}
|
|
7758
|
-
|
|
7759
|
-
|
|
6580
|
+
return dirty;
|
|
6581
|
+
});
|
|
7760
6582
|
}
|
|
7761
|
-
var
|
|
6583
|
+
var CLAUDE_JSON_BACKUP_PATH, SESSION_DIR2, PLUGIN_PATH, PLUGIN_PKG_PATH, PLUGIN_SETTINGS_DIR, PLUGIN_SETTINGS_PATH, PROJECT_MCP_PATH, CLAUDE_JSON_PATH, RUN_SCRIPT_PATH, TMUX_SESSION_NAME, CHANNEL_1_PORT, SESSION_DIR_22, PLUGIN_PATH_2, PLUGIN_PKG_PATH_2, PLUGIN_SETTINGS_DIR_2, PLUGIN_SETTINGS_PATH_2, PROJECT_MCP_PATH_2, RUN_SCRIPT_PATH_2, TMUX_SESSION_NAME_2, CHANNEL_2_PORT, SESSION_DIR_32, PLUGIN_PATH_3, PLUGIN_PKG_PATH_3, PLUGIN_SETTINGS_DIR_3, PLUGIN_SETTINGS_PATH_3, PROJECT_MCP_PATH_3, RUN_SCRIPT_PATH_3, TMUX_SESSION_NAME_3, CHANNEL_3_PORT, SESSION_DIR_42, PLUGIN_PATH_4, PLUGIN_PKG_PATH_4, PLUGIN_SETTINGS_DIR_4, PLUGIN_SETTINGS_PATH_4, PROJECT_MCP_PATH_4, RUN_SCRIPT_PATH_4, TMUX_SESSION_NAME_4, CHANNEL_4_PORT, RUN_SCRIPT_SOURCE, RUN_SCRIPT_SOURCE_2, RUN_SCRIPT_SOURCE_3, RUN_SCRIPT_SOURCE_4, MCP_SERVER_NAME, PLUGIN_PACKAGE_JSON, LocalCCInstallError, CHANNELS;
|
|
7762
6584
|
var init_install2 = __esm({
|
|
7763
|
-
"cli/
|
|
6585
|
+
"cli/local-cc/install.ts"() {
|
|
7764
6586
|
"use strict";
|
|
7765
|
-
|
|
7766
|
-
|
|
7767
|
-
|
|
7768
|
-
|
|
7769
|
-
|
|
7770
|
-
|
|
7771
|
-
|
|
7772
|
-
|
|
7773
|
-
|
|
7774
|
-
|
|
7775
|
-
|
|
7776
|
-
|
|
7777
|
-
|
|
7778
|
-
|
|
7779
|
-
|
|
7780
|
-
|
|
7781
|
-
|
|
7782
|
-
|
|
7783
|
-
|
|
7784
|
-
|
|
7785
|
-
|
|
7786
|
-
|
|
7787
|
-
|
|
7788
|
-
|
|
7789
|
-
|
|
6587
|
+
init_channelSource();
|
|
6588
|
+
CLAUDE_JSON_BACKUP_PATH = join10(homedir10(), ".claude.json.synkro-bak");
|
|
6589
|
+
SESSION_DIR2 = join10(homedir10(), ".synkro", "cc_sessions");
|
|
6590
|
+
PLUGIN_PATH = join10(SESSION_DIR2, "synkro-channel.ts");
|
|
6591
|
+
PLUGIN_PKG_PATH = join10(SESSION_DIR2, "package.json");
|
|
6592
|
+
PLUGIN_SETTINGS_DIR = join10(SESSION_DIR2, ".claude");
|
|
6593
|
+
PLUGIN_SETTINGS_PATH = join10(PLUGIN_SETTINGS_DIR, "settings.json");
|
|
6594
|
+
PROJECT_MCP_PATH = join10(SESSION_DIR2, ".mcp.json");
|
|
6595
|
+
CLAUDE_JSON_PATH = join10(homedir10(), ".claude.json");
|
|
6596
|
+
RUN_SCRIPT_PATH = join10(SESSION_DIR2, "run-claude.sh");
|
|
6597
|
+
TMUX_SESSION_NAME = "synkro-local-cc";
|
|
6598
|
+
CHANNEL_1_PORT = 8941;
|
|
6599
|
+
SESSION_DIR_22 = join10(homedir10(), ".synkro", "cc_sessions_2");
|
|
6600
|
+
PLUGIN_PATH_2 = join10(SESSION_DIR_22, "synkro-channel.ts");
|
|
6601
|
+
PLUGIN_PKG_PATH_2 = join10(SESSION_DIR_22, "package.json");
|
|
6602
|
+
PLUGIN_SETTINGS_DIR_2 = join10(SESSION_DIR_22, ".claude");
|
|
6603
|
+
PLUGIN_SETTINGS_PATH_2 = join10(PLUGIN_SETTINGS_DIR_2, "settings.json");
|
|
6604
|
+
PROJECT_MCP_PATH_2 = join10(SESSION_DIR_22, ".mcp.json");
|
|
6605
|
+
RUN_SCRIPT_PATH_2 = join10(SESSION_DIR_22, "run-claude.sh");
|
|
6606
|
+
TMUX_SESSION_NAME_2 = "synkro-local-cc-2";
|
|
6607
|
+
CHANNEL_2_PORT = 8951;
|
|
6608
|
+
SESSION_DIR_32 = join10(homedir10(), ".synkro", "cc_sessions_3");
|
|
6609
|
+
PLUGIN_PATH_3 = join10(SESSION_DIR_32, "synkro-channel.ts");
|
|
6610
|
+
PLUGIN_PKG_PATH_3 = join10(SESSION_DIR_32, "package.json");
|
|
6611
|
+
PLUGIN_SETTINGS_DIR_3 = join10(SESSION_DIR_32, ".claude");
|
|
6612
|
+
PLUGIN_SETTINGS_PATH_3 = join10(PLUGIN_SETTINGS_DIR_3, "settings.json");
|
|
6613
|
+
PROJECT_MCP_PATH_3 = join10(SESSION_DIR_32, ".mcp.json");
|
|
6614
|
+
RUN_SCRIPT_PATH_3 = join10(SESSION_DIR_32, "run-claude.sh");
|
|
6615
|
+
TMUX_SESSION_NAME_3 = "synkro-local-cc-3";
|
|
6616
|
+
CHANNEL_3_PORT = 8942;
|
|
6617
|
+
SESSION_DIR_42 = join10(homedir10(), ".synkro", "cc_sessions_4");
|
|
6618
|
+
PLUGIN_PATH_4 = join10(SESSION_DIR_42, "synkro-channel.ts");
|
|
6619
|
+
PLUGIN_PKG_PATH_4 = join10(SESSION_DIR_42, "package.json");
|
|
6620
|
+
PLUGIN_SETTINGS_DIR_4 = join10(SESSION_DIR_42, ".claude");
|
|
6621
|
+
PLUGIN_SETTINGS_PATH_4 = join10(PLUGIN_SETTINGS_DIR_4, "settings.json");
|
|
6622
|
+
PROJECT_MCP_PATH_4 = join10(SESSION_DIR_42, ".mcp.json");
|
|
6623
|
+
RUN_SCRIPT_PATH_4 = join10(SESSION_DIR_42, "run-claude.sh");
|
|
6624
|
+
TMUX_SESSION_NAME_4 = "synkro-local-cc-4";
|
|
6625
|
+
CHANNEL_4_PORT = 8952;
|
|
6626
|
+
RUN_SCRIPT_SOURCE = `#!/usr/bin/env bash
|
|
6627
|
+
# Auto-generated by \`synkro install\`. Do not edit.
|
|
6628
|
+
set -uo pipefail
|
|
6629
|
+
|
|
6630
|
+
SESSION=${TMUX_SESSION_NAME}
|
|
6631
|
+
LOG="$HOME/.synkro/cc_sessions/run-claude.log"
|
|
6632
|
+
|
|
6633
|
+
log() { echo "[$(date '+%H:%M:%S')] $*" >> "$LOG"; echo "$*"; }
|
|
6634
|
+
|
|
6635
|
+
# Pre-flight checks
|
|
6636
|
+
if ! command -v claude >/dev/null 2>&1; then
|
|
6637
|
+
log "ERROR: claude CLI not found on PATH. Install Claude Code first."
|
|
6638
|
+
exit 1
|
|
6639
|
+
fi
|
|
6640
|
+
|
|
6641
|
+
if ! command -v tmux >/dev/null 2>&1; then
|
|
6642
|
+
log "ERROR: tmux not found on PATH."
|
|
6643
|
+
exit 1
|
|
6644
|
+
fi
|
|
6645
|
+
|
|
6646
|
+
# Check claude is authenticated
|
|
6647
|
+
if ! claude --version >/dev/null 2>&1; then
|
|
6648
|
+
log "ERROR: claude --version failed. Is Claude Code installed correctly?"
|
|
6649
|
+
exit 1
|
|
6650
|
+
fi
|
|
6651
|
+
|
|
6652
|
+
log "Starting local-CC session..."
|
|
6653
|
+
log "claude version: $(claude --version 2>&1 | head -1)"
|
|
6654
|
+
|
|
6655
|
+
# Kill any previous session so restarts come up clean.
|
|
6656
|
+
tmux kill-session -t "=$SESSION" 2>/dev/null || true
|
|
6657
|
+
|
|
6658
|
+
# Start claude inside a detached tmux session so it has a real pty.
|
|
6659
|
+
# Redirect stderr to the log so we can see why it dies.
|
|
6660
|
+
tmux new-session -d -s "$SESSION" \\
|
|
6661
|
+
"SYNKRO_CHANNEL_PORT=${CHANNEL_1_PORT} claude --dangerously-load-development-channels server:synkro-local --dangerously-skip-permissions --setting-sources project,local --model claude-sonnet-4-6 2>>$LOG; echo 'claude exited with code '$'?' >> $LOG"
|
|
6662
|
+
|
|
6663
|
+
# Claude's --dangerously-load-development-channels shows a confirmation
|
|
6664
|
+
# prompt: option 1 = "I am using this for local development" (accept),
|
|
6665
|
+
# option 2 = "Exit". Auto-accept by sending '1' + Enter.
|
|
6666
|
+
sleep 3
|
|
6667
|
+
if tmux has-session -t "=$SESSION" 2>/dev/null; then
|
|
6668
|
+
tmux send-keys -t "$SESSION" '1' 2>/dev/null || true
|
|
6669
|
+
sleep 1
|
|
6670
|
+
tmux send-keys -t "$SESSION" Enter 2>/dev/null || true
|
|
6671
|
+
sleep 1
|
|
6672
|
+
# Additional Enter for any follow-up prompts (workspace trust, MCP consent)
|
|
6673
|
+
tmux send-keys -t "$SESSION" Enter 2>/dev/null || true
|
|
6674
|
+
log "Sent auto-accept keys to claude session."
|
|
6675
|
+
fi
|
|
6676
|
+
|
|
6677
|
+
sleep 2
|
|
6678
|
+
if ! tmux has-session -t "$SESSION" 2>/dev/null; then
|
|
6679
|
+
log "ERROR: tmux session died immediately. Check $LOG for details."
|
|
6680
|
+
log "Try running claude manually to verify auth: claude --print 'say ok'"
|
|
6681
|
+
exit 1
|
|
6682
|
+
fi
|
|
6683
|
+
|
|
6684
|
+
log "tmux session started successfully."
|
|
6685
|
+
|
|
6686
|
+
# Block on the tmux session so pueue's task lifetime tracks claude's.
|
|
6687
|
+
while tmux has-session -t "=$SESSION" 2>/dev/null; do
|
|
6688
|
+
sleep 5
|
|
6689
|
+
done
|
|
6690
|
+
|
|
6691
|
+
log "tmux session ended."
|
|
6692
|
+
`;
|
|
6693
|
+
RUN_SCRIPT_SOURCE_2 = `#!/usr/bin/env bash
|
|
6694
|
+
# Auto-generated by \`synkro install\`. Channel 2 (CWE scan, port ${CHANNEL_2_PORT}).
|
|
6695
|
+
set -uo pipefail
|
|
6696
|
+
|
|
6697
|
+
SESSION=${TMUX_SESSION_NAME_2}
|
|
6698
|
+
LOG="$HOME/.synkro/cc_sessions_2/run-claude.log"
|
|
6699
|
+
|
|
6700
|
+
log() { echo "[$(date '+%H:%M:%S')] $*" >> "$LOG"; echo "$*"; }
|
|
6701
|
+
|
|
6702
|
+
if ! command -v claude >/dev/null 2>&1; then
|
|
6703
|
+
log "ERROR: claude CLI not found on PATH."
|
|
6704
|
+
exit 1
|
|
6705
|
+
fi
|
|
6706
|
+
|
|
6707
|
+
if ! command -v tmux >/dev/null 2>&1; then
|
|
6708
|
+
log "ERROR: tmux not found on PATH."
|
|
6709
|
+
exit 1
|
|
6710
|
+
fi
|
|
6711
|
+
|
|
6712
|
+
if ! claude --version >/dev/null 2>&1; then
|
|
6713
|
+
log "ERROR: claude --version failed."
|
|
6714
|
+
exit 1
|
|
6715
|
+
fi
|
|
6716
|
+
|
|
6717
|
+
log "Starting local-CC channel 2 (port ${CHANNEL_2_PORT})..."
|
|
6718
|
+
log "claude version: $(claude --version 2>&1 | head -1)"
|
|
6719
|
+
|
|
6720
|
+
tmux kill-session -t "=$SESSION" 2>/dev/null || true
|
|
6721
|
+
|
|
6722
|
+
tmux new-session -d -s "$SESSION" \\
|
|
6723
|
+
"SYNKRO_CHANNEL_PORT=${CHANNEL_2_PORT} claude --dangerously-load-development-channels server:synkro-local --dangerously-skip-permissions --setting-sources project,local --model claude-sonnet-4-6 2>>$LOG; echo 'claude exited with code '$'?' >> $LOG"
|
|
6724
|
+
|
|
6725
|
+
sleep 3
|
|
6726
|
+
if tmux has-session -t "=$SESSION" 2>/dev/null; then
|
|
6727
|
+
tmux send-keys -t "$SESSION" '1' 2>/dev/null || true
|
|
6728
|
+
sleep 1
|
|
6729
|
+
tmux send-keys -t "$SESSION" Enter 2>/dev/null || true
|
|
6730
|
+
sleep 1
|
|
6731
|
+
tmux send-keys -t "$SESSION" Enter 2>/dev/null || true
|
|
6732
|
+
log "Sent auto-accept keys to channel 2 session."
|
|
6733
|
+
fi
|
|
6734
|
+
|
|
6735
|
+
sleep 2
|
|
6736
|
+
if ! tmux has-session -t "$SESSION" 2>/dev/null; then
|
|
6737
|
+
log "ERROR: tmux session died immediately. Check $LOG for details."
|
|
6738
|
+
exit 1
|
|
6739
|
+
fi
|
|
6740
|
+
|
|
6741
|
+
log "tmux session started successfully (port ${CHANNEL_2_PORT})."
|
|
6742
|
+
|
|
6743
|
+
while tmux has-session -t "=$SESSION" 2>/dev/null; do
|
|
6744
|
+
sleep 5
|
|
6745
|
+
done
|
|
6746
|
+
|
|
6747
|
+
log "tmux session ended."
|
|
6748
|
+
`;
|
|
6749
|
+
RUN_SCRIPT_SOURCE_3 = `#!/usr/bin/env bash
|
|
6750
|
+
# Auto-generated by \`synkro install\`. General worker B (port ${CHANNEL_3_PORT}).
|
|
6751
|
+
set -uo pipefail
|
|
6752
|
+
|
|
6753
|
+
SESSION=${TMUX_SESSION_NAME_3}
|
|
6754
|
+
LOG="$HOME/.synkro/cc_sessions_3/run-claude.log"
|
|
6755
|
+
|
|
6756
|
+
log() { echo "[$(date '+%H:%M:%S')] $*" >> "$LOG"; echo "$*"; }
|
|
6757
|
+
|
|
6758
|
+
if ! command -v claude >/dev/null 2>&1; then
|
|
6759
|
+
log "ERROR: claude CLI not found on PATH."
|
|
6760
|
+
exit 1
|
|
6761
|
+
fi
|
|
6762
|
+
|
|
6763
|
+
if ! command -v tmux >/dev/null 2>&1; then
|
|
6764
|
+
log "ERROR: tmux not found on PATH."
|
|
6765
|
+
exit 1
|
|
6766
|
+
fi
|
|
6767
|
+
|
|
6768
|
+
if ! claude --version >/dev/null 2>&1; then
|
|
6769
|
+
log "ERROR: claude --version failed."
|
|
6770
|
+
exit 1
|
|
6771
|
+
fi
|
|
6772
|
+
|
|
6773
|
+
log "Starting local-CC general worker B (port ${CHANNEL_3_PORT})..."
|
|
6774
|
+
log "claude version: $(claude --version 2>&1 | head -1)"
|
|
6775
|
+
|
|
6776
|
+
tmux kill-session -t "=$SESSION" 2>/dev/null || true
|
|
6777
|
+
|
|
6778
|
+
tmux new-session -d -s "$SESSION" \\
|
|
6779
|
+
"SYNKRO_CHANNEL_PORT=${CHANNEL_3_PORT} claude --dangerously-load-development-channels server:synkro-local --dangerously-skip-permissions --setting-sources project,local --model claude-sonnet-4-6 2>>$LOG; echo 'claude exited with code '$'?' >> $LOG"
|
|
6780
|
+
|
|
6781
|
+
sleep 3
|
|
6782
|
+
if tmux has-session -t "=$SESSION" 2>/dev/null; then
|
|
6783
|
+
tmux send-keys -t "$SESSION" '1' 2>/dev/null || true
|
|
6784
|
+
sleep 1
|
|
6785
|
+
tmux send-keys -t "$SESSION" Enter 2>/dev/null || true
|
|
6786
|
+
sleep 1
|
|
6787
|
+
tmux send-keys -t "$SESSION" Enter 2>/dev/null || true
|
|
6788
|
+
log "Sent auto-accept keys to general worker B session."
|
|
6789
|
+
fi
|
|
7790
6790
|
|
|
7791
|
-
|
|
7792
|
-
|
|
7793
|
-
|
|
7794
|
-
|
|
6791
|
+
sleep 2
|
|
6792
|
+
if ! tmux has-session -t "$SESSION" 2>/dev/null; then
|
|
6793
|
+
log "ERROR: tmux session died immediately. Check $LOG for details."
|
|
6794
|
+
exit 1
|
|
6795
|
+
fi
|
|
7795
6796
|
|
|
7796
|
-
|
|
7797
|
-
try { token = readFileSync(TOKEN_PATH, 'utf-8').trim(); } catch {}
|
|
6797
|
+
log "tmux session started successfully (port ${CHANNEL_3_PORT})."
|
|
7798
6798
|
|
|
7799
|
-
|
|
6799
|
+
while tmux has-session -t "=$SESSION" 2>/dev/null; do
|
|
6800
|
+
sleep 5
|
|
6801
|
+
done
|
|
7800
6802
|
|
|
7801
|
-
|
|
7802
|
-
|
|
7803
|
-
|
|
7804
|
-
|
|
7805
|
-
|
|
6803
|
+
log "tmux session ended."
|
|
6804
|
+
`;
|
|
6805
|
+
RUN_SCRIPT_SOURCE_4 = `#!/usr/bin/env bash
|
|
6806
|
+
# Auto-generated by \`synkro install\`. CWE worker B (port ${CHANNEL_4_PORT}).
|
|
6807
|
+
set -uo pipefail
|
|
7806
6808
|
|
|
7807
|
-
|
|
7808
|
-
|
|
7809
|
-
|
|
7810
|
-
|
|
7811
|
-
|
|
7812
|
-
|
|
7813
|
-
|
|
7814
|
-
|
|
7815
|
-
|
|
7816
|
-
|
|
7817
|
-
|
|
7818
|
-
|
|
7819
|
-
|
|
7820
|
-
|
|
7821
|
-
|
|
7822
|
-
|
|
7823
|
-
|
|
7824
|
-
|
|
7825
|
-
|
|
7826
|
-
|
|
7827
|
-
|
|
7828
|
-
|
|
7829
|
-
|
|
6809
|
+
SESSION=${TMUX_SESSION_NAME_4}
|
|
6810
|
+
LOG="$HOME/.synkro/cc_sessions_4/run-claude.log"
|
|
6811
|
+
|
|
6812
|
+
log() { echo "[$(date '+%H:%M:%S')] $*" >> "$LOG"; echo "$*"; }
|
|
6813
|
+
|
|
6814
|
+
if ! command -v claude >/dev/null 2>&1; then
|
|
6815
|
+
log "ERROR: claude CLI not found on PATH."
|
|
6816
|
+
exit 1
|
|
6817
|
+
fi
|
|
6818
|
+
|
|
6819
|
+
if ! command -v tmux >/dev/null 2>&1; then
|
|
6820
|
+
log "ERROR: tmux not found on PATH."
|
|
6821
|
+
exit 1
|
|
6822
|
+
fi
|
|
6823
|
+
|
|
6824
|
+
if ! claude --version >/dev/null 2>&1; then
|
|
6825
|
+
log "ERROR: claude --version failed."
|
|
6826
|
+
exit 1
|
|
6827
|
+
fi
|
|
6828
|
+
|
|
6829
|
+
log "Starting local-CC CWE worker B (port ${CHANNEL_4_PORT})..."
|
|
6830
|
+
log "claude version: $(claude --version 2>&1 | head -1)"
|
|
6831
|
+
|
|
6832
|
+
tmux kill-session -t "=$SESSION" 2>/dev/null || true
|
|
6833
|
+
|
|
6834
|
+
tmux new-session -d -s "$SESSION" \\
|
|
6835
|
+
"SYNKRO_CHANNEL_PORT=${CHANNEL_4_PORT} claude --dangerously-load-development-channels server:synkro-local --dangerously-skip-permissions --setting-sources project,local --model claude-sonnet-4-6 2>>$LOG; echo 'claude exited with code '$'?' >> $LOG"
|
|
6836
|
+
|
|
6837
|
+
sleep 3
|
|
6838
|
+
if tmux has-session -t "=$SESSION" 2>/dev/null; then
|
|
6839
|
+
tmux send-keys -t "$SESSION" '1' 2>/dev/null || true
|
|
6840
|
+
sleep 1
|
|
6841
|
+
tmux send-keys -t "$SESSION" Enter 2>/dev/null || true
|
|
6842
|
+
sleep 1
|
|
6843
|
+
tmux send-keys -t "$SESSION" Enter 2>/dev/null || true
|
|
6844
|
+
log "Sent auto-accept keys to CWE worker B session."
|
|
6845
|
+
fi
|
|
6846
|
+
|
|
6847
|
+
sleep 2
|
|
6848
|
+
if ! tmux has-session -t "$SESSION" 2>/dev/null; then
|
|
6849
|
+
log "ERROR: tmux session died immediately. Check $LOG for details."
|
|
6850
|
+
exit 1
|
|
6851
|
+
fi
|
|
6852
|
+
|
|
6853
|
+
log "tmux session started successfully (port ${CHANNEL_4_PORT})."
|
|
6854
|
+
|
|
6855
|
+
while tmux has-session -t "=$SESSION" 2>/dev/null; do
|
|
6856
|
+
sleep 5
|
|
6857
|
+
done
|
|
6858
|
+
|
|
6859
|
+
log "tmux session ended."
|
|
7830
6860
|
`;
|
|
7831
|
-
|
|
7832
|
-
|
|
7833
|
-
|
|
7834
|
-
|
|
6861
|
+
MCP_SERVER_NAME = "synkro-local";
|
|
6862
|
+
PLUGIN_PACKAGE_JSON = JSON.stringify(
|
|
6863
|
+
{
|
|
6864
|
+
name: "synkro-local-channel",
|
|
6865
|
+
private: true,
|
|
6866
|
+
version: "0.1.0",
|
|
6867
|
+
type: "module",
|
|
6868
|
+
dependencies: {
|
|
6869
|
+
"@modelcontextprotocol/sdk": "^1.0.0"
|
|
6870
|
+
}
|
|
6871
|
+
},
|
|
6872
|
+
null,
|
|
6873
|
+
2
|
|
6874
|
+
) + "\n";
|
|
6875
|
+
LocalCCInstallError = class extends Error {
|
|
6876
|
+
constructor(message, cause) {
|
|
6877
|
+
super(message);
|
|
6878
|
+
this.cause = cause;
|
|
6879
|
+
this.name = "LocalCCInstallError";
|
|
6880
|
+
}
|
|
6881
|
+
cause;
|
|
6882
|
+
};
|
|
6883
|
+
CHANNELS = [
|
|
6884
|
+
{ sessionDir: SESSION_DIR2, pluginPath: PLUGIN_PATH, pluginPkgPath: PLUGIN_PKG_PATH, pluginSettingsDir: PLUGIN_SETTINGS_DIR, pluginSettingsPath: PLUGIN_SETTINGS_PATH, projectMcpPath: PROJECT_MCP_PATH, runScriptPath: RUN_SCRIPT_PATH, runScriptSource: RUN_SCRIPT_SOURCE },
|
|
6885
|
+
{ sessionDir: SESSION_DIR_22, pluginPath: PLUGIN_PATH_2, pluginPkgPath: PLUGIN_PKG_PATH_2, pluginSettingsDir: PLUGIN_SETTINGS_DIR_2, pluginSettingsPath: PLUGIN_SETTINGS_PATH_2, projectMcpPath: PROJECT_MCP_PATH_2, runScriptPath: RUN_SCRIPT_PATH_2, runScriptSource: RUN_SCRIPT_SOURCE_2 },
|
|
6886
|
+
{ sessionDir: SESSION_DIR_32, pluginPath: PLUGIN_PATH_3, pluginPkgPath: PLUGIN_PKG_PATH_3, pluginSettingsDir: PLUGIN_SETTINGS_DIR_3, pluginSettingsPath: PLUGIN_SETTINGS_PATH_3, projectMcpPath: PROJECT_MCP_PATH_3, runScriptPath: RUN_SCRIPT_PATH_3, runScriptSource: RUN_SCRIPT_SOURCE_3 },
|
|
6887
|
+
{ sessionDir: SESSION_DIR_42, pluginPath: PLUGIN_PATH_4, pluginPkgPath: PLUGIN_PKG_PATH_4, pluginSettingsDir: PLUGIN_SETTINGS_DIR_4, pluginSettingsPath: PLUGIN_SETTINGS_PATH_4, projectMcpPath: PROJECT_MCP_PATH_4, runScriptPath: RUN_SCRIPT_PATH_4, runScriptSource: RUN_SCRIPT_SOURCE_4 }
|
|
6888
|
+
];
|
|
7835
6889
|
}
|
|
7836
6890
|
});
|
|
7837
6891
|
|
|
@@ -7840,9 +6894,9 @@ var disconnect_exports = {};
|
|
|
7840
6894
|
__export(disconnect_exports, {
|
|
7841
6895
|
disconnectCommand: () => disconnectCommand
|
|
7842
6896
|
});
|
|
7843
|
-
import { existsSync as
|
|
7844
|
-
import { homedir as
|
|
7845
|
-
import { join as
|
|
6897
|
+
import { existsSync as existsSync11, rmSync } from "fs";
|
|
6898
|
+
import { homedir as homedir11 } from "os";
|
|
6899
|
+
import { join as join11 } from "path";
|
|
7846
6900
|
function tearDownLocalCC() {
|
|
7847
6901
|
const docker = dockerStatus();
|
|
7848
6902
|
if (docker.running) {
|
|
@@ -7897,13 +6951,13 @@ function disconnectCommand(args2 = []) {
|
|
|
7897
6951
|
console.log(`${cursorMcpRemoved ? "\u2713" : "\xB7"} MCP guardrails (Cursor): ${cursorMcpRemoved ? "removed from ~/.cursor/mcp.json" : "no entry found"}`);
|
|
7898
6952
|
}
|
|
7899
6953
|
if (purge) {
|
|
7900
|
-
if (
|
|
6954
|
+
if (existsSync11(SYNKRO_DIR5)) {
|
|
7901
6955
|
rmSync(SYNKRO_DIR5, { recursive: true, force: true });
|
|
7902
6956
|
console.log(`\u2713 Removed ${SYNKRO_DIR5}`);
|
|
7903
6957
|
} else {
|
|
7904
6958
|
console.log(`\xB7 ${SYNKRO_DIR5} already gone, nothing to remove`);
|
|
7905
6959
|
}
|
|
7906
|
-
} else if (
|
|
6960
|
+
} else if (existsSync11(SYNKRO_DIR5)) {
|
|
7907
6961
|
console.log(`Config preserved at ${SYNKRO_DIR5}. Run with --purge to remove.`);
|
|
7908
6962
|
}
|
|
7909
6963
|
console.log("\nSynkro disconnected.");
|
|
@@ -7917,10 +6971,136 @@ var init_disconnect = __esm({
|
|
|
7917
6971
|
init_cursorHookConfig();
|
|
7918
6972
|
init_mcpConfig();
|
|
7919
6973
|
init_pueue();
|
|
7920
|
-
|
|
6974
|
+
init_install2();
|
|
7921
6975
|
init_dockerInstall();
|
|
7922
6976
|
init_macKeychain();
|
|
7923
|
-
SYNKRO_DIR5 =
|
|
6977
|
+
SYNKRO_DIR5 = join11(homedir11(), ".synkro");
|
|
6978
|
+
}
|
|
6979
|
+
});
|
|
6980
|
+
|
|
6981
|
+
// cli/local-cc/turnLog.ts
|
|
6982
|
+
import { appendFileSync, existsSync as existsSync12, mkdirSync as mkdirSync10, openSync as openSync2, readFileSync as readFileSync9, readSync, closeSync as closeSync2, statSync as statSync2, watchFile, unwatchFile } from "fs";
|
|
6983
|
+
import { dirname as dirname5, join as join12 } from "path";
|
|
6984
|
+
import { homedir as homedir12 } from "os";
|
|
6985
|
+
function truncate(s, max = PREVIEW_MAX) {
|
|
6986
|
+
if (s.length <= max) return s;
|
|
6987
|
+
return s.slice(0, max) + "\u2026 [+" + (s.length - max) + " chars]";
|
|
6988
|
+
}
|
|
6989
|
+
function extractSeverity(result) {
|
|
6990
|
+
const m = result.match(/<synkro-(?:verdict|intent)>([\s\S]*?)<\/synkro-(?:verdict|intent)>/);
|
|
6991
|
+
if (!m) return void 0;
|
|
6992
|
+
try {
|
|
6993
|
+
const obj = JSON.parse(m[1]);
|
|
6994
|
+
if (obj.severity) return String(obj.severity);
|
|
6995
|
+
if (typeof obj.ok === "boolean") return obj.ok ? "ok" : "violations";
|
|
6996
|
+
if (obj.type) return String(obj.type);
|
|
6997
|
+
if (obj.verdict) return String(obj.verdict);
|
|
6998
|
+
} catch {
|
|
6999
|
+
}
|
|
7000
|
+
return void 0;
|
|
7001
|
+
}
|
|
7002
|
+
function appendTurn(args2) {
|
|
7003
|
+
try {
|
|
7004
|
+
mkdirSync10(dirname5(TURN_LOG_PATH), { recursive: true });
|
|
7005
|
+
const entry = {
|
|
7006
|
+
ts: new Date(args2.startedAt).toISOString(),
|
|
7007
|
+
role: args2.role,
|
|
7008
|
+
duration_ms: Date.now() - args2.startedAt,
|
|
7009
|
+
status: args2.status,
|
|
7010
|
+
request_preview: truncate(args2.request),
|
|
7011
|
+
response_preview: args2.result ? truncate(args2.result) : "",
|
|
7012
|
+
severity: args2.result ? extractSeverity(args2.result) : void 0,
|
|
7013
|
+
error: args2.error
|
|
7014
|
+
};
|
|
7015
|
+
appendFileSync(TURN_LOG_PATH, JSON.stringify(entry) + "\n", "utf-8");
|
|
7016
|
+
} catch {
|
|
7017
|
+
}
|
|
7018
|
+
}
|
|
7019
|
+
var TURN_LOG_PATH, PREVIEW_MAX;
|
|
7020
|
+
var init_turnLog = __esm({
|
|
7021
|
+
"cli/local-cc/turnLog.ts"() {
|
|
7022
|
+
"use strict";
|
|
7023
|
+
TURN_LOG_PATH = join12(homedir12(), ".synkro", "cc_sessions", "turns.log");
|
|
7024
|
+
PREVIEW_MAX = 400;
|
|
7025
|
+
}
|
|
7026
|
+
});
|
|
7027
|
+
|
|
7028
|
+
// cli/local-cc/client.ts
|
|
7029
|
+
import { request as httpRequest } from "http";
|
|
7030
|
+
import { connect as connect2 } from "net";
|
|
7031
|
+
async function submitToChannel(role, payload, opts = {}) {
|
|
7032
|
+
const body = JSON.stringify({ role, payload, content: payload });
|
|
7033
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
7034
|
+
const port = opts.port ?? CHANNEL_PORT;
|
|
7035
|
+
const startedAt = Date.now();
|
|
7036
|
+
try {
|
|
7037
|
+
const result = await new Promise((resolve3, reject) => {
|
|
7038
|
+
const req = httpRequest({
|
|
7039
|
+
host: CHANNEL_HOST,
|
|
7040
|
+
port,
|
|
7041
|
+
method: "POST",
|
|
7042
|
+
path: "/submit",
|
|
7043
|
+
headers: {
|
|
7044
|
+
"Content-Type": "application/json",
|
|
7045
|
+
"Content-Length": Buffer.byteLength(body)
|
|
7046
|
+
},
|
|
7047
|
+
timeout: timeoutMs
|
|
7048
|
+
}, (res) => {
|
|
7049
|
+
const chunks = [];
|
|
7050
|
+
res.on("data", (c) => chunks.push(c));
|
|
7051
|
+
res.on("end", () => {
|
|
7052
|
+
const text = Buffer.concat(chunks).toString("utf-8");
|
|
7053
|
+
if (res.statusCode !== 200) {
|
|
7054
|
+
reject(new LocalCCError(`channel returned ${res.statusCode}: ${text.slice(0, 500)}`));
|
|
7055
|
+
return;
|
|
7056
|
+
}
|
|
7057
|
+
try {
|
|
7058
|
+
const parsed = JSON.parse(text);
|
|
7059
|
+
if (parsed.error) {
|
|
7060
|
+
reject(new LocalCCError(parsed.error));
|
|
7061
|
+
return;
|
|
7062
|
+
}
|
|
7063
|
+
resolve3(String(parsed.result ?? ""));
|
|
7064
|
+
} catch (err) {
|
|
7065
|
+
reject(new LocalCCError(`malformed channel response: ${text.slice(0, 200)}`, err));
|
|
7066
|
+
}
|
|
7067
|
+
});
|
|
7068
|
+
});
|
|
7069
|
+
req.on("timeout", () => {
|
|
7070
|
+
req.destroy(new LocalCCError(`channel request timed out after ${timeoutMs}ms`));
|
|
7071
|
+
});
|
|
7072
|
+
req.on("error", (err) => {
|
|
7073
|
+
const msg = err.code === "ECONNREFUSED" ? `channel connection refused at ${CHANNEL_HOST}:${CHANNEL_PORT} (is the pueue task running?)` : `channel request failed: ${err.message}`;
|
|
7074
|
+
reject(new LocalCCError(msg, err));
|
|
7075
|
+
});
|
|
7076
|
+
req.write(body);
|
|
7077
|
+
req.end();
|
|
7078
|
+
});
|
|
7079
|
+
appendTurn({ startedAt, role, request: payload, result, status: "ok" });
|
|
7080
|
+
return result;
|
|
7081
|
+
} catch (err) {
|
|
7082
|
+
const message = err.message ?? String(err);
|
|
7083
|
+
const status = /timed out/i.test(message) ? "timeout" : "error";
|
|
7084
|
+
appendTurn({ startedAt, role, request: payload, status, error: message });
|
|
7085
|
+
throw err;
|
|
7086
|
+
}
|
|
7087
|
+
}
|
|
7088
|
+
var CHANNEL_HOST, CHANNEL_PORT, DEFAULT_TIMEOUT_MS, LocalCCError;
|
|
7089
|
+
var init_client = __esm({
|
|
7090
|
+
"cli/local-cc/client.ts"() {
|
|
7091
|
+
"use strict";
|
|
7092
|
+
init_turnLog();
|
|
7093
|
+
CHANNEL_HOST = "127.0.0.1";
|
|
7094
|
+
CHANNEL_PORT = parseInt(process.env.SYNKRO_CHANNEL_PORT || "8929", 10);
|
|
7095
|
+
DEFAULT_TIMEOUT_MS = 9e4;
|
|
7096
|
+
LocalCCError = class extends Error {
|
|
7097
|
+
constructor(message, cause) {
|
|
7098
|
+
super(message);
|
|
7099
|
+
this.cause = cause;
|
|
7100
|
+
this.name = "LocalCCError";
|
|
7101
|
+
}
|
|
7102
|
+
cause;
|
|
7103
|
+
};
|
|
7924
7104
|
}
|
|
7925
7105
|
});
|
|
7926
7106
|
|
|
@@ -7974,15 +7154,15 @@ var init_grade = __esm({
|
|
|
7974
7154
|
});
|
|
7975
7155
|
|
|
7976
7156
|
// cli/bootstrap.js
|
|
7977
|
-
import { readFileSync as
|
|
7157
|
+
import { readFileSync as readFileSync10, existsSync as existsSync13 } from "fs";
|
|
7978
7158
|
import { resolve as resolve2 } from "path";
|
|
7979
7159
|
var envCandidates = [
|
|
7980
7160
|
resolve2(process.cwd(), ".env"),
|
|
7981
7161
|
resolve2(process.env.HOME ?? "", ".synkro", "config.env")
|
|
7982
7162
|
];
|
|
7983
7163
|
for (const envPath of envCandidates) {
|
|
7984
|
-
if (!
|
|
7985
|
-
const envContent =
|
|
7164
|
+
if (!existsSync13(envPath)) continue;
|
|
7165
|
+
const envContent = readFileSync10(envPath, "utf-8");
|
|
7986
7166
|
for (const line of envContent.split("\n")) {
|
|
7987
7167
|
const trimmed = line.trim();
|
|
7988
7168
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
@@ -7997,7 +7177,7 @@ var args = process.argv.slice(2);
|
|
|
7997
7177
|
var cmd = args[0] || "";
|
|
7998
7178
|
var subArgs = args.slice(1);
|
|
7999
7179
|
function printVersion() {
|
|
8000
|
-
console.log("1.4.
|
|
7180
|
+
console.log("1.4.88");
|
|
8001
7181
|
}
|
|
8002
7182
|
function printHelp() {
|
|
8003
7183
|
console.log(`Synkro CLI \u2014 runtime safety for AI coding agents
|
|
@@ -8018,7 +7198,7 @@ Quick start:
|
|
|
8018
7198
|
async function main() {
|
|
8019
7199
|
switch (cmd) {
|
|
8020
7200
|
case "install": {
|
|
8021
|
-
const { installCommand: installCommand2, parseArgs: parseArgs2 } = await Promise.resolve().then(() => (
|
|
7201
|
+
const { installCommand: installCommand2, parseArgs: parseArgs2 } = await Promise.resolve().then(() => (init_install(), install_exports));
|
|
8022
7202
|
await installCommand2(parseArgs2(subArgs));
|
|
8023
7203
|
break;
|
|
8024
7204
|
}
|