@synkro-sh/cli 1.4.61 → 1.4.63

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
@@ -837,7 +837,7 @@ var init_hookScriptsTs = __esm({
837
837
  import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, renameSync, openSync, closeSync, unlinkSync } from 'node:fs';
838
838
  import { join, dirname, basename, extname, resolve as resolvePath } from 'node:path';
839
839
  import { homedir } from 'node:os';
840
- import { execSync, spawn } from 'node:child_process';
840
+ import { execSync } from 'node:child_process';
841
841
  import { constants as FS_CONSTANTS } from 'node:fs';
842
842
 
843
843
  // \u2500\u2500\u2500 Config \u2500\u2500\u2500
@@ -1116,56 +1116,79 @@ export function tag(rt: string, config: HookConfig): string {
1116
1116
  return '[synkro:' + rt + ':' + rs + ']';
1117
1117
  }
1118
1118
 
1119
- // \u2500\u2500\u2500 Local Grading \u2500\u2500\u2500
1119
+ // \u2500\u2500\u2500 Local Grading (direct channel call) \u2500\u2500\u2500
1120
1120
 
1121
- function spawnGrade(surface: string, prompt: string, envOverride?: Record<string, string>, timeoutMs = 22000): Promise<string> {
1122
- return new Promise((resolve, reject) => {
1123
- const cliBin = process.env.SYNKRO_CLI_BIN;
1124
- let cmd: string;
1125
- let args: string[];
1121
+ type GradeRole = 'grade-edit' | 'grade-bash' | 'grade-plan' | 'grade-cwe';
1126
1122
 
1127
- if (cliBin && existsSync(cliBin)) {
1128
- // Use the CLI binary directly with bun/node
1129
- cmd = 'node';
1130
- args = [cliBin, 'grade', surface];
1131
- } else {
1132
- cmd = 'synkro';
1133
- args = ['grade', surface];
1134
- }
1123
+ const ROLE_MAP: Record<string, GradeRole> = {
1124
+ edit: 'grade-edit', bash: 'grade-bash', plan: 'grade-plan', cwe: 'grade-cwe',
1125
+ };
1135
1126
 
1136
- const child = spawn(cmd, args, {
1137
- stdio: ['pipe', 'pipe', 'pipe'],
1138
- env: { ...process.env, ...envOverride },
1139
- });
1127
+ const PRIMER_KEY: Record<GradeRole, string> = {
1128
+ 'grade-edit': 'grader_primer_edit',
1129
+ 'grade-bash': 'grader_primer_bash',
1130
+ 'grade-plan': 'grader_primer_plan',
1131
+ 'grade-cwe': 'grader_primer_cwe',
1132
+ };
1140
1133
 
1141
- let stdout = '';
1142
- let stderr = '';
1143
- child.stdout.on('data', (d: Buffer) => { stdout += d.toString(); });
1144
- child.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });
1145
- child.stdin.write(prompt);
1146
- child.stdin.end();
1147
-
1148
- const timer = setTimeout(() => {
1149
- child.kill();
1150
- reject(new Error('SYNKRO_GRADE_TIMEOUT'));
1151
- }, timeoutMs);
1152
-
1153
- child.on('close', (code: number | null) => {
1154
- clearTimeout(timer);
1155
- if (code === 0) resolve(stdout);
1156
- else reject(new Error(stderr || 'exit ' + code));
1157
- });
1158
- child.on('error', (err: Error) => { clearTimeout(timer); reject(err); });
1134
+ let primerCache: { data: Record<string, string>; ts: number } | null = null;
1135
+
1136
+ async function fetchPrimer(role: GradeRole, jwt: string): Promise<string> {
1137
+ if (primerCache && Date.now() - primerCache.ts < 60_000) {
1138
+ const cached = primerCache.data[PRIMER_KEY[role]];
1139
+ if (cached) return cached;
1140
+ }
1141
+ const resp = await fetch(GATEWAY_URL + '/api/v1/cli/judge-prompts', {
1142
+ headers: { Authorization: 'Bearer ' + jwt },
1143
+ signal: AbortSignal.timeout(4000),
1159
1144
  });
1145
+ if (!resp.ok) throw new Error('primer fetch failed: ' + resp.status);
1146
+ const data = await resp.json() as Record<string, string>;
1147
+ primerCache = { data, ts: Date.now() };
1148
+ return data[PRIMER_KEY[role]] || '';
1149
+ }
1150
+
1151
+ const CHANNEL_REPLY_INSTRUCTIONS = \`
1152
+ DELIVERY METHOD \u2014 MANDATORY, OVERRIDES ALL OTHER OUTPUT RULES:
1153
+ You are running inside a Synkro MCP channel. Do NOT output your verdict as text.
1154
+ Instead, after generating your verdict, call the reply tool EXACTLY ONCE with:
1155
+ - req_id: the req_id from this channel event's meta
1156
+ - result: your complete verdict block as a string (the <synkro-verdict>\u2026</synkro-verdict> XML)
1157
+ Any text output is silently discarded. Only the reply tool call is captured.\`;
1158
+
1159
+ async function channelGrade(role: GradeRole, prompt: string, jwt: string, port: number, timeoutMs = 20000): Promise<string> {
1160
+ const primer = await fetchPrimer(role, jwt);
1161
+ const content = primer + '\\n\\n' + CHANNEL_REPLY_INSTRUCTIONS + '\\n\\n---\\nPAYLOAD (the input to evaluate):\\n\\n' + prompt;
1162
+ const body = JSON.stringify({ role, content });
1163
+
1164
+ const resp = await fetch('http://127.0.0.1:' + port + '/submit', {
1165
+ method: 'POST',
1166
+ headers: { 'Content-Type': 'application/json' },
1167
+ body,
1168
+ signal: AbortSignal.timeout(timeoutMs),
1169
+ });
1170
+
1171
+ if (!resp.ok) {
1172
+ const text = await resp.text().catch(() => '');
1173
+ throw new Error('channel ' + resp.status + ': ' + text.slice(0, 200));
1174
+ }
1175
+
1176
+ const data = await resp.json() as { result?: string; error?: string };
1177
+ if (data.error) throw new Error(data.error);
1178
+ return String(data.result || '');
1160
1179
  }
1161
1180
 
1162
1181
  export async function localGrade(surface: string, prompt: string): Promise<string> {
1163
1182
  if (!(await channelUp())) throw new Error('SYNKRO_CHANNEL_DOWN');
1164
- return spawnGrade(surface, prompt);
1183
+ const jwt = loadJwt();
1184
+ if (!jwt) throw new Error('NO_JWT');
1185
+ return channelGrade(ROLE_MAP[surface] || 'grade-edit', prompt, jwt, 8929);
1165
1186
  }
1166
1187
 
1167
1188
  export async function localGradeCwe(prompt: string): Promise<string> {
1168
- return spawnGrade('cwe', prompt, { SYNKRO_CHANNEL_PORT: '8930' }, 22000);
1189
+ const jwt = loadJwt();
1190
+ if (!jwt) throw new Error('NO_JWT');
1191
+ return channelGrade('grade-cwe', prompt, jwt, 8930, 20000);
1169
1192
  }
1170
1193
 
1171
1194
  // \u2500\u2500\u2500 Verdict Parsing \u2500\u2500\u2500
@@ -1285,6 +1308,20 @@ export function dispatchCapture(
1285
1308
  if (repo) body.repo = repo;
1286
1309
  if (sessionId) body.session_id = sessionId;
1287
1310
 
1311
+ // Local telemetry always gets full content \u2014 data never leaves the machine
1312
+ const localBody = { ...body };
1313
+ if (opts) {
1314
+ if (opts.command) localBody.command = opts.command;
1315
+ if (opts.reasoning) localBody.reasoning = opts.reasoning;
1316
+ if (opts.rulesChecked) localBody.rules_checked = opts.rulesChecked;
1317
+ if (opts.violatedRules) localBody.violated_rules = opts.violatedRules;
1318
+ if (opts.recentUserMessages) localBody.recent_user_messages = opts.recentUserMessages;
1319
+ }
1320
+ appendLocalTelemetry(localBody);
1321
+
1322
+ // local_only: no data leaves the machine
1323
+ if (captureDepth === 'local_only') return;
1324
+
1288
1325
  if (sendFull && opts) {
1289
1326
  body.capture_depth = captureDepth;
1290
1327
  if (opts.command) body.command = opts.command;
@@ -1294,8 +1331,6 @@ export function dispatchCapture(
1294
1331
  if (opts.recentUserMessages) body.recent_user_messages = opts.recentUserMessages;
1295
1332
  }
1296
1333
 
1297
- appendLocalTelemetry(body);
1298
-
1299
1334
  fetch(GATEWAY_URL + '/api/v1/hook/capture', {
1300
1335
  method: 'POST',
1301
1336
  headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
@@ -1323,7 +1358,7 @@ export function ruleMode(ruleId: string, rules: Rule[]): 'blocking' | 'audit' {
1323
1358
  // \u2500\u2500\u2500 Content Reconstruction \u2500\u2500\u2500
1324
1359
 
1325
1360
  export function reconstructContent(toolName: string, toolInput: any, filePath: string, cwd?: string): string {
1326
- const canRead = filePath && (!cwd || isPathUnder(filePath, cwd));
1361
+ const canRead = filePath && cwd && isPathUnder(filePath, cwd);
1327
1362
  switch (toolName) {
1328
1363
  case 'Write':
1329
1364
  return toolInput.content || '';
@@ -1616,6 +1651,66 @@ export function aggregateUsage(transcriptPath: string): { model: string; totals:
1616
1651
  return result;
1617
1652
  }
1618
1653
 
1654
+ // \u2500\u2500\u2500 Scan Finding Dispatch \u2500\u2500\u2500
1655
+
1656
+ export function dispatchFinding(
1657
+ jwt: string,
1658
+ finding: {
1659
+ session_id: string;
1660
+ file_path: string;
1661
+ finding_type: 'cwe' | 'cve';
1662
+ finding_id: string;
1663
+ severity?: string;
1664
+ status: 'open' | 'resolved' | 'exempted';
1665
+ detail?: string;
1666
+ description?: string;
1667
+ package_name?: string;
1668
+ package_version?: string;
1669
+ fixed_version?: string;
1670
+ aliases?: string[];
1671
+ references?: Array<{ type: string; url: string }>;
1672
+ cwe_name?: string;
1673
+ },
1674
+ captureDepth: string,
1675
+ ): void {
1676
+ const localEntry: Record<string, any> = {
1677
+ capture_type: 'scan_finding',
1678
+ ...finding,
1679
+ };
1680
+ appendLocalTelemetry(localEntry);
1681
+
1682
+ if (captureDepth === 'local_only') return;
1683
+
1684
+ const cloudBody: Record<string, any> = {
1685
+ finding_type: finding.finding_type,
1686
+ finding_id: finding.finding_id,
1687
+ severity: finding.severity,
1688
+ status: finding.status,
1689
+ session_id: finding.session_id,
1690
+ };
1691
+
1692
+ if (captureDepth === 'evidence_on_violation' || captureDepth === 'full') {
1693
+ cloudBody.file_path = finding.file_path;
1694
+ cloudBody.package_name = finding.package_name;
1695
+ cloudBody.package_version = finding.package_version;
1696
+ cloudBody.fixed_version = finding.fixed_version;
1697
+ cloudBody.aliases = finding.aliases;
1698
+ cloudBody.references = finding.references;
1699
+ cloudBody.cwe_name = finding.cwe_name;
1700
+ }
1701
+ if (captureDepth === 'full') {
1702
+ cloudBody.detail = finding.detail;
1703
+ cloudBody.description = finding.description;
1704
+ }
1705
+
1706
+ fetch(GATEWAY_URL + '/api/v1/hook/finding', {
1707
+ method: 'POST',
1708
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
1709
+ body: JSON.stringify(cloudBody),
1710
+ signal: AbortSignal.timeout(3000),
1711
+ }).catch(() => {});
1712
+ }
1713
+
1619
1714
  // \u2500\u2500\u2500 Output Helpers \u2500\u2500\u2500
1620
1715
 
1621
1716
  export function outputJson(obj: any): void {
@@ -1841,7 +1936,7 @@ main();
1841
1936
  import {
1842
1937
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, cweRoute, tag,
1843
1938
  localGradeCwe, parseVerdict, reconstructContent, readStdin, log,
1844
- outputJson, outputEmpty, GATEWAY_URL,
1939
+ outputJson, outputEmpty, dispatchFinding, GATEWAY_URL,
1845
1940
  } from './_synkro-common.ts';
1846
1941
  import { basename, extname } from 'node:path';
1847
1942
 
@@ -1878,6 +1973,25 @@ async function main() {
1878
1973
  const proposed = reconstructContent(toolName, toolInput, filePath, cwd);
1879
1974
  if (!proposed) { outputEmpty(); return; }
1880
1975
 
1976
+ // Change-anchored window: for Edit/MultiEdit send context around the diff,
1977
+ // for Write send first 4000 chars (new files have patterns at the top).
1978
+ let cweContent: string;
1979
+ if (toolName === 'Edit' || toolName === 'MultiEdit') {
1980
+ const newStr = toolName === 'Edit'
1981
+ ? (toolInput.new_string || '')
1982
+ : (Array.isArray(toolInput.edits) ? toolInput.edits.map((e: any) => e?.new_string || '').join('\\n') : '');
1983
+ const changeIdx = proposed.indexOf(newStr);
1984
+ if (changeIdx >= 0 && proposed.length > 6000) {
1985
+ const start = Math.max(0, changeIdx - 2000);
1986
+ const end = Math.min(proposed.length, changeIdx + newStr.length + 2000);
1987
+ cweContent = proposed.slice(start, end);
1988
+ } else {
1989
+ cweContent = proposed.slice(0, 6000);
1990
+ }
1991
+ } else {
1992
+ cweContent = proposed.slice(0, 4000);
1993
+ }
1994
+
1881
1995
  const config = await loadConfig(jwt);
1882
1996
  const rt = await cweRoute(config);
1883
1997
 
@@ -1915,7 +2029,7 @@ async function main() {
1915
2029
  const graderPrompt = [
1916
2030
  'File: ' + filePath,
1917
2031
  'Content:',
1918
- proposed,
2032
+ cweContent,
1919
2033
  '',
1920
2034
  'CWE rules to check against:',
1921
2035
  JSON.stringify(cweRules),
@@ -1948,6 +2062,11 @@ async function main() {
1948
2062
  return;
1949
2063
  }
1950
2064
 
2065
+ const cweNameMap = new Map<string, string>();
2066
+ for (const r of cweRules) {
2067
+ if (r.cwe && r.name) cweNameMap.set(r.cwe.toUpperCase(), r.name);
2068
+ }
2069
+
1951
2070
  const displayIds = activeCweIds.slice(0, 3).join(', ');
1952
2071
  const count = activeCweIds.length;
1953
2072
  const label = count === 1 ? 'match' : 'matches';
@@ -1955,6 +2074,19 @@ async function main() {
1955
2074
  const denyDetail = '[' + displayIds + '] ' + (verdict.reason || 'code weakness detected');
1956
2075
  const ctx = 'CWE: ' + denyDetail + '\\nFix all issues before retrying. Do NOT ask the user to make the edit manually \u2014 resolve the weakness in code yourself.';
1957
2076
 
2077
+ for (const cweId of activeCweIds) {
2078
+ dispatchFinding(jwt, {
2079
+ session_id: sessionId,
2080
+ file_path: filePath,
2081
+ finding_type: 'cwe',
2082
+ finding_id: cweId,
2083
+ severity: verdict.severity || 'high',
2084
+ status: 'open',
2085
+ detail: verdict.reason || 'code weakness detected',
2086
+ cwe_name: cweNameMap.get(cweId.toUpperCase()) || undefined,
2087
+ }, config.captureDepth);
2088
+ }
2089
+
1958
2090
  outputJson({
1959
2091
  systemMessage: cweMsg,
1960
2092
  hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: ctx, additionalContext: ctx },
@@ -1962,6 +2094,14 @@ async function main() {
1962
2094
  return;
1963
2095
  }
1964
2096
 
2097
+ dispatchFinding(jwt, {
2098
+ session_id: sessionId,
2099
+ file_path: filePath,
2100
+ finding_type: 'cwe',
2101
+ finding_id: 'pass',
2102
+ status: 'resolved',
2103
+ }, config.captureDepth);
2104
+
1965
2105
  outputJson({ systemMessage: cweTag + ' ' + fileShort + ' \\u2192 clean' });
1966
2106
  return;
1967
2107
  }
@@ -1981,7 +2121,7 @@ main();
1981
2121
  import {
1982
2122
  loadJwt, ensureFreshJwt, detectRepo, loadConfig, route, tag,
1983
2123
  reconstructContent, readStdin, findNearestDeps, log,
1984
- outputJson, outputEmpty, GATEWAY_URL,
2124
+ outputJson, outputEmpty, dispatchFinding, GATEWAY_URL,
1985
2125
  } from './_synkro-common.ts';
1986
2126
  import { basename } from 'node:path';
1987
2127
 
@@ -2011,6 +2151,7 @@ async function main() {
2011
2151
  }
2012
2152
 
2013
2153
  const toolInput = payload.tool_input || {};
2154
+ const sessionId = payload.session_id || '';
2014
2155
  const cwd = payload.cwd || '';
2015
2156
 
2016
2157
  const filePath = toolInput.file_path || toolInput.notebook_path || toolInput.path || '';
@@ -2072,15 +2213,35 @@ async function main() {
2072
2213
 
2073
2214
  const findings = Array.isArray(cveResp?.findings) ? cveResp.findings : [];
2074
2215
  if (findings.length > 0) {
2075
- const top3 = findings.slice(0, 3).map((f: any) => {
2076
- const id = f.cve || f.id || '?';
2216
+ for (const f of findings.slice(0, 10)) {
2217
+ const cveId = (f.aliases || []).find((a: string) => a.startsWith('CVE-')) || f.id || 'unknown';
2218
+ dispatchFinding(jwt, {
2219
+ session_id: sessionId,
2220
+ file_path: filePath,
2221
+ finding_type: 'cve',
2222
+ finding_id: cveId,
2223
+ severity: f.severity || 'high',
2224
+ status: 'open',
2225
+ detail: f.summary || f.title || 'vulnerable dependency',
2226
+ description: f.details || undefined,
2227
+ package_name: f.package || undefined,
2228
+ package_version: f.version || undefined,
2229
+ fixed_version: f.fixed || undefined,
2230
+ aliases: f.aliases || undefined,
2231
+ references: f.references || undefined,
2232
+ }, config.captureDepth);
2233
+ }
2234
+
2235
+ const formatFinding = (f: any): string => {
2236
+ const id = (f.aliases || []).find((a: string) => a.startsWith('CVE-')) || f.id || '?';
2077
2237
  const pkg = f.package || '?';
2078
2238
  const ver = f.version || '?';
2079
2239
  const title = f.title || f.summary || 'vulnerable';
2080
- const fix = f.fixed ? ' (fix: >=' + f.fixed + ')' : ' (no safe version \u2014 use an alternative)';
2240
+ const fix = f.fixed ? ' (fix: >=' + f.fixed + ')' : ' (no safe version)';
2081
2241
  return '[' + id + '] ' + pkg + '@' + ver + ': ' + title + fix;
2082
- }).join('; ');
2242
+ };
2083
2243
 
2244
+ const top3 = findings.slice(0, 3).map(formatFinding).join('; ');
2084
2245
  const count = findings.length;
2085
2246
  const label = count === 1 ? 'advisory' : 'advisories';
2086
2247
  const cveMsg = cveTag + ' ' + fileShort + ' \\u2192 ' + count + ' ' + label;
@@ -2150,14 +2311,32 @@ async function main() {
2150
2311
  // \u2500\u2500\u2500 CVE scan for package install commands \u2500\u2500\u2500
2151
2312
  if (toolName === 'Bash') {
2152
2313
  const pkgInstallMatch = command.match(
2153
- /(?:npm\\s+(?:install|i|add)|pnpm\\s+(?:add|install|i)|yarn\\s+add|bun\\s+(?:add|install|i)|pip\\s+install|pip3\\s+install|go\\s+get|cargo\\s+add|gem\\s+install|composer\\s+require)\\s+(.+)/
2314
+ /(?: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+(.+)/
2154
2315
  );
2316
+ const isPip = /(?:uv\\s+)?pip3?\\s+install/.test(command);
2317
+ const isGo = command.match(/^go\\s+get/);
2318
+ const isCargo = command.match(/^cargo\\s+add/);
2319
+ const isGem = command.match(/^gem\\s+install/);
2320
+ const isComposer = command.match(/^composer\\s+require/);
2155
2321
  if (pkgInstallMatch) {
2156
2322
  const rawArgs = pkgInstallMatch[1];
2157
2323
  const deps: Record<string, string> = {};
2158
2324
  const tokens = rawArgs.split(/\\s+/);
2325
+ let skipNext = false;
2159
2326
  for (const token of tokens) {
2160
- if (token.startsWith('-')) continue;
2327
+ if (skipNext) { skipNext = false; continue; }
2328
+ if (token.startsWith('-')) {
2329
+ if (/^--(python|target|prefix|root|constraint|requirement|index-url|extra-index-url|find-links|build|src|cache-dir)$/.test(token)) skipNext = true;
2330
+ continue;
2331
+ }
2332
+ if (isPip) {
2333
+ const pipMatch = token.match(/^([a-zA-Z0-9_.-]+)(?:[=~!<>]=?(.+))?$/);
2334
+ if (pipMatch) {
2335
+ deps[pipMatch[1]] = pipMatch[2]?.replace(/^=/, '') || '*';
2336
+ continue;
2337
+ }
2338
+ }
2339
+ // npm/yarn: pkg@1.0
2161
2340
  const atIdx = token.lastIndexOf('@');
2162
2341
  if (atIdx > 0) {
2163
2342
  deps[token.slice(0, atIdx)] = token.slice(atIdx + 1);
@@ -2165,9 +2344,18 @@ async function main() {
2165
2344
  deps[token] = '*';
2166
2345
  }
2167
2346
  }
2347
+ const manifestFile = isPip ? 'requirements.txt'
2348
+ : isGo ? 'go.mod'
2349
+ : isCargo ? 'Cargo.toml'
2350
+ : isGem ? 'Gemfile'
2351
+ : isComposer ? 'composer.json'
2352
+ : 'package.json';
2353
+ const manifestContent = isPip
2354
+ ? Object.entries(deps).map(([k, v]) => v === '*' ? k : k + '==' + v).join('\\n')
2355
+ : JSON.stringify({ dependencies: deps });
2168
2356
  if (Object.keys(deps).length > 0) {
2169
2357
  try {
2170
- const cveBody = { file_path: 'package.json', content: JSON.stringify({ dependencies: deps }), dependencies: deps };
2358
+ const cveBody = { file_path: manifestFile, content: manifestContent, dependencies: deps };
2171
2359
  const cveResp = await fetch(GATEWAY_URL + '/api/v1/cve-scan', {
2172
2360
  method: 'POST',
2173
2361
  headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
@@ -2493,7 +2681,7 @@ async function main() {
2493
2681
  const usage = aggregateUsage(transcriptPath);
2494
2682
  if (usage.totals.in + usage.totals.out > 0) {
2495
2683
  const usageBody = {
2496
- capture_type: 'local_verdict',
2684
+ capture_type: 'usage_tick',
2497
2685
  event_id: 'usage_' + Date.now() + '_' + process.pid,
2498
2686
  hook_type: 'stop',
2499
2687
  verdict: 'allow',
@@ -2620,7 +2808,7 @@ main();
2620
2808
  `;
2621
2809
  BASH_FOLLOWUP_TS = `#!/usr/bin/env bun
2622
2810
  import {
2623
- loadJwt, readStdin, hashCommand, consentGrant, consentHasActive, consentConsume,
2811
+ loadJwt, loadConfig, readStdin, hashCommand, consentGrant, consentHasActive, consentConsume,
2624
2812
  outputEmpty, appendLocalTelemetry, GATEWAY_URL,
2625
2813
  } from './_synkro-common.ts';
2626
2814
 
@@ -2664,12 +2852,15 @@ async function main() {
2664
2852
 
2665
2853
  appendLocalTelemetry(body);
2666
2854
 
2667
- fetch(GATEWAY_URL + '/api/v1/hook/capture', {
2668
- method: 'POST',
2669
- headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
2670
- body: JSON.stringify(body),
2671
- signal: AbortSignal.timeout(3000),
2672
- }).catch(() => {});
2855
+ const config = await loadConfig(jwt);
2856
+ if (config.captureDepth !== 'local_only') {
2857
+ fetch(GATEWAY_URL + '/api/v1/hook/capture', {
2858
+ method: 'POST',
2859
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
2860
+ body: JSON.stringify(body),
2861
+ signal: AbortSignal.timeout(3000),
2862
+ }).catch(() => {});
2863
+ }
2673
2864
 
2674
2865
  outputEmpty();
2675
2866
  } catch {
@@ -2822,7 +3013,7 @@ async function main() {
2822
3013
  main();
2823
3014
  `;
2824
3015
  USER_PROMPT_SUBMIT_TS = `#!/usr/bin/env bun
2825
- import { readStdin } from './_synkro-common.ts';
3016
+ import { readStdin, appendLocalTelemetry, aggregateUsage } from './_synkro-common.ts';
2826
3017
  import { writeFileSync, mkdirSync } from 'node:fs';
2827
3018
  import { join, dirname } from 'node:path';
2828
3019
  import { homedir } from 'node:os';
@@ -2838,6 +3029,28 @@ async function main() {
2838
3029
  mkdirSync(dirname(promptFile), { recursive: true });
2839
3030
  writeFileSync(promptFile, msg, 'utf-8');
2840
3031
  }
3032
+
3033
+ const sessionId = payload.session_id || '';
3034
+ const transcriptPath = payload.transcript_path || '';
3035
+ if (sessionId && transcriptPath) {
3036
+ const usage = aggregateUsage(transcriptPath);
3037
+ if (usage.totals.in + usage.totals.out > 0) {
3038
+ appendLocalTelemetry({
3039
+ capture_type: 'usage_tick',
3040
+ event_id: 'usage_' + Date.now() + '_' + process.pid,
3041
+ hook_type: 'prompt_submit',
3042
+ session_id: sessionId,
3043
+ model: usage.model || 'unknown',
3044
+ cc_model: usage.model || '',
3045
+ cc_usage: {
3046
+ input_tokens: usage.totals.in,
3047
+ output_tokens: usage.totals.out,
3048
+ cache_creation_input_tokens: usage.totals.cw,
3049
+ cache_read_input_tokens: usage.totals.cr,
3050
+ },
3051
+ });
3052
+ }
3053
+ }
2841
3054
  } catch {}
2842
3055
  }
2843
3056
 
@@ -5167,7 +5380,7 @@ function writeConfigEnv(opts) {
5167
5380
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
5168
5381
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
5169
5382
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
5170
- `SYNKRO_VERSION=${shellQuoteSingle("1.4.61")}`
5383
+ `SYNKRO_VERSION=${shellQuoteSingle("1.4.63")}`
5171
5384
  ];
5172
5385
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
5173
5386
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);