@synkro-sh/cli 1.6.8 → 1.6.9

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
@@ -1264,45 +1264,28 @@ export async function runInstallScan(command: string, jwt: string): Promise<Inst
1264
1264
  scanned: false, action: 'allow', blockContext: '', summary: '',
1265
1265
  scannedLabel: '', findings: [], violatedIds: [],
1266
1266
  };
1267
- const pkgInstallMatch = command.match(
1268
- /^(?:.*&&\\s*|.*;\\s*)?(?:npm\\s+(?:install|i|add)|pnpm\\s+(?:add|install|i)|yarn\\s+add|bun\\s+(?:add|install|i)|(?:uv\\s+)?pip3?\\s+install|go\\s+get|cargo\\s+add|gem\\s+install|composer\\s+require)\\s+([^|;&><]+)/
1269
- );
1270
- if (!pkgInstallMatch) return empty;
1271
- const isPip = /(?:uv\\s+)?pip3?\\s+install/.test(command);
1272
- const packages: Array<{ name: string; version: string; ecosystem: string }> = [];
1273
- const tokens = pkgInstallMatch[1].split(/\\s+/);
1274
- let skipNext = false;
1275
- for (const token of tokens) {
1276
- if (skipNext) { skipNext = false; continue; }
1277
- if (!token || !/^[@a-zA-Z]/.test(token)) continue;
1278
- if (token.startsWith('-')) {
1279
- if (/^--(python|target|prefix|root|constraint|requirement|index-url|extra-index-url|find-links|build|src|cache-dir|filter|workspace)$/.test(token)) skipNext = true;
1280
- continue;
1281
- }
1282
- const ecosystem = isPip ? 'PyPI' : 'npm';
1283
- if (isPip) {
1284
- const pipMatch = token.match(/^([a-zA-Z0-9_.-]+)(?:[=~!<>]=?(.+))?$/);
1285
- if (pipMatch) {
1286
- packages.push({ name: pipMatch[1], version: pipMatch[2]?.replace(/^=/, '') || '*', ecosystem });
1287
- continue;
1288
- }
1289
- }
1290
- const atIdx = token.lastIndexOf('@');
1291
- if (atIdx > 0) packages.push({ name: token.slice(0, atIdx), version: token.slice(atIdx + 1), ecosystem });
1292
- else packages.push({ name: token, version: '*', ecosystem });
1293
- }
1294
- if (packages.length === 0) return empty;
1295
- const scannedLabel = packages.map(p => p.name + '@' + p.version).join(', ');
1267
+ // Stage 0 \u2014 recall-tuned pre-filter. Cheap, dependency-free, runs on every
1268
+ // Bash command. A false positive only costs a wasted server round-trip; a
1269
+ // false negative would let a vulnerable install through \u2014 so keep it loose.
1270
+ const lc = command.toLowerCase();
1271
+ const HINTS = [
1272
+ 'npm', 'pnpm', 'yarn', 'bun', 'pip', 'cargo', 'gem', 'composer',
1273
+ 'apt', 'apk', 'brew', 'dnf', 'yum', 'pacman', 'install', 'require',
1274
+ 'eval', '$(', '| sh', '| bash', 'curl', 'wget', 'make ',
1275
+ ];
1276
+ if (!HINTS.some(h => lc.includes(h))) return empty;
1277
+
1296
1278
  try {
1297
1279
  const scanResp = await fetch(GATEWAY_URL + '/api/v1/pkg-scan', {
1298
1280
  method: 'POST',
1299
1281
  headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
1300
- body: JSON.stringify({ packages, command }),
1301
- signal: AbortSignal.timeout(15000),
1282
+ body: JSON.stringify({ command }),
1283
+ signal: AbortSignal.timeout(10000),
1302
1284
  }).then(r => r.json()) as any;
1303
1285
  const action = scanResp?.action || 'allow';
1304
1286
  const pkgResults = Array.isArray(scanResp?.packages) ? scanResp.packages : [];
1305
1287
  const summary = scanResp?.summary || '';
1288
+ const scannedLabel = pkgResults.map((p: any) => p.name + '@' + p.version).join(', ');
1306
1289
  if (action === 'block') {
1307
1290
  const blockSignals = pkgResults
1308
1291
  .flatMap((p: any) => (p.signals || []).filter((s: any) => s.severity === 'critical' || s.severity === 'high'))
@@ -1319,7 +1302,7 @@ export async function runInstallScan(command: string, jwt: string): Promise<Inst
1319
1302
  }
1320
1303
  }
1321
1304
  }
1322
- const details = blockSignals.map((s: any) => s.detail).join('\\n');
1305
+ const details = blockSignals.map((s: any) => s.detail).join('\\n') || summary;
1323
1306
  return {
1324
1307
  scanned: true, action: 'block',
1325
1308
  blockContext: details + '\\nDo NOT install packages with security risks. Use a patched version or a different package.',
@@ -1332,7 +1315,14 @@ export async function runInstallScan(command: string, jwt: string): Promise<Inst
1332
1315
  blockContext: '', summary, scannedLabel, findings: [], violatedIds: [],
1333
1316
  };
1334
1317
  } catch {
1335
- return { scanned: true, action: 'allow', blockContext: '', summary: '', scannedLabel, findings: [], violatedIds: [] };
1318
+ // Fail closed \u2014 could not verify the install (timeout / server
1319
+ // unreachable). Blocking an unverified install beats letting a
1320
+ // vulnerable package through.
1321
+ return {
1322
+ scanned: true, action: 'block',
1323
+ blockContext: 'Synkro could not verify this install is safe \u2014 the package scanner timed out or was unreachable. This is a verification failure, not a confirmed vulnerability. Retry once the Synkro server is reachable.',
1324
+ summary: 'install scan unavailable', scannedLabel: '', findings: [], violatedIds: ['scan_unavailable'],
1325
+ };
1336
1326
  }
1337
1327
  }
1338
1328
 
@@ -3091,6 +3081,7 @@ import {
3091
3081
  extractTranscript, readLastPrompt, appendSessionAction, readSessionLog, compressSessionLog, log,
3092
3082
  outputJson, outputEmpty, setupCursorHookSignals, isShellTool, hookSessionId, GATEWAY_URL,
3093
3083
  logGraderUnavailable, filterRules, normalizeMode, appendLocalTelemetry, isSafeInRepoRead,
3084
+ runInstallScan,
3094
3085
  type HookConfig, type Rule,
3095
3086
  } from './_synkro-common.ts';
3096
3087
 
@@ -3161,7 +3152,7 @@ async function main() {
3161
3152
  tool_name: toolName,
3162
3153
  command: command.slice(0, 200),
3163
3154
  session_id: sessionId,
3164
- repo: cwd,
3155
+ repo: gitRepo || cwd,
3165
3156
  cc_model: transcript.ccModel,
3166
3157
  reasoning: 'Safe in-repo read — auto-allowed without an LLM grade.',
3167
3158
  });
@@ -3176,108 +3167,44 @@ async function main() {
3176
3167
  }
3177
3168
 
3178
3169
  // ─── Install protection: server-side pkg-scan (CVE + typosquat + tarball + reputation) ───
3170
+ // Detection + extraction happen server-side (runInstallScan); the hook
3171
+ // relays the verdict. A block is handed to the grader as an authoritative
3172
+ // concern so the normal ask + consent-carryover flow lets the user
3173
+ // override it on the next turn.
3179
3174
  let installScanMsg = '';
