@synkro-sh/cli 1.1.5 → 1.1.7

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
@@ -83,11 +83,18 @@ function writeSettingsAtomic(path, settings) {
83
83
  writeFileSync(tmpPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
84
84
  renameSync(tmpPath, path);
85
85
  }
86
+ function isSynkroEntry(entry) {
87
+ if (entry?.[SYNKRO_MARKER]) return true;
88
+ const hooks = Array.isArray(entry?.hooks) ? entry.hooks : [];
89
+ return hooks.some(
90
+ (h) => typeof h?.command === "string" && h.command.includes("/.synkro/hooks/")
91
+ );
92
+ }
86
93
  function removeSynkroEntries(events, eventName) {
87
94
  if (!events) return;
88
95
  const arr = events[eventName];
89
96
  if (!Array.isArray(arr)) return;
90
- events[eventName] = arr.filter((entry) => !entry?.[SYNKRO_MARKER]);
97
+ events[eventName] = arr.filter((entry) => !isSynkroEntry(entry));
91
98
  }
92
99
  function installCCHooks(settingsPath, config) {
93
100
  const settings = readSettings(settingsPath);
@@ -118,7 +125,7 @@ function installCCHooks(settingsPath, config) {
118
125
  {
119
126
  type: "command",
120
127
  command: config.editPrecheckScriptPath,
121
- timeout: 5
128
+ timeout: 15
122
129
  }
123
130
  ],
124
131
  [SYNKRO_MARKER]: true
@@ -1328,7 +1335,7 @@ returns the assistant's response text. ONE CC startup (~3.5s) amortizes
1328
1335
  across N gradings.
1329
1336
 
1330
1337
  Session bloat is bounded: the daemon rotates its claude subprocess every
1331
- ROTATION_CALLS (default 30) gradings or ROTATION_AGE_SEC (default 1h),
1338
+ ROTATION_CALLS (default 10) gradings or ROTATION_AGE_SEC (default 1h),
1332
1339
  whichever comes first. Each rotation eats a one-time ~5s primer cost; calls
1333
1340
  in between target ~2-3s steady-state.
1334
1341
 
@@ -1358,9 +1365,9 @@ def mode_paths(mode):
1358
1365
  MODE = "edit"
1359
1366
  PID_FILE, SOCK_PATH, LOG_FILE = mode_paths(MODE)
1360
1367
 
1361
- ROTATION_CALLS = int(os.environ.get("SYNKRO_DAEMON_ROTATE_CALLS", "30"))
1368
+ ROTATION_CALLS = int(os.environ.get("SYNKRO_DAEMON_ROTATE_CALLS", "10"))
1362
1369
  ROTATION_AGE_SEC = int(os.environ.get("SYNKRO_DAEMON_ROTATE_AGE", "3600"))
1363
- GRADE_TIMEOUT_SEC = 60
1370
+ GRADE_TIMEOUT_SEC = int(os.environ.get("SYNKRO_DAEMON_GRADE_TIMEOUT", "10"))
1364
1371
  DEFAULT_MODEL = os.environ.get("SYNKRO_DAEMON_MODEL", "claude-sonnet-4-6")
1365
1372
  MAX_PROMPT_BYTES = 4 * 1024 * 1024
1366
1373
 
@@ -1373,6 +1380,8 @@ def log(msg):
1373
1380
  pass
1374
1381
 
1375
1382
 
1383
+ PREWARM_HEADROOM = 4
1384
+
1376
1385
  class GraderDaemon:
1377
1386
  def __init__(self, primer):
1378
1387
  self.primer = primer or ""
@@ -1380,14 +1389,12 @@ class GraderDaemon:
1380
1389
  self.calls = 0
1381
1390
  self.start_time = 0.0
1382
1391
  self.lock = threading.Lock()
1392
+ self._next_proc = None
1393
+ self._prewarm_thread = None
1383
1394
  self._spawn()
1384
1395
 
1385
- def _spawn(self):
1386
- if self.proc and self.proc.poll() is None:
1387
- try: self.proc.terminate(); self.proc.wait(timeout=3)
1388
- except Exception: self.proc.kill()
1389
- log("spawning claude subprocess")
1390
- self.proc = subprocess.Popen(
1396
+ def _make_proc(self):
1397
+ return subprocess.Popen(
1391
1398
  [
1392
1399
  "claude", "--print", "--model", DEFAULT_MODEL,
1393
1400
  "--input-format=stream-json",
@@ -1398,41 +1405,26 @@ class GraderDaemon:
1398
1405
  stdin=subprocess.PIPE, stdout=subprocess.PIPE,
1399
1406
  stderr=subprocess.DEVNULL, text=True, bufsize=1,
1400
1407
  )
1401
- if self.primer:
1402
- self._send(self.primer)
1403
- primer_resp = self._recv()
1404
- log(f"primer ack: {primer_resp[:80]!r}")
1405
- self.calls = 0
1406
- self.start_time = time.time()
1407
1408
 
1408
- def _send(self, text):
1409
+ def _send_to(self, proc, text):
1409
1410
  msg = json.dumps({
1410
1411
  "type": "user",
1411
1412
  "message": {"role": "user", "content": [{"type": "text", "text": text}]},
1412
1413
  "parent_tool_use_id": None,
1413
1414
  "session_id": "",
1414
1415
  })
1415
- try:
1416
- self.proc.stdin.write(msg + "\\n")
1417
- self.proc.stdin.flush()
1418
- except (BrokenPipeError, OSError) as e:
1419
- log(f"send broke: {e}; respawn")
1420
- self._spawn()
1421
- self.proc.stdin.write(msg + "\\n")
1422
- self.proc.stdin.flush()
1416
+ proc.stdin.write(msg + "\\n")
1417
+ proc.stdin.flush()
1423
1418
 
1424
- def _recv(self):
1419
+ def _recv_from(self, proc):
1425
1420
  acc = []
1426
1421
  deadline = time.time() + GRADE_TIMEOUT_SEC
1427
1422
  while True:
1428
1423
  if time.time() > deadline:
1429
- log("recv timeout; respawn")
1430
- self._spawn()
1424
+ log("recv timeout")
1431
1425
  return ""
1432
- line = self.proc.stdout.readline()
1426
+ line = proc.stdout.readline()
1433
1427
  if not line:
1434
- log("subprocess closed stdout; respawn")
1435
- self._spawn()
1436
1428
  return ""
1437
1429
  try:
1438
1430
  obj = json.loads(line)
@@ -1446,18 +1438,87 @@ class GraderDaemon:
1446
1438
  elif t == "result":
1447
1439
  return "".join(acc)
1448
1440
 
1441
+ def _spawn(self):
1442
+ if self.proc and self.proc.poll() is None:
1443
+ try: self.proc.terminate(); self.proc.wait(timeout=3)
1444
+ except Exception: self.proc.kill()
1445
+ log("spawning claude subprocess")
1446
+ self.proc = self._make_proc()
1447
+ if self.primer:
1448
+ self._send_to(self.proc, self.primer)
1449
+ primer_resp = self._recv_from(self.proc)
1450
+ log(f"primer ack: {primer_resp[:80]!r}")
1451
+ self.calls = 0
1452
+ self.start_time = time.time()
1453
+ self._next_proc = None
1454
+
1455
+ def _prewarm_worker(self):
1456
+ try:
1457
+ log("pre-warming next subprocess")
1458
+ proc = self._make_proc()
1459
+ if self.primer:
1460
+ self._send_to(proc, self.primer)
1461
+ resp = self._recv_from(proc)
1462
+ log(f"pre-warm ack: {resp[:80]!r}")
1463
+ self._next_proc = proc
1464
+ log("pre-warm ready")
1465
+ except Exception as e:
1466
+ log(f"pre-warm failed: {e}")
1467
+ self._next_proc = None
1468
+
1469
+ def _maybe_prewarm(self):
1470
+ if self._prewarm_thread and self._prewarm_thread.is_alive():
1471
+ return
1472
+ if self._next_proc is not None:
1473
+ return
1474
+ if self.calls >= ROTATION_CALLS - PREWARM_HEADROOM:
1475
+ self._prewarm_thread = threading.Thread(target=self._prewarm_worker, daemon=True)
1476
+ self._prewarm_thread.start()
1477
+
1478
+ def _try_rotate(self):
1479
+ if self._next_proc and self._next_proc.poll() is None:
1480
+ log("hot-swapping to pre-warmed subprocess")
1481
+ old = self.proc
1482
+ self.proc = self._next_proc
1483
+ self._next_proc = None
1484
+ self._prewarm_thread = None
1485
+ self.calls = 0
1486
+ self.start_time = time.time()
1487
+ if old and old.poll() is None:
1488
+ threading.Thread(target=lambda: (old.terminate(), old.wait()), daemon=True).start()
1489
+ return True
1490
+ log("pre-warm not ready, deferring rotation")
1491
+ return False
1492
+
1493
+ def _send(self, text):
1494
+ try:
1495
+ self._send_to(self.proc, text)
1496
+ except (BrokenPipeError, OSError) as e:
1497
+ log(f"send broke: {e}; respawn")
1498
+ self._spawn()
1499
+ self._send_to(self.proc, text)
1500
+
1501
+ def _recv(self):
1502
+ resp = self._recv_from(self.proc)
1503
+ if not resp and (not self.proc or self.proc.poll() is not None):
1504
+ log("subprocess died; respawn")
1505
+ self._spawn()
1506
+ return resp
1507
+
1449
1508
  def grade(self, prompt):
1450
1509
  with self.lock:
1451
- age = time.time() - self.start_time
1452
- if self.calls >= ROTATION_CALLS or age >= ROTATION_AGE_SEC:
1453
- log(f"rotating: calls={self.calls} age={age:.0f}s")
1454
- self._spawn()
1455
1510
  t0 = time.time()
1456
1511
  self._send(prompt)
1457
1512
  resp = self._recv()
1458
1513
  elapsed = (time.time() - t0) * 1000
1459
1514
  self.calls += 1
1460
1515
  log(f"grade #{self.calls} elapsed={elapsed:.0f}ms resp_chars={len(resp)}")
1516
+ age = time.time() - self.start_time
1517
+ if self.calls >= ROTATION_CALLS or age >= ROTATION_AGE_SEC:
1518
+ if not self._try_rotate():
1519
+ self._maybe_prewarm()
1520
+ else:
1521
+ self._maybe_prewarm()
1461
1522
  return resp
1462
1523
 
1463
1524
 
@@ -2083,7 +2144,7 @@ function writeConfigEnv(opts) {
2083
2144
  `SYNKRO_GATEWAY_URL=${shellQuoteSingle(safeGateway)}`,
2084
2145
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
2085
2146
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
2086
- `SYNKRO_VERSION=${shellQuoteSingle("1.1.5")}`
2147
+ `SYNKRO_VERSION=${shellQuoteSingle("1.1.7")}`
2087
2148
  ];
2088
2149
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
2089
2150
  if (safeOrgId) lines.push(`SYNKRO_ORG_ID=${shellQuoteSingle(safeOrgId)}`);