@synkro-sh/cli 1.4.87 → 1.4.89

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,500 @@ 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
+ var dockerInstall_exports = {};
5357
+ __export(dockerInstall_exports, {
5358
+ DockerInstallError: () => DockerInstallError,
5359
+ SYNKRO_DIR: () => SYNKRO_DIR3,
5360
+ assertDockerAvailable: () => assertDockerAvailable,
5361
+ dockerInstall: () => dockerInstall,
5362
+ dockerStatus: () => dockerStatus,
5363
+ dockerStop: () => dockerStop,
5364
+ dockerUpdate: () => dockerUpdate,
5365
+ imageTag: () => imageTag,
5366
+ waitForContainerReady: () => waitForContainerReady
5367
+ });
5368
+ import { existsSync as existsSync8, mkdirSync as mkdirSync7 } from "fs";
5479
5369
  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"
5370
+ import { join as join7 } from "path";
5371
+ import { spawnSync as spawnSync2 } from "child_process";
5372
+ function imageTag() {
5373
+ const registry = process.env.SYNKRO_IMAGE_REGISTRY || "";
5374
+ const tag = process.env.SYNKRO_IMAGE_TAG || DEFAULT_IMAGE;
5375
+ return registry ? `${registry.replace(/\/+$/, "")}/${tag.replace(/^.*\//, "")}` : tag;
5376
+ }
5377
+ function assertDockerAvailable() {
5378
+ const v = spawnSync2("docker", ["version", "--format", "{{.Server.Version}}"], {
5379
+ encoding: "utf-8",
5380
+ timeout: 5e3
5381
+ });
5382
+ if (v.status !== 0) {
5383
+ throw new DockerInstallError(
5384
+ "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
5385
  );
5496
- writeFileSync6(c.runScriptPath, c.runScriptSource, "utf-8");
5497
- chmodSync(c.runScriptPath, 493);
5498
5386
  }
5499
5387
  }
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
- }
5388
+ function claudeCredsHostDir() {
5389
+ if (needsKeychainBridge()) return CLAUDE_CREDS_DIR;
5390
+ return join7(homedir7(), ".claude");
5513
5391
  }
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
5392
+ async function dockerInstall(opts = {}) {
5393
+ assertDockerAvailable();
5394
+ const image = imageTag();
5395
+ const workers = String(opts.workersPerPool ?? 4);
5396
+ mkdirSync7(PGDATA_PATH, { recursive: true });
5397
+ if (!existsSync8(MCP_JWT_PATH)) {
5398
+ throw new DockerInstallError(
5399
+ `MCP JWT missing at ${MCP_JWT_PATH}. The installer should mint this before calling dockerInstall.`
5526
5400
  );
5527
5401
  }
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.`
5402
+ if (needsKeychainBridge()) {
5403
+ const path = exportKeychainCreds();
5404
+ if (!path) {
5405
+ throw new DockerInstallError(
5406
+ "Claude Code keychain entry not found. Run `claude login` (or open Claude Code and sign in) before installing the container."
5535
5407
  );
5536
5408
  }
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");
5409
+ const plist = writeRefreshAgent("/usr/local/bin/synkro");
5552
5410
  try {
5553
- fsyncSync(fd);
5554
- } finally {
5555
- closeSync(fd);
5411
+ loadRefreshAgent();
5412
+ } catch (err) {
5413
+ console.warn(` \u26A0 launchd refresh agent not loaded: ${err.message}`);
5414
+ console.warn(` Plist written to ${plist} \u2014 load manually with launchctl bootstrap when ready.`);
5556
5415
  }
5557
- renameSync4(tmpPath, CLAUDE_JSON_PATH);
5558
- } catch (err) {
5416
+ } else {
5417
+ mkdirSync7(join7(homedir7(), ".claude"), { recursive: true });
5418
+ }
5419
+ console.log(` Pulling ${image}...`);
5420
+ const pull = spawnSync2("docker", ["pull", image], { encoding: "utf-8", stdio: "inherit", timeout: 6e5 });
5421
+ if (pull.status !== 0) {
5422
+ throw new DockerInstallError(`docker pull ${image} failed`);
5423
+ }
5424
+ spawnSync2("docker", ["rm", "-f", CONTAINER_NAME], { encoding: "utf-8", timeout: 3e4 });
5425
+ const credsDir = claudeCredsHostDir();
5426
+ const args2 = [
5427
+ "run",
5428
+ "--detach",
5429
+ "--name",
5430
+ CONTAINER_NAME,
5431
+ "--restart",
5432
+ "unless-stopped",
5433
+ "-p",
5434
+ `127.0.0.1:${HOST_MCP_PORT}:8931`,
5435
+ "-p",
5436
+ `127.0.0.1:${HOST_GRADER_PORT}:8929`,
5437
+ "-p",
5438
+ `127.0.0.1:${HOST_CWE_PORT}:8930`,
5439
+ "-p",
5440
+ `127.0.0.1:${HOST_PG_PORT}:5433`,
5441
+ "-v",
5442
+ `${PGDATA_PATH}:/data/pgdata`,
5443
+ "-v",
5444
+ `${MCP_JWT_PATH}:/data/.mcp-jwt:ro`,
5445
+ "-v",
5446
+ `${credsDir}:/home/synkro/.claude:rw`,
5447
+ "-e",
5448
+ `WORKERS_PER_POOL=${workers}`,
5449
+ image
5450
+ ];
5451
+ const run = spawnSync2("docker", args2, { encoding: "utf-8", stdio: "inherit", timeout: 6e4 });
5452
+ if (run.status !== 0) {
5453
+ throw new DockerInstallError(`docker run failed (image ${image})`);
5454
+ }
5455
+ return { image, hostMcpPort: HOST_MCP_PORT, hostGraderPort: HOST_GRADER_PORT, hostCwePort: HOST_CWE_PORT };
5456
+ }
5457
+ async function waitForContainerReady(timeoutMs = 6e4) {
5458
+ const start = Date.now();
5459
+ const url = `http://127.0.0.1:${HOST_MCP_PORT}/health`;
5460
+ while (Date.now() - start < timeoutMs) {
5559
5461
  try {
5560
- unlinkSync4(tmpPath);
5462
+ const r = await fetch(url, { signal: AbortSignal.timeout(2e3) });
5463
+ if (r.ok) return true;
5561
5464
  } catch {
5562
5465
  }
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
- );
5466
+ await new Promise((r) => setTimeout(r, 1e3));
5571
5467
  }
5468
+ return false;
5572
5469
  }
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
- }
5470
+ function dockerStop() {
5471
+ spawnSync2("docker", ["stop", CONTAINER_NAME], { encoding: "utf-8", timeout: 3e4 });
5472
+ spawnSync2("docker", ["rm", "-f", CONTAINER_NAME], { encoding: "utf-8", timeout: 3e4 });
5473
+ }
5474
+ async function dockerUpdate(workersPerPool) {
5475
+ dockerStop();
5476
+ await dockerInstall({ workersPerPool });
5477
+ }
5478
+ function dockerStatus() {
5479
+ const r = spawnSync2("docker", ["inspect", "--format", "{{.State.Status}}", CONTAINER_NAME], {
5480
+ encoding: "utf-8",
5481
+ timeout: 5e3
5482
+ });
5483
+ const status = (r.stdout || "").trim();
5484
+ if (status !== "running") return { running: false };
5485
+ return {
5486
+ running: true,
5487
+ image: imageTag(),
5488
+ healthz: `http://127.0.0.1:${HOST_MCP_PORT}/`
5489
+ };
5490
+ }
5491
+ 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;
5492
+ var init_dockerInstall = __esm({
5493
+ "cli/local-cc/dockerInstall.ts"() {
5494
+ "use strict";
5495
+ init_macKeychain();
5496
+ SYNKRO_DIR3 = join7(homedir7(), ".synkro");
5497
+ MCP_JWT_PATH = join7(SYNKRO_DIR3, ".mcp-jwt");
5498
+ PGDATA_PATH = join7(SYNKRO_DIR3, "pgdata");
5499
+ HOST_MCP_PORT = parseInt(process.env.SYNKRO_HOST_MCP_PORT || "18931", 10);
5500
+ HOST_GRADER_PORT = parseInt(process.env.SYNKRO_HOST_GRADER_PORT || "18929", 10);
5501
+ HOST_CWE_PORT = parseInt(process.env.SYNKRO_HOST_CWE_PORT || "18930", 10);
5502
+ HOST_PG_PORT = parseInt(process.env.SYNKRO_HOST_PG_PORT || "15433", 10);
5503
+ CONTAINER_NAME = "synkro-server";
5504
+ DEFAULT_IMAGE = "ghcr.io/synkro-sh/server:latest";
5505
+ DockerInstallError = class extends Error {
5506
+ constructor(message, cause) {
5507
+ super(message);
5508
+ this.cause = cause;
5509
+ this.name = "DockerInstallError";
5581
5510
  }
5511
+ cause;
5582
5512
  };
5583
- writeFileSync6(c.projectMcpPath, JSON.stringify(mcp, null, 2) + "\n", "utf-8");
5584
5513
  }
5514
+ });
5515
+
5516
+ // cli/commands/install.ts
5517
+ var install_exports = {};
5518
+ __export(install_exports, {
5519
+ installCommand: () => installCommand,
5520
+ parseArgs: () => parseArgs
5521
+ });
5522
+ import { existsSync as existsSync9, mkdirSync as mkdirSync8, writeFileSync as writeFileSync7, chmodSync as chmodSync2, readFileSync as readFileSync7, readdirSync } from "fs";
5523
+ import { homedir as homedir8 } from "os";
5524
+ import { join as join8 } from "path";
5525
+ import { execSync as execSync5 } from "child_process";
5526
+ import { createInterface as createInterface3 } from "readline";
5527
+ function sanitizeGatewayCandidate(raw) {
5528
+ if (!raw) return void 0;
5529
+ return /^https?:\/\//.test(raw) ? raw : void 0;
5585
5530
  }
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;
5531
+ function parseArgs(argv) {
5532
+ const opts = {};
5533
+ for (const a of argv) {
5534
+ if (a.startsWith("--api-key=")) opts.apiKey = a.slice("--api-key=".length);
5535
+ else if (a.startsWith("--gateway=")) opts.gatewayUrl = a.slice("--gateway=".length);
5536
+ else if (a === "--skip-auth") opts.skipAuth = true;
5537
+ else if (a === "--no-mcp") opts.noMcp = true;
5538
+ else if (a === "--force" || a === "-f") opts.force = true;
5539
+ else if (a === "--link-repo") opts.linkRepo = true;
5540
+ }
5541
+ if (!opts.gatewayUrl) {
5542
+ const fromEnv = sanitizeGatewayCandidate(process.env.SYNKRO_GATEWAY_URL);
5543
+ if (fromEnv) opts.gatewayUrl = fromEnv;
5544
+ }
5545
+ return opts;
5546
+ }
5547
+ async function promptTranscriptConsent() {
5548
+ const rl = createInterface3({ input: process.stdin, output: process.stdout });
5549
+ return new Promise((resolve3) => {
5550
+ rl.question(
5551
+ "Would you like Synkro to use Claude Code session transcripts\nto generate guardrail rules and policies for your team? (Y/n) ",
5552
+ (answer) => {
5553
+ rl.close();
5554
+ const trimmed = answer.trim().toLowerCase();
5555
+ resolve3(trimmed === "" || trimmed === "y" || trimmed === "yes");
5612
5556
  }
5613
- }
5614
- return dirty;
5557
+ );
5615
5558
  });
