@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 CHANGED
@@ -5243,1567 +5243,484 @@ var init_promptFetcher = __esm({
5243
5243
  }
5244
5244
  });
5245
5245
 
5246
- // cli/local-cc/settings.ts
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
- function isLocalCCEnabled() {
5251
- if (!existsSync7(CONFIG_PATH2)) return false;
5252
- try {
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
- async function refreshPrimers(): Promise<void> {
5318
- const { jwt, gatewayUrl } = readCreds();
5319
- if (!jwt) return;
5320
- try {
5321
- const resp = await fetch(gatewayUrl + '/api/v1/cli/judge-prompts', {
5322
- headers: { Authorization: 'Bearer ' + jwt },
5323
- signal: AbortSignal.timeout(5000),
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
- function getPrimer(role: string): string {
5333
- const key = PRIMER_KEY[role];
5334
- return (key ? primerData[key] : '') || '';
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
- function buildContent(role: string, payload: string): string {
5338
- const primer = getPrimer(role);
5339
- return (primer ? primer + '\\n\\n' : '') + REPLY_INSTRUCTIONS + '\\n\\n---\\nPAYLOAD (the input to evaluate):\\n\\n' + payload;
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
- // Kick off initial fetch + periodic refresh
5343
- refreshPrimers();
5344
- primerRefreshTimer = setInterval(refreshPrimers, PRIMER_REFRESH_MS);
5345
-
5346
- interface PendingRequest {
5347
- resolve: (result: string) => void;
5348
- reject: (err: Error) => void;
5349
- timer: ReturnType<typeof setTimeout>;
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
- const pending = new Map<string, PendingRequest>();
5353
- let nextRequestId = 1;
5354
-
5355
- const mcp = new Server(
5356
- { name: 'synkro-local', version: '0.1.0' },
5357
- {
5358
- capabilities: {
5359
- experimental: { 'claude/channel': {} },
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
- return new Response(JSON.stringify({ result }), {
5460
- headers: { 'Content-Type': 'application/json' },
5461
- });
5462
- },
5463
- });
5464
-
5465
- process.on('SIGTERM', () => process.exit(0));
5466
- process.on('SIGINT', () => process.exit(0));
5467
-
5468
- // MCP stdio handshake last. The transport's read loop keeps the process
5469
- // alive; the TCP listener is already bound at this point so the CLI can
5470
- // hit it as soon as Claude finishes its end of the handshake.
5471
- await mcp.connect(new StdioServerTransport());
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/install.ts
5477
- import { existsSync as existsSync8, mkdirSync as mkdirSync6, writeFileSync as writeFileSync6, readFileSync as readFileSync7, chmodSync, copyFileSync, renameSync as renameSync4, unlinkSync as unlinkSync4, openSync, fsyncSync, closeSync } from "fs";
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 { spawnSync } from "child_process";
5481
- function writePluginFiles() {
5482
- for (const c of CHANNELS) {
5483
- mkdirSync6(c.sessionDir, { recursive: true });
5484
- mkdirSync6(c.pluginSettingsDir, { recursive: true });
5485
- writeFileSync6(c.pluginPath, CHANNEL_PLUGIN_SOURCE, "utf-8");
5486
- chmodSync(c.pluginPath, 493);
5487
- writeFileSync6(c.pluginPkgPath, PLUGIN_PACKAGE_JSON, "utf-8");
5488
- writeFileSync6(
5489
- c.pluginSettingsPath,
5490
- JSON.stringify({
5491
- fastMode: true,
5492
- enabledMcpjsonServers: ["synkro-local"]
5493
- }, null, 2) + "\n",
5494
- "utf-8"
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 runBunInstall() {
5501
- for (const c of CHANNELS) {
5502
- const r = spawnSync("bun", ["install", "--silent"], {
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 safelyMutateClaudeJson(mutator) {
5515
- if (!existsSync8(CLAUDE_JSON_PATH)) {
5516
- return;
5517
- }
5518
- const originalText = readFileSync7(CLAUDE_JSON_PATH, "utf-8");
5519
- let parsed;
5520
- try {
5521
- parsed = JSON.parse(originalText);
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
- const originalKeys = new Set(Object.keys(parsed));
5529
- const dirty = mutator(parsed);
5530
- if (!dirty) return;
5531
- for (const k of originalKeys) {
5532
- if (!(k in parsed)) {
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
- fsyncSync(fd);
5554
- } finally {
5555
- closeSync(fd);
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
- renameSync4(tmpPath, CLAUDE_JSON_PATH);
5558
- } catch (err) {
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
- unlinkSync4(tmpPath);
5450
+ const r = await fetch(url, { signal: AbortSignal.timeout(2e3) });
5451
+ if (r.ok) return true;
5561
5452
  } catch {
5562
5453
  }
5563
- try {
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 writeProjectMcpJson() {
5574
- for (const c of CHANNELS) {
5575
- const mcp = {
5576
- mcpServers: {
5577
- [MCP_SERVER_NAME]: {
5578
- command: "bun",
5579
- args: [c.pluginPath]
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 patchClaudeJson() {
5587
- safelyMutateClaudeJson((parsed) => {
5588
- let dirty = false;
5589
- if (parsed.mcpServers && typeof parsed.mcpServers === "object" && parsed.mcpServers[MCP_SERVER_NAME]) {
5590
- delete parsed.mcpServers[MCP_SERVER_NAME];
5591
- dirty = true;
5592
- }
5593
- if (!parsed.projects || typeof parsed.projects !== "object") {
5594
- parsed.projects = {};
5595
- }
5596
- const projects = parsed.projects;
5597
- for (const dir of CHANNELS.map((c) => c.sessionDir)) {
5598
- const existing = projects[dir] && typeof projects[dir] === "object" ? projects[dir] : {};
5599
- const wantEnabled = Array.from(/* @__PURE__ */ new Set([
5600
- ...existing.enabledMcpjsonServers ?? [],
5601
- MCP_SERVER_NAME
5602
- ]));
5603
- const next = {
5604
- ...existing,
5605
- hasTrustDialogAccepted: true,
5606
- hasCompletedProjectOnboarding: true,
5607
- enabledMcpjsonServers: wantEnabled
5608
- };
5609
- if (existing.hasTrustDialogAccepted !== true || existing.hasCompletedProjectOnboarding !== true || JSON.stringify(existing.enabledMcpjsonServers ?? []) !== JSON.stringify(wantEnabled)) {
5610
- projects[dir] = next;
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 installLocalCC() {
5618
- let bunCheck = spawnSync("bun", ["--version"], { encoding: "utf-8" });
5619
- if (bunCheck.status !== 0) {
5620
- if (process.platform === "darwin") {
5621
- console.log(" Installing bun via brew...");
5622
- const brewR = spawnSync("brew", ["install", "oven-sh/bun/bun"], { encoding: "utf-8", stdio: "inherit", timeout: 12e4 });
5623
- if (brewR.status !== 0) {
5624
- throw new LocalCCInstallError("bun auto-install failed. Install manually: curl -fsSL https://bun.sh/install | bash");
5625
- }
5626
- bunCheck = spawnSync("bun", ["--version"], { encoding: "utf-8" });
5627
- if (bunCheck.status !== 0) {
5628
- throw new LocalCCInstallError("bun installed but not found on PATH. Restart your terminal and re-run install.");
5629
- }
5630
- } else {
5631
- throw new LocalCCInstallError("bun is required. Install it: curl -fsSL https://bun.sh/install | bash");
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
- writePluginFiles();
5635
- runBunInstall();
5636
- writeProjectMcpJson();
5637
- patchClaudeJson();
5638
- return { sessionDir: SESSION_DIR, pluginPath: PLUGIN_PATH };
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 uninstallLocalCC() {
5641
- safelyMutateClaudeJson((parsed) => {
5642
- let dirty = false;
5643
- if (parsed.mcpServers && parsed.mcpServers[MCP_SERVER_NAME]) {
5644
- delete parsed.mcpServers[MCP_SERVER_NAME];
5645
- dirty = true;
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
- for (const dir of CHANNELS.map((c) => c.sessionDir)) {
5648
- if (parsed.projects && typeof parsed.projects === "object" && parsed.projects[dir]) {
5649
- delete parsed.projects[dir];
5650
- dirty = true;
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
- return dirty;
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
- join12(HOOKS_DIR, "cc-bash-judge.ts"),
6856
- join12(HOOKS_DIR, "cc-bash-followup.ts"),
6857
- join12(HOOKS_DIR, "cc-edit-precheck.ts"),
6858
- join12(HOOKS_DIR, "cc-cve-precheck.ts"),
6859
- join12(HOOKS_DIR, "cc-plan-judge.ts"),
6860
- join12(HOOKS_DIR, "cc-stop-summary.ts"),
6861
- join12(HOOKS_DIR, "cc-session-start.ts")
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) => existsSync12(p))) return false;
6864
- if (!existsSync12(CONFIG_PATH3)) return false;
6865
- const settingsPath = join12(homedir12(), ".claude", "settings.json");
6866
- if (!existsSync12(settingsPath)) return false;
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(readFileSync10(settingsPath, "utf-8"));
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-cli update` to refresh hook scripts and judge prompts.");
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 = join12(SYNKRO_DIR4, "daemon", mode, "daemon.pid");
5899
+ const pidFile = join8(SYNKRO_DIR4, "daemon", mode, "daemon.pid");
7245
5900
  try {
7246
- const pid = parseInt(readFileSync10(pidFile, "utf-8").trim(), 10);
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
- writeFileSync8(join12(SYNKRO_DIR4, ".mcp-jwt"), mcpJwt + "\n", { mode: 384 });
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-cli install` to retry.");
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
- writeFileSync8(join12(SYNKRO_DIR4, ".mcp-jwt"), minted.token + "\n", { mode: 384 });
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-cli install` to retry MCP setup.");
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 = join12(SYNKRO_DIR4, ".mcp-jwt");
7376
- if (!existsSync12(jwtPath)) {
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
- writeFileSync8(jwtPath, minted.token + "\n", { mode: 384 });
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
- writeFileSync8(join12(SYNKRO_DIR4, ".mcp-jwt"), minted.token + "\n", { mode: 384 });
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() === "docker" ? "docker" : "bare-host";
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 ${CONFIG_PATH3}`);
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
- console.log("Setting up local-CC dependencies...");
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 = join12(homedir12(), ".claude", "projects", sanitized);
7585
- return existsSync12(projectsDir) ? projectsDir : null;
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 = join12(projectsDir, file);
6158
+ const filePath = join8(projectsDir, file);
7593
6159
  try {
7594
- const content = readFileSync10(filePath, "utf-8");
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 = readFileSync10(filePath, "utf-8");
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 = join12(projectsDir, file);
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
- const resp = await fetch(`${gatewayUrl}/api/v1/cli/sync-transcripts`, {
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
- for (const file of batch) {
7749
- const sessionId = file.replace(".jsonl", "");
7750
- const filePath = join12(projectsDir, file);
7751
- try {
7752
- const content = readFileSync10(filePath, "utf-8");
7753
- const lineCount = content.split("\n").filter(Boolean).length;
7754
- writeFileSync8(join12(OFFSETS_DIR, sessionId), String(lineCount), "utf-8");
7755
- } catch {
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
- return { sessions: totalSessions, messages: totalMessages };
6580
+ return dirty;
6581
+ });
7760
6582
  }
7761
- var SYNKRO_DIR4, HOOKS_DIR, BIN_DIR, CONFIG_PATH3, MCP_STDIO_PROXY_SRC, OFFSETS_DIR, RULES_PATH, MCP_LOCAL_PORT, PGLITE_PORT;
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/commands/install.ts"() {
6585
+ "cli/local-cc/install.ts"() {
7764
6586
  "use strict";
7765
- init_agentDetect();
7766
- init_ccHookConfig();
7767
- init_cursorHookConfig();
7768
- init_mcpConfig();
7769
- init_hookScripts();
7770
- init_hookScriptsTs();
7771
- init_stub();
7772
- init_repoConnect();
7773
- init_projects();
7774
- init_setupGithub();
7775
- init_promptFetcher();
7776
- init_settings();
7777
- init_install();
7778
- init_dockerInstall();
7779
- init_pueue();
7780
- init_client();
7781
- SYNKRO_DIR4 = join12(homedir12(), ".synkro");
7782
- HOOKS_DIR = join12(SYNKRO_DIR4, "hooks");
7783
- BIN_DIR = join12(SYNKRO_DIR4, "bin");
7784
- CONFIG_PATH3 = join12(SYNKRO_DIR4, "config.env");
7785
- MCP_STDIO_PROXY_SRC = `#!/usr/bin/env bun
7786
- import { readFileSync } from 'node:fs';
7787
- import { homedir } from 'node:os';
7788
- import { join } from 'node:path';
7789
- import { createInterface } from 'node:readline';
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
- const HOME = homedir();
7792
- const TOKEN_PATH = join(HOME, '.synkro', '.mcp-jwt');
7793
- const PORT = parseInt(process.env.SYNKRO_MCP_PORT || '8931', 10);
7794
- const URL = \`http://127.0.0.1:\${PORT}\`;
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
- let token = '';
7797
- try { token = readFileSync(TOKEN_PATH, 'utf-8').trim(); } catch {}
6797
+ log "tmux session started successfully (port ${CHANNEL_3_PORT})."
7798
6798
 
7799
- const rl = createInterface({ input: process.stdin, terminal: false });
6799
+ while tmux has-session -t "=$SESSION" 2>/dev/null; do
6800
+ sleep 5
6801
+ done
7800
6802
 
7801
- rl.on('line', async (line) => {
7802
- if (!line.trim()) return;
7803
- let msg;
7804
- try { msg = JSON.parse(line); } catch { return; }
7805
- if (!msg.id && msg.method?.startsWith('notifications/')) return;
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
- try {
7808
- const resp = await fetch(URL, {
7809
- method: 'POST',
7810
- headers: {
7811
- 'Content-Type': 'application/json',
7812
- 'Authorization': \`Bearer \${token}\`,
7813
- },
7814
- body: line,
7815
- signal: AbortSignal.timeout(30000),
7816
- });
7817
- if (resp.status === 204) return;
7818
- const body = await resp.text();
7819
- process.stdout.write(body + '\\n');
7820
- } catch (err) {
7821
- if (msg.id != null) {
7822
- process.stdout.write(JSON.stringify({
7823
- jsonrpc: '2.0',
7824
- id: msg.id,
7825
- error: { code: -32603, message: 'MCP proxy: HTTP server unreachable' },
7826
- }) + '\\n');
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
- OFFSETS_DIR = join12(SYNKRO_DIR4, ".transcript-offsets");
7832
- RULES_PATH = join12(SYNKRO_DIR4, "rules.json");
7833
- MCP_LOCAL_PORT = 8931;
7834
- PGLITE_PORT = parseInt(process.env.SYNKRO_PG_PORT || "5433", 10);
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 existsSync13, rmSync } from "fs";
7844
- import { homedir as homedir13 } from "os";
7845
- import { join as join13 } from "path";
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 (existsSync13(SYNKRO_DIR5)) {
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 (existsSync13(SYNKRO_DIR5)) {
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
- init_install();
6974
+ init_install2();
7921
6975
  init_dockerInstall();
7922
6976
  init_macKeychain();
7923
- SYNKRO_DIR5 = join13(homedir13(), ".synkro");
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 readFileSync11, existsSync as existsSync14 } from "fs";
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 (!existsSync14(envPath)) continue;
7985
- const envContent = readFileSync11(envPath, "utf-8");
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.87");
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(() => (init_install2(), install_exports));
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
  }