3175
+ let scanConcern = '';
3176
+ let scanBlockContext = '';
3180
3177
  if (toolName === 'Bash') {
3181
- const pkgInstallMatch = command.match(
3182
- /^(?:.*&&\s*|.*;\s*)?(?:npm\s+(?:install|i|add)|pnpm\s+(?:add|install|i)|yarn\s+add|bun\s+(?:add|install|i)|(?:uv\s+)?pip3?\s+install|go\s+get|cargo\s+add|gem\s+install|composer\s+require)\s+([^|;&><]+)/
3183
- );
3184
- const isPip = /(?:uv\s+)?pip3?\s+install/.test(command);
3185
- if (pkgInstallMatch) {
3186
- const rawArgs = pkgInstallMatch[1];
3187
- const packages: Array<{ name: string; version: string; ecosystem: string }> = [];
3188
- const tokens = rawArgs.split(/\s+/);
3189
- let skipNext = false;
3190
- for (const token of tokens) {
3191
- if (skipNext) { skipNext = false; continue; }
3192
- if (!token || !/^[@a-zA-Z]/.test(token)) continue;
3193
- if (token.startsWith('-')) {
3194
- if (/^--(python|target|prefix|root|constraint|requirement|index-url|extra-index-url|find-links|build|src|cache-dir|filter|workspace)$/.test(token)) skipNext = true;
3195
- continue;
3196
- }
3197
- const ecosystem = isPip ? 'PyPI' : 'npm';
3198
- if (isPip) {
3199
- const pipMatch = token.match(/^([a-zA-Z0-9_.-]+)(?:[=~!<>]=?(.+))?$/);
3200
- if (pipMatch) {
3201
- packages.push({ name: pipMatch[1], version: pipMatch[2]?.replace(/^=/, '') || '*', ecosystem });
3202
- continue;
3203
- }
3204
- }
3205
- const atIdx = token.lastIndexOf('@');
3206
- if (atIdx > 0) {
3207
- packages.push({ name: token.slice(0, atIdx), version: token.slice(atIdx + 1), ecosystem });
3208
- } else {
3209
- packages.push({ name: token, version: '*', ecosystem });
3210
- }
3211
- }
3212
-
3213
- if (packages.length > 0) {
3214
- try {
3215
- const scanResp = await fetch(GATEWAY_URL + '/api/v1/pkg-scan', {
3216
- method: 'POST',
3217
- headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
3218
- body: JSON.stringify({ packages, command }),
3219
- signal: AbortSignal.timeout(15000),
3220
- }).then(r => r.json()) as any;
3221
-
3222
- const action = scanResp?.action || 'allow';
3223
- const pkgResults = Array.isArray(scanResp?.packages) ? scanResp.packages : [];
3224
- const summary = scanResp?.summary || '';
3225
-
3226
- if (action === 'block') {
3227
- const blockSignals = pkgResults
3228
- .flatMap((p: any) => (p.signals || []).filter((s: any) => s.severity === 'critical' || s.severity === 'high'))
3229
- .slice(0, 5);
3230
- const scanMsg = '[synkro:installScan] ' + cmdShort + ' → blocked';
3231
- const details = blockSignals.map((s: any) => s.detail).join('\n');
3232
- const ctx = details + '\nDo NOT install packages with security risks. Use a patched version or a different package.';
3233
-
3234
- const config = await loadConfig(jwt);
3235
- for (const p of pkgResults) {
3236
- for (const s of (p.signals || [])) {
3237
- if (s.severity === 'critical' || s.severity === 'high') {
3238
- const advisoryMatch = (s.detail || '').match(/\\b(GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}|CVE-\\d{4}-\\d+)\\b/i);
3239
- const advisoryId = advisoryMatch ? advisoryMatch[1] : s.type;
3240
- dispatchFinding(jwt, {
3241
- session_id: sessionId,
3242
- file_path: command,
3243
- finding_type: 'cve' as const,
3244
- finding_id: advisoryId + ':' + p.name,
3245
- severity: s.severity,
3246
- status: 'open',
3247
- detail: s.detail,
3248
- package_name: p.name,
3249
- package_version: p.version,
3250
- }, config.captureDepth);
3251
- }
3252
- }
3253
- }
3254
-
3255
- const violatedIds = blockSignals.map((s: any) => s.type + ':' + s.detail.slice(0, 40));
3256
- dispatchCapture(jwt, 'pkg', 'block', 'critical', 'security',
3257
- 'Bash', gitRepo, sessionId, config.captureDepth, {
3258
- command,
3259
- reasoning: details.slice(0, 200),
3260
- violatedRules: violatedIds,
3261
- ccModel: transcript.ccModel,
3262
- });
3263
-
3264
- outputJson({
3265
- systemMessage: scanMsg,
3266
- hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: ctx, additionalContext: ctx },
3267
- });
3268
- return;
3269
- }
3270
-
3271
- if (action === 'warn') {
3272
- installScanMsg = '[synkro:installScan] ' + summary;
3273
- } else {
3274
- const scannedPkgs = packages.map(p => p.name + '@' + p.version).join(', ');
3275
- installScanMsg = '[synkro:installScan] ' + scannedPkgs + ' → clean';
3276
- }
3277
- } catch (e) {
3278
- log('bashGuard pkg-scan failed: ' + String(e));
3279
- }
3178
+ const scan = await runInstallScan(command, jwt);
3179
+ if (scan.action === 'block') {
3180
+ for (const f of scan.findings) {
3181
+ dispatchFinding(jwt, {
3182
+ session_id: sessionId,
3183
+ file_path: command,
3184
+ finding_type: 'cve' as const,
3185
+ finding_id: f.advisoryId + ':' + f.name,
3186
+ severity: f.severity,
3187
+ status: 'open',
3188
+ detail: f.detail,
3189
+ package_name: f.name,
3190
+ package_version: f.version,
3191
+ }, config.captureDepth);
3280
3192
  }
3193
+ dispatchCapture(jwt, 'pkg', 'block', 'critical', 'security',
3194
+ 'Bash', gitRepo, sessionId, config.captureDepth, {
3195
+ command,
3196
+ reasoning: scan.blockContext.slice(0, 200),
3197
+ violatedRules: scan.violatedIds,
3198
+ ccModel: transcript.ccModel,
3199
+ });
3200
+ scanBlockContext = scan.blockContext;
3201
+ scanConcern = 'PACKAGE SCANNER FLAG (authoritative — do NOT re-evaluate whether the vulnerability is real): '
3202
+ + scan.blockContext
3203
+ + ' For this concern you MUST return ok=false with rule_id "SYNKRO_PKGSCAN", rule_mode "ask", and the reason above — UNLESS the user has explicitly consented in this conversation to installing this despite the warning, in which case return ok=true.';
3204
+ } else if (scan.scanned && scan.action === 'warn') {
3205
+ installScanMsg = '[synkro:installScan] ' + scan.summary;
3206
+ } else if (scan.scanned) {
3207
+ installScanMsg = '[synkro:installScan] ' + (scan.scannedLabel || cmdShort) + ' → clean';
3281
3208
  }
3282
3209
  }
3283
3210
 
@@ -3298,6 +3225,7 @@ async function main() {
3298
3225
  'User intent (last human message): ' + (transcript.userIntent || 'none stated'),
3299
3226
  'Last user prompt: ' + (lastPrompt || 'none'),
3300
3227
  'Org rules: ' + JSON.stringify(relevantRules),
3228
+ scanConcern,
3301
3229
  'IMPORTANT: If a rule is violated, ALWAYS return ok=false with the rule_id and reason, regardless of the rule mode. Do NOT pass a command just because the rule mode is "fix". The enforcement layer handles ask vs fix — your job is only to detect violations.',
3302
3230
  'The rules shown were pre-selected as the ones relevant to this command — every rule here IS relevant, do not label any "not relevant". When passing (ok=true), give a terse, specific reason each rule passes. Format: "R003: no secrets in grep args. R005: in-repo path only." Cover every rule shown.',
3303
3231
  'Rules with preconditions (e.g. "run X before Y") are CONSUMED after the protected action completes. Use the session history timestamps to determine ordering: a precondition satisfied before the last occurrence of the protected action does NOT satisfy the next occurrence. Each new protected action needs its precondition re-satisfied.',
@@ -3308,6 +3236,16 @@ async function main() {
3308
3236
  gradeResp = await localGrade('bash', graderPrompt);
3309
3237
  } catch (err) {
3310
3238
  logGraderUnavailable('bashGuard', toolInput.command?.slice(0, 200) || '', (err as Error).message || String(err));
3239
+ if (scanConcern) {
3240
+ // Grader unavailable to run the consent check — fail closed on a
3241
+ // scanner-flagged install (ask-mode so the user can still consent).
3242
+ const ctx = scanBlockContext + ' Synkro flagged this install but the grader is unavailable to check consent — ask the user for explicit consent, then retry.';
3243
+ outputJson({
3244
+ systemMessage: tagStr + ' bashGuard → install blocked (scan flagged; grader unavailable)',
3245
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: ctx, additionalContext: ctx },
3246
+ });
3247
+ return;
3248
+ }
3311
3249
  outputJson({ systemMessage: tagStr + ' bashGuard → local grader unavailable, skipped' });
3312
3250
  return;
3313
3251
  }
@@ -3371,6 +3309,18 @@ async function main() {
3371
3309
  session_summary: transcript.sessionSummary || null,
3372
3310
  };
3373
3311
 
3312
+ if (scanConcern) {
3313
+ // Cloud grading does not carry the package-scanner concern through the
3314
+ // consent flow — block here with ask-mode messaging so the user can
3315
+ // consent and retry.
3316
+ const ctx = scanBlockContext + ' Ask the user for explicit consent before retrying.';
3317
+ outputJson({
3318
+ systemMessage: tagStr + ' bashGuard → install blocked: ' + cmdShort,
3319
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: ctx, additionalContext: ctx },
3320
+ });
3321
+ return;
3322
+ }
3323
+
3374
3324
  const resp = await postWithRetry(GATEWAY_URL + '/api/v1/hook/judge', body, jwt, 8000);
3375
3325
 