5616
5559
  }
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
- }
5560
+ function ensureSynkroDir() {
5561
+ mkdirSync8(SYNKRO_DIR4, { recursive: true });
5562
+ mkdirSync8(HOOKS_DIR, { recursive: true });
5563
+ mkdirSync8(BIN_DIR, { recursive: true });
5564
+ mkdirSync8(OFFSETS_DIR, { recursive: true });
5565
+ mkdirSync8(join8(SYNKRO_DIR4, "sessions"), { recursive: true });
5566
+ }
5567
+ function writeHookScripts() {
5568
+ const bashScriptPath = join8(HOOKS_DIR, "cc-bash-judge.ts");
5569
+ const bashFollowupScriptPath = join8(HOOKS_DIR, "cc-bash-followup.ts");
5570
+ const editPrecheckScriptPath = join8(HOOKS_DIR, "cc-edit-precheck.ts");
5571
+ const cwePrecheckScriptPath = join8(HOOKS_DIR, "cc-cwe-precheck.ts");
5572
+ const cvePrecheckScriptPath = join8(HOOKS_DIR, "cc-cve-precheck.ts");
5573
+ const planJudgeScriptPath = join8(HOOKS_DIR, "cc-plan-judge.ts");
5574
+ const agentJudgeScriptPath = join8(HOOKS_DIR, "cc-agent-judge.ts");
5575
+ const stopSummaryScriptPath = join8(HOOKS_DIR, "cc-stop-summary.ts");
5576
+ const sessionStartScriptPath = join8(HOOKS_DIR, "cc-session-start.ts");
5577
+ const transcriptSyncScriptPath = join8(HOOKS_DIR, "cc-transcript-sync.ts");
5578
+ const userPromptSubmitScriptPath = join8(HOOKS_DIR, "cc-user-prompt-submit.ts");
5579
+ const commonScriptPath = join8(HOOKS_DIR, "_synkro-common.ts");
5580
+ const commonBashScriptPath = join8(HOOKS_DIR, "_synkro-common.sh");
5581
+ const cursorBashJudgePath = join8(HOOKS_DIR, "cursor-bash-judge.ts");
5582
+ const cursorEditCapturePath = join8(HOOKS_DIR, "cursor-edit-capture.ts");
5583
+ const mcpStdioProxyPath = join8(HOOKS_DIR, "mcp-stdio-proxy.ts");
5584
+ writeFileSync7(bashScriptPath, BASH_JUDGE_TS, "utf-8");
5585
+ writeFileSync7(bashFollowupScriptPath, BASH_FOLLOWUP_TS, "utf-8");
5586
+ writeFileSync7(editPrecheckScriptPath, EDIT_PRECHECK_TS, "utf-8");
5587
+ writeFileSync7(cwePrecheckScriptPath, CWE_PRECHECK_TS, "utf-8");
5588
+ writeFileSync7(cvePrecheckScriptPath, CVE_PRECHECK_TS, "utf-8");
5589
+ writeFileSync7(planJudgeScriptPath, PLAN_JUDGE_TS, "utf-8");
5590
+ writeFileSync7(agentJudgeScriptPath, AGENT_JUDGE_TS, "utf-8");
5591
+ writeFileSync7(stopSummaryScriptPath, STOP_SUMMARY_TS, "utf-8");
5592
+ writeFileSync7(sessionStartScriptPath, SESSION_START_TS, "utf-8");
5593
+ writeFileSync7(transcriptSyncScriptPath, TRANSCRIPT_SYNC_TS, "utf-8");
5594
+ writeFileSync7(userPromptSubmitScriptPath, USER_PROMPT_SUBMIT_TS, "utf-8");
5595
+ writeFileSync7(commonScriptPath, SYNKRO_COMMON_TS, "utf-8");
5596
+ writeFileSync7(commonBashScriptPath, SYNKRO_COMMON_SCRIPT, "utf-8");
5597
+ writeFileSync7(cursorBashJudgePath, CURSOR_BASH_JUDGE_TS, "utf-8");
5598
+ writeFileSync7(cursorEditCapturePath, CURSOR_EDIT_CAPTURE_TS, "utf-8");
5599
+ writeFileSync7(mcpStdioProxyPath, MCP_STDIO_PROXY_SRC, "utf-8");
5600
+ chmodSync2(bashScriptPath, 493);
5601
+ chmodSync2(bashFollowupScriptPath, 493);
5602
+ chmodSync2(editPrecheckScriptPath, 493);
5603
+ chmodSync2(cwePrecheckScriptPath, 493);
5604
+ chmodSync2(cvePrecheckScriptPath, 493);
5605
+ chmodSync2(planJudgeScriptPath, 493);
5606
+ chmodSync2(agentJudgeScriptPath, 493);
5607
+ chmodSync2(stopSummaryScriptPath, 493);
5608
+ chmodSync2(sessionStartScriptPath, 493);
5609
+ chmodSync2(transcriptSyncScriptPath, 493);
5610
+ chmodSync2(userPromptSubmitScriptPath, 493);
5611
+ chmodSync2(commonScriptPath, 493);
5612
+ chmodSync2(commonBashScriptPath, 493);
5613
+ chmodSync2(cursorBashJudgePath, 493);
5614
+ chmodSync2(cursorEditCapturePath, 493);
5615
+ chmodSync2(mcpStdioProxyPath, 493);
5616
+ return {
5617
+ bashScript: bashScriptPath,
5618
+ bashFollowupScript: bashFollowupScriptPath,
5619
+ editPrecheckScript: editPrecheckScriptPath,
5620
+ cwePrecheckScript: cwePrecheckScriptPath,
5621
+ cvePrecheckScript: cvePrecheckScriptPath,
5622
+ planJudgeScript: planJudgeScriptPath,
5623
+ agentJudgeScript: agentJudgeScriptPath,
5624
+ stopSummaryScript: stopSummaryScriptPath,
5625
+ sessionStartScript: sessionStartScriptPath,
5626
+ transcriptSyncScript: transcriptSyncScriptPath,
5627
+ userPromptSubmitScript: userPromptSubmitScriptPath,
5628
+ cursorBashJudgeScript: cursorBashJudgePath,
5629
+ cursorEditCaptureScript: cursorEditCapturePath
5630
+ };
5631
+ }
5632
+ function sanitizeConfigValue(raw, maxLen = 256) {
5633
+ if (!raw) return "";
5634
+ return raw.replace(/[^\x20-\x7E]/g, "").slice(0, maxLen);
5635
+ }
5636
+ function shellQuoteSingle(value) {
5637
+ return `'${value.replace(/'/g, "'\\''")}'`;
5638
+ }
5639
+ function resolveSynkroBundle() {
5640
+ const scriptPath = process.argv[1];
5641
+ if (scriptPath && existsSync9(scriptPath)) return scriptPath;
5642
+ return null;
5643
+ }
5644
+ function writeConfigEnv(opts) {
5645
+ const credsPath = join8(SYNKRO_DIR4, "credentials.json");
5646
+ const safeGateway = sanitizeConfigValue(opts.gatewayUrl);
5647
+ const safeUserId = sanitizeConfigValue(opts.userId);
5648
+ const safeOrgId = sanitizeConfigValue(opts.orgId);
5649
+ const safeEmail = sanitizeConfigValue(opts.email);
5650
+ const safeTier = sanitizeConfigValue(opts.tier ?? "pro", 32);
5651
+ const safeInference = sanitizeConfigValue(opts.inference ?? "fast", 16);
5652
+ const safeSynkroBin = sanitizeConfigValue(opts.synkroBin ?? "", 1024);
5653
+ const lines = [
5654
+ "# Synkro CLI config (managed by synkro install)",
5655
+ "# JWT auth \u2014 the hook scripts read SYNKRO_CREDENTIALS_PATH at runtime",
5656
+ "# and send Authorization: Bearer <access_token> on every gateway call.",
5657
+ `SYNKRO_GATEWAY_URL=${shellQuoteSingle(safeGateway)}`,
5658
+ `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
5659
+ `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
5660
+ `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
5661
+ `SYNKRO_VERSION=${shellQuoteSingle("1.4.89")}`
5662
+ ];
5663
+ if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
5664
+ if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
5665
+ if (safeOrgId) lines.push(`SYNKRO_ORG_ID=${shellQuoteSingle(safeOrgId)}`);
5666
+ if (safeEmail) lines.push(`SYNKRO_EMAIL=${shellQuoteSingle(safeEmail)}`);
5667
+ if (opts.transcriptConsent !== void 0) {
5668
+ lines.push(`SYNKRO_TRANSCRIPT_CONSENT=${shellQuoteSingle(opts.transcriptConsent ? "yes" : "no")}`);
5633
5669
  }
5634
- writePluginFiles();
5635
- runBunInstall();
5636
- writeProjectMcpJson();
5637
- patchClaudeJson();
5638
- return { sessionDir: SESSION_DIR, pluginPath: PLUGIN_PATH };
5670
+ lines.push(`SYNKRO_LOCAL_INFERENCE=${shellQuoteSingle(opts.localInference ? "yes" : "no")}`);
5671
+ const safeMode = sanitizeConfigValue(opts.deploymentMode ?? "docker", 16);
5672
+ lines.push(`SYNKRO_DEPLOYMENT_MODE=${shellQuoteSingle(safeMode)}`);
5673
+ lines.push("");
5674
+ writeFileSync7(CONFIG_PATH2, lines.join("\n"), "utf-8");
5675
+ chmodSync2(CONFIG_PATH2, 384);
5639
5676
  }
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;
5677
+ function resolveDeploymentMode() {
5678
+ const envOverride = process.env.SYNKRO_DEPLOYMENT_MODE;
5679
+ if (envOverride) return envOverride.toLowerCase();
5680
+ try {
5681
+ if (existsSync9(CONFIG_PATH2)) {
5682
+ const m = readFileSync7(CONFIG_PATH2, "utf-8").match(/^SYNKRO_DEPLOYMENT_MODE='([^']*)'/m);
5683
+ if (m && m[1]) return m[1].toLowerCase();
5646
5684
  }
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;
5685
+ } catch {
5686
+ }
5687
+ return "docker";
5688
+ }
5689
+ function collectLocalMetadata() {
5690
+ const meta = { platform: process.platform };
5691
+ try {
5692
+ meta.display_name = execSync5("git config user.name", { encoding: "utf-8", timeout: 3e3 }).trim();
5693
+ } catch {
5694
+ }
5695
+ try {
5696
+ const remote = execSync5("git remote get-url origin", { encoding: "utf-8", timeout: 3e3 }).trim();
5697
+ const sshMatch = remote.match(/^git@[^:]+:(.+?)(?:\.git)?$/);
5698
+ const httpMatch = remote.match(/^https?:\/\/[^/]+\/(.+?)(?:\.git)?$/);
5699
+ const m = sshMatch || httpMatch;
5700
+ if (m) meta.active_repo = m[1];
5701
+ } catch {
5702
+ }
5703
+ try {
5704
+ meta.cc_version = execSync5("claude --version", { encoding: "utf-8", timeout: 5e3 }).trim().split("\n")[0];
5705
+ } catch {
5706
+ }
5707
+ const claudeDir = join8(homedir8(), ".claude");
5708
+ try {
5709
+ const settings = JSON.parse(readFileSync7(join8(claudeDir, "settings.json"), "utf-8"));
5710
+ const plugins = Object.keys(settings.enabledPlugins ?? {}).filter((k) => settings.enabledPlugins[k]);
5711
+ if (plugins.length) meta.enabled_plugins = plugins;
5712
+ if (settings.permissions?.defaultMode) meta.permissions_mode = settings.permissions.defaultMode;
5713
+ } catch {
5714
+ }
5715
+ try {
5716
+ const mcpCache = JSON.parse(readFileSync7(join8(claudeDir, "mcp-needs-auth-cache.json"), "utf-8"));
5717
+ const mcpNames = Object.keys(mcpCache);
5718
+ if (mcpNames.length) meta.mcp_servers = mcpNames;
5719
+ } catch {
5720
+ }
5721
+ try {
5722
+ const mcpList = execSync5("claude mcp list 2>/dev/null", { encoding: "utf-8", timeout: 1e4 });
5723
+ const connected = mcpList.split("\n").filter((l) => l.includes("Connected")).map((l) => l.split(":")[0].trim()).filter(Boolean);
5724
+ if (connected.length) meta.mcp_servers_connected = connected;
5725
+ } catch {
5726
+ }
5727
+ try {
5728
+ const sessionsDir = join8(claudeDir, "sessions");
5729
+ const files = readdirSync(sessionsDir).filter((f) => f.endsWith(".json")).slice(-5);
5730
+ for (const f of files) {
5731
+ const s = JSON.parse(readFileSync7(join8(sessionsDir, f), "utf-8"));
5732
+ if (s.version) {
5733
+ meta.cc_version = meta.cc_version || s.version;
5734
+ break;
5651
5735
  }
5652
5736
  }
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;
5737
+ } catch {
5738
+ }
5739
+ return meta;
6807
5740
  }
6808
5741
  async function fetchUserProfile(gatewayUrl, token) {
6809
5742
  try {
@@ -6852,20 +5785,20 @@ function assertGatewayAllowed(gatewayUrl) {
6852
5785
  }
6853
5786
  function isAlreadyInstalled() {
6854
5787
  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")
5788
+ join8(HOOKS_DIR, "cc-bash-judge.ts"),
5789
+ join8(HOOKS_DIR, "cc-bash-followup.ts"),
5790
+ join8(HOOKS_DIR, "cc-edit-precheck.ts"),
5791
+ join8(HOOKS_DIR, "cc-cve-precheck.ts"),
5792
+ join8(HOOKS_DIR, "cc-plan-judge.ts"),
5793
+ join8(HOOKS_DIR, "cc-stop-summary.ts"),
5794
+ join8(HOOKS_DIR, "cc-session-start.ts")
6862
5795
  ];
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;
5796
+ if (!requiredScripts.every((p) => existsSync9(p))) return false;
5797
+ if (!existsSync9(CONFIG_PATH2)) return false;
5798
+ const settingsPath = join8(homedir8(), ".claude", "settings.json");
5799
+ if (!existsSync9(settingsPath)) return false;
6867
5800
  try {
6868
- const settings = JSON.parse(readFileSync10(settingsPath, "utf-8"));
5801
+ const settings = JSON.parse(readFileSync7(settingsPath, "utf-8"));
6869
5802
  const hooks = settings?.hooks;
6870
5803
  if (!hooks || typeof hooks !== "object") return false;
6871
5804
  const hasManaged = (kind) => Array.isArray(hooks[kind]) && hooks[kind].some((entry) => entry?.__synkro_managed__ === true);
@@ -6878,226 +5811,6 @@ function isAlreadyInstalled() {
6878
5811
  }
6879
5812
  return true;
6880
5813
  }
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
5814
  async function installCommand(opts = {}) {
7102
5815
  const gatewayUrl = opts.gatewayUrl || sanitizeGatewayCandidate(process.env.SYNKRO_GATEWAY_URL) || "https://api.synkro.sh";
7103
5816
  try {
@@ -7125,50 +5838,8 @@ async function installCommand(opts = {}) {
7125
5838
  } catch {
7126
5839
  }
7127
5840
  }
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
5841
  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.");
5842
+ console.log(" Run `synkro install --force` to reinstall from scratch.");
7172
5843
  return;
7173
5844
  }
7174
5845
  console.log("Synkro install starting...\n");
@@ -7241,9 +5912,9 @@ async function installCommand(opts = {}) {
7241
5912
  console.log(` ${scripts.transcriptSyncScript}
7242
5913
  `);
7243
5914
  for (const mode of ["edit", "bash"]) {
7244
- const pidFile = join12(SYNKRO_DIR4, "daemon", mode, "daemon.pid");
5915
+ const pidFile = join8(SYNKRO_DIR4, "daemon", mode, "daemon.pid");
7245
5916
  try {
7246
- const pid = parseInt(readFileSync10(pidFile, "utf-8").trim(), 10);
5917
+ const pid = parseInt(readFileSync7(pidFile, "utf-8").trim(), 10);
7247
5918
  if (pid > 0) {
7248
5919
  process.kill(pid, "SIGTERM");
7249
5920
  console.log(`Stopped stale ${mode} grader daemon (pid ${pid})`);
@@ -7324,20 +5995,17 @@ async function installCommand(opts = {}) {
7324
5995
  if (mintResp.ok) {
7325
5996
  const minted = await mintResp.json();
7326
5997
  mcpJwt = minted.token;
7327
- writeFileSync8(join12(SYNKRO_DIR4, ".mcp-jwt"), mcpJwt + "\n", { mode: 384 });
5998
+ writeFileSync7(join8(SYNKRO_DIR4, ".mcp-jwt"), mcpJwt + "\n", { mode: 384 });
7328
5999
  } else {
7329
6000
  console.warn(" \u26A0 Could not mint MCP token \u2014 local server will reject requests until re-installed.");
7330
6001
  }
7331
6002
  const mcp = installMcpConfig({ gatewayUrl, bearerToken: mcpJwt, local: true });
7332
6003
  console.log(`Registered local MCP guardrails server in ${mcp.path}`);
7333
6004
  console.log(` url: ${mcp.url}`);
7334
- console.log(" (rules stored in ~/.synkro/rules.json)");
7335
6005
  console.log();
7336
- await backfillLocalRules(gatewayUrl, token);
7337
- await startLocalMcpServer();
7338
6006
  } catch (err) {
7339
6007
  console.warn(` \u26A0 Local MCP setup failed: ${err.message}`);
7340
- console.warn(" Hooks are still installed. Re-run `synkro-cli install` to retry.");
6008
+ console.warn(" Hooks are still installed. Re-run `synkro install` to retry.");
7341
6009
  console.log();
7342
6010
  }
7343
6011
  } else {
@@ -7355,7 +6023,7 @@ async function installCommand(opts = {}) {
7355
6023
  throw new Error(`mcp-token mint failed (${mintResp.status}): ${errText.slice(0, 200)}`);
7356
6024
  }
7357
6025
  const minted = await mintResp.json();
7358
- writeFileSync8(join12(SYNKRO_DIR4, ".mcp-jwt"), minted.token + "\n", { mode: 384 });
6026
+ writeFileSync7(join8(SYNKRO_DIR4, ".mcp-jwt"), minted.token + "\n", { mode: 384 });
7359
6027
  const mcp = installMcpConfig({ gatewayUrl, bearerToken: minted.token });
7360
6028
  console.log(`Registered Synkro guardrails MCP server in ${mcp.path}`);
7361
6029
  console.log(` url: ${mcp.url}`);
@@ -7364,7 +6032,7 @@ async function installCommand(opts = {}) {
7364
6032
  console.log();
7365
6033
  } catch (err) {
7366
6034
  console.warn(` \u26A0 MCP registration failed: ${err.message}`);
7367
- console.warn(" Hooks are still installed. Re-run `synkro-cli install` to retry MCP setup.");
6035
+ console.warn(" Hooks are still installed. Re-run `synkro install` to retry MCP setup.");
7368
6036
  console.log();
7369
6037
  }
7370
6038
  }
@@ -7372,8 +6040,8 @@ async function installCommand(opts = {}) {
7372
6040
  if (hasCursor && !opts.noMcp) {
7373
6041
  try {
7374
6042
  if (useLocalMcp) {
7375
- const jwtPath = join12(SYNKRO_DIR4, ".mcp-jwt");
7376
- if (!existsSync12(jwtPath)) {
6043
+ const jwtPath = join8(SYNKRO_DIR4, ".mcp-jwt");
6044
+ if (!existsSync9(jwtPath)) {
7377
6045
  const mintResp = await fetch(`${gatewayUrl}/api/v1/cli/mcp-token`, {
7378
6046
  method: "POST",
7379
6047
  headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json" },
@@ -7381,7 +6049,7 @@ async function installCommand(opts = {}) {
7381
6049
  });
7382
6050
  if (mintResp.ok) {
7383
6051
  const minted = await mintResp.json();
7384
- writeFileSync8(jwtPath, minted.token + "\n", { mode: 384 });
6052
+ writeFileSync7(jwtPath, minted.token + "\n", { mode: 384 });
7385
6053
  }
7386
6054
  }
7387
6055
  const mcp = installCursorMcpConfig({ gatewayUrl, bearerToken: "", local: true });
@@ -7401,7 +6069,7 @@ async function installCommand(opts = {}) {
7401
6069
  throw new Error(`mcp-token mint failed (${mintResp.status}): ${errText.slice(0, 200)}`);
7402
6070
  }
7403
6071
  const minted = await mintResp.json();
7404
- writeFileSync8(join12(SYNKRO_DIR4, ".mcp-jwt"), minted.token + "\n", { mode: 384 });
6072
+ writeFileSync7(join8(SYNKRO_DIR4, ".mcp-jwt"), minted.token + "\n", { mode: 384 });
7405
6073
  const mcp = installCursorMcpConfig({ gatewayUrl, bearerToken: minted.token });
7406
6074
  console.log(`Registered Synkro guardrails MCP server in ${mcp.path}`);
7407
6075
  console.log(` url: ${mcp.url}`);
@@ -7412,18 +6080,10 @@ async function installCommand(opts = {}) {
7412
6080
  console.log();
7413
6081
  }
7414
6082
  }
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
6083
  const synkroBundle = resolveSynkroBundle();
7424
- const persistedMode = resolveDeploymentMode() === "docker" ? "docker" : "bare-host";
6084
+ const persistedMode = resolveDeploymentMode();
7425
6085
  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}`);
6086
+ console.log(`Wrote config to ${CONFIG_PATH2}`);
7427
6087
  console.log(` inference: ${profile.inference} (server-side grading)`);
7428
6088
  if (profile.localInference) console.log(` local inference: enabled (gradingProvider=claude-code)`);
7429
6089
  if (synkroBundle) console.log(` SYNKRO_CLI_BIN=${synkroBundle}`);
@@ -7435,103 +6095,30 @@ async function installCommand(opts = {}) {
7435
6095
  console.warn(` \u26A0 Could not cache judge prompts: ${err.message}`);
7436
6096
  }
7437
6097
  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") {
6098
+ if (profile.localInference) {
6099
+ const { assertDockerAvailable: assertDockerAvailable2 } = await Promise.resolve().then(() => (init_dockerInstall(), dockerInstall_exports));
7466
6100
  try {
7467
- console.log("Installing Synkro server container...");
7468
- const workersPerPool = parseInt(process.env.SYNKRO_WORKERS_PER_POOL || "4", 10);
7469
- const { image, hostMcpPort, hostGraderPort, hostCwePort } = await dockerInstall({ workersPerPool });
7470
- console.log(` pulled ${image}`);
7471
- console.log(` container started \u2014 MCP=${hostMcpPort} general=${hostGraderPort} CWE=${hostCwePort}`);
7472
- console.log(" waiting for container /healthz...");
7473
- const ready = await waitForContainerReady(6e4);
7474
- 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.");
7479
- } else {
7480
- console.warn(" \u26A0 container did not become healthy within 60s \u2014 check `docker logs synkro-server`");
7481
- }
7482
- console.log();
6101
+ assertDockerAvailable2();
7483
6102
  } catch (err) {
7484
- console.warn(` \u26A0 Docker install failed: ${err.message}
7485
- `);
6103
+ console.error(`
6104
+ \u2717 ${err.message}`);
6105
+ process.exit(1);
7486
6106
  }
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
- `);
6107
+ console.log("Installing Synkro server container...");
6108
+ const workersPerPool = parseInt(process.env.SYNKRO_WORKERS_PER_POOL || "4", 10);
6109
+ const { image, hostMcpPort, hostGraderPort, hostCwePort } = await dockerInstall({ workersPerPool });
6110
+ console.log(` \u2713 pulled ${image}`);
6111
+ console.log(` container started \u2014 MCP=${hostMcpPort} general=${hostGraderPort} CWE=${hostCwePort}`);
6112
+ console.log(" waiting for container to be ready...");
6113
+ const ready = await waitForContainerReady(6e4);
6114
+ if (ready) {
6115
+ console.log(" \u2713 container ready");
6116
+ } else {
6117
+ console.error(" \u2717 container did not become healthy within 60s");
6118
+ console.error(" Run `docker logs synkro-server` to debug.");
6119
+ process.exit(1);
7534
6120
  }
6121
+ console.log();
7535
6122
  }
7536
6123
  if (transcriptConsent) {
7537
6124
  try {
@@ -7581,17 +6168,17 @@ function detectGitRepo2() {
7581
6168
  function getClaudeProjectsFolder() {
7582
6169
  const cwd = process.cwd();
7583
6170
  const sanitized = "-" + cwd.replace(/\//g, "-");
7584
- const projectsDir = join12(homedir12(), ".claude", "projects", sanitized);
7585
- return existsSync12(projectsDir) ? projectsDir : null;
6171
+ const projectsDir = join8(homedir8(), ".claude", "projects", sanitized);
6172
+ return existsSync9(projectsDir) ? projectsDir : null;
7586
6173
  }
7587
6174
  function extractSessionInsights(projectsDir) {
7588
6175
  const insights = [];
7589
6176
  const files = readdirSync(projectsDir).filter((f) => f.endsWith(".jsonl"));
7590
6177
  for (const file of files) {
7591
6178
  const sessionId = file.replace(".jsonl", "");
7592
- const filePath = join12(projectsDir, file);
6179
+ const filePath = join8(projectsDir, file);
7593
6180
  try {
7594
- const content = readFileSync10(filePath, "utf-8");
6181
+ const content = readFileSync7(filePath, "utf-8");
7595
6182
  const lines = content.split("\n").filter(Boolean);
7596
6183
  for (let i = 0; i < lines.length; i++) {
7597
6184
  try {
@@ -7667,7 +6254,7 @@ function extractTextContent(content) {
7667
6254
  return "";
7668
6255
  }
7669
6256
  function parseTranscriptFile(filePath) {
7670
- const content = readFileSync10(filePath, "utf-8");
6257
+ const content = readFileSync7(filePath, "utf-8");
7671
6258
  const lines = content.split("\n").filter(Boolean);
7672
6259
  const messages = [];
7673
6260
  for (let i = 0; i < lines.length; i++) {
@@ -7701,137 +6288,625 @@ function parseTranscriptFile(filePath) {
7701
6288
  if (msg.content.length > 0) messages.push(msg);
7702
6289
  } catch {
7703
6290
  }
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;
6291
+ }
6292
+ return messages;
6293
+ }
6294
+ async function syncTranscriptsBulk(gatewayUrl, token, repo) {
6295
+ const projectsDir = getClaudeProjectsFolder();
6296
+ if (!projectsDir) return { sessions: 0, messages: 0 };
6297
+ const files = readdirSync(projectsDir).filter((f) => f.endsWith(".jsonl"));
6298
+ if (files.length === 0) return { sessions: 0, messages: 0 };
6299
+ console.log(`Found ${files.length} CC session transcripts, syncing...`);
6300
+ const maxMessagesPerSession = 500;
6301
+ let totalSessions = 0;
6302
+ let totalMessages = 0;
6303
+ for (let i = 0; i < files.length; i += 5) {
6304
+ const batch = files.slice(i, i + 5);
6305
+ const sessions = [];
6306
+ for (const file of batch) {
6307
+ const sessionId = file.replace(".jsonl", "");
6308
+ const filePath = join8(projectsDir, file);
6309
+ try {
6310
+ const allMessages = parseTranscriptFile(filePath);
6311
+ const messages = allMessages.length > maxMessagesPerSession ? allMessages.slice(-maxMessagesPerSession) : allMessages;
6312
+ if (messages.length > 0) {
6313
+ sessions.push({ cc_session_id: sessionId, messages });
6314
+ }
6315
+ } catch {
6316
+ }
6317
+ }
6318
+ if (sessions.length === 0) continue;
6319
+ try {
6320
+ const resp = await fetch(`${gatewayUrl}/api/v1/cli/sync-transcripts`, {
6321
+ method: "POST",
6322
+ headers: {
6323
+ "Authorization": `Bearer ${token}`,
6324
+ "Content-Type": "application/json"
6325
+ },
6326
+ body: JSON.stringify({ repo, sessions })
6327
+ });
6328
+ if (resp.ok) {
6329
+ const result = await resp.json();
6330
+ totalMessages += result.accepted;
6331
+ totalSessions += result.sessions;
6332
+ }
6333
+ } catch {
6334
+ }
6335
+ for (const file of batch) {
6336
+ const sessionId = file.replace(".jsonl", "");
6337
+ const filePath = join8(projectsDir, file);
6338
+ try {
6339
+ const content = readFileSync7(filePath, "utf-8");
6340
+ const lineCount = content.split("\n").filter(Boolean).length;
6341
+ writeFileSync7(join8(OFFSETS_DIR, sessionId), String(lineCount), "utf-8");
6342
+ } catch {
6343
+ }
6344
+ }
6345
+ }
6346
+ return { sessions: totalSessions, messages: totalMessages };
6347
+ }
6348
+ var SYNKRO_DIR4, HOOKS_DIR, BIN_DIR, CONFIG_PATH2, MCP_STDIO_PROXY_SRC, OFFSETS_DIR;
6349
+ var init_install = __esm({
6350
+ "cli/commands/install.ts"() {
6351
+ "use strict";
6352
+ init_agentDetect();
6353
+ init_ccHookConfig();
6354
+ init_cursorHookConfig();
6355
+ init_mcpConfig();
6356
+ init_hookScripts();
6357
+ init_hookScriptsTs();
6358
+ init_stub();
6359
+ init_repoConnect();
6360
+ init_projects();
6361
+ init_setupGithub();
6362
+ init_promptFetcher();
6363
+ init_dockerInstall();
6364
+ SYNKRO_DIR4 = join8(homedir8(), ".synkro");
6365
+ HOOKS_DIR = join8(SYNKRO_DIR4, "hooks");
6366
+ BIN_DIR = join8(SYNKRO_DIR4, "bin");
6367
+ CONFIG_PATH2 = join8(SYNKRO_DIR4, "config.env");
6368
+ MCP_STDIO_PROXY_SRC = `#!/usr/bin/env bun
6369
+ import { readFileSync } from 'node:fs';
6370
+ import { homedir } from 'node:os';
6371
+ import { join } from 'node:path';
6372
+ import { createInterface } from 'node:readline';
6373
+
6374
+ const HOME = homedir();
6375
+ const TOKEN_PATH = join(HOME, '.synkro', '.mcp-jwt');
6376
+ const PORT = parseInt(process.env.SYNKRO_MCP_PORT || '8931', 10);
6377
+ const URL = \`http://127.0.0.1:\${PORT}\`;
6378
+
6379
+ let token = '';
6380
+ try { token = readFileSync(TOKEN_PATH, 'utf-8').trim(); } catch {}
6381
+
6382
+ const rl = createInterface({ input: process.stdin, terminal: false });
6383
+
6384
+ rl.on('line', async (line) => {
6385
+ if (!line.trim()) return;
6386
+ let msg;
6387
+ try { msg = JSON.parse(line); } catch { return; }
6388
+ if (!msg.id && msg.method?.startsWith('notifications/')) return;
6389
+
6390
+ try {
6391
+ const resp = await fetch(URL, {
6392
+ method: 'POST',
6393
+ headers: {
6394
+ 'Content-Type': 'application/json',
6395
+ 'Authorization': \`Bearer \${token}\`,
6396
+ },
6397
+ body: line,
6398
+ signal: AbortSignal.timeout(30000),
6399
+ });
6400
+ if (resp.status === 204) return;
6401
+ const body = await resp.text();
6402
+ process.stdout.write(body + '\\n');
6403
+ } catch (err) {
6404
+ if (msg.id != null) {
6405
+ process.stdout.write(JSON.stringify({
6406
+ jsonrpc: '2.0',
6407
+ id: msg.id,
6408
+ error: { code: -32603, message: 'MCP proxy: HTTP server unreachable' },
6409
+ }) + '\\n');
6410
+ }
6411
+ }
6412
+ });
6413
+ `;
6414
+ OFFSETS_DIR = join8(SYNKRO_DIR4, ".transcript-offsets");
6415
+ }
6416
+ });
6417
+
6418
+ // cli/local-cc/pueue.ts
6419
+ import { execFileSync, spawnSync as spawnSync3, spawn } from "child_process";
6420
+ import { homedir as homedir9 } from "os";
6421
+ import { join as join9 } from "path";
6422
+ import { connect } from "net";
6423
+ function pueueAvailable() {
6424
+ const r = spawnSync3("pueue", ["--version"], { encoding: "utf-8" });
6425
+ if (r.status !== 0) {
6426
+ throw new PueueError("pueue CLI not found on PATH. Install pueue (https://github.com/Nukesor/pueue) and start `pueued`.");
6427
+ }
6428
+ }
6429
+ function statusJson() {
6430
+ pueueAvailable();
6431
+ const r = spawnSync3("pueue", ["status", "--json"], { encoding: "utf-8" });
6432
+ if (r.status !== 0) {
6433
+ throw new PueueError(`pueue status failed: ${r.stderr || r.stdout || "unknown error"} \u2014 is pueued running?`);
6434
+ }
6435
+ try {
6436
+ return JSON.parse(r.stdout);
6437
+ } catch (err) {
6438
+ throw new PueueError(`pueue status returned non-JSON output: ${r.stdout.slice(0, 200)}`, err);
6439
+ }
6440
+ }
6441
+ function statusName(s) {
6442
+ if (typeof s === "string") return s;
6443
+ if (s && typeof s === "object") {
6444
+ if ("Running" in s) return "Running";
6445
+ if ("Done" in s) {
6446
+ const result = s.Done?.result;
6447
+ if (typeof result === "string") return `Done (${result})`;
6448
+ if (result && typeof result === "object") return `Done (${Object.keys(result)[0] ?? "unknown"})`;
6449
+ return "Done";
6450
+ }
6451
+ return Object.keys(s)[0] ?? "unknown";
6452
+ }
6453
+ return "unknown";
6454
+ }
6455
+ function findTask(channel = CHANNEL_PRIMARY) {
6456
+ const data = statusJson();
6457
+ for (const [id, t] of Object.entries(data.tasks)) {
6458
+ if (t.label === channel.taskLabel) {
6459
+ return {
6460
+ id: Number(id),
6461
+ label: t.label,
6462
+ status: statusName(t.status),
6463
+ command: t.command,
6464
+ cwd: t.path
6465
+ };
6466
+ }
6467
+ }
6468
+ return null;
6469
+ }
6470
+ function stopTask(channel = CHANNEL_PRIMARY) {
6471
+ spawnSync3("tmux", ["kill-session", "-t", `=${channel.tmuxSession}`], { encoding: "utf-8" });
6472
+ let t = findTask(channel);
6473
+ while (t) {
6474
+ if (t.status === "Running" || t.status === "Queued") {
6475
+ spawnSync3("pueue", ["kill", String(t.id)], { encoding: "utf-8" });
6476
+ for (let i = 0; i < 10; i++) {
6477
+ const check = findTask(channel);
6478
+ if (!check || check.id !== t.id || check.status !== "Running" && check.status !== "Queued") break;
6479
+ spawnSync3("sleep", ["0.5"], { encoding: "utf-8" });
6480
+ }
6481
+ }
6482
+ spawnSync3("pueue", ["remove", String(t.id)], { encoding: "utf-8" });
6483
+ t = findTask(channel);
6484
+ }
6485
+ }
6486
+ 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;
6487
+ var init_pueue = __esm({
6488
+ "cli/local-cc/pueue.ts"() {
6489
+ "use strict";
6490
+ TASK_LABEL = "synkro-local-cc";
6491
+ TMUX_SESSION = "synkro-local-cc";
6492
+ SESSION_DIR = join9(homedir9(), ".synkro", "cc_sessions");
6493
+ TASK_LABEL_2 = "synkro-local-cc-2";
6494
+ TMUX_SESSION_2 = "synkro-local-cc-2";
6495
+ SESSION_DIR_2 = join9(homedir9(), ".synkro", "cc_sessions_2");
6496
+ TASK_LABEL_3 = "synkro-local-cc-3";
6497
+ TMUX_SESSION_3 = "synkro-local-cc-3";
6498
+ SESSION_DIR_3 = join9(homedir9(), ".synkro", "cc_sessions_3");
6499
+ TASK_LABEL_4 = "synkro-local-cc-4";
6500
+ TMUX_SESSION_4 = "synkro-local-cc-4";
6501
+ SESSION_DIR_4 = join9(homedir9(), ".synkro", "cc_sessions_4");
6502
+ PueueError = class extends Error {
6503
+ constructor(message, cause) {
6504
+ super(message);
6505
+ this.cause = cause;
6506
+ this.name = "PueueError";
6507
+ }
6508
+ cause;
6509
+ };
6510
+ CHANNEL_PRIMARY = { taskLabel: TASK_LABEL, tmuxSession: TMUX_SESSION, sessionDir: SESSION_DIR };
6511
+ CHANNEL_SECONDARY = { taskLabel: TASK_LABEL_2, tmuxSession: TMUX_SESSION_2, sessionDir: SESSION_DIR_2 };
6512
+ CHANNEL_TERTIARY = { taskLabel: TASK_LABEL_3, tmuxSession: TMUX_SESSION_3, sessionDir: SESSION_DIR_3 };
6513
+ CHANNEL_QUATERNARY = { taskLabel: TASK_LABEL_4, tmuxSession: TMUX_SESSION_4, sessionDir: SESSION_DIR_4 };
6514
+ }
6515
+ });
6516
+
6517
+ // cli/local-cc/channelSource.ts
6518
+ var init_channelSource = __esm({
6519
+ "cli/local-cc/channelSource.ts"() {
6520
+ "use strict";
6521
+ }
6522
+ });
6523
+
6524
+ // cli/local-cc/install.ts
6525
+ 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";
6526
+ import { join as join10 } from "path";
6527
+ import { homedir as homedir10 } from "os";
6528
+ import { spawnSync as spawnSync4 } from "child_process";
6529
+ function safelyMutateClaudeJson(mutator) {
6530
+ if (!existsSync10(CLAUDE_JSON_PATH)) {
6531
+ return;
6532
+ }
6533
+ const originalText = readFileSync8(CLAUDE_JSON_PATH, "utf-8");
6534
+ let parsed;
6535
+ try {
6536
+ parsed = JSON.parse(originalText);
6537
+ } catch (err) {
6538
+ throw new LocalCCInstallError(
6539
+ `refusing to modify malformed ${CLAUDE_JSON_PATH}: ${err.message}. Please fix the JSON manually before retrying.`,
6540
+ err
6541
+ );
6542
+ }
6543
+ const originalKeys = new Set(Object.keys(parsed));
6544
+ const dirty = mutator(parsed);
6545
+ if (!dirty) return;
6546
+ for (const k of originalKeys) {
6547
+ if (!(k in parsed)) {
6548
+ throw new LocalCCInstallError(
6549
+ `refusing to write ${CLAUDE_JSON_PATH}: mutator dropped top-level key "${k}". This is a bug \u2014 please report.`
6550
+ );
6551
+ }
6552
+ }
6553
+ const newText = JSON.stringify(parsed, null, 2) + "\n";
6554
+ try {
6555
+ JSON.parse(newText);
6556
+ } catch (err) {
6557
+ throw new LocalCCInstallError(
6558
+ `refusing to write ${CLAUDE_JSON_PATH}: serialized result is not valid JSON. This is a bug \u2014 please report.`,
6559
+ err
6560
+ );
6561
+ }
6562
+ copyFileSync(CLAUDE_JSON_PATH, CLAUDE_JSON_BACKUP_PATH);
6563
+ const tmpPath = `${CLAUDE_JSON_PATH}.synkro-tmp.${process.pid}`;
6564
+ try {
6565
+ writeFileSync8(tmpPath, newText, "utf-8");
6566
+ const fd = openSync(tmpPath, "r");
6567
+ try {
6568
+ fsyncSync(fd);
6569
+ } finally {
6570
+ closeSync(fd);
6571
+ }
6572
+ renameSync4(tmpPath, CLAUDE_JSON_PATH);
6573
+ } catch (err) {
6574
+ try {
6575
+ unlinkSync4(tmpPath);
6576
+ } catch {
6577
+ }
7732
6578
  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
- }
6579
+ copyFileSync(CLAUDE_JSON_BACKUP_PATH, CLAUDE_JSON_PATH);
7746
6580
  } catch {
7747
6581
  }
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 {
6582
+ throw new LocalCCInstallError(
6583
+ `failed to write ${CLAUDE_JSON_PATH}: ${err.message}. Backup at ${CLAUDE_JSON_BACKUP_PATH} preserves the prior state.`,
6584
+ err
6585
+ );
6586
+ }
6587
+ }
6588
+ function uninstallLocalCC() {
6589
+ safelyMutateClaudeJson((parsed) => {
6590
+ let dirty = false;
6591
+ if (parsed.mcpServers && parsed.mcpServers[MCP_SERVER_NAME]) {
6592
+ delete parsed.mcpServers[MCP_SERVER_NAME];
6593
+ dirty = true;
6594
+ }
6595
+ for (const dir of CHANNELS.map((c) => c.sessionDir)) {
6596
+ if (parsed.projects && typeof parsed.projects === "object" && parsed.projects[dir]) {
6597
+ delete parsed.projects[dir];
6598
+ dirty = true;
7756
6599
  }
7757
6600
  }
7758
- }
7759
- return { sessions: totalSessions, messages: totalMessages };
6601
+ return dirty;
6602
+ });
7760
6603
  }
7761
- var SYNKRO_DIR4, HOOKS_DIR, BIN_DIR, CONFIG_PATH3, MCP_STDIO_PROXY_SRC, OFFSETS_DIR, RULES_PATH, MCP_LOCAL_PORT, PGLITE_PORT;
6604
+ 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
6605
  var init_install2 = __esm({
7763
- "cli/commands/install.ts"() {
6606
+ "cli/local-cc/install.ts"() {
7764
6607
  "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';
6608
+ init_channelSource();
6609
+ CLAUDE_JSON_BACKUP_PATH = join10(homedir10(), ".claude.json.synkro-bak");
6610
+ SESSION_DIR2 = join10(homedir10(), ".synkro", "cc_sessions");
6611
+ PLUGIN_PATH = join10(SESSION_DIR2, "synkro-channel.ts");
6612
+ PLUGIN_PKG_PATH = join10(SESSION_DIR2, "package.json");
6613
+ PLUGIN_SETTINGS_DIR = join10(SESSION_DIR2, ".claude");
6614
+ PLUGIN_SETTINGS_PATH = join10(PLUGIN_SETTINGS_DIR, "settings.json");
6615
+ PROJECT_MCP_PATH = join10(SESSION_DIR2, ".mcp.json");
6616
+ CLAUDE_JSON_PATH = join10(homedir10(), ".claude.json");
6617
+ RUN_SCRIPT_PATH = join10(SESSION_DIR2, "run-claude.sh");
6618
+ TMUX_SESSION_NAME = "synkro-local-cc";
6619
+ CHANNEL_1_PORT = 8941;
6620
+ SESSION_DIR_22 = join10(homedir10(), ".synkro", "cc_sessions_2");
6621
+ PLUGIN_PATH_2 = join10(SESSION_DIR_22, "synkro-channel.ts");
6622
+ PLUGIN_PKG_PATH_2 = join10(SESSION_DIR_22, "package.json");
6623
+ PLUGIN_SETTINGS_DIR_2 = join10(SESSION_DIR_22, ".claude");
6624
+ PLUGIN_SETTINGS_PATH_2 = join10(PLUGIN_SETTINGS_DIR_2, "settings.json");
6625
+ PROJECT_MCP_PATH_2 = join10(SESSION_DIR_22, ".mcp.json");
6626
+ RUN_SCRIPT_PATH_2 = join10(SESSION_DIR_22, "run-claude.sh");
6627
+ TMUX_SESSION_NAME_2 = "synkro-local-cc-2";
6628
+ CHANNEL_2_PORT = 8951;
6629
+ SESSION_DIR_32 = join10(homedir10(), ".synkro", "cc_sessions_3");
6630
+ PLUGIN_PATH_3 = join10(SESSION_DIR_32, "synkro-channel.ts");
6631
+ PLUGIN_PKG_PATH_3 = join10(SESSION_DIR_32, "package.json");
6632
+ PLUGIN_SETTINGS_DIR_3 = join10(SESSION_DIR_32, ".claude");
6633
+ PLUGIN_SETTINGS_PATH_3 = join10(PLUGIN_SETTINGS_DIR_3, "settings.json");
6634
+ PROJECT_MCP_PATH_3 = join10(SESSION_DIR_32, ".mcp.json");
6635
+ RUN_SCRIPT_PATH_3 = join10(SESSION_DIR_32, "run-claude.sh");
6636
+ TMUX_SESSION_NAME_3 = "synkro-local-cc-3";
6637
+ CHANNEL_3_PORT = 8942;
6638
+ SESSION_DIR_42 = join10(homedir10(), ".synkro", "cc_sessions_4");
6639
+ PLUGIN_PATH_4 = join10(SESSION_DIR_42, "synkro-channel.ts");
6640
+ PLUGIN_PKG_PATH_4 = join10(SESSION_DIR_42, "package.json");
6641
+ PLUGIN_SETTINGS_DIR_4 = join10(SESSION_DIR_42, ".claude");
6642
+ PLUGIN_SETTINGS_PATH_4 = join10(PLUGIN_SETTINGS_DIR_4, "settings.json");
6643
+ PROJECT_MCP_PATH_4 = join10(SESSION_DIR_42, ".mcp.json");
6644
+ RUN_SCRIPT_PATH_4 = join10(SESSION_DIR_42, "run-claude.sh");
6645
+ TMUX_SESSION_NAME_4 = "synkro-local-cc-4";
6646
+ CHANNEL_4_PORT = 8952;
6647
+ RUN_SCRIPT_SOURCE = `#!/usr/bin/env bash
6648
+ # Auto-generated by \`synkro install\`. Do not edit.
6649
+ set -uo pipefail
6650
+
6651
+ SESSION=${TMUX_SESSION_NAME}
6652
+ LOG="$HOME/.synkro/cc_sessions/run-claude.log"
6653
+
6654
+ log() { echo "[$(date '+%H:%M:%S')] $*" >> "$LOG"; echo "$*"; }
6655
+
6656
+ # Pre-flight checks
6657
+ if ! command -v claude >/dev/null 2>&1; then
6658
+ log "ERROR: claude CLI not found on PATH. Install Claude Code first."
6659
+ exit 1
6660
+ fi
6661
+
6662
+ if ! command -v tmux >/dev/null 2>&1; then
6663
+ log "ERROR: tmux not found on PATH."
6664
+ exit 1
6665
+ fi
6666
+
6667
+ # Check claude is authenticated
6668
+ if ! claude --version >/dev/null 2>&1; then
6669
+ log "ERROR: claude --version failed. Is Claude Code installed correctly?"
6670
+ exit 1
6671
+ fi
6672
+
6673
+ log "Starting local-CC session..."
6674
+ log "claude version: $(claude --version 2>&1 | head -1)"
6675
+
6676
+ # Kill any previous session so restarts come up clean.
6677
+ tmux kill-session -t "=$SESSION" 2>/dev/null || true
6678
+
6679
+ # Start claude inside a detached tmux session so it has a real pty.
6680
+ # Redirect stderr to the log so we can see why it dies.
6681
+ tmux new-session -d -s "$SESSION" \\
6682
+ "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"
6683
+
6684
+ # Claude's --dangerously-load-development-channels shows a confirmation
6685
+ # prompt: option 1 = "I am using this for local development" (accept),
6686
+ # option 2 = "Exit". Auto-accept by sending '1' + Enter.
6687
+ sleep 3
6688
+ if tmux has-session -t "=$SESSION" 2>/dev/null; then
6689
+ tmux send-keys -t "$SESSION" '1' 2>/dev/null || true
6690
+ sleep 1
6691
+ tmux send-keys -t "$SESSION" Enter 2>/dev/null || true
6692
+ sleep 1
6693
+ # Additional Enter for any follow-up prompts (workspace trust, MCP consent)
6694
+ tmux send-keys -t "$SESSION" Enter 2>/dev/null || true
6695
+ log "Sent auto-accept keys to claude session."
6696
+ fi
6697
+
6698
+ sleep 2
6699
+ if ! tmux has-session -t "$SESSION" 2>/dev/null; then
6700
+ log "ERROR: tmux session died immediately. Check $LOG for details."
6701
+ log "Try running claude manually to verify auth: claude --print 'say ok'"
6702
+ exit 1
6703
+ fi
6704
+
6705
+ log "tmux session started successfully."
6706
+
6707
+ # Block on the tmux session so pueue's task lifetime tracks claude's.
6708
+ while tmux has-session -t "=$SESSION" 2>/dev/null; do
6709
+ sleep 5
6710
+ done
6711
+
6712
+ log "tmux session ended."
6713
+ `;
6714
+ RUN_SCRIPT_SOURCE_2 = `#!/usr/bin/env bash
6715
+ # Auto-generated by \`synkro install\`. Channel 2 (CWE scan, port ${CHANNEL_2_PORT}).
6716
+ set -uo pipefail
6717
+
6718
+ SESSION=${TMUX_SESSION_NAME_2}
6719
+ LOG="$HOME/.synkro/cc_sessions_2/run-claude.log"
6720
+
6721
+ log() { echo "[$(date '+%H:%M:%S')] $*" >> "$LOG"; echo "$*"; }
6722
+
6723
+ if ! command -v claude >/dev/null 2>&1; then
6724
+ log "ERROR: claude CLI not found on PATH."
6725
+ exit 1
6726
+ fi
6727
+
6728
+ if ! command -v tmux >/dev/null 2>&1; then
6729
+ log "ERROR: tmux not found on PATH."
6730
+ exit 1
6731
+ fi
6732
+
6733
+ if ! claude --version >/dev/null 2>&1; then
6734
+ log "ERROR: claude --version failed."
6735
+ exit 1
6736
+ fi
6737
+
6738
+ log "Starting local-CC channel 2 (port ${CHANNEL_2_PORT})..."
6739
+ log "claude version: $(claude --version 2>&1 | head -1)"
6740
+
6741
+ tmux kill-session -t "=$SESSION" 2>/dev/null || true
6742
+
6743
+ tmux new-session -d -s "$SESSION" \\
6744
+ "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"
6745
+
6746
+ sleep 3
6747
+ if tmux has-session -t "=$SESSION" 2>/dev/null; then
6748
+ tmux send-keys -t "$SESSION" '1' 2>/dev/null || true
6749
+ sleep 1
6750
+ tmux send-keys -t "$SESSION" Enter 2>/dev/null || true
6751
+ sleep 1
6752
+ tmux send-keys -t "$SESSION" Enter 2>/dev/null || true
6753
+ log "Sent auto-accept keys to channel 2 session."
6754
+ fi
6755
+
6756
+ sleep 2
6757
+ if ! tmux has-session -t "$SESSION" 2>/dev/null; then
6758
+ log "ERROR: tmux session died immediately. Check $LOG for details."
6759
+ exit 1
6760
+ fi
6761
+
6762
+ log "tmux session started successfully (port ${CHANNEL_2_PORT})."
6763
+
6764
+ while tmux has-session -t "=$SESSION" 2>/dev/null; do
6765
+ sleep 5
6766
+ done
6767
+
6768
+ log "tmux session ended."
6769
+ `;
6770
+ RUN_SCRIPT_SOURCE_3 = `#!/usr/bin/env bash
6771
+ # Auto-generated by \`synkro install\`. General worker B (port ${CHANNEL_3_PORT}).
6772
+ set -uo pipefail
6773
+
6774
+ SESSION=${TMUX_SESSION_NAME_3}
6775
+ LOG="$HOME/.synkro/cc_sessions_3/run-claude.log"
6776
+
6777
+ log() { echo "[$(date '+%H:%M:%S')] $*" >> "$LOG"; echo "$*"; }
6778
+
6779
+ if ! command -v claude >/dev/null 2>&1; then
6780
+ log "ERROR: claude CLI not found on PATH."
6781
+ exit 1
6782
+ fi
6783
+
6784
+ if ! command -v tmux >/dev/null 2>&1; then
6785
+ log "ERROR: tmux not found on PATH."
6786
+ exit 1
6787
+ fi
6788
+
6789
+ if ! claude --version >/dev/null 2>&1; then
6790
+ log "ERROR: claude --version failed."
6791
+ exit 1
6792
+ fi
6793
+
6794
+ log "Starting local-CC general worker B (port ${CHANNEL_3_PORT})..."
6795
+ log "claude version: $(claude --version 2>&1 | head -1)"
6796
+
6797
+ tmux kill-session -t "=$SESSION" 2>/dev/null || true
6798
+
6799
+ tmux new-session -d -s "$SESSION" \\
6800
+ "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"
6801
+
6802
+ sleep 3
6803
+ if tmux has-session -t "=$SESSION" 2>/dev/null; then
6804
+ tmux send-keys -t "$SESSION" '1' 2>/dev/null || true
6805
+ sleep 1
6806
+ tmux send-keys -t "$SESSION" Enter 2>/dev/null || true
6807
+ sleep 1
6808
+ tmux send-keys -t "$SESSION" Enter 2>/dev/null || true
6809
+ log "Sent auto-accept keys to general worker B session."
6810
+ fi
7790
6811
 
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}\`;
6812
+ sleep 2
6813
+ if ! tmux has-session -t "$SESSION" 2>/dev/null; then
6814
+ log "ERROR: tmux session died immediately. Check $LOG for details."
6815
+ exit 1
6816
+ fi
7795
6817
 
7796
- let token = '';
7797
- try { token = readFileSync(TOKEN_PATH, 'utf-8').trim(); } catch {}
6818
+ log "tmux session started successfully (port ${CHANNEL_3_PORT})."
7798
6819
 
7799
- const rl = createInterface({ input: process.stdin, terminal: false });
6820
+ while tmux has-session -t "=$SESSION" 2>/dev/null; do
6821
+ sleep 5
6822
+ done
7800
6823
 
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;
6824
+ log "tmux session ended."
6825
+ `;
6826
+ RUN_SCRIPT_SOURCE_4 = `#!/usr/bin/env bash
6827
+ # Auto-generated by \`synkro install\`. CWE worker B (port ${CHANNEL_4_PORT}).
6828
+ set -uo pipefail
7806
6829
 
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
- });
6830
+ SESSION=${TMUX_SESSION_NAME_4}
6831
+ LOG="$HOME/.synkro/cc_sessions_4/run-claude.log"
6832
+
6833
+ log() { echo "[$(date '+%H:%M:%S')] $*" >> "$LOG"; echo "$*"; }
6834
+
6835
+ if ! command -v claude >/dev/null 2>&1; then
6836
+ log "ERROR: claude CLI not found on PATH."
6837
+ exit 1
6838
+ fi
6839
+
6840
+ if ! command -v tmux >/dev/null 2>&1; then
6841
+ log "ERROR: tmux not found on PATH."
6842
+ exit 1
6843
+ fi
6844
+
6845
+ if ! claude --version >/dev/null 2>&1; then
6846
+ log "ERROR: claude --version failed."
6847
+ exit 1
6848
+ fi
6849
+
6850
+ log "Starting local-CC CWE worker B (port ${CHANNEL_4_PORT})..."
6851
+ log "claude version: $(claude --version 2>&1 | head -1)"
6852
+
6853
+ tmux kill-session -t "=$SESSION" 2>/dev/null || true
6854
+
6855
+ tmux new-session -d -s "$SESSION" \\
6856
+ "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"
6857
+
6858
+ sleep 3
6859
+ if tmux has-session -t "=$SESSION" 2>/dev/null; then
6860
+ tmux send-keys -t "$SESSION" '1' 2>/dev/null || true
6861
+ sleep 1
6862
+ tmux send-keys -t "$SESSION" Enter 2>/dev/null || true
6863
+ sleep 1
6864
+ tmux send-keys -t "$SESSION" Enter 2>/dev/null || true
6865
+ log "Sent auto-accept keys to CWE worker B session."
6866
+ fi
6867
+
6868
+ sleep 2
6869
+ if ! tmux has-session -t "$SESSION" 2>/dev/null; then
6870
+ log "ERROR: tmux session died immediately. Check $LOG for details."
6871
+ exit 1
6872
+ fi
6873
+
6874
+ log "tmux session started successfully (port ${CHANNEL_4_PORT})."
6875
+
6876
+ while tmux has-session -t "=$SESSION" 2>/dev/null; do
6877
+ sleep 5
6878
+ done
6879
+
6880
+ log "tmux session ended."
7830
6881
  `;
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);
6882
+ MCP_SERVER_NAME = "synkro-local";
6883
+ PLUGIN_PACKAGE_JSON = JSON.stringify(
6884
+ {
6885
+ name: "synkro-local-channel",
6886
+ private: true,
6887
+ version: "0.1.0",
6888
+ type: "module",
6889
+ dependencies: {
6890
+ "@modelcontextprotocol/sdk": "^1.0.0"
6891
+ }
6892
+ },
6893
+ null,
6894
+ 2
6895
+ ) + "\n";
6896
+ LocalCCInstallError = class extends Error {
6897
+ constructor(message, cause) {
6898
+ super(message);
6899
+ this.cause = cause;
6900
+ this.name = "LocalCCInstallError";
6901
+ }
6902
+ cause;
6903
+ };
6904
+ CHANNELS = [
6905
+ { 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 },
6906
+ { 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 },
6907
+ { 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 },
6908
+ { 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 }
6909
+ ];
7835
6910
  }
7836
6911
  });