3376
3326
  if (!resp) {
@@ -4295,7 +4245,7 @@ async function main() {
4295
4245
  appendLocalTelemetry({
4296
4246
  capture_type: 'local_verdict', verdict: 'pass', hook_type: 'bash',
4297
4247
  category: 'safe_read', tool_name: toolName, command: command.slice(0, 200),
4298
- session_id: sessionId, repo: cwd,
4248
+ session_id: sessionId, repo: repo || cwd,
4299
4249
  cc_model: model,
4300
4250
  reasoning: 'Safe in-repo read \u2014 auto-allowed without an LLM grade.',
4301
4251
  });
@@ -4316,6 +4266,10 @@ async function main() {
4316
4266
  const tagStr = tag(rt, config);
4317
4267
 
4318
4268
  // Install protection \u2014 scan packages before any npm/pip/cargo/etc. install.
4269
+ // A block is handed to the grader as an authoritative concern so the
4270
+ // normal ask + consent-carryover flow can let the user override it.
4271
+ let scanConcern = '';
4272
+ let scanBlockContext = '';
4319
4273
  if (SHELL_TOOL_NAMES.has(toolName)) {
4320
4274
  const scan = await runInstallScan(command, jwt);
4321
4275
  if (scan.action === 'block') {
@@ -4332,11 +4286,10 @@ async function main() {
4332
4286
  command, reasoning: scan.blockContext.slice(0, 200),
4333
4287
  violatedRules: scan.violatedIds, ccModel: model,
4334
4288
  });
4335
- finishWith({
4336
- permission: 'deny',
4337
- user_message: tagStr + ' installScan \u2192 blocked: ' + cmdShort,
4338
- agent_message: 'Synkro blocked this install \u2014 flagged package(s). ' + scan.blockContext,
4339
- });
4289
+ scanBlockContext = scan.blockContext;
4290
+ scanConcern = 'PACKAGE SCANNER FLAG (authoritative \u2014 do NOT re-evaluate whether the vulnerability is real): '
4291
+ + scan.blockContext
4292
+ + ' For this concern you MUST return ok=false with rule_id "SYNKRO_PKGSCAN", rule_mode "ask", and the reason above \u2014 UNLESS the user has explicitly consented in this conversation to installing this despite the warning, in which case return ok=true.';
4340
4293
  } else if (scan.scanned && scan.action === 'warn') {
4341
4294
  log('bashGuard installScan warn: ' + scan.summary);
4342
4295
  }
@@ -4354,6 +4307,7 @@ async function main() {
4354
4307
  'User intent (last human message): ' + (transcript.userIntent || lastPrompt || 'none stated'),
4355
4308
  'Last user prompt: ' + (lastPrompt || 'none'),
4356
4309
  'Org rules: ' + JSON.stringify(relevantRules),
4310
+ scanConcern,
4357
4311
  'IMPORTANT: If a rule is violated, ALWAYS return ok=false with the rule_id and reason, regardless of the rule mode. Do NOT pass a command just because the rule mode is "fix". The enforcement layer handles ask vs fix \u2014 your job is only to detect violations.',
4358
4312
  'The rules shown were pre-selected as the ones relevant to this command \u2014 every rule here IS relevant, do not label any "not relevant". When passing (ok=true), give a terse, specific reason each rule passes. Format: "R003: no secrets in grep args. R005: in-repo path only." Cover every rule shown.',
4359
4313
  'Rules with preconditions (e.g. "run X before Y") are CONSUMED after the protected action completes. Use the session history timestamps to determine ordering: a precondition satisfied before the last occurrence of the protected action does NOT satisfy the next occurrence. Each new protected action needs its precondition re-satisfied.',
@@ -4364,6 +4318,15 @@ async function main() {
4364
4318
  gradeResp = await localGrade('bash', graderPrompt, CURSOR_GRADE_TIMEOUT_MS, 'cursor');
4365
4319
  } catch (e) {
4366
4320
  logGraderUnavailable('bashGuard', command.slice(0, 200), (e as Error).message || String(e));
4321
+ if (scanConcern) {
4322
+ // Grader unavailable to run the consent check \u2014 fail closed on a
4323
+ // scanner-flagged install (ask-mode so the user can still consent).
4324
+ finishWith({
4325
+ permission: 'deny',
4326
+ user_message: tagStr + ' installScan \u2192 blocked (grader unavailable): ' + cmdShort,
4327
+ agent_message: 'Synkro flagged this install: ' + scanBlockContext + ' The grader is unavailable to check consent \u2014 ask the user for explicit consent, then retry.',
4328
+ });
4329
+ }
4367
4330
  log('bashGuard ' + cmdShort + ' \u2192 pass (grade unavailable): ' + String(e));
4368
4331
  finishWith({ permission: 'allow' });
4369
4332
  }
@@ -4402,6 +4365,17 @@ async function main() {
4402
4365
  finishWith({ permission: 'allow' });
4403
4366
  }
4404
4367
 
4368
+ if (scanConcern) {
4369
+ // Cloud grading does not carry the package-scanner concern through the
4370
+ // consent flow \u2014 block here with ask-mode messaging so the user can
4371
+ // consent and retry.
4372
+ finishWith({
4373
+ permission: 'deny',
4374
+ user_message: tagStr + ' installScan \u2192 blocked: ' + cmdShort,
4375
+ agent_message: 'Synkro flagged this install: ' + scanBlockContext + ' Ask the user for explicit consent before retrying.',
4376
+ });
4377
+ }
4378
+
4405
4379
  const body: Record<string, any> = {
4406
4380
  hook_event: 'PreToolUse',
4407
4381
  tool_name: toolName || 'Bash',
@@ -5698,6 +5672,7 @@ __export(macKeychain_exports, {
5698
5672
  readKeychainCreds: () => readKeychainCreds,
5699
5673
  refreshCreds: () => refreshCreds,
5700
5674
  uninstallRefreshAgent: () => uninstallRefreshAgent,
5675
+ validateCursorApiKey: () => validateCursorApiKey,
5701
5676
  writeCursorApiKey: () => writeCursorApiKey,
5702
5677
  writeRefreshAgent: () => writeRefreshAgent
5703
5678
  });
@@ -5742,6 +5717,27 @@ function writeCursorApiKey(key) {
5742
5717
  writeFileSync6(CURSOR_API_KEY_FILE, trimmed, "utf-8");
5743
5718
  chmodSync(CURSOR_API_KEY_FILE, 384);
5744
5719
  }
5720
+ async function validateCursorApiKey() {
5721
+ let key;
5722
+ try {
5723
+ key = readFileSync6(CURSOR_API_KEY_FILE, "utf-8").trim();
5724
+ } catch {
5725
+ return null;
5726
+ }
5727
+ if (!key) return null;
5728
+ try {
5729
+ const auth = Buffer.from(`${key}:`).toString("base64");
5730
+ const r = await fetch("https://api.cursor.com/v1/me", {
5731
+ headers: { Authorization: `Basic ${auth}` },
5732
+ signal: AbortSignal.timeout(8e3)
5733
+ });
5734
+ if (r.status === 401 || r.status === 403) return false;
5735
+ if (r.ok) return true;
5736
+ return null;
5737
+ } catch {
5738
+ return null;
5739
+ }
5740
+ }
5745
5741
  function credsAreStale() {
5746
5742
  if (!existsSync7(CLAUDE_CREDS_FILE)) return true;
5747
5743
  try {
@@ -5756,7 +5752,7 @@ function writeRefreshAgent(synkroBinPath) {
5756
5752
  throw new KeychainExportError("writeRefreshAgent is darwin-only");
5757
5753
  }
5758
5754
  mkdirSync6(join6(homedir6(), "Library", "LaunchAgents"), { recursive: true });
5759
- const shellCmd = `export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH" && "${synkroBinPath}" local-cc refresh-creds`;
5755
+ const shellCmd = `export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH" && { "${synkroBinPath}" local-cc refresh-creds || synkro local-cc refresh-creds; }`;
5760
5756
  const plist = `<?xml version="1.0" encoding="UTF-8"?>
5761
5757
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
5762
5758
  <plist version="1.0">
@@ -5946,6 +5942,11 @@ function claudeCredsHostDir() {
5946
5942
  if (needsKeychainBridge()) return CLAUDE_CREDS_DIR;
5947
5943
  return join7(homedir7(), ".claude");
5948
5944
  }
5945
+ function resolveSynkroBin() {
5946
+ const which2 = spawnSync2("which", ["synkro"], { encoding: "utf-8", timeout: 5e3 });
5947
+ const resolved = (which2.stdout || "").split("\n")[0].trim();
5948
+ return resolved || "synkro";
5949
+ }
5949
5950
  async function dockerInstall(opts = {}) {
5950
5951
  assertDockerAvailable();
5951
5952
  const image = imageTag();
@@ -5979,7 +5980,7 @@ async function dockerInstall(opts = {}) {
5979
5980
  console.warn(" Generate a key at cursor.com \u2192 Settings \u2192 API Keys, then:");
5980
5981
  console.warn(` echo 'YOUR_KEY' > ~/.synkro/cursor-creds/api-key && chmod 600 ~/.synkro/cursor-creds/api-key`);
5981
5982
  }
5982
- const plist = writeRefreshAgent("/usr/local/bin/synkro");
5983
+ const plist = writeRefreshAgent(resolveSynkroBin());
5983
5984
  try {
5984
5985
  loadRefreshAgent();
5985
5986
  } catch (err) {
@@ -6324,8 +6325,16 @@ async function promptAgentSelection(detected) {
6324
6325
  return ask2();
6325
6326
  }
6326
6327
  async function promptCursorApiKey(opts) {
6327
- const { cursorApiKeyConfigured: cursorApiKeyConfigured2, writeCursorApiKey: writeCursorApiKey2 } = await Promise.resolve().then(() => (init_macKeychain(), macKeychain_exports));
6328
- if (cursorApiKeyConfigured2()) return;
6328
+ const { cursorApiKeyConfigured: cursorApiKeyConfigured2, writeCursorApiKey: writeCursorApiKey2, validateCursorApiKey: validateCursorApiKey2 } = await Promise.resolve().then(() => (init_macKeychain(), macKeychain_exports));
6329
+ if (cursorApiKeyConfigured2()) {
6330
+ const valid = await validateCursorApiKey2();
6331
+ if (valid !== false) {
6332
+ console.log(" \u2713 Cursor API key validated.");
6333
+ return;
6334
+ }
6335
+ console.warn(" \u26A0 The saved Cursor API key was rejected by Cursor (revoked or expired).");
6336
+ console.warn(" Paste a fresh key below, or press Enter to skip (Cursor workers stay idle).");
6337
+ }
6329
6338
  const provided = (opts.cursorApiKey || process.env.SYNKRO_CURSOR_API_KEY || "").trim();
6330
6339
  if (provided) {
6331
6340
  writeCursorApiKey2(provided);
@@ -6450,7 +6459,7 @@ function writeConfigEnv(opts) {
6450
6459
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
6451
6460
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
6452
6461
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
6453
- `SYNKRO_VERSION=${shellQuoteSingle("1.6.8")}`
6462
+ `SYNKRO_VERSION=${shellQuoteSingle("1.6.9")}`
6454
6463
  ];
6455
6464
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
6456
6465
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -7181,6 +7190,37 @@ import { existsSync as existsSync10, mkdirSync as mkdirSync9, writeFileSync as w
7181
7190
  import { join as join9 } from "path";
7182
7191
  import { homedir as homedir9 } from "os";
7183
7192
  import { spawnSync as spawnSync3 } from "child_process";
7193
+ function writePluginFiles() {
7194
+ for (const c of CHANNELS) {
7195
+ mkdirSync9(c.sessionDir, { recursive: true });
7196
+ mkdirSync9(c.pluginSettingsDir, { recursive: true });
7197
+ writeFileSync8(c.pluginPkgPath, PLUGIN_PACKAGE_JSON, "utf-8");
7198
+ writeFileSync8(
7199
+ c.pluginSettingsPath,
7200
+ JSON.stringify({
7201
+ fastMode: true,
7202
+ enabledMcpjsonServers: ["synkro-local"]
7203
+ }, null, 2) + "\n",
7204
+ "utf-8"
7205
+ );
7206
+ writeFileSync8(c.runScriptPath, c.runScriptSource, "utf-8");
7207
+ chmodSync3(c.runScriptPath, 493);
7208
+ }
7209
+ }
7210
+ function runBunInstall() {
7211
+ for (const c of CHANNELS) {
7212
+ const r = spawnSync3("bun", ["install", "--silent"], {
7213
+ cwd: c.sessionDir,
7214
+ encoding: "utf-8",
7215
+ timeout: 12e4
7216
+ });
7217
+ if (r.status !== 0) {
7218
+ throw new LocalCCInstallError(
7219
+ `bun install failed in ${c.sessionDir}: ${r.stderr || r.stdout || "unknown"}`
7220
+ );
7221
+ }
7222
+ }
7223
+ }
7184
7224
  function safelyMutateClaudeJson(mutator) {
7185
7225
  if (!existsSync10(CLAUDE_JSON_PATH)) {
7186
7226
  return;
@@ -7240,6 +7280,73 @@ function safelyMutateClaudeJson(mutator) {
7240
7280
  );
7241
7281
  }
7242
7282
  }
7283
+ function writeProjectMcpJson() {
7284
+ for (const c of CHANNELS) {
7285
+ const mcp = {
7286
+ mcpServers: {
7287
+ [MCP_SERVER_NAME]: {
7288
+ command: "bun",
7289
+ args: [c.pluginPath]
7290
+ }
7291
+ }
7292
+ };
7293
+ writeFileSync8(c.projectMcpPath, JSON.stringify(mcp, null, 2) + "\n", "utf-8");
7294
+ }
7295
+ }
7296
+ function patchClaudeJson() {
7297
+ safelyMutateClaudeJson((parsed) => {
7298
+ let dirty = false;
7299
+ if (parsed.mcpServers && typeof parsed.mcpServers === "object" && parsed.mcpServers[MCP_SERVER_NAME]) {
7300
+ delete parsed.mcpServers[MCP_SERVER_NAME];
7301
+ dirty = true;
7302
+ }
7303
+ if (!parsed.projects || typeof parsed.projects !== "object") {
7304
+ parsed.projects = {};
7305
+ }
7306
+ const projects = parsed.projects;
7307
+ for (const dir of CHANNELS.map((c) => c.sessionDir)) {
7308
+ const existing = projects[dir] && typeof projects[dir] === "object" ? projects[dir] : {};
7309
+ const wantEnabled = Array.from(/* @__PURE__ */ new Set([
7310
+ ...existing.enabledMcpjsonServers ?? [],
7311
+ MCP_SERVER_NAME
7312
+ ]));
7313
+ const next = {
7314
+ ...existing,
7315
+ hasTrustDialogAccepted: true,
7316
+ hasCompletedProjectOnboarding: true,
7317
+ enabledMcpjsonServers: wantEnabled
7318
+ };
7319
+ if (existing.hasTrustDialogAccepted !== true || existing.hasCompletedProjectOnboarding !== true || JSON.stringify(existing.enabledMcpjsonServers ?? []) !== JSON.stringify(wantEnabled)) {
7320
+ projects[dir] = next;
7321
+ dirty = true;
7322
+ }
7323
+ }
7324
+ return dirty;
7325
+ });
7326
+ }
7327
+ function installLocalCC() {
7328
+ let bunCheck = spawnSync3("bun", ["--version"], { encoding: "utf-8" });
7329
+ if (bunCheck.status !== 0) {
7330
+ if (process.platform === "darwin") {
7331
+ console.log(" Installing bun via brew...");
7332
+ const brewR = spawnSync3("brew", ["install", "oven-sh/bun/bun"], { encoding: "utf-8", stdio: "inherit", timeout: 12e4 });
7333
+ if (brewR.status !== 0) {
7334
+ throw new LocalCCInstallError("bun auto-install failed. Install manually: curl -fsSL https://bun.sh/install | bash");
7335
+ }
7336
+ bunCheck = spawnSync3("bun", ["--version"], { encoding: "utf-8" });
7337
+ if (bunCheck.status !== 0) {
7338
+ throw new LocalCCInstallError("bun installed but not found on PATH. Restart your terminal and re-run install.");
7339
+ }
7340
+ } else {
7341
+ throw new LocalCCInstallError("bun is required. Install it: curl -fsSL https://bun.sh/install | bash");
7342
+ }
7343
+ }
7344
+ writePluginFiles();
7345
+ runBunInstall();
7346
+ writeProjectMcpJson();
7347
+ patchClaudeJson();
7348
+ return { sessionDir: SESSION_DIR, pluginPath: PLUGIN_PATH };
7349
+ }
7243
7350
  function uninstallLocalCC() {
7244
7351
  safelyMutateClaudeJson((parsed) => {
7245
7352
  let dirty = false;
@@ -7725,6 +7832,86 @@ function appendTurn(args2) {
7725
7832
  } catch {
7726
7833
  }
7727
7834
  }
7835
+ function readRecentTurns(n = 20) {
7836
+ if (!existsSync12(TURN_LOG_PATH)) return [];
7837
+ try {
7838
+ const size = statSync2(TURN_LOG_PATH).size;
7839
+ if (size === 0) return [];
7840
+ const text = readFileSync9(TURN_LOG_PATH, "utf-8");
7841
+ const lines = text.split("\n").filter(Boolean);
7842
+ const lastN = lines.slice(-n).reverse();
7843
+ return lastN.map((line) => {
7844
+ try {
7845
+ return JSON.parse(line);
7846
+ } catch {
7847
+ return null;
7848
+ }
7849
+ }).filter((x) => x !== null);
7850
+ } catch {
7851
+ return [];
7852
+ }
7853
+ }
7854
+ function followTurns(onEntry) {
7855
+ try {
7856
+ mkdirSync10(dirname5(TURN_LOG_PATH), { recursive: true });
7857
+ if (!existsSync12(TURN_LOG_PATH)) {
7858
+ appendFileSync(TURN_LOG_PATH, "", "utf-8");
7859
+ }
7860
+ } catch {
7861
+ }
7862
+ let lastSize = (() => {
7863
+ try {
7864
+ return statSync2(TURN_LOG_PATH).size;
7865
+ } catch {
7866
+ return 0;
7867
+ }
7868
+ })();
7869
+ let pendingPartial = "";
7870
+ const drainNewBytes = (from, to) => {
7871
+ if (to <= from) return;
7872
+ let fd = null;
7873
+ try {
7874
+ fd = openSync2(TURN_LOG_PATH, "r");
7875
+ const len = to - from;
7876
+ const buf = Buffer.alloc(len);
7877
+ readSync(fd, buf, 0, len, from);
7878
+ const text = pendingPartial + buf.toString("utf-8");
7879
+ const lastNewline = text.lastIndexOf("\n");
7880
+ if (lastNewline === -1) {
7881
+ pendingPartial = text;
7882
+ return;
7883
+ }
7884
+ const complete = text.slice(0, lastNewline);
7885
+ pendingPartial = text.slice(lastNewline + 1);
7886
+ for (const line of complete.split("\n")) {
7887
+ if (!line) continue;
7888
+ try {
7889
+ onEntry(JSON.parse(line));
7890
+ } catch {
7891
+ }
7892
+ }
7893
+ } catch {
7894
+ } finally {
7895
+ if (fd !== null) {
7896
+ try {
7897
+ closeSync2(fd);
7898
+ } catch {
7899
+ }
7900
+ }
7901
+ }
7902
+ };
7903
+ watchFile(TURN_LOG_PATH, { interval: 250 }, (curr, prev) => {
7904
+ if (curr.size < lastSize) {
7905
+ lastSize = 0;
7906
+ pendingPartial = "";
7907
+ }
7908
+ if (curr.size > lastSize) {
7909
+ drainNewBytes(lastSize, curr.size);
7910
+ lastSize = curr.size;
7911
+ }
7912
+ });
7913
+ return () => unwatchFile(TURN_LOG_PATH);
7914
+ }
7728
7915
  var TURN_LOG_PATH, PREVIEW_MAX;
7729
7916
  var init_turnLog = __esm({
7730
7917
  "cli/local-cc/turnLog.ts"() {
@@ -7794,6 +7981,21 @@ async function submitToChannel(role, payload, opts = {}) {
7794
7981
  throw err;
7795
7982
  }
7796
7983
  }
7984
+ function isChannelAvailable(port = CHANNEL_PORT, timeoutMs = 500) {
7985
+ return new Promise((resolve3) => {
7986
+ const sock = connect(port, CHANNEL_HOST);
7987
+ const done = (ok) => {
7988
+ try {
7989
+ sock.destroy();
7990
+ } catch {
7991
+ }
7992
+ resolve3(ok);
7993
+ };
7994
+ sock.once("connect", () => done(true));
7995
+ sock.once("error", () => done(false));
7996
+ sock.setTimeout(timeoutMs, () => done(false));
7997
+ });
7998
+ }
7797
7999
  var CHANNEL_HOST, CHANNEL_PORT, DEFAULT_TIMEOUT_MS, LocalCCError;
7798
8000
  var init_client = __esm({
7799
8001
  "cli/local-cc/client.ts"() {
@@ -7862,6 +8064,795 @@ var init_grade = __esm({
7862
8064
  }
7863
8065
  });
7864
8066
 
8067
+ // cli/local-cc/pueue.ts
8068
+ import { execFileSync, spawnSync as spawnSync5, spawn } from "child_process";
8069
+ import { homedir as homedir12 } from "os";
8070
+ import { join as join12 } from "path";
8071
+ import { connect as connect2 } from "net";
8072
+ function pueueAvailable() {
8073
+ const r = spawnSync5("pueue", ["--version"], { encoding: "utf-8" });
8074
+ if (r.status !== 0) {
8075
+ throw new PueueError("pueue CLI not found on PATH. Install pueue (https://github.com/Nukesor/pueue) and start `pueued`.");
8076
+ }
8077
+ }
8078
+ function statusJson() {
8079
+ pueueAvailable();
8080
+ const r = spawnSync5("pueue", ["status", "--json"], { encoding: "utf-8" });
8081
+ if (r.status !== 0) {
8082
+ throw new PueueError(`pueue status failed: ${r.stderr || r.stdout || "unknown error"} \u2014 is pueued running?`);
8083
+ }
8084
+ try {
8085
+ return JSON.parse(r.stdout);
8086
+ } catch (err) {
8087
+ throw new PueueError(`pueue status returned non-JSON output: ${r.stdout.slice(0, 200)}`, err);
8088
+ }
8089
+ }
8090
+ function statusName(s) {
8091
+ if (typeof s === "string") return s;
8092
+ if (s && typeof s === "object") {
8093
+ if ("Running" in s) return "Running";
8094
+ if ("Done" in s) {
8095
+ const result = s.Done?.result;
8096
+ if (typeof result === "string") return `Done (${result})`;
8097
+ if (result && typeof result === "object") return `Done (${Object.keys(result)[0] ?? "unknown"})`;
8098
+ return "Done";
8099
+ }
8100
+ return Object.keys(s)[0] ?? "unknown";
8101
+ }
8102
+ return "unknown";
8103
+ }
8104
+ function findTask(channel = CHANNEL_PRIMARY) {
8105
+ const data = statusJson();
8106
+ for (const [id, t] of Object.entries(data.tasks)) {
8107
+ if (t.label === channel.taskLabel) {
8108
+ return {
8109
+ id: Number(id),
8110
+ label: t.label,
8111
+ status: statusName(t.status),
8112
+ command: t.command,
8113
+ cwd: t.path
8114
+ };
8115
+ }
8116
+ }
8117
+ return null;
8118
+ }
8119
+ function startTask(opts = {}) {
8120
+ const ch = opts.channel ?? CHANNEL_PRIMARY;
8121
+ const cwd = opts.cwd ?? ch.sessionDir;
8122
+ let existing = findTask(ch);
8123
+ while (existing) {
8124
+ if (existing.status === "Running" || existing.status === "Queued") {
8125
+ spawnSync5("tmux", ["kill-session", "-t", `=${ch.tmuxSession}`], { encoding: "utf-8" });
8126
+ spawnSync5("pueue", ["kill", String(existing.id)], { encoding: "utf-8" });
8127
+ for (let i = 0; i < 10; i++) {
8128
+ const check = findTask(ch);
8129
+ if (!check || check.id !== existing.id || check.status !== "Running" && check.status !== "Queued") break;
8130
+ spawnSync5("sleep", ["0.5"], { encoding: "utf-8" });
8131
+ }
8132
+ }
8133
+ spawnSync5("pueue", ["remove", String(existing.id)], { encoding: "utf-8" });
8134
+ existing = findTask(ch);
8135
+ }
8136
+ const runScript = join12(cwd, "run-claude.sh");
8137
+ const args2 = [
8138
+ "add",
8139
+ "--label",
8140
+ ch.taskLabel,
8141
+ "--working-directory",
8142
+ cwd,
8143
+ "--",
8144
+ "bash",
8145
+ runScript
8146
+ ];
8147
+ const r = spawnSync5("pueue", args2, { encoding: "utf-8" });
8148
+ if (r.status !== 0) {
8149
+ throw new PueueError(`pueue add failed: ${r.stderr || r.stdout}`);
8150
+ }
8151
+ const created = findTask(ch);
8152
+ if (!created) {
8153
+ throw new PueueError(`pueue add succeeded but no task with label ${ch.taskLabel} found`);
8154
+ }
8155
+ return created;
8156
+ }
8157
+ function stopTask(channel = CHANNEL_PRIMARY) {
8158
+ spawnSync5("tmux", ["kill-session", "-t", `=${channel.tmuxSession}`], { encoding: "utf-8" });
8159
+ let t = findTask(channel);
8160
+ while (t) {
8161
+ if (t.status === "Running" || t.status === "Queued") {
8162
+ spawnSync5("pueue", ["kill", String(t.id)], { encoding: "utf-8" });
8163
+ for (let i = 0; i < 10; i++) {
8164
+ const check = findTask(channel);
8165
+ if (!check || check.id !== t.id || check.status !== "Running" && check.status !== "Queued") break;
8166
+ spawnSync5("sleep", ["0.5"], { encoding: "utf-8" });
8167
+ }
8168
+ }
8169
+ spawnSync5("pueue", ["remove", String(t.id)], { encoding: "utf-8" });
8170
+ t = findTask(channel);
8171
+ }
8172
+ }
8173
+ function tailLogs(lines = 80, channel = CHANNEL_PRIMARY) {
8174
+ const t = findTask(channel);
8175
+ if (!t) return `(no ${channel.taskLabel} task)`;
8176
+ const r = spawnSync5("pueue", ["log", "--lines", String(lines), String(t.id)], { encoding: "utf-8" });
8177
+ return r.stdout || r.stderr || "(no output)";
8178
+ }
8179
+ function ensureRunning(opts = {}) {
8180
+ const ch = opts.channel ?? CHANNEL_PRIMARY;
8181
+ const t = findTask(ch);
8182
+ if (t && t.status === "Running") return t;
8183
+ return startTask(opts);
8184
+ }
8185
+ function probePort(host, port, timeoutMs = 500) {
8186
+ return new Promise((resolve3) => {
8187
+ const sock = connect2(port, host);
8188
+ const done = (ok) => {
8189
+ try {
8190
+ sock.destroy();
8191
+ } catch {
8192
+ }
8193
+ resolve3(ok);
8194
+ };
8195
+ sock.once("connect", () => done(true));
8196
+ sock.once("error", () => done(false));
8197
+ sock.setTimeout(timeoutMs, () => done(false));
8198
+ });
8199
+ }
8200
+ function tmuxDismissPrompts(tmuxSession = TMUX_SESSION) {
8201
+ spawnSync5("tmux", ["send-keys", "-t", tmuxSession, "1"], { encoding: "utf-8" });
8202
+ spawnSync5("tmux", ["send-keys", "-t", tmuxSession, "Enter"], { encoding: "utf-8" });
8203
+ }
8204
+ async function waitForChannelReady(port, timeoutMs = 6e4, host = "127.0.0.1", tmuxSession = TMUX_SESSION) {
8205
+ const deadline = Date.now() + timeoutMs;
8206
+ while (Date.now() < deadline) {
8207
+ if (await probePort(host, port)) return true;
8208
+ tmuxDismissPrompts(tmuxSession);
8209
+ await new Promise((r) => setTimeout(r, 1e3));
8210
+ }
8211
+ return probePort(host, port);
8212
+ }
8213
+ function brewInstall(pkg) {
8214
+ const brew = spawnSync5("brew", ["--version"], { encoding: "utf-8" });
8215
+ if (brew.status !== 0) return false;
8216
+ console.log(` Installing ${pkg} via brew...`);
8217
+ const r = spawnSync5("brew", ["install", pkg], { encoding: "utf-8", stdio: "inherit", timeout: 12e4 });
8218
+ return r.status === 0;
8219
+ }
8220
+ function assertPueueInstalled() {
8221
+ let r = spawnSync5("pueue", ["--version"], { encoding: "utf-8" });
8222
+ if (r.status !== 0) {
8223
+ if (process.platform === "darwin" && brewInstall("pueue")) {
8224
+ r = spawnSync5("pueue", ["--version"], { encoding: "utf-8" });
8225
+ if (r.status !== 0) throw new PueueError("pueue install succeeded but binary not found on PATH.");
8226
+ } else {
8227
+ throw new PueueError("pueue not found. Install it: brew install pueue (macOS) or https://github.com/Nukesor/pueue");
8228
+ }
8229
+ }
8230
+ const status = spawnSync5("pueue", ["status", "--json"], { encoding: "utf-8", timeout: 5e3 });
8231
+ if (status.status !== 0) {
8232
+ console.log(" Starting pueued daemon...");
8233
+ const child = spawn("pueued", ["-d"], { stdio: "ignore", detached: true });
8234
+ child.unref();
8235
+ spawnSync5("sleep", ["1"]);
8236
+ const retry = spawnSync5("pueue", ["status", "--json"], { encoding: "utf-8", timeout: 5e3 });
8237
+ if (retry.status !== 0) {
8238
+ throw new PueueError("pueue daemon not reachable after starting pueued. Check `pueued` manually.");
8239
+ }
8240
+ }
8241
+ spawnSync5("pueue", ["parallel", "2"], { encoding: "utf-8" });
8242
+ }
8243
+ function assertClaudeInstalled() {
8244
+ const r = spawnSync5("claude", ["--version"], { encoding: "utf-8" });
8245
+ if (r.status !== 0) {
8246
+ throw new PueueError("claude CLI not found on PATH. Install Claude Code first: https://docs.claude.com/claude-code");
8247
+ }
8248
+ }
8249
+ function assertTmuxInstalled() {
8250
+ let r = spawnSync5("tmux", ["-V"], { encoding: "utf-8" });
8251
+ if (r.status !== 0) {
8252
+ if (process.platform === "darwin" && brewInstall("tmux")) {
8253
+ r = spawnSync5("tmux", ["-V"], { encoding: "utf-8" });
8254
+ if (r.status !== 0) throw new PueueError("tmux install succeeded but binary not found on PATH.");
8255
+ } else {
8256
+ throw new PueueError("tmux not found. Install it: brew install tmux (macOS) or apt install tmux (Linux)");
8257
+ }
8258
+ }
8259
+ }
8260
+ var TASK_LABEL, TMUX_SESSION, SESSION_DIR2, TASK_LABEL_2, TMUX_SESSION_2, SESSION_DIR_22, SESSION_DIR_32, SESSION_DIR_42, PueueError, CHANNEL_PRIMARY, CHANNEL_SECONDARY;
8261
+ var init_pueue = __esm({
8262
+ "cli/local-cc/pueue.ts"() {
8263
+ "use strict";
8264
+ TASK_LABEL = "synkro-local-cc";
8265
+ TMUX_SESSION = "synkro-local-cc";
8266
+ SESSION_DIR2 = join12(homedir12(), ".synkro", "cc_sessions");
8267
+ TASK_LABEL_2 = "synkro-local-cc-2";
8268
+ TMUX_SESSION_2 = "synkro-local-cc-2";
8269
+ SESSION_DIR_22 = join12(homedir12(), ".synkro", "cc_sessions_2");
8270
+ SESSION_DIR_32 = join12(homedir12(), ".synkro", "cc_sessions_3");
8271
+ SESSION_DIR_42 = join12(homedir12(), ".synkro", "cc_sessions_4");
8272
+ PueueError = class extends Error {
8273
+ constructor(message, cause) {
8274
+ super(message);
8275
+ this.cause = cause;
8276
+ this.name = "PueueError";
8277
+ }
8278
+ cause;
8279
+ };
8280
+ CHANNEL_PRIMARY = { taskLabel: TASK_LABEL, tmuxSession: TMUX_SESSION, sessionDir: SESSION_DIR2 };
8281
+ CHANNEL_SECONDARY = { taskLabel: TASK_LABEL_2, tmuxSession: TMUX_SESSION_2, sessionDir: SESSION_DIR_22 };
8282
+ }
8283
+ });
8284
+
8285
+ // cli/local-cc/settings.ts
8286
+ import { existsSync as existsSync13, readFileSync as readFileSync10 } from "fs";
8287
+ import { homedir as homedir13 } from "os";
8288
+ import { join as join13 } from "path";
8289
+ function isLocalCCEnabled() {
8290
+ if (!existsSync13(CONFIG_PATH3)) return false;
8291
+ try {
8292
+ const content = readFileSync10(CONFIG_PATH3, "utf-8");
8293
+ const match = content.match(/^SYNKRO_LOCAL_INFERENCE='([^']*)'/m);
8294
+ return match?.[1] === "yes";
8295
+ } catch {
8296
+ return false;
8297
+ }
8298
+ }
8299
+ var CONFIG_PATH3;
8300
+ var init_settings = __esm({
8301
+ "cli/local-cc/settings.ts"() {
8302
+ "use strict";
8303
+ CONFIG_PATH3 = join13(homedir13(), ".synkro", "config.env");
8304
+ }
8305
+ });
8306
+
8307
+ // cli/commands/localCc.ts
8308
+ var localCc_exports = {};
8309
+ __export(localCc_exports, {
8310
+ localCcCommand: () => localCcCommand
8311
+ });
8312
+ import { spawnSync as spawnSync6 } from "child_process";
8313
+ import { homedir as homedir14 } from "os";
8314
+ import { join as join14 } from "path";
8315
+ import { readFileSync as fsReadFileSync, existsSync as fsExistsSync } from "fs";
8316
+ import { existsSync as existsSync14, readFileSync as readFileSync11, writeFileSync as writeFileSync9 } from "fs";
8317
+ function deploymentMode() {
8318
+ const env = (process.env.SYNKRO_DEPLOYMENT_MODE || "").toLowerCase();
8319
+ if (env === "docker") return "docker";
8320
+ if (env === "bare-host") return "bare-host";
8321
+ try {
8322
+ if (fsExistsSync(SYNKRO_CONFIG_PATH)) {
8323
+ const m = fsReadFileSync(SYNKRO_CONFIG_PATH, "utf-8").match(/^SYNKRO_DEPLOYMENT_MODE='([^']*)'/m);
8324
+ if (m && m[1] === "docker") return "docker";
8325
+ }
8326
+ } catch {
8327
+ }
8328
+ return "bare-host";
8329
+ }
8330
+ function inDockerMode() {
8331
+ return deploymentMode() === "docker";
8332
+ }
8333
+ function printHelp() {
8334
+ console.log(`synkro local-cc \u2014 manage the local Claude Code inference session
8335
+
8336
+ OVERVIEW
8337
+ Routes Synkro's grading and intent-classification work through a long-running
8338
+ Claude Code session on this machine instead of remote Inngest+Gemini.
8339
+
8340
+ When enabled, three call sites switch over:
8341
+ \u2022 security grading on edit/bash hooks
8342
+ \u2022 intent classification (agent input \u2192 structured intent)
8343
+ \u2022 remediate intent classification
8344
+
8345
+ The session is hosted in a detached tmux session managed by a pueue task.
8346
+ The CLI talks to it via Claude Code's channels API: a Bun MCP plugin that
8347
+ pushes events into the session and receives Claude's responses through a
8348
+ \`reply\` MCP tool.
8349
+
8350
+ USAGE
8351
+ synkro local-cc <subcommand> [args]
8352
+
8353
+ SUBCOMMANDS
8354
+ enable Install plugin, start pueue task, flip toggle to local-cc
8355
+ disable Flip toggle back to inngest (pueue task left running)
8356
+ status Show provider toggle, pueue task state, channel reachability
8357
+ start Idempotently bring up the pueue task + wait for the channel
8358
+ stop Kill the tmux session and remove the pueue task
8359
+ restart stop, then start
8360
+ install Regenerate ~/.synkro/cc_sessions/ files (plugin, runner, settings)
8361
+ logs [N] [--raw] [--live]
8362
+ Show the last N (default 20) channel turns: when, role,
8363
+ duration, severity, request preview.
8364
+ --raw / -r include full request/response payloads
8365
+ --live / -f tail the log; print new turns as they arrive
8366
+ (Ctrl-C to exit)
8367
+ --tmux escape hatch \u2014 print the raw pueue/tmux
8368
+ pane log instead
8369
+ attach [--readonly] Attach to the tmux session hosting claude (Ctrl-B D to detach;
8370
+ --readonly / -r to attach view-only)
8371
+ test Send a smoke-test classification through the channel
8372
+ help Show this message
8373
+
8374
+ CONFIGURATION
8375
+ Provider toggle:
8376
+ Stored server-side in your inference settings (gradingProvider).
8377
+ Toggle via: synkro local-cc enable / disable
8378
+ Or via dashboard inference settings (set grading provider to claude-code).
8379
+
8380
+ Claude Code session settings (scoped to ~/.synkro/cc_sessions only):
8381
+ ${PLUGIN_SETTINGS_PATH}
8382
+ Currently sets {"fastMode": true}. Edit to add other CC settings for this
8383
+ session without affecting your other CC projects.
8384
+
8385
+ MCP server registration (for the channel plugin):
8386
+ ${CLAUDE_JSON_PATH}
8387
+ A 'synkro-local' entry under mcpServers, pointing at:
8388
+ bun ${PLUGIN_PATH}
8389
+ Workspace trust is also pre-accepted under projects[\`${SESSION_DIR}\`].
8390
+
8391
+ Channel runtime files:
8392
+ ${RUN_SCRIPT_PATH}
8393
+ bash wrapper that pueue invokes; owns the tmux session lifecycle.
8394
+ ${PLUGIN_PATH}
8395
+ Bun MCP channel plugin (auto-generated, do not edit).
8396
+ 127.0.0.1:${CHANNEL_PORT}
8397
+ Loopback TCP endpoint the CLI POSTs to in order to submit a request.
8398
+
8399
+ ENVIRONMENT VARIABLES
8400
+ SYNKRO_CHANNEL_PORT Override the TCP port used by both the plugin and
8401
+ the CLI client (loopback only). Default: 8929
8402
+ SYNKRO_CHANNEL_TIMEOUT_MS Per-request timeout inside the channel plugin
8403
+ (default: 120000)
8404
+
8405
+ REQUIRED TOOLS
8406
+ claude Claude Code CLI, authenticated to your subscription
8407
+ pueue pueue + pueued (https://github.com/Nukesor/pueue) running
8408
+ tmux For detached pty around claude
8409
+ bun Runtime for the MCP channel plugin
8410
+
8411
+ TROUBLESHOOTING
8412
+ \u2022 Channel unreachable after \`enable\`?
8413
+ synkro local-cc logs # check pueue task output
8414
+ tmux attach -t ${TMUX_SESSION_NAME} # see what claude is showing
8415
+ \u2022 Stale state after a crash:
8416
+ synkro local-cc stop && synkro local-cc start
8417
+ \u2022 Want to inspect or interact with the live session:
8418
+ synkro local-cc attach
8419
+ `);
8420
+ }
8421
+ function readGatewayUrl() {
8422
+ if (existsSync14(CONFIG_PATH4)) {
8423
+ const m = readFileSync11(CONFIG_PATH4, "utf-8").match(/^SYNKRO_GATEWAY_URL='([^']*)'/m);
8424
+ if (m) return m[1];
8425
+ }
8426
+ return "https://api.synkro.sh";
8427
+ }
8428
+ function updateLocalInferenceFlag(enabled) {
8429
+ if (!existsSync14(CONFIG_PATH4)) return;
8430
+ let content = readFileSync11(CONFIG_PATH4, "utf-8");
8431
+ const flag = enabled ? "yes" : "no";
8432
+ if (content.includes("SYNKRO_LOCAL_INFERENCE=")) {
8433
+ content = content.replace(/^SYNKRO_LOCAL_INFERENCE='[^']*'/m, `SYNKRO_LOCAL_INFERENCE='${flag}'`);
8434
+ } else {
8435
+ content = content.trimEnd() + `
8436
+ SYNKRO_LOCAL_INFERENCE='${flag}'
8437
+ `;
8438
+ }
8439
+ writeFileSync9(CONFIG_PATH4, content, "utf-8");
8440
+ }
8441
+ async function setServerGradingProvider(provider) {
8442
+ await ensureValidToken();
8443
+ const jwt2 = getAccessToken();
8444
+ if (!jwt2) throw new Error("Not authenticated. Run `synkro install` first.");
8445
+ const gatewayUrl = readGatewayUrl();
8446
+ const body = provider ? { roles: { grading: { provider, model: "default" } } } : { roles: { grading: { provider: null, model: null } } };
8447
+ const resp = await fetch(`${gatewayUrl}/api/settings/inference?scope=user`, {
8448
+ method: "PUT",
8449
+ headers: { "Authorization": `Bearer ${jwt2}`, "Content-Type": "application/json" },
8450
+ body: JSON.stringify(body)
8451
+ });
8452
+ if (!resp.ok) {
8453
+ const text = await resp.text().catch(() => "");
8454
+ throw new Error(`Failed to update inference settings: ${resp.status} ${text.slice(0, 200)}`);
8455
+ }
8456
+ }
8457
+ async function cmdStatus() {
8458
+ console.log(`Local inference: ${isLocalCCEnabled() ? "enabled" : "disabled"}`);
8459
+ console.log(`Deployment mode: ${deploymentMode()}`);
8460
+ if (inDockerMode()) {
8461
+ const status = dockerStatus();
8462
+ if (!status.running) {
8463
+ console.log("synkro-server container: not running");
8464
+ console.log("Run `synkro install` (with SYNKRO_DEPLOYMENT_MODE=docker) to provision.");
8465
+ } else {
8466
+ console.log(`synkro-server container: running (${status.image})`);
8467
+ try {
8468
+ const r = await fetch(`${status.healthz}healthz`, { signal: AbortSignal.timeout(3e3) });
8469
+ console.log(`Health probe: ${r.ok ? "ok" : `HTTP ${r.status}`}`);
8470
+ } catch (err) {
8471
+ console.log(`Health probe: ${err.message}`);
8472
+ }
8473
+ }
8474
+ if (needsKeychainBridge()) {
8475
+ console.log(`Keychain creds: ${credsAreStale() ? "STALE \u2014 run `synkro local-cc refresh-creds`" : "fresh"}`);
8476
+ }
8477
+ return;
8478
+ }
8479
+ try {
8480
+ assertPueueInstalled();
8481
+ } catch (err) {
8482
+ console.log(`Pueue: NOT AVAILABLE (${err.message})`);
8483
+ return;
8484
+ }
8485
+ const t = findTask(CHANNEL_PRIMARY);
8486
+ if (!t) {
8487
+ console.log("Channel 1 (judge) pueue task: not present");
8488
+ } else {
8489
+ console.log(`Channel 1 (judge) pueue task: id=${t.id} status=${t.status}`);
8490
+ }
8491
+ const ch1Up = await isChannelAvailable();
8492
+ console.log(`Channel 1 ${CHANNEL_HOST}:${CHANNEL_PORT}: ${ch1Up ? "reachable" : "unreachable"}`);
8493
+ const tmux1 = spawnSync6("tmux", ["has-session", "-t", `=${TMUX_SESSION_NAME}`], { encoding: "utf-8" });
8494
+ console.log(`tmux '${TMUX_SESSION_NAME}': ${tmux1.status === 0 ? "live" : "absent"}`);
8495
+ const t2 = findTask(CHANNEL_SECONDARY);
8496
+ if (!t2) {
8497
+ console.log("Channel 2 (CWE) pueue task: not present");
8498
+ } else {
8499
+ console.log(`Channel 2 (CWE) pueue task: id=${t2.id} status=${t2.status}`);
8500
+ }
8501
+ const ch2Up = await isChannelAvailable(CHANNEL_2_PORT);
8502
+ console.log(`Channel 2 ${CHANNEL_HOST}:${CHANNEL_2_PORT}: ${ch2Up ? "reachable" : "unreachable"}`);
8503
+ const tmux2 = spawnSync6("tmux", ["has-session", "-t", `=${TMUX_SESSION_NAME_2}`], { encoding: "utf-8" });
8504
+ console.log(`tmux '${TMUX_SESSION_NAME_2}': ${tmux2.status === 0 ? "live" : "absent"}`);
8505
+ }
8506
+ async function cmdEnable() {
8507
+ assertClaudeInstalled();
8508
+ assertPueueInstalled();
8509
+ assertTmuxInstalled();
8510
+ console.log("Installing local-CC channel plugin...");
8511
+ const r = installLocalCC();
8512
+ console.log(` plugin: ${r.pluginPath}`);
8513
+ console.log(` cwd: ${r.sessionDir}`);
8514
+ console.log("Starting channel 1 (judge)...");
8515
+ const t1 = ensureRunning({ channel: CHANNEL_PRIMARY });
8516
+ console.log(` task: id=${t1.id} status=${t1.status}`);
8517
+ console.log("Starting channel 2 (CWE)...");
8518
+ const t2 = ensureRunning({ channel: CHANNEL_SECONDARY });
8519
+ console.log(` task: id=${t2.id} status=${t2.status}`);
8520
+ console.log("Waiting for channels (auto-confirming any CC prompts)...");
8521
+ const [ready1, ready2] = await Promise.all([
8522
+ waitForChannelReady(CHANNEL_PORT, 6e4, CHANNEL_HOST, CHANNEL_PRIMARY.tmuxSession),
8523
+ waitForChannelReady(CHANNEL_2_PORT, 6e4, CHANNEL_HOST, CHANNEL_SECONDARY.tmuxSession)
8524
+ ]);
8525
+ if (ready1) console.log(` channel 1 ready at ${CHANNEL_HOST}:${CHANNEL_PORT}`);
8526
+ else console.warn(` \u26A0 channel 1 did not come up within 60s \u2014 check \`synkro local-cc logs\``);
8527
+ if (ready2) console.log(` channel 2 ready at ${CHANNEL_HOST}:${CHANNEL_2_PORT}`);
8528
+ else console.warn(` \u26A0 channel 2 (CWE) did not come up within 60s`);
8529
+ console.log("Updating inference settings...");
8530
+ await setServerGradingProvider("claude-code");
8531
+ updateLocalInferenceFlag(true);
8532
+ console.log("Grading provider set to claude-code (local inference enabled).");
8533
+ }
8534
+ async function cmdDisable() {
8535
+ console.log("Updating inference settings...");
8536
+ await setServerGradingProvider(null);
8537
+ updateLocalInferenceFlag(false);
8538
+ console.log("Grading provider cleared (remote inference restored). Pueue task left running \u2014 use `synkro local-cc stop` to terminate.");
8539
+ }
8540
+ async function warmChannels(ready1, ready2) {
8541
+ const warmups = [];
8542
+ if (ready1) {
8543
+ warmups.push(
8544
+ submitToChannel("grade-bash", "Proposed command: echo hello\nUser intent: warmup\nRecent user messages: []\nRecent actions: []\nOrg rules: []\n", { timeoutMs: 3e4 }).then(() => console.log(" channel 1 warm.")).catch(() => console.log(" channel 1 warmup skipped (non-fatal)."))
8545
+ );
8546
+ }
8547
+ if (ready2) {
8548
+ warmups.push(
8549
+ submitToChannel("grade-cwe", 'File: /tmp/warmup.ts\nContent (first 4000 chars):\nconsole.log("hello");\n\nCWE rules to check against:\n[]\n', { timeoutMs: 3e4, port: CHANNEL_2_PORT }).then(() => console.log(" channel 2 warm.")).catch(() => console.log(" channel 2 warmup skipped (non-fatal)."))
8550
+ );
8551
+ }
8552
+ if (warmups.length) {
8553
+ console.log("Warming up inference...");
8554
+ await Promise.all(warmups);
8555
+ }
8556
+ }
8557
+ async function cmdStart(rest = []) {
8558
+ if (inDockerMode()) {
8559
+ if (rest.length > 0) {
8560
+ const { claudeWorkers, cursorWorkers } = resolveWorkerConfig(rest);
8561
+ console.log(`Starting synkro-server container (${claudeWorkers} claude + ${cursorWorkers} cursor)...`);
8562
+ await dockerUpdate({ claudeWorkers, cursorWorkers });
8563
+ const ready3 = await waitForContainerReady(6e4);
8564
+ console.log(ready3 ? "\u2713 container ready" : "\u26A0 container did not pass /healthz within 60s");
8565
+ return;
8566
+ }
8567
+ const status = dockerStatus();
8568
+ if (!status.running) {
8569
+ console.warn("synkro-server container is not running. Run `synkro install` to provision it.");
8570
+ process.exitCode = 1;
8571
+ return;
8572
+ }
8573
+ console.log("synkro-server container already running \u2014 waiting for /healthz...");
8574
+ const ready = await waitForContainerReady(6e4);
8575
+ console.log(ready ? `\u2713 container ready (${status.healthz})` : "\u26A0 container did not pass /healthz within 60s");
8576
+ return;
8577
+ }
8578
+ assertClaudeInstalled();
8579
+ assertPueueInstalled();
8580
+ assertTmuxInstalled();
8581
+ const t1 = ensureRunning({ channel: CHANNEL_PRIMARY });
8582
+ console.log(`Channel 1 (judge): id=${t1.id} status=${t1.status}`);
8583
+ const t2 = ensureRunning({ channel: CHANNEL_SECONDARY });
8584
+ console.log(`Channel 2 (CWE): id=${t2.id} status=${t2.status}`);
8585
+ const [ready1, ready2] = await Promise.all([
8586
+ waitForChannelReady(CHANNEL_PORT, 6e4, CHANNEL_HOST, CHANNEL_PRIMARY.tmuxSession),
8587
+ waitForChannelReady(CHANNEL_2_PORT, 6e4, CHANNEL_HOST, CHANNEL_SECONDARY.tmuxSession)
8588
+ ]);
8589
+ console.log(ready1 ? `channel 1 ready (${CHANNEL_PORT}).` : "\u26A0 channel 1 did not come up within 60s.");
8590
+ console.log(ready2 ? `channel 2 ready (${CHANNEL_2_PORT}).` : "\u26A0 channel 2 (CWE) did not come up within 60s.");
8591
+ await warmChannels(ready1, ready2);
8592
+ }
8593
+ function cmdStop() {
8594
+ if (inDockerMode()) {
8595
+ dockerStop();
8596
+ console.log("synkro-server container stopped and removed.");
8597
+ return;
8598
+ }
8599
+ stopTask(CHANNEL_PRIMARY);
8600
+ stopTask(CHANNEL_SECONDARY);
8601
+ console.log("Both channels stopped.");
8602
+ }
8603
+ async function cmdRestart(rest = []) {
8604
+ if (inDockerMode()) {
8605
+ const { claudeWorkers, cursorWorkers } = resolveWorkerConfig(rest);
8606
+ console.log(`Restarting synkro-server container (${claudeWorkers} claude + ${cursorWorkers} cursor, pulling latest image)...`);
8607
+ await dockerUpdate({ claudeWorkers, cursorWorkers });
8608
+ const ready = await waitForContainerReady(6e4);
8609
+ console.log(ready ? "\u2713 container ready" : "\u26A0 container did not pass /healthz within 60s");
8610
+ return;
8611
+ }
8612
+ stopTask(CHANNEL_PRIMARY);
8613
+ stopTask(CHANNEL_SECONDARY);
8614
+ const t1 = startTask({ channel: CHANNEL_PRIMARY });
8615
+ const t2 = startTask({ channel: CHANNEL_SECONDARY });
8616
+ console.log(`Channel 1 restarted: id=${t1.id} status=${t1.status}`);
8617
+ console.log(`Channel 2 restarted: id=${t2.id} status=${t2.status}`);
8618
+ const [ready1, ready2] = await Promise.all([
8619
+ waitForChannelReady(CHANNEL_PORT, 6e4, CHANNEL_HOST, CHANNEL_PRIMARY.tmuxSession),
8620
+ waitForChannelReady(CHANNEL_2_PORT, 6e4, CHANNEL_HOST, CHANNEL_SECONDARY.tmuxSession)
8621
+ ]);
8622
+ console.log(ready1 ? `channel 1 ready (${CHANNEL_PORT}).` : "\u26A0 channel 1 did not come up within 60s.");
8623
+ console.log(ready2 ? `channel 2 ready (${CHANNEL_2_PORT}).` : "\u26A0 channel 2 (CWE) did not come up within 60s.");
8624
+ await warmChannels(ready1, ready2);
8625
+ }
8626
+ function relativeTime(iso) {
8627
+ const ts = new Date(iso).getTime();
8628
+ if (!Number.isFinite(ts)) return iso;
8629
+ const sec = Math.max(0, Math.round((Date.now() - ts) / 1e3));
8630
+ if (sec < 60) return `${sec}s ago`;
8631
+ if (sec < 3600) return `${Math.round(sec / 60)}m ago`;
8632
+ if (sec < 86400) return `${Math.round(sec / 3600)}h ago`;
8633
+ return `${Math.round(sec / 86400)}d ago`;
8634
+ }
8635
+ function colorize(s, code) {
8636
+ if (!process.stdout.isTTY) return s;
8637
+ return `\x1B[${code}m${s}\x1B[0m`;
8638
+ }
8639
+ function statusGlyph(t) {
8640
+ if (t.status === "ok") return colorize("\u2713", 32);
8641
+ if (t.status === "timeout") return colorize("\u23F1", 33);
8642
+ return colorize("\u2717", 31);
8643
+ }
8644
+ function severityCell(t) {
8645
+ if (t.severity) {
8646
+ const sev = t.severity;
8647
+ if (sev === "block" || sev === "violations" || sev === "unclear") return colorize(sev, 33);
8648
+ return colorize(sev, 36);
8649
+ }
8650
+ if (t.error) return colorize(t.error.slice(0, 40), 31);
8651
+ return "\u2014";
8652
+ }
8653
+ function firstLine(s) {
8654
+ return s.split("\n").find((l) => l.trim().length > 0)?.trim() ?? "";
8655
+ }
8656
+ function formatTurn(t, raw) {
8657
+ const when = relativeTime(t.ts).padEnd(8);
8658
+ const dur = (t.duration_ms < 1e3 ? `${t.duration_ms}ms` : `${(t.duration_ms / 1e3).toFixed(1)}s`).padStart(7);
8659
+ const role = t.role.padEnd(24);
8660
+ const sev = severityCell(t).padEnd(20);
8661
+ const preview = (() => {
8662
+ const req = firstLine(t.request_preview).slice(0, 60);
8663
+ return colorize(req, 90);
8664
+ })();
8665
+ const head = `${statusGlyph(t)} ${when} ${dur} ${role} ${sev} ${preview}`;
8666
+ if (!raw) return head;
8667
+ const blocks = [head];
8668
+ blocks.push(colorize(" request:", 90));
8669
+ blocks.push(" " + t.request_preview.replace(/\n/g, "\n "));
8670
+ if (t.response_preview) {
8671
+ blocks.push(colorize(" response:", 90));
8672
+ blocks.push(" " + t.response_preview.replace(/\n/g, "\n "));
8673
+ }
8674
+ if (t.error) {
8675
+ blocks.push(colorize(" error:", 31) + " " + t.error);
8676
+ }
8677
+ return blocks.join("\n");
8678
+ }
8679
+ function cmdLogs(rest) {
8680
+ let n = 20;
8681
+ let raw = false;
8682
+ let live = false;
8683
+ if (inDockerMode()) {
8684
+ const followFlag = rest.includes("--live") || rest.includes("-f") ? ["--follow"] : [];
8685
+ const tailArg = (() => {
8686
+ for (const arg of rest) {
8687
+ const parsed = parseInt(arg, 10);
8688
+ if (parsed > 0) return String(parsed);
8689
+ }
8690
+ return "200";
8691
+ })();
8692
+ spawnSync6("docker", ["logs", "--tail", tailArg, ...followFlag, "synkro-server"], { stdio: "inherit" });
8693
+ return;
8694
+ }
8695
+ for (const arg of rest) {
8696
+ if (arg === "--raw" || arg === "-r") raw = true;
8697
+ else if (arg === "--live" || arg === "-f") live = true;
8698
+ else if (arg === "--tmux") {
8699
+ console.log(tailLogs(80));
8700
+ return;
8701
+ } else {
8702
+ const parsed = parseInt(arg, 10);
8703
+ if (parsed > 0) n = parsed;
8704
+ }
8705
+ }
8706
+ const header = " " + colorize("status when dur role severity request", 90);
8707
+ const turns = readRecentTurns(n);
8708
+ if (turns.length === 0) {
8709
+ if (!live) {
8710
+ console.log(`No turns logged yet at ${TURN_LOG_PATH}.`);
8711
+ console.log("Run a few requests through the channel (synkro local-cc test) and try again.");
8712
+ return;
8713
+ }
8714
+ console.log(`No turns logged yet at ${TURN_LOG_PATH} \u2014 waiting for new entries\u2026 (Ctrl-C to exit)`);
8715
+ } else {
8716
+ console.log(`Last ${turns.length} channel turn(s) (newest first):`);
8717
+ console.log(header);
8718
+ for (const t of turns) console.log(" " + formatTurn(t, raw));
8719
+ }
8720
+ if (!live) {
8721
+ if (!raw) console.log(" " + colorize("(use --raw / -r to see full payloads, --live / -f to follow)", 90));
8722
+ return;
8723
+ }
8724
+ return new Promise((resolve3) => {
8725
+ console.log(" " + colorize("\u2014 following new turns (Ctrl-C to exit) \u2014", 90));
8726
+ const stop = followTurns((t) => {
8727
+ console.log(" " + formatTurn(t, raw));
8728
+ });
8729
+ const onSigint = () => {
8730
+ stop();
8731
+ process.removeListener("SIGINT", onSigint);
8732
+ resolve3();
8733
+ };
8734
+ process.on("SIGINT", onSigint);
8735
+ });
8736
+ }
8737
+ function cmdAttach(rest) {
8738
+ assertTmuxInstalled();
8739
+ const readonly = rest.some((a) => a === "--readonly" || a === "-r");
8740
+ const has = spawnSync6("tmux", ["has-session", "-t", `=${TMUX_SESSION_NAME}`], { encoding: "utf-8" });
8741
+ if (has.status !== 0) {
8742
+ console.error(`No tmux session '${TMUX_SESSION_NAME}' running. Start it with: synkro local-cc start`);
8743
+ process.exit(1);
8744
+ }
8745
+ if (!process.stdout.isTTY) {
8746
+ console.error("attach requires a TTY. Run this command directly in your terminal, not piped.");
8747
+ process.exit(1);
8748
+ }
8749
+ console.log(`Attaching to tmux session '${TMUX_SESSION_NAME}'${readonly ? " (read-only)" : ""}.`);
8750
+ console.log("Detach with Ctrl-B then D. (Do not press Ctrl-C \u2014 that would interrupt claude.)");
8751
+ console.log();
8752
+ const args2 = readonly ? ["attach-session", "-r", "-t", TMUX_SESSION_NAME] : ["attach-session", "-t", TMUX_SESSION_NAME];
8753
+ const r = spawnSync6("tmux", args2, { stdio: "inherit" });
8754
+ process.exit(r.status ?? 0);
8755
+ }
8756
+ async function cmdTest() {
8757
+ console.log("Sending smoke-test grading request through the channel...");
8758
+ const result = await submitToChannel(
8759
+ "grade-bash",
8760
+ 'Command: echo "hello world"\nIntent: testing channel connectivity'
8761
+ );
8762
+ console.log("Raw reply:");
8763
+ console.log(result);
8764
+ }
8765
+ function cmdInstall() {
8766
+ const r = installLocalCC();
8767
+ console.log(`Reinstalled plugin at ${r.pluginPath}`);
8768
+ }
8769
+ function cmdRefreshCreds() {
8770
+ if (!needsKeychainBridge()) {
8771
+ console.log("No-op on this platform \u2014 credentials are already file-based.");
8772
+ return;
8773
+ }
8774
+ const refreshed = refreshCreds();
8775
+ if (refreshed) {
8776
+ console.log(`Exported claude credentials to ${CLAUDE_CREDS_FILE}`);
8777
+ } else {
8778
+ console.warn("No Claude Code credentials found in the keychain.");
8779
+ console.warn("Run `claude login` first, then re-run this command.");
8780
+ process.exitCode = 1;
8781
+ }
8782
+ if (credsAreStale()) {
8783
+ console.warn("\u26A0 Exported credentials are older than the refresh interval.");
8784
+ }
8785
+ }
8786
+ async function localCcCommand(args2) {
8787
+ const sub = args2[0] ?? "";
8788
+ try {
8789
+ switch (sub) {
8790
+ case "enable":
8791
+ await cmdEnable();
8792
+ break;
8793
+ case "disable":
8794
+ cmdDisable();
8795
+ break;
8796
+ case "status":
8797
+ await cmdStatus();
8798
+ break;
8799
+ case "start":
8800
+ await cmdStart(args2.slice(1));
8801
+ break;
8802
+ case "stop":
8803
+ cmdStop();
8804
+ break;
8805
+ case "restart":
8806
+ await cmdRestart(args2.slice(1));
8807
+ break;
8808
+ case "install":
8809
+ cmdInstall();
8810
+ break;
8811
+ case "logs":
8812
+ await cmdLogs(args2.slice(1));
8813
+ break;
8814
+ case "attach":
8815
+ cmdAttach(args2.slice(1));
8816
+ break;
8817
+ case "test":
8818
+ await cmdTest();
8819
+ break;
8820
+ case "refresh-creds":
8821
+ cmdRefreshCreds();
8822
+ break;
8823
+ case "":
8824
+ case "help":
8825
+ case "--help":
8826
+ case "-h":
8827
+ printHelp();
8828
+ break;
8829
+ default:
8830
+ console.error(`Unknown subcommand: ${sub}`);
8831
+ printHelp();
8832
+ process.exit(1);
8833
+ }
8834
+ } catch (err) {
8835
+ console.error(err.message);
8836
+ process.exit(1);
8837
+ }
8838
+ }
8839
+ var SYNKRO_CONFIG_PATH, CONFIG_PATH4;
8840
+ var init_localCc = __esm({
8841
+ "cli/commands/localCc.ts"() {
8842
+ "use strict";
8843
+ init_install2();
8844
+ init_turnLog();
8845
+ init_pueue();
8846
+ init_settings();
8847
+ init_macKeychain();
8848
+ init_dockerInstall();
8849
+ init_client();
8850
+ init_stub();
8851
+ SYNKRO_CONFIG_PATH = join14(homedir14(), ".synkro", "config.env");
8852
+ CONFIG_PATH4 = join14(homedir14(), ".synkro", "config.env");
8853
+ }
8854
+ });
8855
+
7865
8856
  // cli/commands/lifecycle.ts
7866
8857
  var lifecycle_exports = {};
7867
8858
  __export(lifecycle_exports, {
@@ -7957,15 +8948,15 @@ var init_lifecycle = __esm({
7957
8948
  });
7958
8949
 
7959
8950
  // cli/bootstrap.js
7960
- import { readFileSync as readFileSync10, existsSync as existsSync13 } from "fs";
8951
+ import { readFileSync as readFileSync12, existsSync as existsSync15 } from "fs";
7961
8952
  import { resolve as resolve2 } from "path";
7962
8953
  var envCandidates = [
7963
8954
  resolve2(process.cwd(), ".env"),
7964
8955
  resolve2(process.env.HOME ?? "", ".synkro", "config.env")
7965
8956
  ];
7966
8957
  for (const envPath of envCandidates) {
7967
- if (!existsSync13(envPath)) continue;
7968
- const envContent = readFileSync10(envPath, "utf-8");
8958
+ if (!existsSync15(envPath)) continue;
8959
+ const envContent = readFileSync12(envPath, "utf-8");
7969
8960
  for (const line of envContent.split("\n")) {
7970
8961
  const trimmed = line.trim();
7971
8962
  if (!trimmed || trimmed.startsWith("#")) continue;
@@ -7980,9 +8971,9 @@ var args = process.argv.slice(2);
7980
8971
  var cmd = args[0] || "";
7981
8972
  var subArgs = args.slice(1);
7982
8973
  function printVersion() {
7983
- console.log("1.6.8");
8974
+ console.log("1.6.9");
7984
8975
  }
7985
- function printHelp() {
8976
+ function printHelp2() {
7986
8977
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents
7987
8978
 
7988
8979
  Usage:
@@ -8025,6 +9016,11 @@ async function main() {
8025
9016
  await gradeCommand2(subArgs);
8026
9017
  break;
8027
9018
  }
9019
+ case "local-cc": {
9020
+ const { localCcCommand: localCcCommand2 } = await Promise.resolve().then(() => (init_localCc(), localCc_exports));
9021
+ await localCcCommand2(subArgs);
9022
+ break;
9023
+ }
8028
9024
  case "version":
8029
9025
  case "--version":
8030
9026
  case "-v": {
@@ -8035,7 +9031,7 @@ async function main() {
8035
9031
  case "--help":
8036
9032
  case "-h":
8037
9033
  case "": {
8038
- printHelp();
9034
+ printHelp2();
8039
9035
  break;
8040
9036
  }
8041
9037
  case "stop": {
@@ -8060,7 +9056,7 @@ async function main() {
8060
9056
  }
8061
9057
  default: {
8062
9058
  console.error(`Unknown command: ${cmd}`);
8063
- printHelp();
9059
+ printHelp2();
8064
9060
  process.exit(1);
8065
9061
  }
8066
9062
  }