7837
6912
 
@@ -7840,9 +6915,9 @@ var disconnect_exports = {};
7840
6915
  __export(disconnect_exports, {
7841
6916
  disconnectCommand: () => disconnectCommand
7842
6917
  });
7843
- import { existsSync as existsSync13, rmSync } from "fs";
7844
- import { homedir as homedir13 } from "os";
7845
- import { join as join13 } from "path";
6918
+ import { existsSync as existsSync11, rmSync } from "fs";
6919
+ import { homedir as homedir11 } from "os";
6920
+ import { join as join11 } from "path";
7846
6921
  function tearDownLocalCC() {
7847
6922
  const docker = dockerStatus();
7848
6923
  if (docker.running) {
@@ -7897,13 +6972,13 @@ function disconnectCommand(args2 = []) {
7897
6972
  console.log(`${cursorMcpRemoved ? "\u2713" : "\xB7"} MCP guardrails (Cursor): ${cursorMcpRemoved ? "removed from ~/.cursor/mcp.json" : "no entry found"}`);
7898
6973
  }
7899
6974
  if (purge) {
7900
- if (existsSync13(SYNKRO_DIR5)) {
6975
+ if (existsSync11(SYNKRO_DIR5)) {
7901
6976
  rmSync(SYNKRO_DIR5, { recursive: true, force: true });
7902
6977
  console.log(`\u2713 Removed ${SYNKRO_DIR5}`);
7903
6978
  } else {
7904
6979
  console.log(`\xB7 ${SYNKRO_DIR5} already gone, nothing to remove`);
7905
6980
  }
7906
- } else if (existsSync13(SYNKRO_DIR5)) {
6981
+ } else if (existsSync11(SYNKRO_DIR5)) {
7907
6982
  console.log(`Config preserved at ${SYNKRO_DIR5}. Run with --purge to remove.`);
7908
6983
  }
7909
6984
  console.log("\nSynkro disconnected.");
@@ -7917,10 +6992,136 @@ var init_disconnect = __esm({
7917
6992
  init_cursorHookConfig();
7918
6993
  init_mcpConfig();
7919
6994
  init_pueue();
7920
- init_install();
6995
+ init_install2();
7921
6996
  init_dockerInstall();
7922
6997
  init_macKeychain();
7923
- SYNKRO_DIR5 = join13(homedir13(), ".synkro");
6998
+ SYNKRO_DIR5 = join11(homedir11(), ".synkro");
6999
+ }
7000
+ });
7001
+
7002
+ // cli/local-cc/turnLog.ts
7003
+ 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";
7004
+ import { dirname as dirname5, join as join12 } from "path";
7005
+ import { homedir as homedir12 } from "os";
7006
+ function truncate(s, max = PREVIEW_MAX) {
7007
+ if (s.length <= max) return s;
7008
+ return s.slice(0, max) + "\u2026 [+" + (s.length - max) + " chars]";
7009
+ }
7010
+ function extractSeverity(result) {
7011
+ const m = result.match(/<synkro-(?:verdict|intent)>([\s\S]*?)<\/synkro-(?:verdict|intent)>/);
7012
+ if (!m) return void 0;
7013
+ try {
7014
+ const obj = JSON.parse(m[1]);
7015
+ if (obj.severity) return String(obj.severity);
7016
+ if (typeof obj.ok === "boolean") return obj.ok ? "ok" : "violations";
7017
+ if (obj.type) return String(obj.type);
7018
+ if (obj.verdict) return String(obj.verdict);
7019
+ } catch {
7020
+ }
7021
+ return void 0;
7022
+ }
7023
+ function appendTurn(args2) {
7024
+ try {
7025
+ mkdirSync10(dirname5(TURN_LOG_PATH), { recursive: true });
7026
+ const entry = {
7027
+ ts: new Date(args2.startedAt).toISOString(),
7028
+ role: args2.role,
7029
+ duration_ms: Date.now() - args2.startedAt,
7030
+ status: args2.status,
7031
+ request_preview: truncate(args2.request),
7032
+ response_preview: args2.result ? truncate(args2.result) : "",
7033
+ severity: args2.result ? extractSeverity(args2.result) : void 0,
7034
+ error: args2.error
7035
+ };
7036
+ appendFileSync(TURN_LOG_PATH, JSON.stringify(entry) + "\n", "utf-8");
7037
+ } catch {
7038
+ }
7039
+ }
7040
+ var TURN_LOG_PATH, PREVIEW_MAX;
7041
+ var init_turnLog = __esm({
7042
+ "cli/local-cc/turnLog.ts"() {
7043
+ "use strict";
7044
+ TURN_LOG_PATH = join12(homedir12(), ".synkro", "cc_sessions", "turns.log");
7045
+ PREVIEW_MAX = 400;
7046
+ }
7047
+ });
7048
+
7049
+ // cli/local-cc/client.ts
7050
+ import { request as httpRequest } from "http";
7051
+ import { connect as connect2 } from "net";
7052
+ async function submitToChannel(role, payload, opts = {}) {
7053
+ const body = JSON.stringify({ role, payload, content: payload });
7054
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
7055
+ const port = opts.port ?? CHANNEL_PORT;
7056
+ const startedAt = Date.now();
7057
+ try {
7058
+ const result = await new Promise((resolve3, reject) => {
7059
+ const req = httpRequest({
7060
+ host: CHANNEL_HOST,
7061
+ port,
7062
+ method: "POST",
7063
+ path: "/submit",
7064
+ headers: {
7065
+ "Content-Type": "application/json",
7066
+ "Content-Length": Buffer.byteLength(body)
7067
+ },
7068
+ timeout: timeoutMs
7069
+ }, (res) => {
7070
+ const chunks = [];
7071
+ res.on("data", (c) => chunks.push(c));
7072
+ res.on("end", () => {
7073
+ const text = Buffer.concat(chunks).toString("utf-8");
7074
+ if (res.statusCode !== 200) {
7075
+ reject(new LocalCCError(`channel returned ${res.statusCode}: ${text.slice(0, 500)}`));
7076
+ return;
7077
+ }
7078
+ try {
7079
+ const parsed = JSON.parse(text);
7080
+ if (parsed.error) {
7081
+ reject(new LocalCCError(parsed.error));
7082
+ return;
7083
+ }
7084
+ resolve3(String(parsed.result ?? ""));
7085
+ } catch (err) {
7086
+ reject(new LocalCCError(`malformed channel response: ${text.slice(0, 200)}`, err));
7087
+ }
7088
+ });
7089
+ });
7090
+ req.on("timeout", () => {
7091
+ req.destroy(new LocalCCError(`channel request timed out after ${timeoutMs}ms`));
7092
+ });
7093
+ req.on("error", (err) => {
7094
+ const msg = err.code === "ECONNREFUSED" ? `channel connection refused at ${CHANNEL_HOST}:${CHANNEL_PORT} (is the pueue task running?)` : `channel request failed: ${err.message}`;
7095
+ reject(new LocalCCError(msg, err));
7096
+ });
7097
+ req.write(body);
7098
+ req.end();
7099
+ });
7100
+ appendTurn({ startedAt, role, request: payload, result, status: "ok" });
7101
+ return result;
7102
+ } catch (err) {
7103
+ const message = err.message ?? String(err);
7104
+ const status = /timed out/i.test(message) ? "timeout" : "error";
7105
+ appendTurn({ startedAt, role, request: payload, status, error: message });
7106
+ throw err;
7107
+ }
7108
+ }
7109
+ var CHANNEL_HOST, CHANNEL_PORT, DEFAULT_TIMEOUT_MS, LocalCCError;
7110
+ var init_client = __esm({
7111
+ "cli/local-cc/client.ts"() {
7112
+ "use strict";
7113
+ init_turnLog();
7114
+ CHANNEL_HOST = "127.0.0.1";
7115
+ CHANNEL_PORT = parseInt(process.env.SYNKRO_CHANNEL_PORT || "8929", 10);
7116
+ DEFAULT_TIMEOUT_MS = 9e4;
7117
+ LocalCCError = class extends Error {
7118
+ constructor(message, cause) {
7119
+ super(message);
7120
+ this.cause = cause;
7121
+ this.name = "LocalCCError";
7122
+ }
7123
+ cause;
7124
+ };
7924
7125
  }
7925
7126
  });
7926
7127
 
@@ -7974,15 +7175,15 @@ var init_grade = __esm({
7974
7175
  });
7975
7176
 
7976
7177
  // cli/bootstrap.js
7977
- import { readFileSync as readFileSync11, existsSync as existsSync14 } from "fs";
7178
+ import { readFileSync as readFileSync10, existsSync as existsSync13 } from "fs";
7978
7179
  import { resolve as resolve2 } from "path";
7979
7180
  var envCandidates = [
7980
7181
  resolve2(process.cwd(), ".env"),
7981
7182
  resolve2(process.env.HOME ?? "", ".synkro", "config.env")
7982
7183
  ];
7983
7184
  for (const envPath of envCandidates) {
7984
- if (!existsSync14(envPath)) continue;
7985
- const envContent = readFileSync11(envPath, "utf-8");
7185
+ if (!existsSync13(envPath)) continue;
7186
+ const envContent = readFileSync10(envPath, "utf-8");
7986
7187
  for (const line of envContent.split("\n")) {
7987
7188
  const trimmed = line.trim();
7988
7189
  if (!trimmed || trimmed.startsWith("#")) continue;
@@ -7997,7 +7198,7 @@ var args = process.argv.slice(2);
7997
7198
  var cmd = args[0] || "";
7998
7199
  var subArgs = args.slice(1);
7999
7200
  function printVersion() {
8000
- console.log("1.4.87");
7201
+ console.log("1.4.89");
8001
7202
  }
8002
7203
  function printHelp() {
8003
7204
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents
@@ -8018,7 +7219,7 @@ Quick start:
8018
7219
  async function main() {
8019
7220
  switch (cmd) {
8020
7221
  case "install": {
8021
- const { installCommand: installCommand2, parseArgs: parseArgs2 } = await Promise.resolve().then(() => (init_install2(), install_exports));
7222
+ const { installCommand: installCommand2, parseArgs: parseArgs2 } = await Promise.resolve().then(() => (init_install(), install_exports));
8022
7223
  await installCommand2(parseArgs2(subArgs));
8023
7224
  break;
8024
7225
  }