@synkro-sh/cli 1.8.0 → 1.10.2

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
@@ -147,7 +147,7 @@ function getIdentity() {
147
147
  if (cached2) return cached2;
148
148
  let cliVersion2 = "0.0.0";
149
149
  try {
150
- cliVersion2 = "1.8.0";
150
+ cliVersion2 = "1.10.2";
151
151
  } catch {
152
152
  }
153
153
  const creds = loadCredentialsIdentity();
@@ -234,7 +234,7 @@ function emit(eventType, context, opts) {
234
234
  const cwd = opts?.cwd ?? process.cwd();
235
235
  const git = deriveGit(cwd);
236
236
  const emitter = process.env.SYNKRO_TELEMETRY_EMITTER || "bootstrap";
237
- const row = {
237
+ const row2 = {
238
238
  client_event_id: randomUUID2(),
239
239
  event_type: eventType,
240
240
  occurred_at: (/* @__PURE__ */ new Date()).toISOString(),
@@ -263,7 +263,7 @@ function emit(eventType, context, opts) {
263
263
  context: redactContext(context)
264
264
  };
265
265
  ensureDir2();
266
- appendFileSync(PENDING_PATH, JSON.stringify(row) + "\n", { mode: 384 });
266
+ appendFileSync(PENDING_PATH, JSON.stringify(row2) + "\n", { mode: 384 });
267
267
  } catch (err) {
268
268
  const msg = err instanceof Error ? err.message : String(err);
269
269
  process.stderr.write(`[synkro] telemetry emit(${eventType}) failed: ${msg}
@@ -297,9 +297,9 @@ function shiftPlaceholders(text, offset) {
297
297
  function bulkFragment(rows, columns) {
298
298
  if (!rows.length || !columns.length) throw new Error("empty SQL helper");
299
299
  const params = [];
300
- const tuples = rows.map((row) => {
300
+ const tuples = rows.map((row2) => {
301
301
  const placeholders = columns.map((column) => {
302
- let value = row[column];
302
+ let value = row2[column];
303
303
  let cast = "";
304
304
  if (column === "context" && typeof value === "object" && value !== null) {
305
305
  value = JSON.stringify(value);
@@ -740,11 +740,19 @@ async function insertEvents(sql, rows, mirroredIds) {
740
740
  async function drainToPglite() {
741
741
  const queued = readQueue().rows;
742
742
  if (queued.length === 0) return { ok: true, ingested: 0, pending: 0, reason: "no_pending" };
743
- const rows = queued.length > MIRROR_MAX_PER_DRAIN ? queued.slice(-MIRROR_MAX_PER_DRAIN) : queued;
743
+ const bounded = queued.length > MIRROR_MAX_PER_DRAIN ? queued.slice(-MIRROR_MAX_PER_DRAIN) : queued;
744
+ const candidates = bounded.filter((r) => !mirroredMemo.has(r.client_event_id));
745
+ if (candidates.length === 0) return { ok: true, ingested: 0, pending: queued.length, reason: "no_pending" };
744
746
  const sql = await connectDb();
745
747
  if (!sql) return { ok: false, ingested: 0, pending: queued.length, reason: "db_unavailable" };
746
748
  try {
747
- const ingested = await insertEvents(sql, rows);
749
+ const ids = candidates.map((r) => r.client_event_id);
750
+ const present = await sql`
751
+ SELECT client_event_id FROM telemetry_events WHERE client_event_id = ANY(${ids})`;
752
+ if (mirroredMemo.size > MIRRORED_MEMO_CAP) mirroredMemo.clear();
753
+ for (const row2 of present) mirroredMemo.add(row2.client_event_id);
754
+ const fresh = candidates.filter((r) => !mirroredMemo.has(r.client_event_id));
755
+ const ingested = fresh.length ? await insertEvents(sql, fresh, mirroredMemo) : 0;
748
756
  patchMetaCache({ last_drained_at: (/* @__PURE__ */ new Date()).toISOString() });
749
757
  return { ok: true, ingested, pending: queued.length };
750
758
  } catch (err) {
@@ -752,7 +760,7 @@ async function drainToPglite() {
752
760
  return { ok: false, ingested: 0, pending: queued.length, reason: "error", error: msg };
753
761
  }
754
762
  }
755
- var SYNKRO_DIR3, QUEUE_LOCK, PROCESSING_PATH, CHUNK, MIRROR_MAX_PER_DRAIN, STALE_LOCK_MS, MAX_QUEUE_ROWS;
763
+ var SYNKRO_DIR3, QUEUE_LOCK, PROCESSING_PATH, CHUNK, MIRROR_MAX_PER_DRAIN, STALE_LOCK_MS, MAX_QUEUE_ROWS, mirroredMemo, MIRRORED_MEMO_CAP;
756
764
  var init_drain = __esm({
757
765
  "cli/telemetry/drain.ts"() {
758
766
  "use strict";
@@ -766,6 +774,8 @@ var init_drain = __esm({
766
774
  MIRROR_MAX_PER_DRAIN = 5e3;
767
775
  STALE_LOCK_MS = 6e4;
768
776
  MAX_QUEUE_ROWS = 5e4;
777
+ mirroredMemo = /* @__PURE__ */ new Set();
778
+ MIRRORED_MEMO_CAP = 5e4;
769
779
  }
770
780
  });
771
781
 
@@ -1169,7 +1179,7 @@ async function getStats() {
1169
1179
  GROUP BY event_type
1170
1180
  ORDER BY c DESC
1171
1181
  `;
1172
- for (const row of types) base.by_type[String(row.event_type)] = Number(row.c);
1182
+ for (const row2 of types) base.by_type[String(row2.event_type)] = Number(row2.c);
1173
1183
  const newest = await sql`
1174
1184
  SELECT occurred_at FROM telemetry_events ORDER BY occurred_at DESC LIMIT 1
1175
1185
  `;
@@ -1224,8 +1234,8 @@ async function exportEvents(path) {
1224
1234
  const lines = [];
1225
1235
  try {
1226
1236
  const rows = await sql`SELECT * FROM telemetry_events ORDER BY occurred_at ASC`;
1227
- for (const row of rows) {
1228
- lines.push(JSON.stringify(row));
1237
+ for (const row2 of rows) {
1238
+ lines.push(JSON.stringify(row2));
1229
1239
  }
1230
1240
  } catch {
1231
1241
  }
@@ -1684,6 +1694,18 @@ function installCCHooks(settingsPath, config) {
1684
1694
  [SYNKRO_MARKER]: true
1685
1695
  });
1686
1696
  }
1697
+ if (config.mcpFollowupScriptPath) {
1698
+ settings.hooks.PostToolUse.push({
1699
+ matcher: "mcp__.*",
1700
+ hooks: [
1701
+ {
1702
+ type: "command",
1703
+ command: config.mcpFollowupScriptPath
1704
+ }
1705
+ ],
1706
+ [SYNKRO_MARKER]: true
1707
+ });
1708
+ }
1687
1709
  if (config.taskActivateIntentScriptPath) {
1688
1710
  settings.hooks.PreToolUse.push({
1689
1711
  matcher: "mcp__synkro[-_]guardrails__activate_standard",
@@ -1730,7 +1752,12 @@ function installCCHooks(settingsPath, config) {
1730
1752
  {
1731
1753
  type: "command",
1732
1754
  command: config.userPromptSubmitScriptPath,
1733
- timeout: 5
1755
+ // 15s, not 5: the stub is fire-and-forget (no server round-trip on the
1756
+ // interactive path), so the budget only covers LOCAL work — but the SCM
1757
+ // reconcile runs git twice, and on a large dirty checkout with worktrees
1758
+ // and file churn that alone can spike past 5s. A trip here discards the
1759
+ // whole hook output, so size the budget to the slow-git tail.
1760
+ timeout: 15
1734
1761
  }
1735
1762
  ],
1736
1763
  [SYNKRO_MARKER]: true
@@ -2880,8 +2907,8 @@ Print a short readiness summary: rules now active, security scanning on, the gra
2880
2907
  // cli/installer/skillParser.ts
2881
2908
  import { existsSync as existsSync15 } from "fs";
2882
2909
  import { resolve as resolve3 } from "path";
2883
- function resolveSkillPaths(skills, repoRoot2) {
2884
- return skills.filter((s) => s.endsWith(".md")).map((s) => resolve3(repoRoot2, s)).filter((p) => existsSync15(p));
2910
+ function resolveSkillPaths(skills, repoRoot3) {
2911
+ return skills.filter((s) => s.endsWith(".md")).map((s) => resolve3(repoRoot3, s)).filter((p) => existsSync15(p));
2885
2912
  }
2886
2913
  var init_skillParser = __esm({
2887
2914
  "cli/installer/skillParser.ts"() {
@@ -2893,12 +2920,12 @@ var init_skillParser = __esm({
2893
2920
  function stubHook(surface, optsLiteral) {
2894
2921
  return "#!/usr/bin/env bun\nimport { runStub } from './_synkro-stub-common.ts';\nrunStub(" + JSON.stringify(surface) + ", " + optsLiteral + ");\n";
2895
2922
  }
2896
- var STUB_COMMON_TS, STUB_EDIT_PRECHECK_TS, STUB_EDIT_FOLLOWUP_TS, STUB_CWE_PRECHECK_TS, STUB_CVE_PRECHECK_TS, STUB_BASH_JUDGE_TS, STUB_SKILL_JUDGE_TS, STUB_INSTALL_SCAN_TS, STUB_AGENT_JUDGE_TS, STUB_MCP_GATE_TS, STUB_PLAN_JUDGE_TS, STUB_STOP_SUMMARY_TS, STUB_SESSION_START_TS, STUB_TRANSCRIPT_SYNC_TS, STUB_CODEX_CWE_STOP_TS, STUB_CODEX_CVE_STOP_TS, STUB_SUBAGENT_START_TS, STUB_SUBAGENT_STOP_TS, STUB_USER_PROMPT_SUBMIT_TS, STUB_BASH_FOLLOWUP_TS, STUB_PROMPT_ROUTE_TS, STUB_TASK_ACTIVATE_INTENT_TS, STUB_CURSOR_BASH_JUDGE_TS, STUB_CURSOR_SKILL_JUDGE_TS, STUB_CURSOR_EDIT_CAPTURE_TS, STUB_CURSOR_AGENT_CAPTURE_TS;
2923
+ var STUB_COMMON_TS, STUB_EDIT_PRECHECK_TS, STUB_EDIT_FOLLOWUP_TS, STUB_CWE_PRECHECK_TS, STUB_CVE_PRECHECK_TS, STUB_BASH_JUDGE_TS, STUB_SKILL_JUDGE_TS, STUB_INSTALL_SCAN_TS, STUB_AGENT_JUDGE_TS, STUB_MCP_GATE_TS, STUB_MCP_FOLLOWUP_TS, STUB_PLAN_JUDGE_TS, STUB_STOP_SUMMARY_TS, STUB_SESSION_START_TS, STUB_TRANSCRIPT_SYNC_TS, STUB_CODEX_CWE_STOP_TS, STUB_CODEX_CVE_STOP_TS, STUB_SUBAGENT_START_TS, STUB_SUBAGENT_STOP_TS, STUB_USER_PROMPT_SUBMIT_TS, STUB_BASH_FOLLOWUP_TS, STUB_PROMPT_ROUTE_TS, STUB_TASK_ACTIVATE_INTENT_TS, STUB_CURSOR_BASH_JUDGE_TS, STUB_CURSOR_SKILL_JUDGE_TS, STUB_CURSOR_EDIT_CAPTURE_TS, STUB_CURSOR_AGENT_CAPTURE_TS;
2897
2924
  var init_hookScriptsTs = __esm({
2898
2925
  "cli/installer/hookScriptsTs.ts"() {
2899
2926
  "use strict";
2900
- STUB_COMMON_TS = String.raw`import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync, appendFileSync, statSync, realpathSync, openSync, readSync, closeSync } from 'node:fs';
2901
- import { execFileSync, execSync } from 'node:child_process';
2927
+ STUB_COMMON_TS = String.raw`import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync, appendFileSync, statSync, realpathSync, openSync, readSync, closeSync, unlinkSync } from 'node:fs';
2928
+ import { execFileSync, execSync, spawn } from 'node:child_process';
2902
2929
  import { homedir } from 'node:os';
2903
2930
  import { basename, dirname, join, resolve, relative, isAbsolute } from 'node:path';
2904
2931
  import { randomUUID, createHash } from 'node:crypto';
@@ -3136,6 +3163,28 @@ function gitRoot(cwd: string): string {
3136
3163
  } catch { return ''; }
3137
3164
  }
3138
3165
 
3166
+ // synkro.toml is per-machine and untracked, so a LINKED git worktree never has
3167
+ // its own copy — its root is a fresh checkout. Resolve the main checkout through
3168
+ // the worktree's .git pointer file (a linked worktree's .git is a FILE reading
3169
+ // "gitdir: <main>/.git/worktrees/<name>") so worktrees inherit the repo's
3170
+ // config instead of silently skipping all grading. Opt-in semantics survive: a
3171
+ // repo whose MAIN root has no synkro.toml stays dormant. Pure fs — no
3172
+ // subprocess on the hook hot path.
3173
+ function mainWorktreeRoot(root: string): string {
3174
+ try {
3175
+ const dotGit = join(root, '.git');
3176
+ if (statSync(dotGit).isDirectory()) return root;
3177
+ const pointer = readFileSync(dotGit, 'utf-8');
3178
+ const match = pointer.match(/^gitdir:\s*(.+?)\s*$/m);
3179
+ if (!match) return root;
3180
+ const gitDir = isAbsolute(match[1]) ? match[1] : join(root, match[1]);
3181
+ const marker = join('.git', 'worktrees') + '/';
3182
+ const at = gitDir.lastIndexOf('/' + marker);
3183
+ if (at === -1) return root;
3184
+ return gitDir.slice(0, at) || root;
3185
+ } catch { return root; }
3186
+ }
3187
+
3139
3188
  function taskRepoContext(root: string): any {
3140
3189
  if (!root) return undefined;
3141
3190
  try {
@@ -3182,16 +3231,144 @@ function taskRepoContext(root: string): any {
3182
3231
  } catch { return undefined; }
3183
3232
  }
3184
3233
 
3185
- function taskScmBlockResponse(harness: string, reason: string): string {
3186
- const message = '[synkro:scm] ' + reason;
3187
- if (harness === 'cursor') return JSON.stringify({ permission: 'deny', user_message: message, agent_message: message });
3234
+ // One task-workspace message per TOOL CALL, not per hook. A single Bash call fans
3235
+ // out to install-scan + bash-judge + skill-judge (an Edit fans out to four), and
3236
+ // every hook is its own process, so the dedupe has to live on disk rather than in
3237
+ // a Map the way the server-side status-line dedupe does (see stableHookEventIdentity
3238
+ // in scanning/scanRouter). Atomic first-writer-wins via an exclusive create: the
3239
+ // hook that wins says it in full, the rest stay terse.
3240
+ const TOOL_CALL_MARK_DIR = join(HOME, '.synkro', 'tool-call-marks');
3241
+ const TOOL_CALL_MARK_WINDOW_MS = 15000;
3242
+
3243
+ function pruneToolCallMarks(now: number): void {
3244
+ try {
3245
+ const entries = readdirSync(TOOL_CALL_MARK_DIR);
3246
+ if (entries.length < 200) return;
3247
+ for (const name of entries) {
3248
+ const path = join(TOOL_CALL_MARK_DIR, name);
3249
+ try {
3250
+ if (now - statSync(path).mtimeMs > TOOL_CALL_MARK_WINDOW_MS) unlinkSync(path);
3251
+ } catch {}
3252
+ }
3253
+ } catch {}
3254
+ }
3255
+
3256
+ // The user's answer to "move into the task worktree, or stay here?". Written by the
3257
+ // "synkro workspace stay" command (the CLI owns ~/.synkro, so nothing hand-writes it)
3258
+ // and read here. Keyed by task alone: the decision resets when the active task
3259
+ // changes, which is the scope the workspace gate is asking about.
3260
+ const WORKSPACE_CHOICE_DIR = join(HOME, '.synkro', 'workspace-choice');
3261
+
3262
+ // The user answers the workspace question in plain language on their next
3263
+ // prompt. Writing the marker is exactly what the CLI's "workspace stay"
3264
+ // command does — but the consent matcher only accepts one literal spelling,
3265
+ // and on a machine where PATH shadows the binary (observed: a venv python
3266
+ // named synkro, and an older global without the command) that spelling cannot
3267
+ // succeed, which deadlocked the ask. Patterns are DIRECTIVES, never
3268
+ // questions: a trailing question mark rejects, and the bare verb matches only
3269
+ // as a short standalone reply. Callers gate on a PENDING ask, so ordinary
3270
+ // conversation containing "stay" is never scanned against this.
3271
+ function isWorkspaceStayIntent(prompt: string): boolean {
3272
+ const normalized = String(prompt || '').toLowerCase().replace(/\s+/g, ' ').trim();
3273
+ if (!normalized || normalized.length > 240) return false;
3274
+ if (/\?\s*$/.test(normalized)) return false;
3275
+ if (/^(?:yes[,.\s]+)?(?:please\s+)?stay(?:\s+(?:here|put))?(?:\s*(?:pls|please))?[.!]?$/.test(normalized)) return true;
3276
+ return [
3277
+ /\b(?:stay|remain|keep working|keep going|continue)\b[^?]{0,60}\b(?:current|same|this)\s+(?:worktree|workspace|checkout|directory)\b/,
3278
+ /\b(?:do not|don't|dont|no need to)\s+(?:move|switch|change)\b[^?]{0,40}\b(?:worktrees?|workspaces?|checkouts?)\b/,
3279
+ ].some((pattern) => pattern.test(normalized));
3280
+ }
3281
+
3282
+ function taskWorkspaceStayRecorded(taskId: string): boolean {
3283
+ if (!/^task_[a-z0-9]{8}$/i.test(String(taskId || ''))) return false;
3284
+ try { return existsSync(join(WORKSPACE_CHOICE_DIR, taskId + '.stay')); } catch { return false; }
3285
+ }
3286
+
3287
+ // Our own context string, so the shape is stable: '... task=<id> ...'.
3288
+ function taskIdFromScmContext(context: string): string {
3289
+ const marker = ' task=';
3290
+ const at = String(context || '').indexOf(marker);
3291
+ if (at === -1) return '';
3292
+ const rest = context.slice(at + marker.length);
3293
+ const end = rest.indexOf(' ');
3294
+ return (end === -1 ? rest : rest.slice(0, end)).trim();
3295
+ }
3296
+
3297
+ // Consent must never deadlock behind the block it resolves: the command that records
3298
+ // the answer is allowed through for the task currently being asked about, and nothing
3299
+ // else is.
3300
+ function isWorkspaceConsentCommand(payload: any, taskId: string): boolean {
3301
+ if (!taskId || String(payload?.tool_name || '') !== 'Bash') return false;
3302
+ const input = payload?.tool_input && typeof payload.tool_input === 'object' ? payload.tool_input : {};
3303
+ const command = String(input.command || input.cmd || '').trim();
3304
+ return command === 'synkro workspace stay ' + taskId;
3305
+ }
3306
+
3307
+ function firstHookForToolCall(sessionId: string, payload: any): boolean {
3308
+ const p = payload && typeof payload === 'object' ? payload : {};
3309
+ const id = String(p.tool_use_id || p.tool_call_id || p.call_id || p.event_id || '').trim();
3310
+ // Fallback when the harness omits a call id: tool name + input is identical across
3311
+ // the surfaces of one call and distinct across different calls. Identical
3312
+ // back-to-back commands can collide inside the window, which costs one repeated
3313
+ // line and nothing else.
3314
+ const material = id || (String(p.tool_name || '') + ':' + (p.tool_input ? JSON.stringify(p.tool_input) : ''));
3315
+ if (!sessionId || !material) return true;
3316
+ const key = createHash('sha256').update(sessionId + '\0' + material).digest('hex').slice(0, 24);
3317
+ const marker = join(TOOL_CALL_MARK_DIR, key);
3318
+ const now = Date.now();
3319
+ try { mkdirSync(TOOL_CALL_MARK_DIR, { recursive: true }); } catch {}
3320
+ pruneToolCallMarks(now);
3321
+ try {
3322
+ writeFileSync(marker, '', { flag: 'wx', mode: 0o600 });
3323
+ return true;
3324
+ } catch (err: any) {
3325
+ if (err && err.code === 'EEXIST') {
3326
+ // Outside the burst window this is a genuinely new call reusing the fallback
3327
+ // key, so re-arm the marker and let it speak.
3328
+ try {
3329
+ if (now - statSync(marker).mtimeMs > TOOL_CALL_MARK_WINDOW_MS) {
3330
+ writeFileSync(marker, '', { mode: 0o600 });
3331
+ return true;
3332
+ }
3333
+ } catch {}
3334
+ return false;
3335
+ }
3336
+ // Any other failure (read-only home, quota) must not silence the message.
3337
+ return true;
3338
+ }
3339
+ }
3340
+
3341
+ function taskScmBlockResponse(harness: string, reason: string, verbose = true): string {
3342
+ // Every fanned-out hook still denies — suppressing the decision itself would let the
3343
+ // tool through if the winning hook's response were ever dropped. Only the TEXT
3344
+ // collapses: the later surfaces of one tool call deny with no systemMessage and no
3345
+ // additionalContext, so the transcript carries one workspace message, not three.
3346
+ const tag = synkroOriginTag('synkro:scm', harness);
3347
+ const message = tag + ' ' + reason;
3348
+ if (harness === 'cursor') {
3349
+ return verbose
3350
+ ? JSON.stringify({ permission: 'deny', user_message: message, agent_message: message })
3351
+ : JSON.stringify({ permission: 'deny', user_message: '', agent_message: '' });
3352
+ }
3353
+ if (!verbose) {
3354
+ return JSON.stringify({
3355
+ systemMessage: '',
3356
+ hookSpecificOutput: {
3357
+ hookEventName: 'PreToolUse',
3358
+ permissionDecision: 'deny',
3359
+ // Only surfaces if this hook's denial is the one the harness reports.
3360
+ permissionDecisionReason: tag + ' blocked — see the workspace message above',
3361
+ additionalContext: '',
3362
+ },
3363
+ });
3364
+ }
3188
3365
  return JSON.stringify({
3189
3366
  systemMessage: message,
3190
3367
  hookSpecificOutput: {
3191
3368
  hookEventName: 'PreToolUse',
3192
3369
  permissionDecision: 'deny',
3193
3370
  permissionDecisionReason: message,
3194
- additionalContext: message + ' Resolve the repository state, then retry the tool call.',
3371
+ additionalContext: message,
3195
3372
  },
3196
3373
  });
3197
3374
  }
@@ -3228,8 +3405,36 @@ function sharedRepoRoot(root: string): string {
3228
3405
  return dirname(common);
3229
3406
  }
3230
3407
 
3231
- function taskWorktreePath(root: string, taskId: string): string {
3232
- return join(sharedRepoRoot(root), '.synkro-worktrees', taskId);
3408
+ // Claude Code only permits switching BETWEEN worktrees when the target lives under
3409
+ // <repo>/.claude/worktrees, so a cc session that activates a second task can never
3410
+ // reach another .synkro-worktrees path. Provisioning cc task worktrees under
3411
+ // .claude/worktrees keeps mid-session task switching working; every other harness
3412
+ // keeps the original location.
3413
+ const CC_MANAGED_WORKTREE_DIR = join('.claude', 'worktrees');
3414
+ const LEGACY_MANAGED_WORKTREE_DIR = '.synkro-worktrees';
3415
+
3416
+ function managedWorktreeDir(harness: string): string {
3417
+ return harness === 'cc' ? CC_MANAGED_WORKTREE_DIR : LEGACY_MANAGED_WORKTREE_DIR;
3418
+ }
3419
+
3420
+ function taskWorktreePath(root: string, taskId: string, harness: string): string {
3421
+ return join(sharedRepoRoot(root), managedWorktreeDir(harness), taskId);
3422
+ }
3423
+
3424
+ function taskWorktreeCandidatePaths(root: string, taskId: string): string[] {
3425
+ const shared = sharedRepoRoot(root);
3426
+ return [CC_MANAGED_WORKTREE_DIR, LEGACY_MANAGED_WORKTREE_DIR]
3427
+ .map((dir) => join(shared, dir, taskId));
3428
+ }
3429
+
3430
+ // A task bound before the cc relocation keeps the worktree it already owns: the
3431
+ // canonical branch is checked out there, so provisioning a second one would fail
3432
+ // on 'the canonical task branch already exists in another workspace'.
3433
+ function resolveTaskWorktreePath(root: string, taskId: string, harness: string): string {
3434
+ const records = taskWorktreeRecords(root);
3435
+ const registered = taskWorktreeCandidatePaths(root, taskId)
3436
+ .find((candidate) => records.some((item) => samePath(item.path, candidate)));
3437
+ return registered || taskWorktreePath(root, taskId, harness);
3233
3438
  }
3234
3439
 
3235
3440
  function codexDesktopOrigin(): boolean {
@@ -3238,10 +3443,13 @@ function codexDesktopOrigin(): boolean {
3238
3443
  return origin === 'codex desktop' || bundle === 'com.openai.codex';
3239
3444
  }
3240
3445
 
3241
- function ignoreManagedWorktreeDirectory(root: string): void {
3242
- const common = join(sharedRepoRoot(root), '.git');
3446
+ function ignoreManagedWorktreeDirectory(root: string, worktreePath: string): void {
3447
+ const shared = sharedRepoRoot(root);
3448
+ const common = join(shared, '.git');
3243
3449
  const excludePath = join(common, 'info', 'exclude');
3244
- const entry = '.synkro-worktrees/';
3450
+ // Ignore the directory the worktree actually landed in, which differs per harness.
3451
+ const entry = relative(shared, dirname(worktreePath)) + '/';
3452
+ if (entry.startsWith('..')) return;
3245
3453
  let existing = '';
3246
3454
  try { existing = readFileSync(excludePath, 'utf-8'); } catch {}
3247
3455
  if (existing.split(/\r?\n/).includes(entry)) return;
@@ -3279,9 +3487,11 @@ function gitOperationInProgress(root: string): boolean {
3279
3487
  }
3280
3488
 
3281
3489
  function validateTaskWorktree(root: string, currentRoot: string, op: any): string {
3282
- const expected = taskWorktreePath(root, String(op.taskId || ''));
3490
+ // Accept either managed location: a task bound before the cc relocation still
3491
+ // pushes from its legacy .synkro-worktrees path.
3492
+ const expected = taskWorktreeCandidatePaths(root, String(op.taskId || ''));
3283
3493
  const supplied = String(op.worktreePath || '');
3284
- const managed = Boolean(supplied) && samePath(supplied, expected);
3494
+ const managed = Boolean(supplied) && expected.some((candidate) => samePath(supplied, candidate));
3285
3495
  const nativeCurrent = Boolean(supplied) && samePath(supplied, currentRoot);
3286
3496
  if (!managed && !nativeCurrent) {
3287
3497
  throw new Error('task worktree path does not match the managed or current native workspace');
@@ -3307,7 +3517,7 @@ async function completeTaskScm(sessionId: string, op: any, ok: boolean, error: s
3307
3517
  } catch {}
3308
3518
  }
3309
3519
 
3310
- function executeTaskScm(root: string, op: any): string {
3520
+ function executeTaskScm(root: string, op: any, harness: string): string {
3311
3521
  if (!op || typeof op !== 'object') throw new Error('invalid SCM operation');
3312
3522
  if (!/^task_[a-z0-9]{8}$/i.test(String(op.taskId || ''))) throw new Error('invalid task id');
3313
3523
  const branch = String(op.branchName || '');
@@ -3372,7 +3582,7 @@ function executeTaskScm(root: string, op: any): string {
3372
3582
  }
3373
3583
  return root;
3374
3584
  }
3375
- const worktreePath = taskWorktreePath(boundRoot, String(op.taskId));
3585
+ const worktreePath = resolveTaskWorktreePath(boundRoot, String(op.taskId), harness);
3376
3586
  const existing = taskWorktreeRecords(boundRoot).find((item) => samePath(item.path, worktreePath));
3377
3587
  if (existing) {
3378
3588
  if (existing.branch !== branch || gitOutput(worktreePath, ['rev-parse', '--abbrev-ref', 'HEAD']) !== branch) {
@@ -3398,7 +3608,7 @@ function executeTaskScm(root: string, op: any): string {
3398
3608
  throw new Error('task base revision is unavailable in this repository');
3399
3609
  }
3400
3610
  const originalBranch = gitOutput(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
3401
- ignoreManagedWorktreeDirectory(boundRoot);
3611
+ ignoreManagedWorktreeDirectory(boundRoot, worktreePath);
3402
3612
  mkdirSync(dirname(worktreePath), { recursive: true });
3403
3613
  gitOutput(root, ['worktree', 'add', '-b', branch, worktreePath, String(op.baseSha)], 30000);
3404
3614
  if (gitOutput(root, ['rev-parse', 'HEAD']) !== head
@@ -3430,14 +3640,23 @@ function executeTaskScm(root: string, op: any): string {
3430
3640
 
3431
3641
  interface TaskScmReconcileResult { reason: string; context: string }
3432
3642
 
3433
- function taskScmWorkspaceContext(workspace: any): string {
3643
+ // Who acted and where it ran: a message in a shared terminal should say which
3644
+ // harness produced it and whether this install is grading locally or in the cloud.
3645
+ function synkroOriginTag(prefix: string, harness: string): string {
3646
+ return '[' + prefix
3647
+ + (harness ? ':' + harness : '')
3648
+ + ':' + (deployIsCloud() ? 'cloud' : 'local')
3649
+ + ']';
3650
+ }
3651
+
3652
+ function taskScmWorkspaceContext(workspace: any, harness = ''): string {
3434
3653
  if (!workspace || typeof workspace !== 'object') return '';
3435
3654
  const taskId = String(workspace.taskId || '');
3436
3655
  const linearRef = String(workspace.linearRef || '');
3437
3656
  const branchName = String(workspace.branchName || '');
3438
3657
  const worktreePath = String(workspace.worktreePath || '');
3439
3658
  if (!taskId || !branchName) return '';
3440
- return '[synkro:task-workspace] task=' + taskId
3659
+ return synkroOriginTag('synkro:task-workspace', harness) + ' task=' + taskId
3441
3660
  + (linearRef ? ' linear=' + linearRef : '')
3442
3661
  + ' branch=' + branchName
3443
3662
  + (worktreePath ? ' worktree=' + worktreePath : ' worktree=pending-native-handoff');
@@ -3448,7 +3667,7 @@ function shellTaskWorkspaceArg(value: string): string {
3448
3667
  }
3449
3668
 
3450
3669
  function taskScmWorkspaceInstruction(harness: string, sessionId: string, workspace: any): string {
3451
- const context = taskScmWorkspaceContext(workspace);
3670
+ const context = taskScmWorkspaceContext(workspace, harness);
3452
3671
  const worktreePath = String(workspace?.worktreePath || '');
3453
3672
  const branchName = String(workspace?.branchName || '');
3454
3673
  if (!context || !branchName) return '';
@@ -3456,9 +3675,14 @@ function taskScmWorkspaceInstruction(harness: string, sessionId: string, workspa
3456
3675
  + 'or ask the user to perform the transition. Synkro keeps substantive tools blocked until this exact session reports '
3457
3676
  + 'the task worktree and canonical branch.';
3458
3677
  if (harness === 'cc') {
3459
- if (!worktreePath) return context + '\n[synkro:workspace-handoff] Task worktree creation failed closed; retry workspace preparation.';
3460
- return context + '\n[synkro:workspace-handoff] Invoke Claude Code EnterWorktree now for the existing worktree '
3461
- + JSON.stringify(worktreePath) + ' on branch ' + JSON.stringify(branchName) + '. ' + requirement;
3678
+ if (!worktreePath) return context + '\nTask worktree creation failed closed; retry workspace preparation.';
3679
+ // Ask, do not command. The user may legitimately want to keep working where they
3680
+ // are, and Synkro should not move them without their say-so.
3681
+ return context + '\nAsk the user whether to move this task into its isolated worktree '
3682
+ + JSON.stringify(worktreePath) + ' on branch ' + JSON.stringify(branchName)
3683
+ + ', or keep working in the current workspace. Do not decide for them. '
3684
+ + 'To move: invoke EnterWorktree on that path. '
3685
+ + 'To stay: run "synkro workspace stay ' + String(workspace?.taskId || '') + '".';
3462
3686
  }
3463
3687
  if (harness === 'cursor') {
3464
3688
  if (!worktreePath) return context + '\n[synkro:workspace-handoff] Task worktree creation failed closed; retry workspace preparation.';
@@ -3477,6 +3701,26 @@ function taskScmWorkspaceInstruction(harness: string, sessionId: string, workspa
3477
3701
  + 'The Codex Environment panel must show that worktree and branch before retrying the blocked action. ' + requirement;
3478
3702
  }
3479
3703
 
3704
+ // CC validates hookSpecificOutput.hookEventName against the event that actually fired.
3705
+ // Server responses and the SCM-context injection historically stamped 'PreToolUse'
3706
+ // unconditionally; on any other surface CC rejects the whole output ("expected
3707
+ // 'UserPromptSubmit' but got 'PreToolUse'") and the hook's work is discarded. The
3708
+ // payload's hook_event_name is the ground truth, so re-stamp at the last mile.
3709
+ // Only CC-shaped responses carry hookSpecificOutput, so no harness check is needed.
3710
+ function withActualHookEvent(responseText: string, payload: any): string {
3711
+ const actual = String((payload && (payload.hook_event_name || payload.hookEventName)) || '');
3712
+ if (!actual) return responseText;
3713
+ try {
3714
+ const response = JSON.parse(responseText || '{}');
3715
+ const hso = response && response.hookSpecificOutput;
3716
+ if (hso && typeof hso === 'object' && hso.hookEventName !== actual) {
3717
+ hso.hookEventName = actual;
3718
+ return JSON.stringify(response);
3719
+ }
3720
+ } catch { /* non-JSON hook output - leave untouched */ }
3721
+ return responseText;
3722
+ }
3723
+
3480
3724
  function withTaskScmContext(responseText: string, harness: string, context: string): string {
3481
3725
  if (!context) return responseText;
3482
3726
  try {
@@ -3539,11 +3783,11 @@ async function reconcileTaskScm(
3539
3783
  ? (taskScmWorkspaceInstruction(harness, sessionId, workspace)
3540
3784
  || String(result.reason || 'task source-control preparation is pending'))
3541
3785
  : '',
3542
- context: taskScmWorkspaceContext(workspace),
3786
+ context: taskScmWorkspaceContext(workspace, harness),
3543
3787
  };
3544
3788
  }
3545
3789
  try {
3546
- const worktreePath = executeTaskScm(root, result.op);
3790
+ const worktreePath = executeTaskScm(root, result.op, harness);
3547
3791
  await completeTaskScm(sessionId, result.op, true, '', worktreePath);
3548
3792
  if (result.op.kind === 'branch') {
3549
3793
  if (result.op.adoptExistingWorktree === true) {
@@ -3551,12 +3795,12 @@ async function reconcileTaskScm(
3551
3795
  if (rebound.ok) {
3552
3796
  const reboundResult = await rebound.json() as any;
3553
3797
  if (!reboundResult?.waiting && reboundResult?.workspace) {
3554
- return { reason: '', context: taskScmWorkspaceContext(reboundResult.workspace) };
3798
+ return { reason: '', context: taskScmWorkspaceContext(reboundResult.workspace, harness) };
3555
3799
  }
3556
3800
  if (reboundResult?.waiting) {
3557
3801
  return {
3558
3802
  reason: String(reboundResult.reason || 'Codex native worktree binding is pending'),
3559
- context: taskScmWorkspaceContext(reboundResult.workspace),
3803
+ context: taskScmWorkspaceContext(reboundResult.workspace, harness),
3560
3804
  };
3561
3805
  }
3562
3806
  }
@@ -4188,10 +4432,19 @@ export async function runStub(surface: string, opts: StubOpts = {}): Promise<voi
4188
4432
  // Dormancy: a repo is onboarded only if it has a synkro.toml FILE at its git
4189
4433
  // root. Guard root !== HOME — ~/.synkro is the config DIRECTORY, so without
4190
4434
  // this a home-rooted cwd (dotfiles in git, non-git dir under home) could
4191
- // look onboarded.
4435
+ // look onboarded. A linked worktree checked out at a commit predating the
4436
+ // tracked synkro.toml has no copy of its own — fall back to the main
4437
+ // checkout's config rather than silently skipping enforcement there.
4192
4438
  let synkroFileText = '';
4193
- if (root && root !== HOME && existsSync(join(root, 'synkro.toml'))) {
4194
- try { synkroFileText = readFileSync(join(root, 'synkro.toml'), 'utf-8'); } catch {}
4439
+ if (root && root !== HOME) {
4440
+ let configRoot = root;
4441
+ if (!existsSync(join(configRoot, 'synkro.toml'))) {
4442
+ const mainRoot = mainWorktreeRoot(root);
4443
+ if (mainRoot !== root && mainRoot !== HOME && existsSync(join(mainRoot, 'synkro.toml'))) {
4444
+ configRoot = mainRoot;
4445
+ }
4446
+ }
4447
+ try { synkroFileText = readFileSync(join(configRoot, 'synkro.toml'), 'utf-8'); } catch {}
4195
4448
  }
4196
4449
  if (!synkroFileText) {
4197
4450
  // Repo not onboarded — emit a minimal tool_call so usage analytics still
@@ -4220,8 +4473,26 @@ export async function runStub(surface: string, opts: StubOpts = {}): Promise<voi
4220
4473
 
4221
4474
  const scm = await reconcileTaskScm(root || cwd, sessionId, harness, payload);
4222
4475
  const substantiveTool = /^(?:Bash|Edit|Write|MultiEdit|NotebookEdit|apply_patch|file_change)$/i.test(String(payload.tool_name || ''));
4223
- if (scm.reason && substantiveTool) {
4224
- out(taskScmBlockResponse(harness, scm.reason));
4476
+ const scmTaskId = taskIdFromScmContext(scm.context);
4477
+ // The user can settle the workspace question in plain language: while the
4478
+ // ask is pending for this session's task, a stay-directive on the user's
4479
+ // prompt records the same durable marker the CLI command writes. Reconcile
4480
+ // just told us WHICH task is being asked, so no extra state is needed and
4481
+ // ordinary prompts outside a pending ask are never scanned.
4482
+ if (surface === 'prompt-submit' && scm.reason && scmTaskId && !taskWorkspaceStayRecorded(scmTaskId)) {
4483
+ const promptText = String(payload.prompt || payload.user_message || '');
4484
+ if (isWorkspaceStayIntent(promptText)) {
4485
+ try {
4486
+ mkdirSync(WORKSPACE_CHOICE_DIR, { recursive: true });
4487
+ writeFileSync(join(WORKSPACE_CHOICE_DIR, scmTaskId + '.stay'), new Date().toISOString() + '\n');
4488
+ } catch { /* fail-open: the CLI command and the exact-string path remain */ }
4489
+ }
4490
+ }
4491
+ // The user was asked and chose to keep working here, or is answering right now.
4492
+ const workspaceConsentSettled = Boolean(scmTaskId)
4493
+ && (taskWorkspaceStayRecorded(scmTaskId) || isWorkspaceConsentCommand(payload, scmTaskId));
4494
+ if (scm.reason && substantiveTool && !workspaceConsentSettled) {
4495
+ out(taskScmBlockResponse(harness, scm.reason, firstHookForToolCall(sessionId, payload)));
4225
4496
  return;
4226
4497
  }
4227
4498
 
@@ -4368,12 +4639,30 @@ export async function runStub(surface: string, opts: StubOpts = {}): Promise<voi
4368
4639
  // Dropping an occasional prompt tick is harmless; stalling the user is not.
4369
4640
  const attempts = (opts.telemetry && surface !== 'prompt-submit') ? 3 : 1;
4370
4641
  let text = '';
4371
- for (let i = 0; i < attempts; i++) {
4642
+ if (surface === 'prompt-submit' && process.env.SYNKRO_PROMPT_SUBMIT_SYNC !== '1') {
4643
+ // Fire-and-forget: prompt-submit's reply carries no verdict (telemetry-class,
4644
+ // response ignored) and the SCM workspace context is computed host-side, so
4645
+ // the server round-trip must never hold the user's KEYSTROKE hostage to DB
4646
+ // tail latency — a busy single-threaded PGLite stretch was tripping the 5s
4647
+ // hook budget on every prompt. A detached child delivers the envelope with
4648
+ // retries the interactive path could never afford; delivery reliability goes
4649
+ // UP versus the old single attempt racing a 5s clock.
4372
4650
  try {
4373
- const resp = await fetch(url, { method: 'POST', headers, body, signal: AbortSignal.timeout(timeoutMs) });
4374
- if (resp.ok) { text = (await resp.text()).trim(); break; }
4375
- } catch (e) { /* connection refused / timeout → retry below */ }
4376
- if (i < attempts - 1) await new Promise((r) => setTimeout(r, 500 * (i + 1)));
4651
+ const spoolDir = join(HOME, '.synkro', 'prompt-spool');
4652
+ mkdirSync(spoolDir, { recursive: true });
4653
+ const spoolPath = join(spoolDir, randomUUID() + '.json');
4654
+ writeFileSync(spoolPath, body);
4655
+ const child = spawn(process.execPath, [import.meta.path, '--deliver-spool', spoolPath], { detached: true, stdio: 'ignore' });
4656
+ child.unref();
4657
+ } catch { /* best-effort - the prompt must never block on delivery */ }
4658
+ } else {
4659
+ for (let i = 0; i < attempts; i++) {
4660
+ try {
4661
+ const resp = await fetch(url, { method: 'POST', headers, body, signal: AbortSignal.timeout(timeoutMs) });
4662
+ if (resp.ok) { text = (await resp.text()).trim(); break; }
4663
+ } catch (e) { /* connection refused / timeout → retry below */ }
4664
+ if (i < attempts - 1) await new Promise((r) => setTimeout(r, 500 * (i + 1)));
4665
+ }
4377
4666
  }
4378
4667
  const rawResponseText = text || failOpen(harness);
4379
4668
  // A completion-report sync can arm the push after the pre-scan reconciliation ran.
@@ -4382,7 +4671,19 @@ export async function runStub(surface: string, opts: StubOpts = {}): Promise<voi
4382
4671
  const contractResponseText = opts.stopContract && harness === 'codex'
4383
4672
  ? codexStopResponse(rawResponseText)
4384
4673
  : rawResponseText;
4385
- const responseText = withTaskScmContext(contractResponseText, harness, scm.context);
4674
+ // The informational workspace banner rides prompt-submit ONLY. Tool-call and stop
4675
+ // surfaces stay quiet: a pending workspace question already blocks the next
4676
+ // substantive tool with taskScmBlockResponse (which carries the full instructions
4677
+ // itself), and the server still receives scm.context via the envelope's
4678
+ // taskWorkspaceContext on every surface — so nothing enforcement- or data-bearing
4679
+ // is lost by silencing the per-tool-call and per-stop repeats.
4680
+ let scmContext = scm.context && surface === 'prompt-submit' && firstHookForToolCall(sessionId, payload) ? scm.context : '';
4681
+ // Say plainly that work is continuing outside the task worktree by the user's choice,
4682
+ // so the state is never mistaken for a binding that silently failed.
4683
+ if (scmContext && scmTaskId && taskWorkspaceStayRecorded(scmTaskId)) {
4684
+ scmContext += ' workspace=staying-here-by-user-choice';
4685
+ }
4686
+ const responseText = withActualHookEvent(withTaskScmContext(contractResponseText, harness, scmContext), payload);
4386
4687
  out(responseText);
4387
4688
  emitStubTelemetry(surface, harness, telemPayload, responseText, Date.now() - startedAt, telemCwd, telemSessionId);
4388
4689
  } catch (err) {
@@ -4615,6 +4916,31 @@ function emitStubTelemetry(
4615
4916
  }, opts);
4616
4917
  }
4617
4918
  }
4919
+
4920
+ // ─── Detached prompt-submit delivery child ──────────────────────────────────
4921
+ // The interactive parent spools the envelope and exits immediately; this child
4922
+ // (spawned detached from runStub's prompt-submit path) delivers it with retries
4923
+ // and a budget the 5s hook clock could never allow. Runs only when this file is
4924
+ // executed directly with --deliver-spool; a plain import never reaches it.
4925
+ async function deliverSpooledEnvelope(path: string): Promise<void> {
4926
+ let body = '';
4927
+ try { body = readFileSync(path, 'utf-8'); } catch { return; }
4928
+ const url = 'http://127.0.0.1:' + PORT + '/api/scan/prompt-submit';
4929
+ const headers = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + loadMcpJwt() };
4930
+ for (let i = 0; i < 3; i++) {
4931
+ try {
4932
+ const resp = await fetch(url, { method: 'POST', headers, body, signal: AbortSignal.timeout(20000) });
4933
+ if (resp.ok) break;
4934
+ } catch { /* server busy or restarting - retry below */ }
4935
+ if (i < 2) await new Promise((r) => setTimeout(r, 2000 * (i + 1)));
4936
+ }
4937
+ try { unlinkSync(path); } catch { /* already collected */ }
4938
+ }
4939
+
4940
+ if (import.meta.main && process.argv[2] === '--deliver-spool' && process.argv[3]) {
4941
+ await deliverSpooledEnvelope(process.argv[3]);
4942
+ process.exit(0);
4943
+ }
4618
4944
  `;
4619
4945
  STUB_EDIT_PRECHECK_TS = stubHook("edit-precheck", "{ needsFile: true, needsTranscript: true }");
4620
4946
  STUB_EDIT_FOLLOWUP_TS = stubHook("edit-followup", "{ needsFile: true, needsTranscript: true, postEdit: true }");
@@ -4625,6 +4951,7 @@ function emitStubTelemetry(
4625
4951
  STUB_INSTALL_SCAN_TS = stubHook("install-scan", "{ needsTranscript: true }");
4626
4952
  STUB_AGENT_JUDGE_TS = stubHook("agent-judge", "{ needsTranscript: true }");
4627
4953
  STUB_MCP_GATE_TS = stubHook("mcp-gate", "{ needsTranscript: true }");
4954
+ STUB_MCP_FOLLOWUP_TS = stubHook("mcp-followup", "{ telemetry: true }");
4628
4955
  STUB_PLAN_JUDGE_TS = stubHook("plan-judge", "{ needsPlan: true }");
4629
4956
  STUB_STOP_SUMMARY_TS = stubHook("stop-summary", "{ needsTranscript: true, fullTranscript: true }");
4630
4957
  STUB_SESSION_START_TS = stubHook("session-start", "{ telemetry: true }");
@@ -7281,8 +7608,8 @@ async function dockerInstall(opts = {}) {
7281
7608
  "SYNKRO_TELEMETRY_QUEUE=/data/synkro-host/telemetry-pending.jsonl",
7282
7609
  image
7283
7610
  ];
7284
- const run = spawnSync3("docker", args2, { encoding: "utf-8", stdio: "inherit", timeout: 6e4 });
7285
- if (run.status !== 0) {
7611
+ const run2 = spawnSync3("docker", args2, { encoding: "utf-8", stdio: "inherit", timeout: 6e4 });
7612
+ if (run2.status !== 0) {
7286
7613
  throw new DockerInstallError(`docker run failed (image ${image})`);
7287
7614
  }
7288
7615
  return {
@@ -7513,7 +7840,7 @@ var init_dockerInstall = __esm({
7513
7840
  HOST_PGLITE_PORT = parseInt(process.env.SYNKRO_HOST_PGLITE_PORT || "15433", 10);
7514
7841
  CONTAINER_NAME = resolveContainerName();
7515
7842
  defaultImageVersion = () => {
7516
- if (true) return "1.8.0";
7843
+ if (true) return "1.10.2";
7517
7844
  try {
7518
7845
  const pkg = JSON.parse(readFileSync17(new URL("../../package.json", import.meta.url), "utf8"));
7519
7846
  if (pkg.version) return pkg.version;
@@ -8221,7 +8548,7 @@ function isoDay(value, fallbackDay) {
8221
8548
  function parseClaudeTranscriptUsage(transcript, fallbackDay = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10), options = {}) {
8222
8549
  const seen = options.seenStableIds ?? /* @__PURE__ */ new Set();
8223
8550
  const rollups = /* @__PURE__ */ new Map();
8224
- const usage = {
8551
+ const usage2 = {
8225
8552
  input_tokens: 0,
8226
8553
  output_tokens: 0,
8227
8554
  cache_creation_input_tokens: 0,
@@ -8255,7 +8582,7 @@ function parseClaudeTranscriptUsage(transcript, fallbackDay = (/* @__PURE__ */ n
8255
8582
  if (entryModel !== "<synthetic>") model = entryModel;
8256
8583
  const day = isoDay(entry.timestamp, fallbackDay);
8257
8584
  const key = `${day}\0${entryModel}`;
8258
- const row = rollups.get(key) ?? {
8585
+ const row2 = rollups.get(key) ?? {
8259
8586
  day,
8260
8587
  model: entryModel,
8261
8588
  turns: 0,
@@ -8264,23 +8591,23 @@ function parseClaudeTranscriptUsage(transcript, fallbackDay = (/* @__PURE__ */ n
8264
8591
  cache_creation_input_tokens: 0,
8265
8592
  cache_read_input_tokens: 0
8266
8593
  };
8267
- row.turns += 1;
8268
- row.input_tokens += counts.input_tokens;
8269
- row.output_tokens += counts.output_tokens;
8270
- row.cache_creation_input_tokens += counts.cache_creation_input_tokens;
8271
- row.cache_read_input_tokens += counts.cache_read_input_tokens;
8272
- rollups.set(key, row);
8594
+ row2.turns += 1;
8595
+ row2.input_tokens += counts.input_tokens;
8596
+ row2.output_tokens += counts.output_tokens;
8597
+ row2.cache_creation_input_tokens += counts.cache_creation_input_tokens;
8598
+ row2.cache_read_input_tokens += counts.cache_read_input_tokens;
8599
+ rollups.set(key, row2);
8273
8600
  turns += 1;
8274
- usage.input_tokens += counts.input_tokens;
8275
- usage.output_tokens += counts.output_tokens;
8276
- usage.cache_creation_input_tokens += counts.cache_creation_input_tokens;
8277
- usage.cache_read_input_tokens += counts.cache_read_input_tokens;
8601
+ usage2.input_tokens += counts.input_tokens;
8602
+ usage2.output_tokens += counts.output_tokens;
8603
+ usage2.cache_creation_input_tokens += counts.cache_creation_input_tokens;
8604
+ usage2.cache_read_input_tokens += counts.cache_read_input_tokens;
8278
8605
  } catch {
8279
8606
  }
8280
8607
  }
8281
8608
  if (turns === 0) return null;
8282
8609
  return {
8283
- usage,
8610
+ usage: usage2,
8284
8611
  model: model || "unknown",
8285
8612
  rollups: [...rollups.values()].sort(
8286
8613
  (a, b) => a.day.localeCompare(b.day) || a.model.localeCompare(b.model)
@@ -8473,6 +8800,7 @@ function writeHookScripts() {
8473
8800
  const mcpStdioProxyPath = join19(HOOKS_DIR, "mcp-stdio-proxy.ts");
8474
8801
  const taskActivateIntentScriptPath = join19(HOOKS_DIR, "cc-task-activate-intent.ts");
8475
8802
  const mcpGateScriptPath = join19(HOOKS_DIR, "cc-mcp-gate.ts");
8803
+ const mcpFollowupScriptPath = join19(HOOKS_DIR, "cc-mcp-followup.ts");
8476
8804
  const stubCommonPath = join19(HOOKS_DIR, "_synkro-stub-common.ts");
8477
8805
  const stubFiles = [
8478
8806
  [stubCommonPath, STUB_COMMON_TS],
@@ -8498,6 +8826,7 @@ function writeHookScripts() {
8498
8826
  [installScanScriptPath, STUB_INSTALL_SCAN_TS],
8499
8827
  [taskActivateIntentScriptPath, STUB_TASK_ACTIVATE_INTENT_TS],
8500
8828
  [mcpGateScriptPath, STUB_MCP_GATE_TS],
8829
+ [mcpFollowupScriptPath, STUB_MCP_FOLLOWUP_TS],
8501
8830
  [cursorBashJudgePath, STUB_CURSOR_BASH_JUDGE_TS],
8502
8831
  [cursorEditCapturePath, STUB_CURSOR_EDIT_CAPTURE_TS],
8503
8832
  [cursorAgentCapturePath, STUB_CURSOR_AGENT_CAPTURE_TS]
@@ -8538,7 +8867,8 @@ function writeHookScripts() {
8538
8867
  cursorEditCaptureScript: cursorEditCapturePath,
8539
8868
  cursorAgentCaptureScript: cursorAgentCapturePath,
8540
8869
  taskActivateIntentScript: taskActivateIntentScriptPath,
8541
- mcpGateScript: mcpGateScriptPath
8870
+ mcpGateScript: mcpGateScriptPath,
8871
+ mcpFollowupScript: mcpFollowupScriptPath
8542
8872
  };
8543
8873
  }
8544
8874
  function sanitizeConfigValue(raw, maxLen = 256) {
@@ -8570,7 +8900,7 @@ function writeConfigEnv(opts) {
8570
8900
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle2(credsPath)}`,
8571
8901
  `SYNKRO_TIER=${shellQuoteSingle2(safeTier)}`,
8572
8902
  `SYNKRO_INFERENCE=${shellQuoteSingle2(safeInference)}`,
8573
- `SYNKRO_VERSION=${shellQuoteSingle2("1.8.0")}`
8903
+ `SYNKRO_VERSION=${shellQuoteSingle2("1.10.2")}`
8574
8904
  ];
8575
8905
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle2(safeSynkroBin)}`);
8576
8906
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle2(safeUserId)}`);
@@ -9317,7 +9647,7 @@ async function installCommand(opts = {}) {
9317
9647
  await setTelemetryState({ enabled: true, remoteFlushEnabled: telemetryConsent });
9318
9648
  emit("install", {
9319
9649
  phase: "started",
9320
- cli_version_to: "1.8.0",
9650
+ cli_version_to: "1.10.2",
9321
9651
  agents_detected: agents.map((a) => a.kind),
9322
9652
  with_github: false,
9323
9653
  with_local_cc: false,
@@ -9361,6 +9691,7 @@ async function installCommand(opts = {}) {
9361
9691
  installScanScriptPath: scripts.installScanScript,
9362
9692
  taskActivateIntentScriptPath: scripts.taskActivateIntentScript,
9363
9693
  mcpGateScriptPath: scripts.mcpGateScript,
9694
+ mcpFollowupScriptPath: scripts.mcpFollowupScript,
9364
9695
  skipTranscriptSync: !transcriptCC
9365
9696
  });
9366
9697
  console.log(`Configured ${agent.name} hooks at ${agent.settingsPath}`);
@@ -10178,7 +10509,7 @@ async function syncSkillFiles() {
10178
10509
  function normSkillName(name) {
10179
10510
  return name.toLowerCase().replace(/\.mdx?$/, "");
10180
10511
  }
10181
- function discoverSkillFiles(repoRoot2, excludeHashes, ingestedHashes, ingestedNames) {
10512
+ function discoverSkillFiles(repoRoot3, excludeHashes, ingestedHashes, ingestedNames) {
10182
10513
  const roots = [];
10183
10514
  const add = (p) => {
10184
10515
  try {
@@ -10188,9 +10519,9 @@ function discoverSkillFiles(repoRoot2, excludeHashes, ingestedHashes, ingestedNa
10188
10519
  };
10189
10520
  add(join19(homedir21(), ".claude", "skills"));
10190
10521
  add(join19(homedir21(), ".agents", "skills"));
10191
- if (repoRoot2) {
10192
- add(join19(repoRoot2, ".claude", "skills"));
10193
- add(join19(repoRoot2, ".agents", "skills"));
10522
+ if (repoRoot3) {
10523
+ add(join19(repoRoot3, ".claude", "skills"));
10524
+ add(join19(repoRoot3, ".agents", "skills"));
10194
10525
  }
10195
10526
  const out = [];
10196
10527
  const seen = /* @__PURE__ */ new Set();
@@ -10273,7 +10604,7 @@ Found ${found.length} skill${found.length === 1 ? "" : "s"} in your Claude Code
10273
10604
  async function discoverAndIngestSkills() {
10274
10605
  try {
10275
10606
  const sf = readFullSynkroFile();
10276
- const repoRoot2 = sf?._repoRoot || detectGitRepo2();
10607
+ const repoRoot3 = sf?._repoRoot || detectGitRepo2();
10277
10608
  const mcpPort = process.env.SYNKRO_MCP_PORT || "18931";
10278
10609
  const excludeHashes = /* @__PURE__ */ new Set();
10279
10610
  if (sf?.skills?.length) {
@@ -10297,7 +10628,7 @@ async function discoverAndIngestSkills() {
10297
10628
  }
10298
10629
  } catch {
10299
10630
  }
10300
- const found = discoverSkillFiles(repoRoot2, excludeHashes, ingestedHashes, ingestedNames);
10631
+ const found = discoverSkillFiles(repoRoot3, excludeHashes, ingestedHashes, ingestedNames);
10301
10632
  if (found.length === 0) return;
10302
10633
  const selectable = found.filter((f) => !f.ingested);
10303
10634
  if (selectable.length === 0) {
@@ -10333,18 +10664,18 @@ async function discoverAndIngestSkills() {
10333
10664
  }
10334
10665
  }
10335
10666
  function resolveSynkroBinPath() {
10336
- const run = (cmd3) => {
10667
+ const run2 = (cmd3) => {
10337
10668
  try {
10338
10669
  return execSync4(cmd3, { encoding: "utf-8", timeout: 5e3, stdio: ["pipe", "pipe", "pipe"] }).trim();
10339
10670
  } catch {
10340
10671
  return "";
10341
10672
  }
10342
10673
  };
10343
- const p = run("command -v synkro").split("\n")[0].trim();
10674
+ const p = run2("command -v synkro").split("\n")[0].trim();
10344
10675
  return p && isAbsolute(p) ? p : "";
10345
10676
  }
10346
10677
  function ensureReachabilityGitHook() {
10347
- const run = (cmd3) => {
10678
+ const run2 = (cmd3) => {
10348
10679
  try {
10349
10680
  return execSync4(cmd3, { encoding: "utf-8", timeout: 5e3, stdio: ["pipe", "pipe", "pipe"] }).trim();
10350
10681
  } catch {
@@ -10352,9 +10683,9 @@ function ensureReachabilityGitHook() {
10352
10683
  }
10353
10684
  };
10354
10685
  try {
10355
- const root = run("git rev-parse --show-toplevel");
10686
+ const root = run2("git rev-parse --show-toplevel");
10356
10687
  if (!root) return null;
10357
- let hooksDir = run("git config --get core.hooksPath");
10688
+ let hooksDir = run2("git config --get core.hooksPath");
10358
10689
  hooksDir = hooksDir ? isAbsolute(hooksDir) ? hooksDir : join19(root, hooksDir) : join19(root, ".git", "hooks");
10359
10690
  if (!existsSync22(hooksDir)) mkdirSync15(hooksDir, { recursive: true });
10360
10691
  const hookPath = join19(hooksDir, "post-commit");
@@ -10396,18 +10727,18 @@ function ensureReachabilityGitHook() {
10396
10727
  }
10397
10728
  }
10398
10729
  function detectGitRepo2() {
10399
- const run = (cmd3) => {
10730
+ const run2 = (cmd3) => {
10400
10731
  try {
10401
10732
  return execSync4(cmd3, { encoding: "utf-8", timeout: 5e3, stdio: ["pipe", "pipe", "pipe"] }).trim();
10402
10733
  } catch {
10403
10734
  return "";
10404
10735
  }
10405
10736
  };
10406
- const remoteUrl = run("git remote get-url origin");
10737
+ const remoteUrl = run2("git remote get-url origin");
10407
10738
  if (remoteUrl) {
10408
10739
  return remoteUrl.replace(/^git@[^:]+:/, "").replace(/^https?:\/\/[^/]+\//, "").replace(/\.git$/, "");
10409
10740
  }
10410
- const root = run("git rev-parse --show-toplevel");
10741
+ const root = run2("git rev-parse --show-toplevel");
10411
10742
  return root ? root.split("/").pop() || null : null;
10412
10743
  }
10413
10744
  function getClaudeProjectsFolder() {
@@ -13856,10 +14187,10 @@ var init_packVerify = __esm({
13856
14187
  // cli/installer/lockfile.ts
13857
14188
  import { existsSync as existsSync30, readFileSync as readFileSync28, writeFileSync as writeFileSync20 } from "fs";
13858
14189
  import { join as join28 } from "path";
13859
- function lockPath(repoRoot2) {
13860
- return join28(repoRoot2, LOCK_FILE);
14190
+ function lockPath(repoRoot3) {
14191
+ return join28(repoRoot3, LOCK_FILE);
13861
14192
  }
13862
- function writeLockfile(repoRoot2, entries) {
14193
+ function writeLockfile(repoRoot3, entries) {
13863
14194
  const sorted = [...entries].sort((a, b) => a.ref.localeCompare(b.ref));
13864
14195
  const body = [
13865
14196
  "# synkro.lock \u2014 generated by `synkro sync`. Commit this file.",
@@ -13875,7 +14206,7 @@ function writeLockfile(repoRoot2, entries) {
13875
14206
  ""
13876
14207
  ])
13877
14208
  ].join("\n");
13878
- writeFileSync20(lockPath(repoRoot2), body, "utf-8");
14209
+ writeFileSync20(lockPath(repoRoot3), body, "utf-8");
13879
14210
  }
13880
14211
  var LOCK_FILE;
13881
14212
  var init_lockfile = __esm({
@@ -14087,6 +14418,822 @@ var init_whoami = __esm({
14087
14418
  }
14088
14419
  });
14089
14420
 
14421
+ // cli/commands/workspace.ts
14422
+ var workspace_exports = {};
14423
+ __export(workspace_exports, {
14424
+ workspaceCommand: () => workspaceCommand
14425
+ });
14426
+ import { existsSync as existsSync33, mkdirSync as mkdirSync19, readdirSync as readdirSync8, rmSync as rmSync5, writeFileSync as writeFileSync22 } from "fs";
14427
+ import { join as join31 } from "path";
14428
+ import { homedir as homedir31 } from "os";
14429
+ function markerPath(taskId) {
14430
+ return join31(WORKSPACE_CHOICE_DIR, `${taskId}.stay`);
14431
+ }
14432
+ function usage() {
14433
+ console.log(`synkro workspace \u2014 record where a task's work happens
14434
+
14435
+ Usage:
14436
+ synkro workspace stay <taskId> keep working in the current checkout
14437
+ synkro workspace clear <taskId> forget the choice (the gate asks again)
14438
+ synkro workspace status [taskId] show recorded choices
14439
+
14440
+ The gate asks once per task. "stay" is remembered until the choice is cleared or
14441
+ the active task changes.`);
14442
+ }
14443
+ async function workspaceCommand(args2) {
14444
+ const sub = String(args2[0] || "").trim();
14445
+ const taskId = String(args2[1] || "").trim();
14446
+ if (!sub || sub === "help" || sub === "--help" || sub === "-h") {
14447
+ usage();
14448
+ return;
14449
+ }
14450
+ if (sub === "status") {
14451
+ let recorded = [];
14452
+ try {
14453
+ recorded = existsSync33(WORKSPACE_CHOICE_DIR) ? readdirSync8(WORKSPACE_CHOICE_DIR).filter((name) => name.endsWith(".stay")) : [];
14454
+ } catch {
14455
+ recorded = [];
14456
+ }
14457
+ if (taskId) {
14458
+ const on = recorded.includes(`${taskId}.stay`);
14459
+ console.log(`${taskId}: ${on ? "stay recorded" : "no choice recorded"}`);
14460
+ return;
14461
+ }
14462
+ if (recorded.length === 0) {
14463
+ console.log("No workspace choices recorded.");
14464
+ return;
14465
+ }
14466
+ console.log("Staying in the current checkout for:");
14467
+ for (const name of recorded.sort()) console.log(` ${name.replace(/\.stay$/, "")}`);
14468
+ return;
14469
+ }
14470
+ if (sub !== "stay" && sub !== "clear") {
14471
+ console.error(`Unknown workspace subcommand: ${sub}`);
14472
+ usage();
14473
+ process.exitCode = 2;
14474
+ return;
14475
+ }
14476
+ if (!TASK_ID.test(taskId)) {
14477
+ console.error(taskId ? `Not a task id: ${taskId} (expected task_ followed by 8 characters)` : `Usage: synkro workspace ${sub} <taskId>`);
14478
+ process.exitCode = 2;
14479
+ return;
14480
+ }
14481
+ if (sub === "clear") {
14482
+ try {
14483
+ rmSync5(markerPath(taskId), { force: true });
14484
+ } catch {
14485
+ }
14486
+ console.log(`Cleared the workspace choice for ${taskId}.`);
14487
+ return;
14488
+ }
14489
+ try {
14490
+ mkdirSync19(WORKSPACE_CHOICE_DIR, { recursive: true });
14491
+ writeFileSync22(markerPath(taskId), `${(/* @__PURE__ */ new Date()).toISOString()}
14492
+ `, "utf-8");
14493
+ } catch (error) {
14494
+ console.error(`Could not record the workspace choice: ${error?.message || error}`);
14495
+ process.exitCode = 1;
14496
+ return;
14497
+ }
14498
+ console.log(`Staying in the current checkout for ${taskId}. Synkro will not ask again for this task.`);
14499
+ }
14500
+ var WORKSPACE_CHOICE_DIR, TASK_ID;
14501
+ var init_workspace = __esm({
14502
+ "cli/commands/workspace.ts"() {
14503
+ "use strict";
14504
+ WORKSPACE_CHOICE_DIR = join31(homedir31(), ".synkro", "workspace-choice");
14505
+ TASK_ID = /^task_[a-z0-9]{8}$/i;
14506
+ }
14507
+ });
14508
+
14509
+ // cli/ui/tmux.ts
14510
+ import { execFile as execFile2, spawnSync as spawnSync11 } from "child_process";
14511
+ import { promisify } from "util";
14512
+ function runnerArgs(runner, argv) {
14513
+ return runner.kind === "container" ? ["docker", "exec", "-u", CONTAINER_USER, runner.container, ...argv] : argv;
14514
+ }
14515
+ function runnerInteractiveArgs(runner, argv) {
14516
+ return runner.kind === "container" ? ["docker", "exec", "-it", "-u", CONTAINER_USER, runner.container, ...argv] : argv;
14517
+ }
14518
+ async function run(runner, argv) {
14519
+ const [cmd3, ...args2] = runnerArgs(runner, argv);
14520
+ try {
14521
+ const { stdout, stderr } = await execFileAsync(cmd3, args2, { timeout: 8e3, maxBuffer: 1024 * 1024 });
14522
+ return { ok: true, stdout: String(stdout || ""), stderr: String(stderr || "") };
14523
+ } catch (error) {
14524
+ return { ok: false, stdout: String(error?.stdout || ""), stderr: String(error?.stderr || error?.message || "") };
14525
+ }
14526
+ }
14527
+ function runInherit(argv) {
14528
+ const [cmd3, ...args2] = argv;
14529
+ const result = spawnSync11(cmd3, args2, { stdio: "inherit" });
14530
+ return result.status ?? 1;
14531
+ }
14532
+ function slugify(name) {
14533
+ return String(name || "").toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "agent";
14534
+ }
14535
+ function agentSession(name) {
14536
+ return AGENT_PREFIX + slugify(name);
14537
+ }
14538
+ function buildSpawnAgent(opts) {
14539
+ const session = agentSession(opts.name);
14540
+ return [
14541
+ ["tmux", "new-session", "-d", "-s", session, "-c", opts.cwd, opts.command],
14542
+ ["tmux", "set-option", "-t", session, "status", "off"],
14543
+ ["tmux", "set-option", "-t", session, "-q", "@synkro_harness", opts.harness],
14544
+ ["tmux", "set-option", "-t", session, "-q", "@synkro_space", opts.space],
14545
+ ["tmux", "set-option", "-t", session, "-q", "@synkro_backend", opts.backend],
14546
+ // Keep the pane visible after exit so the sidebar can render 'done'
14547
+ // instead of the agent silently vanishing.
14548
+ ["tmux", "set-option", "-t", session, "remain-on-exit", "on"]
14549
+ ];
14550
+ }
14551
+ function buildListAgents() {
14552
+ return ["tmux", "list-sessions", "-F", ["#{session_name}", "#{?pane_dead,dead,alive}", "#{@synkro_harness}", "#{@synkro_space}", "#{@synkro_backend}", "#{@synkro_pueue}"].join(FIELD_SEP)];
14553
+ }
14554
+ function buildCapture(session, lines = 14) {
14555
+ return ["tmux", "capture-pane", "-p", "-t", session, "-S", String(-lines)];
14556
+ }
14557
+ function buildKillSession(session) {
14558
+ return ["tmux", "kill-session", "-t", session];
14559
+ }
14560
+ function buildSetOption(session, option, value) {
14561
+ return ["tmux", "set-option", "-t", session, "-q", option, value];
14562
+ }
14563
+ function buildSendText(session, text) {
14564
+ return [
14565
+ ["tmux", "send-keys", "-t", session, "-l", text],
14566
+ ["tmux", "send-keys", "-t", session, "Enter"]
14567
+ ];
14568
+ }
14569
+ function buildInterrupt(session) {
14570
+ return ["tmux", "send-keys", "-t", session, "Escape"];
14571
+ }
14572
+ function buildCenterAttachCommand(runner, session) {
14573
+ const argv = runnerInteractiveArgs(runner, ["tmux", "attach-session", "-t", session]);
14574
+ return "env TMUX= " + argv.map(shellQuote3).join(" ");
14575
+ }
14576
+ function shellQuote3(value) {
14577
+ return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : "'" + value.replace(/'/g, "'\\''") + "'";
14578
+ }
14579
+ function parseAgentSessions(output, backend) {
14580
+ return String(output || "").split("\n").map((line) => line.split(FIELD_SEP)).filter((cols) => cols[0]?.startsWith(AGENT_PREFIX)).map((cols) => ({
14581
+ session: cols[0],
14582
+ name: cols[0].slice(AGENT_PREFIX.length),
14583
+ dead: cols[1] === "dead",
14584
+ harness: cols[2] || "claude",
14585
+ space: cols[3] || "",
14586
+ backend: cols[4] || backend,
14587
+ pueue: cols[5] || ""
14588
+ }));
14589
+ }
14590
+ function parseWorktrees(porcelain) {
14591
+ const rows = [];
14592
+ let current = {};
14593
+ for (const line of String(porcelain || "").split("\n")) {
14594
+ if (line.startsWith("worktree ")) current = { path: line.slice(9).trim() };
14595
+ else if (line.startsWith("branch ")) current.branch = line.slice(7).replace("refs/heads/", "").trim();
14596
+ else if (line.trim() === "" && current.path) {
14597
+ rows.push({
14598
+ path: current.path,
14599
+ branch: current.branch || "detached",
14600
+ name: current.path.split("/").filter(Boolean).pop() || current.path
14601
+ });
14602
+ current = {};
14603
+ }
14604
+ }
14605
+ if (current.path) {
14606
+ rows.push({
14607
+ path: current.path,
14608
+ branch: current.branch || "detached",
14609
+ name: current.path.split("/").filter(Boolean).pop() || current.path
14610
+ });
14611
+ }
14612
+ return rows;
14613
+ }
14614
+ var execFileAsync, AGENT_PREFIX, UI_SESSION, CONTAINER_USER, FIELD_SEP;
14615
+ var init_tmux = __esm({
14616
+ "cli/ui/tmux.ts"() {
14617
+ "use strict";
14618
+ execFileAsync = promisify(execFile2);
14619
+ AGENT_PREFIX = "synkro-agent-";
14620
+ UI_SESSION = "synkro-ui";
14621
+ CONTAINER_USER = "synkro";
14622
+ FIELD_SEP = "|";
14623
+ }
14624
+ });
14625
+
14626
+ // cli/ui/launch.ts
14627
+ function welcomeCommand() {
14628
+ const banner = [
14629
+ "",
14630
+ " synkro ui",
14631
+ " governed agents, one screen",
14632
+ "",
14633
+ " enter attach selected agent",
14634
+ " n new agent in selected space",
14635
+ " c new container agent",
14636
+ " g/s/y consent: track / skip / stay",
14637
+ " T new tab q quit",
14638
+ ""
14639
+ ].join("\\n");
14640
+ return "printf " + shellQuote3(banner + "\\n") + "; tail -f /dev/null";
14641
+ }
14642
+ function sidebarCommand(bootPath, centerPane, repoCwd) {
14643
+ const env = [
14644
+ "SYNKRO_UI_CENTER=" + shellQuote3(centerPane),
14645
+ "SYNKRO_UI_OUTER=" + UI_SESSION,
14646
+ "SYNKRO_UI_BOOT=" + shellQuote3(bootPath),
14647
+ "SYNKRO_UI_REPO=" + shellQuote3(repoCwd)
14648
+ ].join(" ");
14649
+ return "env " + env + " node " + shellQuote3(bootPath) + " ui --sidebar";
14650
+ }
14651
+ async function styleOuterSession() {
14652
+ const style = [
14653
+ ["set-option", "-t", UI_SESSION, "status-position", "top"],
14654
+ ["set-option", "-t", UI_SESSION, "status-style", "bg=colour233,fg=colour245"],
14655
+ ["set-option", "-t", UI_SESSION, "status-left", " synkro "],
14656
+ ["set-option", "-t", UI_SESSION, "status-left-style", "fg=colour135,bold"],
14657
+ ["set-option", "-t", UI_SESSION, "status-right", " + (T new tab) "],
14658
+ ["set-option", "-t", UI_SESSION, "status-right-style", "fg=colour240"],
14659
+ ["set-option", "-t", UI_SESSION, "-w", "window-status-format", " #W "],
14660
+ ["set-option", "-t", UI_SESSION, "-w", "window-status-current-format", "#[bg=colour135,fg=colour233,bold] #W #[default]"],
14661
+ ["set-option", "-t", UI_SESSION, "pane-border-style", "fg=colour236"],
14662
+ ["set-option", "-t", UI_SESSION, "pane-active-border-style", "fg=colour135"],
14663
+ // Tab keys without the prefix: Alt+t new tab, Alt+arrows to move.
14664
+ ["bind-key", "-n", "M-t", "new-window"],
14665
+ ["bind-key", "-n", "M-Right", "next-window"],
14666
+ ["bind-key", "-n", "M-Left", "previous-window"]
14667
+ ];
14668
+ for (const argv of style) await run(HOST, ["tmux", ...argv]);
14669
+ }
14670
+ async function buildTab(bootPath, repoCwd, windowTarget) {
14671
+ if (windowTarget === void 0) {
14672
+ await run(HOST, ["tmux", "new-session", "-d", "-s", UI_SESSION, "-x", "220", "-y", "55", welcomeCommand()]);
14673
+ windowTarget = UI_SESSION + ":0";
14674
+ } else {
14675
+ const created = await run(HOST, ["tmux", "new-window", "-t", UI_SESSION, "-P", "-F", "#{window_id}", welcomeCommand()]);
14676
+ windowTarget = created.stdout.trim() || windowTarget;
14677
+ }
14678
+ await run(HOST, ["tmux", "rename-window", "-t", windowTarget, "space"]);
14679
+ const split = await run(HOST, [
14680
+ "tmux",
14681
+ "split-window",
14682
+ "-hb",
14683
+ "-t",
14684
+ windowTarget,
14685
+ "-l",
14686
+ SIDEBAR_WIDTH,
14687
+ "-P",
14688
+ "-F",
14689
+ "#{pane_id}",
14690
+ "tail -f /dev/null"
14691
+ ]);
14692
+ const sidebarPane = split.stdout.trim();
14693
+ const panes = await run(HOST, ["tmux", "list-panes", "-t", windowTarget, "-F", "#{pane_id}"]);
14694
+ const centerPane = panes.stdout.split("\n").map((line) => line.trim()).filter(Boolean).find((id) => id !== sidebarPane) || "";
14695
+ await run(HOST, ["tmux", "respawn-pane", "-k", "-t", sidebarPane, sidebarCommand(bootPath, centerPane, repoCwd)]);
14696
+ }
14697
+ async function uiSessionExists() {
14698
+ const result = await run(HOST, ["tmux", "has-session", "-t", UI_SESSION]);
14699
+ return result.ok;
14700
+ }
14701
+ async function launchUi(bootPath, repoCwd) {
14702
+ if (!await uiSessionExists()) {
14703
+ await buildTab(bootPath, repoCwd);
14704
+ await styleOuterSession();
14705
+ }
14706
+ return runInherit(process.env.TMUX ? ["tmux", "switch-client", "-t", UI_SESSION] : ["tmux", "attach-session", "-t", UI_SESSION]);
14707
+ }
14708
+ var SIDEBAR_WIDTH, HOST;
14709
+ var init_launch = __esm({
14710
+ "cli/ui/launch.ts"() {
14711
+ "use strict";
14712
+ init_tmux();
14713
+ SIDEBAR_WIDTH = "34";
14714
+ HOST = { kind: "host" };
14715
+ }
14716
+ });
14717
+
14718
+ // cli/ui/model.ts
14719
+ function detectAsk(tail2) {
14720
+ for (const marker of BLOCK_MARKERS) {
14721
+ if (marker.pattern.test(String(tail2 || ""))) return marker.ask;
14722
+ }
14723
+ return null;
14724
+ }
14725
+ function deriveStatus(input) {
14726
+ if (input.dead) return { status: "done" };
14727
+ const ask3 = detectAsk(input.tail);
14728
+ if (ask3) return { status: "blocked", ask: ask3 };
14729
+ return { status: input.changed ? "working" : "idle" };
14730
+ }
14731
+ function hashTail(tail2) {
14732
+ let hash = 0;
14733
+ const text = String(tail2 || "");
14734
+ for (let index = 0; index < text.length; index += 1) {
14735
+ hash = (hash << 5) - hash + text.charCodeAt(index) | 0;
14736
+ }
14737
+ return String(hash);
14738
+ }
14739
+ function mapAgentToTask(spacePath, tasks) {
14740
+ const normalized = String(spacePath || "").replace(/\/+$/, "");
14741
+ if (!normalized) return void 0;
14742
+ const hit = tasks.find((task) => task.worktree && task.worktree.replace(/\/+$/, "") === normalized);
14743
+ return hit?.linear || void 0;
14744
+ }
14745
+ async function fetchConductorTasks(baseUrl) {
14746
+ try {
14747
+ const controller = new AbortController();
14748
+ const timer = setTimeout(() => controller.abort(), 900);
14749
+ const response = await fetch(baseUrl + "/api/local/conductor/repositories", { signal: controller.signal });
14750
+ clearTimeout(timer);
14751
+ if (!response.ok) return [];
14752
+ const payload = await response.json().catch(() => null);
14753
+ return Array.isArray(payload?.tasks) ? payload.tasks.map((task) => ({
14754
+ worktree: task?.worktree || null,
14755
+ linear: task?.linear?.key || null,
14756
+ status: String(task?.status || "")
14757
+ })) : [];
14758
+ } catch {
14759
+ return [];
14760
+ }
14761
+ }
14762
+ async function discoverHostSpaces(repoCwd) {
14763
+ const result = await run({ kind: "host" }, ["git", "-C", repoCwd, "worktree", "list", "--porcelain"]);
14764
+ if (!result.ok) return [];
14765
+ return parseWorktrees(result.stdout).map((row2) => ({
14766
+ name: row2.name,
14767
+ branch: row2.branch,
14768
+ path: row2.path,
14769
+ backend: "host"
14770
+ }));
14771
+ }
14772
+ async function discoverContainerSpaces(runner) {
14773
+ if (runner.kind !== "container") return [];
14774
+ const result = await run(runner, ["sh", "-c", "ls -1 /home/synkro/work 2>/dev/null"]);
14775
+ if (!result.ok) return [];
14776
+ return result.stdout.split("\n").map((line) => line.trim()).filter((name) => /^ui-/.test(name)).map((name) => ({
14777
+ name,
14778
+ branch: "container",
14779
+ path: "/home/synkro/work/" + name,
14780
+ backend: "container"
14781
+ }));
14782
+ }
14783
+ async function discoverAgents(runner, backend, previousHashes) {
14784
+ const listed = await run(runner, buildListAgents());
14785
+ const rows = listed.ok ? parseAgentSessions(listed.stdout, backend) : [];
14786
+ const hashes = /* @__PURE__ */ new Map();
14787
+ const agents = [];
14788
+ for (const row2 of rows) {
14789
+ const capture = row2.dead ? { ok: true, stdout: "" } : await run(runner, buildCapture(row2.session));
14790
+ const tail2 = capture.ok ? capture.stdout : "";
14791
+ const nextHash = hashTail(tail2);
14792
+ const changed = previousHashes.has(row2.session) && previousHashes.get(row2.session) !== nextHash;
14793
+ hashes.set(row2.session, nextHash);
14794
+ const derived = deriveStatus({ dead: row2.dead, tail: tail2, changed });
14795
+ agents.push({
14796
+ name: row2.name,
14797
+ session: row2.session,
14798
+ harness: row2.harness,
14799
+ space: row2.space,
14800
+ backend: row2.backend,
14801
+ status: derived.status,
14802
+ ask: derived.ask
14803
+ });
14804
+ }
14805
+ return { agents, hashes };
14806
+ }
14807
+ var BLOCK_MARKERS;
14808
+ var init_model = __esm({
14809
+ "cli/ui/model.ts"() {
14810
+ "use strict";
14811
+ init_tmux();
14812
+ BLOCK_MARKERS = [
14813
+ { pattern: /needs a tracking decision|Task tracking suggestion|tracking decision for "/i, ask: "tracking" },
14814
+ { pattern: /\[synkro:task-workspace|\[synkro:scm|keep working in the current workspace/i, ask: "workspace" },
14815
+ { pattern: /⛔/, ask: "tracking" }
14816
+ ];
14817
+ }
14818
+ });
14819
+
14820
+ // cli/ui/consent.ts
14821
+ function actionsForAsk(ask3) {
14822
+ return ask3 === "workspace" ? ["stay"] : ["track", "skip"];
14823
+ }
14824
+ var CONSENT_PHRASES;
14825
+ var init_consent = __esm({
14826
+ "cli/ui/consent.ts"() {
14827
+ "use strict";
14828
+ CONSENT_PHRASES = {
14829
+ /** Settles the conductor tracking ask by durable decline. */
14830
+ skip: "skip the task tracking, continue without a task",
14831
+ /** Settles the task-workspace ask in place. */
14832
+ stay: "stay in the current worktree",
14833
+ /** Asks the agent to run the two-phase create flow (draft → approve). */
14834
+ track: "yes, track this work \u2014 draft the requirements and create the task"
14835
+ };
14836
+ }
14837
+ });
14838
+
14839
+ // cli/ui/render.ts
14840
+ function pad(text, width) {
14841
+ return text.length >= width ? text.slice(0, width) : text + " ".repeat(width - text.length);
14842
+ }
14843
+ function row(selected, width, content) {
14844
+ const body = stripForPad(" " + content, width);
14845
+ return selected ? STYLE.select + body + STYLE.reset : body;
14846
+ }
14847
+ function stripForPad(text, width) {
14848
+ let visible = 0;
14849
+ let out = "";
14850
+ let index = 0;
14851
+ while (index < text.length && visible < width) {
14852
+ if (text.startsWith(ESC, index)) {
14853
+ const end = text.indexOf("m", index);
14854
+ if (end === -1) break;
14855
+ out += text.slice(index, end + 1);
14856
+ index = end + 1;
14857
+ } else {
14858
+ out += text[index];
14859
+ index += 1;
14860
+ visible += 1;
14861
+ }
14862
+ }
14863
+ return out + " ".repeat(Math.max(0, width - visible));
14864
+ }
14865
+ function renderSidebar(state, width = 32, height = 40) {
14866
+ const lines = [];
14867
+ lines.push("");
14868
+ lines.push(" " + STYLE.header + "spaces" + STYLE.reset);
14869
+ lines.push("");
14870
+ state.spaces.forEach((space, index) => {
14871
+ const selected = state.section === "spaces" && index === state.spaceIndex;
14872
+ const badge = space.backend === "container" ? STYLE.accent + "\u25A3 " + STYLE.reset : "";
14873
+ lines.push(row(selected, width, STYLE.done + "\u25CF" + STYLE.reset + " " + badge + STYLE.bold + space.name + STYLE.reset));
14874
+ lines.push(row(selected, width, " " + STYLE.branch + space.branch + STYLE.reset));
14875
+ });
14876
+ if (state.spaces.length === 0) lines.push(row(false, width, STYLE.dim + "no spaces found" + STYLE.reset));
14877
+ lines.push("");
14878
+ lines.push(" " + STYLE.header + "agents" + STYLE.reset);
14879
+ lines.push("");
14880
+ state.agents.forEach((agent, index) => {
14881
+ const selected = state.section === "agents" && index === state.agentIndex;
14882
+ const dot = DOT[agent.status] || DOT.idle;
14883
+ const badge = agent.backend === "container" ? STYLE.accent + "\u25A3 " + STYLE.reset : "";
14884
+ const linear = agent.linear ? " " + STYLE.dim + agent.linear + STYLE.reset : "";
14885
+ lines.push(row(selected, width, dot + " " + badge + STYLE.bold + agent.name + STYLE.reset + linear));
14886
+ const statusStyle = agent.status === "blocked" ? STYLE.blocked : STYLE.dim;
14887
+ lines.push(row(selected, width, " " + statusStyle + agent.status + STYLE.reset + STYLE.dim + " \xB7 " + agent.harness + STYLE.reset));
14888
+ });
14889
+ if (state.agents.length === 0) lines.push(row(false, width, STYLE.dim + "no agents \u2014 n to spawn" + STYLE.reset));
14890
+ const selectedAgent = state.section === "agents" ? state.agents[state.agentIndex] : void 0;
14891
+ if (selectedAgent?.status === "blocked" && selectedAgent.ask) {
14892
+ lines.push("");
14893
+ lines.push(row(false, width, STYLE.blocked + "\u26D4 needs consent" + STYLE.reset));
14894
+ for (const action of actionsForAsk(selectedAgent.ask)) {
14895
+ const key = action === "track" ? "g" : action === "skip" ? "s" : "y";
14896
+ lines.push(row(false, width, STYLE.dim + " " + key + " \u2014 " + action + STYLE.reset));
14897
+ }
14898
+ }
14899
+ while (lines.length < height - 4) lines.push(pad("", width));
14900
+ lines.push(pad("", width));
14901
+ if (state.message) lines.push(row(false, width, STYLE.accent + state.message.slice(0, width - 2) + STYLE.reset));
14902
+ else lines.push(row(false, width, STYLE.dim + "n new \xB7 enter attach \xB7 x kill" + STYLE.reset));
14903
+ lines.push(row(false, width, STYLE.dim + "T tab \xB7 i interrupt \xB7 q quit" + STYLE.reset));
14904
+ lines.push(row(false, width, STYLE.dim + state.backendNote + STYLE.reset));
14905
+ return lines.slice(0, height).join("\n");
14906
+ }
14907
+ var ESC, STYLE, DOT;
14908
+ var init_render = __esm({
14909
+ "cli/ui/render.ts"() {
14910
+ "use strict";
14911
+ init_consent();
14912
+ ESC = "\x1B[";
14913
+ STYLE = {
14914
+ reset: ESC + "0m",
14915
+ dim: ESC + "2m",
14916
+ bold: ESC + "1m",
14917
+ header: ESC + "38;5;245m",
14918
+ select: ESC + "48;5;236m",
14919
+ working: ESC + "38;5;214m",
14920
+ idle: ESC + "38;5;244m",
14921
+ blocked: ESC + "38;5;203m",
14922
+ done: ESC + "38;5;114m",
14923
+ accent: ESC + "38;5;135m",
14924
+ branch: ESC + "38;5;140m"
14925
+ };
14926
+ DOT = {
14927
+ working: STYLE.working + "\u25CF" + STYLE.reset,
14928
+ idle: STYLE.idle + "\u25CB" + STYLE.reset,
14929
+ blocked: STYLE.blocked + "\u25CF" + STYLE.reset,
14930
+ done: STYLE.done + "\u25CF" + STYLE.reset
14931
+ };
14932
+ }
14933
+ });
14934
+
14935
+ // cli/ui/pueue.ts
14936
+ function buildEnsureGroup() {
14937
+ return ["pueue", "group", "add", PUEUE_GROUP];
14938
+ }
14939
+ function buildGroupParallel() {
14940
+ return ["pueue", "parallel", "-g", PUEUE_GROUP, "32"];
14941
+ }
14942
+ function buildSentinel(session) {
14943
+ const loop = "while tmux has-session -t " + shellQuote3(session) + " 2>/dev/null; do sleep 10; done";
14944
+ return ["pueue", "add", "-g", PUEUE_GROUP, "-l", session, "--", loop];
14945
+ }
14946
+ function buildRemove(taskId) {
14947
+ return ["pueue", "remove", taskId];
14948
+ }
14949
+ async function pueueAvailable2(runner) {
14950
+ const result = await run(runner, ["sh", "-c", "command -v pueue >/dev/null 2>&1 && pueue status >/dev/null 2>&1 && echo ok"]);
14951
+ return result.ok && result.stdout.includes("ok");
14952
+ }
14953
+ async function registerAgent(runner, session) {
14954
+ if (!await pueueAvailable2(runner)) return "";
14955
+ await run(runner, buildEnsureGroup());
14956
+ await run(runner, buildGroupParallel());
14957
+ const added = await run(runner, buildSentinel(session));
14958
+ const match = added.stdout.match(/id\s+(\d+)/i) || added.stderr.match(/id\s+(\d+)/i);
14959
+ return match ? match[1] : "";
14960
+ }
14961
+ async function releaseAgent(runner, pueueId) {
14962
+ if (!pueueId) return;
14963
+ await run(runner, buildRemove(pueueId)).catch?.(() => {
14964
+ });
14965
+ }
14966
+ var PUEUE_GROUP;
14967
+ var init_pueue2 = __esm({
14968
+ "cli/ui/pueue.ts"() {
14969
+ "use strict";
14970
+ init_tmux();
14971
+ PUEUE_GROUP = "synkro-ui";
14972
+ }
14973
+ });
14974
+
14975
+ // cli/ui/backend.ts
14976
+ async function detectContainerBackend() {
14977
+ const runner = { kind: "container", container: CONTAINER_NAME2 };
14978
+ const probe = await run({ kind: "host" }, [
14979
+ "docker",
14980
+ "exec",
14981
+ CONTAINER_NAME2,
14982
+ "sh",
14983
+ "-c",
14984
+ "command -v tmux >/dev/null && command -v claude >/dev/null && ls " + shellQuote3(AUTH_SEED) + " >/dev/null 2>&1 && echo ready"
14985
+ ]);
14986
+ if (probe.ok && probe.stdout.includes("ready")) {
14987
+ return { runner, backend: "container", note: "runtime: container (" + CONTAINER_NAME2 + ")" };
14988
+ }
14989
+ return { runner: { kind: "host" }, backend: "host", note: "runtime: host (container unavailable)" };
14990
+ }
14991
+ async function provisionContainerWorkspace(runner, slug) {
14992
+ const dir = CONTAINER_WORK + "/ui-" + slug;
14993
+ await run(runner, [
14994
+ "sh",
14995
+ "-c",
14996
+ "mkdir -p " + shellQuote3(dir) + " && cp -n " + shellQuote3(AUTH_SEED) + " " + shellQuote3(dir + "/.claude.json") + " 2>/dev/null; true"
14997
+ ]);
14998
+ return dir;
14999
+ }
15000
+ async function spawnAgent(info, request) {
15001
+ const slug = slugify(request.name);
15002
+ const session = agentSession(slug);
15003
+ const runner = request.backend === "container" ? { kind: "container", container: CONTAINER_NAME2 } : { kind: "host" };
15004
+ let cwd = request.cwd;
15005
+ if (request.backend === "container") {
15006
+ cwd = request.cwd.startsWith(CONTAINER_WORK) ? request.cwd : await provisionContainerWorkspace(runner, slug);
15007
+ }
15008
+ const command = request.harness === "codex" ? "codex" : "claude";
15009
+ for (const argv of buildSpawnAgent({ name: slug, cwd, command, harness: request.harness, space: request.spaceName, backend: request.backend })) {
15010
+ const result = await run(runner, argv);
15011
+ if (!result.ok && argv[1] === "new-session") {
15012
+ return { ok: false, session, error: result.stderr.trim() || "tmux new-session failed" };
15013
+ }
15014
+ }
15015
+ const pueueId = await registerAgent(runner, session);
15016
+ if (pueueId) await run(runner, buildSetOption(session, "@synkro_pueue", pueueId));
15017
+ return { ok: true, session };
15018
+ }
15019
+ async function killAgent(backend, session, pueueId) {
15020
+ const runner = backend === "container" ? { kind: "container", container: CONTAINER_NAME2 } : { kind: "host" };
15021
+ await run(runner, buildKillSession(session));
15022
+ await releaseAgent(runner, pueueId);
15023
+ }
15024
+ var CONTAINER_NAME2, CONTAINER_WORK, AUTH_SEED;
15025
+ var init_backend = __esm({
15026
+ "cli/ui/backend.ts"() {
15027
+ "use strict";
15028
+ init_tmux();
15029
+ init_pueue2();
15030
+ CONTAINER_NAME2 = "synkro-server";
15031
+ CONTAINER_WORK = "/home/synkro/work";
15032
+ AUTH_SEED = CONTAINER_WORK + "/claude-1/.claude.json";
15033
+ }
15034
+ });
15035
+
15036
+ // cli/ui/sidebar.ts
15037
+ function runnerFor(backend) {
15038
+ return backend === "container" ? { kind: "container", container: CONTAINER_NAME2 } : { kind: "host" };
15039
+ }
15040
+ async function runSidebar() {
15041
+ const centerPane = process.env.SYNKRO_UI_CENTER || "";
15042
+ const outerSession = process.env.SYNKRO_UI_OUTER || "synkro-ui";
15043
+ const bootPath = process.env.SYNKRO_UI_BOOT || process.argv[1];
15044
+ const repoCwd = process.env.SYNKRO_UI_REPO || process.cwd();
15045
+ const info = await detectContainerBackend();
15046
+ const state = {
15047
+ spaces: [],
15048
+ agents: [],
15049
+ section: "agents",
15050
+ spaceIndex: 0,
15051
+ agentIndex: 0,
15052
+ backendNote: info.note,
15053
+ message: ""
15054
+ };
15055
+ let hashes = /* @__PURE__ */ new Map();
15056
+ let lastFrame = "";
15057
+ let spawnCounter = 1;
15058
+ const host = { kind: "host" };
15059
+ const containerRunner = { kind: "container", container: CONTAINER_NAME2 };
15060
+ async function refresh() {
15061
+ const [hostSpaces, containerSpaces, conductorTasks] = await Promise.all([
15062
+ discoverHostSpaces(repoCwd),
15063
+ info.backend === "container" ? discoverContainerSpaces(containerRunner) : Promise.resolve([]),
15064
+ fetchConductorTasks(CONDUCTOR_URL)
15065
+ ]);
15066
+ state.spaces = [...hostSpaces, ...containerSpaces];
15067
+ const hostAgents = await discoverAgents(host, "host", hashes);
15068
+ const containerAgents = info.backend === "container" ? await discoverAgents(containerRunner, "container", hashes) : { agents: [], hashes: /* @__PURE__ */ new Map() };
15069
+ hashes = new Map([...hostAgents.hashes, ...containerAgents.hashes]);
15070
+ state.agents = [...hostAgents.agents, ...containerAgents.agents].map((agent) => ({
15071
+ ...agent,
15072
+ linear: mapAgentToTask(agent.space, conductorTasks)
15073
+ }));
15074
+ state.spaceIndex = Math.min(state.spaceIndex, Math.max(0, state.spaces.length - 1));
15075
+ state.agentIndex = Math.min(state.agentIndex, Math.max(0, state.agents.length - 1));
15076
+ }
15077
+ function draw() {
15078
+ const rows = Number(process.stdout.rows || 42);
15079
+ const cols = Number(process.stdout.columns || 32);
15080
+ const frame = renderSidebar(state, cols, rows);
15081
+ if (frame === lastFrame) return;
15082
+ lastFrame = frame;
15083
+ process.stdout.write("\x1B[2J\x1B[H" + frame);
15084
+ }
15085
+ async function attachSelected() {
15086
+ const agent = state.agents[state.agentIndex];
15087
+ if (!agent || !centerPane) return;
15088
+ const command = buildCenterAttachCommand(runnerFor(agent.backend), agent.session);
15089
+ await run(host, ["tmux", "respawn-pane", "-k", "-t", centerPane, command]);
15090
+ await run(host, ["tmux", "rename-window", "-t", outerSession, agent.name]);
15091
+ state.message = "attached " + agent.name;
15092
+ }
15093
+ async function spawnInSelectedSpace() {
15094
+ const space = state.spaces[state.spaceIndex] || state.spaces[0];
15095
+ if (!space) {
15096
+ state.message = "no space selected";
15097
+ return;
15098
+ }
15099
+ const name = space.name + "-" + spawnCounter++;
15100
+ const result = await spawnAgent(info, {
15101
+ name,
15102
+ harness: "claude",
15103
+ spaceName: space.name,
15104
+ cwd: space.path,
15105
+ backend: space.backend === "container" && info.backend === "container" ? "container" : "host"
15106
+ });
15107
+ state.message = result.ok ? "spawned " + name : "spawn failed: " + (result.error || "").slice(0, 24);
15108
+ }
15109
+ async function spawnContainerAgent() {
15110
+ if (info.backend !== "container") {
15111
+ state.message = "container runtime unavailable";
15112
+ return;
15113
+ }
15114
+ const name = "box-" + spawnCounter++;
15115
+ const result = await spawnAgent(info, { name, harness: "claude", spaceName: name, cwd: "", backend: "container" });
15116
+ state.message = result.ok ? "spawned \u25A3 " + name : "spawn failed: " + (result.error || "").slice(0, 24);
15117
+ }
15118
+ async function consent(action) {
15119
+ const agent = state.agents[state.agentIndex];
15120
+ if (!agent || agent.status !== "blocked" || !agent.ask) return;
15121
+ if (!actionsForAsk(agent.ask).includes(action)) return;
15122
+ const runner = runnerFor(agent.backend);
15123
+ for (const argv of buildSendText(agent.session, CONSENT_PHRASES[action])) await run(runner, argv);
15124
+ state.message = action + " \u2192 " + agent.name;
15125
+ }
15126
+ async function newTab() {
15127
+ await run(host, ["node", String(bootPath), "ui", "--new-tab", outerSession]);
15128
+ }
15129
+ process.stdin.setRawMode?.(true);
15130
+ process.stdin.resume();
15131
+ process.stdin.on("data", (chunk) => {
15132
+ const key = chunk.toString("utf8");
15133
+ void (async () => {
15134
+ const list = state.section === "spaces" ? state.spaces : state.agents;
15135
+ if (key === " ") state.section = state.section === "spaces" ? "agents" : "spaces";
15136
+ else if (key === "j" || key === "\x1B[B") {
15137
+ if (state.section === "spaces") state.spaceIndex = Math.min(state.spaceIndex + 1, Math.max(0, list.length - 1));
15138
+ else state.agentIndex = Math.min(state.agentIndex + 1, Math.max(0, list.length - 1));
15139
+ } else if (key === "k" || key === "\x1B[A") {
15140
+ if (state.section === "spaces") state.spaceIndex = Math.max(0, state.spaceIndex - 1);
15141
+ else state.agentIndex = Math.max(0, state.agentIndex - 1);
15142
+ } else if (key === "\r") {
15143
+ if (state.section === "agents") await attachSelected();
15144
+ else state.message = "space: " + (state.spaces[state.spaceIndex]?.name || "");
15145
+ } else if (key === "n") await spawnInSelectedSpace();
15146
+ else if (key === "c") await spawnContainerAgent();
15147
+ else if (key === "x") {
15148
+ const agent = state.agents[state.agentIndex];
15149
+ if (agent) {
15150
+ await killAgent(agent.backend, agent.session, "");
15151
+ state.message = "killed " + agent.name;
15152
+ }
15153
+ } else if (key === "i") {
15154
+ const agent = state.agents[state.agentIndex];
15155
+ if (agent) await run(runnerFor(agent.backend), buildInterrupt(agent.session));
15156
+ } else if (key === "g") await consent("track");
15157
+ else if (key === "s") await consent("skip");
15158
+ else if (key === "y") await consent("stay");
15159
+ else if (key === "T") await newTab();
15160
+ else if (key === "q" || key === "") {
15161
+ await run(host, ["tmux", "kill-session", "-t", outerSession]);
15162
+ process.exit(0);
15163
+ }
15164
+ await refresh();
15165
+ draw();
15166
+ })();
15167
+ });
15168
+ await refresh();
15169
+ draw();
15170
+ setInterval(() => {
15171
+ void refresh().then(draw);
15172
+ }, POLL_MS);
15173
+ }
15174
+ var POLL_MS, CONDUCTOR_URL;
15175
+ var init_sidebar = __esm({
15176
+ "cli/ui/sidebar.ts"() {
15177
+ "use strict";
15178
+ init_model();
15179
+ init_render();
15180
+ init_consent();
15181
+ init_backend();
15182
+ init_tmux();
15183
+ POLL_MS = 2e3;
15184
+ CONDUCTOR_URL = "http://127.0.0.1:" + (process.env.SYNKRO_HOST_MCP_PORT || "18931");
15185
+ }
15186
+ });
15187
+
15188
+ // cli/commands/ui.ts
15189
+ var ui_exports = {};
15190
+ __export(ui_exports, {
15191
+ uiCommand: () => uiCommand
15192
+ });
15193
+ import { execSync as execSync7 } from "child_process";
15194
+ function repoRoot() {
15195
+ try {
15196
+ return execSync7("git rev-parse --show-toplevel", { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim() || process.cwd();
15197
+ } catch {
15198
+ return process.cwd();
15199
+ }
15200
+ }
15201
+ function tmuxPresent() {
15202
+ try {
15203
+ execSync7("tmux -V", { stdio: ["pipe", "pipe", "pipe"] });
15204
+ return true;
15205
+ } catch {
15206
+ return false;
15207
+ }
15208
+ }
15209
+ async function uiCommand(args2) {
15210
+ const bootPath = String(process.argv[1] || "");
15211
+ if (args2.includes("--sidebar")) {
15212
+ await runSidebar();
15213
+ await new Promise(() => {
15214
+ });
15215
+ return;
15216
+ }
15217
+ if (args2.includes("--new-tab")) {
15218
+ await buildTab(bootPath, repoRoot(), "new");
15219
+ return;
15220
+ }
15221
+ if (!tmuxPresent()) {
15222
+ console.error("synkro ui needs tmux. Install it (brew install tmux) and rerun.");
15223
+ process.exitCode = 1;
15224
+ return;
15225
+ }
15226
+ const code = await launchUi(bootPath, repoRoot());
15227
+ process.exitCode = code;
15228
+ }
15229
+ var init_ui = __esm({
15230
+ "cli/commands/ui.ts"() {
15231
+ "use strict";
15232
+ init_launch();
15233
+ init_sidebar();
15234
+ }
15235
+ });
15236
+
14090
15237
  // cli/commands/refresh.ts
14091
15238
  var refresh_exports = {};
14092
15239
  __export(refresh_exports, {
@@ -14124,11 +15271,11 @@ __export(linear_exports, {
14124
15271
  linearCommand: () => linearCommand
14125
15272
  });
14126
15273
  import { readFileSync as readFileSync30 } from "fs";
14127
- import { homedir as homedir31 } from "os";
14128
- import { join as join31 } from "path";
15274
+ import { homedir as homedir32 } from "os";
15275
+ import { join as join32 } from "path";
14129
15276
  function mcpJwt() {
14130
15277
  try {
14131
- return readFileSync30(join31(SYNKRO_DIR14, ".mcp-jwt"), "utf-8").trim();
15278
+ return readFileSync30(join32(SYNKRO_DIR14, ".mcp-jwt"), "utf-8").trim();
14132
15279
  } catch {
14133
15280
  return "";
14134
15281
  }
@@ -14167,7 +15314,7 @@ var SYNKRO_DIR14, PORT2, BASE;
14167
15314
  var init_linear = __esm({
14168
15315
  "cli/commands/linear.ts"() {
14169
15316
  "use strict";
14170
- SYNKRO_DIR14 = join31(homedir31(), ".synkro");
15317
+ SYNKRO_DIR14 = join32(homedir32(), ".synkro");
14171
15318
  PORT2 = process.env.SYNKRO_MCP_PORT || "18931";
14172
15319
  BASE = `http://127.0.0.1:${PORT2}`;
14173
15320
  }
@@ -14316,33 +15463,33 @@ var init_cveReachability = __esm({
14316
15463
  });
14317
15464
 
14318
15465
  // cli/reachability/reachabilityScan.ts
14319
- import { spawnSync as spawnSync11, execFileSync as execFileSync5 } from "child_process";
14320
- import { readFileSync as readFileSync32, writeFileSync as writeFileSync22, existsSync as existsSync33, readdirSync as readdirSync8 } from "fs";
14321
- import { join as join32 } from "path";
14322
- import { homedir as homedir32 } from "os";
15466
+ import { spawnSync as spawnSync12, execFileSync as execFileSync5 } from "child_process";
15467
+ import { readFileSync as readFileSync32, writeFileSync as writeFileSync23, existsSync as existsSync34, readdirSync as readdirSync9 } from "fs";
15468
+ import { join as join33 } from "path";
15469
+ import { homedir as homedir33 } from "os";
14323
15470
  import { createRequire } from "module";
14324
- function walkSourceFiles(repoRoot2, maxFiles = 4e3, maxBytes = 5e5) {
15471
+ function walkSourceFiles(repoRoot3, maxFiles = 4e3, maxBytes = 5e5) {
14325
15472
  const SKIP = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", "coverage", ".next", ".turbo", "out", ".cache", ".synkro", ".claude", "vendor", "__tests__", "test-results"]);
14326
15473
  const EXT = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
14327
15474
  const files = [];
14328
- const stack = [repoRoot2];
15475
+ const stack = [repoRoot3];
14329
15476
  while (stack.length && files.length < maxFiles) {
14330
15477
  const dir = stack.pop();
14331
15478
  let ents;
14332
15479
  try {
14333
- ents = readdirSync8(dir, { withFileTypes: true });
15480
+ ents = readdirSync9(dir, { withFileTypes: true });
14334
15481
  } catch {
14335
15482
  continue;
14336
15483
  }
14337
15484
  for (const e of ents) {
14338
15485
  if (files.length >= maxFiles) break;
14339
- const full = join32(dir, e.name);
15486
+ const full = join33(dir, e.name);
14340
15487
  if (e.isDirectory()) {
14341
15488
  if (!SKIP.has(e.name) && !e.name.startsWith(".")) stack.push(full);
14342
15489
  continue;
14343
15490
  }
14344
15491
  if (!EXT.test(e.name) || e.name.endsWith(".d.ts")) continue;
14345
- const rel = full.startsWith(repoRoot2 + "/") ? full.slice(repoRoot2.length + 1) : full;
15492
+ const rel = full.startsWith(repoRoot3 + "/") ? full.slice(repoRoot3.length + 1) : full;
14346
15493
  try {
14347
15494
  const content = readFileSync32(full, "utf8");
14348
15495
  if (content.length <= maxBytes) files.push({ path: rel, content });
@@ -14360,15 +15507,15 @@ function cleanVersion(spec) {
14360
15507
  const c = s.replace(/^[\^~>=<\s]+/, "");
14361
15508
  return /^\d[\w.\-+]*$/.test(c) ? c : null;
14362
15509
  }
14363
- function gatherManifestVersions(repoRoot2) {
15510
+ function gatherManifestVersions(repoRoot3) {
14364
15511
  const out = {};
14365
- const dirs = [repoRoot2];
14366
- const pkgsDir = join32(repoRoot2, "packages");
14367
- if (existsSync33(pkgsDir)) {
15512
+ const dirs = [repoRoot3];
15513
+ const pkgsDir = join33(repoRoot3, "packages");
15514
+ if (existsSync34(pkgsDir)) {
14368
15515
  try {
14369
- for (const d of readdirSync8(pkgsDir)) {
14370
- const pd = join32(pkgsDir, d);
14371
- if (existsSync33(join32(pd, "package.json"))) dirs.push(pd);
15516
+ for (const d of readdirSync9(pkgsDir)) {
15517
+ const pd = join33(pkgsDir, d);
15518
+ if (existsSync34(join33(pd, "package.json"))) dirs.push(pd);
14372
15519
  }
14373
15520
  } catch {
14374
15521
  }
@@ -14377,7 +15524,7 @@ function gatherManifestVersions(repoRoot2) {
14377
15524
  for (const dir of dirs) {
14378
15525
  let pkg;
14379
15526
  try {
14380
- pkg = JSON.parse(readFileSync32(join32(dir, "package.json"), "utf8"));
15527
+ pkg = JSON.parse(readFileSync32(join33(dir, "package.json"), "utf8"));
14381
15528
  } catch {
14382
15529
  continue;
14383
15530
  }
@@ -14393,32 +15540,32 @@ function gatherManifestVersions(repoRoot2) {
14393
15540
  }
14394
15541
  return out;
14395
15542
  }
14396
- function findJelly(repoRoot2) {
15543
+ function findJelly(repoRoot3) {
14397
15544
  try {
14398
15545
  const pkgJson = require2.resolve("@cs-au-dk/jelly/package.json");
14399
15546
  const dir = pkgJson.slice(0, pkgJson.length - "package.json".length);
14400
15547
  const pkg = JSON.parse(readFileSync32(pkgJson, "utf8"));
14401
15548
  const bin = typeof pkg.bin === "string" ? pkg.bin : pkg.bin && (pkg.bin.jelly || pkg.bin[Object.keys(pkg.bin)[0]]);
14402
15549
  if (bin) {
14403
- const p = join32(dir, bin);
14404
- if (existsSync33(p)) return p;
15550
+ const p = join33(dir, bin);
15551
+ if (existsSync34(p)) return p;
14405
15552
  }
14406
15553
  } catch {
14407
15554
  }
14408
- for (const base of [repoRoot2, process.cwd()]) {
14409
- const b = join32(base, "node_modules", ".bin", "jelly");
14410
- if (existsSync33(b)) return b;
15555
+ for (const base of [repoRoot3, process.cwd()]) {
15556
+ const b = join33(base, "node_modules", ".bin", "jelly");
15557
+ if (existsSync34(b)) return b;
14411
15558
  }
14412
15559
  return null;
14413
15560
  }
14414
- function findEntries(repoRoot2) {
14415
- const dirs = [repoRoot2];
14416
- const pkgsDir = join32(repoRoot2, "packages");
14417
- if (existsSync33(pkgsDir)) {
15561
+ function findEntries(repoRoot3) {
15562
+ const dirs = [repoRoot3];
15563
+ const pkgsDir = join33(repoRoot3, "packages");
15564
+ if (existsSync34(pkgsDir)) {
14418
15565
  try {
14419
- for (const d of readdirSync8(pkgsDir)) {
14420
- const pd = join32(pkgsDir, d);
14421
- if (existsSync33(join32(pd, "package.json"))) dirs.push(pd);
15566
+ for (const d of readdirSync9(pkgsDir)) {
15567
+ const pd = join33(pkgsDir, d);
15568
+ if (existsSync34(join33(pd, "package.json"))) dirs.push(pd);
14422
15569
  }
14423
15570
  } catch {
14424
15571
  }
@@ -14426,12 +15573,12 @@ function findEntries(repoRoot2) {
14426
15573
  const entries = [];
14427
15574
  for (const dir of dirs) {
14428
15575
  try {
14429
- const pkg = JSON.parse(readFileSync32(join32(dir, "package.json"), "utf8"));
15576
+ const pkg = JSON.parse(readFileSync32(join33(dir, "package.json"), "utf8"));
14430
15577
  const cands = [pkg.source, pkg.module, pkg.main, "src/index.ts", "src/index.js", "src/main.ts", "src/server.ts", "index.ts", "index.js"];
14431
15578
  for (const c of cands) {
14432
15579
  if (typeof c !== "string") continue;
14433
- const f = join32(dir, c);
14434
- if (existsSync33(f)) {
15580
+ const f = join33(dir, c);
15581
+ if (existsSync34(f)) {
14435
15582
  entries.push(f);
14436
15583
  break;
14437
15584
  }
@@ -14441,9 +15588,9 @@ function findEntries(repoRoot2) {
14441
15588
  }
14442
15589
  return entries.slice(0, 40);
14443
15590
  }
14444
- function currentCommit(repoRoot2) {
15591
+ function currentCommit(repoRoot3) {
14445
15592
  try {
14446
- return execFileSync5("git", ["rev-parse", "HEAD"], { cwd: repoRoot2, encoding: "utf8" }).trim();
15593
+ return execFileSync5("git", ["rev-parse", "HEAD"], { cwd: repoRoot3, encoding: "utf8" }).trim();
14447
15594
  } catch {
14448
15595
  return "";
14449
15596
  }
@@ -14462,9 +15609,9 @@ function parseApiUsage(log) {
14462
15609
  for (const k of Object.keys(map)) out[k] = { reachableApis: [...map[k]].slice(0, 60) };
14463
15610
  return out;
14464
15611
  }
14465
- function runReachabilityScan(repoRoot2, opts = {}) {
14466
- const commit = currentCommit(repoRoot2);
14467
- if (!opts.force && commit && existsSync33(REACHABILITY_PATH)) {
15612
+ function runReachabilityScan(repoRoot3, opts = {}) {
15613
+ const commit = currentCommit(repoRoot3);
15614
+ if (!opts.force && commit && existsSync34(REACHABILITY_PATH)) {
14468
15615
  try {
14469
15616
  const prev = JSON.parse(readFileSync32(REACHABILITY_PATH, "utf8"));
14470
15617
  if (prev.commit === commit) return { ok: true, cached: true, packages: Object.keys(prev.packages || {}).length };
@@ -14513,13 +15660,13 @@ function runReachabilityScan(repoRoot2, opts = {}) {
14513
15660
  }
14514
15661
  };
14515
15662
  let tool = "ast";
14516
- const jelly = findJelly(repoRoot2);
15663
+ const jelly = findJelly(repoRoot3);
14517
15664
  if (jelly) {
14518
- const entries = findEntries(repoRoot2);
15665
+ const entries = findEntries(repoRoot3);
14519
15666
  if (entries.length > 0) {
14520
- const r = spawnSync11(
15667
+ const r = spawnSync12(
14521
15668
  process.execPath,
14522
- [jelly, "-b", repoRoot2, "--api-usage", ...entries],
15669
+ [jelly, "-b", repoRoot3, "--api-usage", ...entries],
14523
15670
  { encoding: "utf8", timeout: opts.timeoutMs ?? 18e4, maxBuffer: 2e8 }
14524
15671
  );
14525
15672
  const jp = parseApiUsage((r.stdout || "") + (r.stderr || ""));
@@ -14529,7 +15676,7 @@ function runReachabilityScan(repoRoot2, opts = {}) {
14529
15676
  }
14530
15677
  }
14531
15678
  }
14532
- const astPackages = extractAllPackageUsage(walkSourceFiles(repoRoot2));
15679
+ const astPackages = extractAllPackageUsage(walkSourceFiles(repoRoot3));
14533
15680
  for (const [k, v] of Object.entries(astPackages)) {
14534
15681
  addAll(k, v.apis);
14535
15682
  addSites(k, v.sites);
@@ -14553,9 +15700,9 @@ function runReachabilityScan(repoRoot2, opts = {}) {
14553
15700
  packages[k] = entry;
14554
15701
  }
14555
15702
  if (Object.keys(packages).length === 0) return { ok: false, reason: "no package usage found (no jelly output, no AST imports)" };
14556
- const file = { generatedAt: (/* @__PURE__ */ new Date()).toISOString(), commit, tool, packages, versions: gatherManifestVersions(repoRoot2) };
15703
+ const file = { generatedAt: (/* @__PURE__ */ new Date()).toISOString(), commit, tool, packages, versions: gatherManifestVersions(repoRoot3) };
14557
15704
  try {
14558
- writeFileSync22(REACHABILITY_PATH, JSON.stringify(file, null, 2));
15705
+ writeFileSync23(REACHABILITY_PATH, JSON.stringify(file, null, 2));
14559
15706
  } catch (e) {
14560
15707
  return { ok: false, reason: "write failed: " + String(e.message || e) };
14561
15708
  }
@@ -14567,7 +15714,7 @@ var init_reachabilityScan = __esm({
14567
15714
  "use strict";
14568
15715
  init_cveReachability();
14569
15716
  require2 = createRequire(import.meta.url);
14570
- REACHABILITY_PATH = join32(homedir32(), ".synkro", "reachability.json");
15717
+ REACHABILITY_PATH = join33(homedir33(), ".synkro", "reachability.json");
14571
15718
  }
14572
15719
  });
14573
15720
 
@@ -14576,13 +15723,13 @@ var reachabilityScan_exports = {};
14576
15723
  __export(reachabilityScan_exports, {
14577
15724
  reachabilityScanCommand: () => reachabilityScanCommand
14578
15725
  });
14579
- import { readFileSync as readFileSync33, existsSync as existsSync34 } from "fs";
14580
- import { join as join33 } from "path";
14581
- import { homedir as homedir33 } from "os";
15726
+ import { readFileSync as readFileSync33, existsSync as existsSync35 } from "fs";
15727
+ import { join as join34 } from "path";
15728
+ import { homedir as homedir34 } from "os";
14582
15729
  import { execFileSync as execFileSync6 } from "child_process";
14583
15730
  function readConfigEnv4() {
14584
- const p = join33(SYNKRO_DIR15, "config.env");
14585
- if (!existsSync34(p)) return {};
15731
+ const p = join34(SYNKRO_DIR15, "config.env");
15732
+ if (!existsSync35(p)) return {};
14586
15733
  const out = {};
14587
15734
  for (const line of readFileSync33(p, "utf-8").split("\n")) {
14588
15735
  const t = line.trim();
@@ -14592,7 +15739,7 @@ function readConfigEnv4() {
14592
15739
  }
14593
15740
  return out;
14594
15741
  }
14595
- function repoRoot() {
15742
+ function repoRoot2() {
14596
15743
  try {
14597
15744
  return execFileSync6("git", ["rev-parse", "--show-toplevel"], { encoding: "utf-8" }).trim();
14598
15745
  } catch {
@@ -14600,14 +15747,14 @@ function repoRoot() {
14600
15747
  }
14601
15748
  }
14602
15749
  function repoSlug(root) {
14603
- const run = (a) => {
15750
+ const run2 = (a) => {
14604
15751
  try {
14605
15752
  return execFileSync6("git", a, { encoding: "utf-8" }).trim();
14606
15753
  } catch {
14607
15754
  return "";
14608
15755
  }
14609
15756
  };
14610
- const remote = run(["remote", "get-url", "origin"]);
15757
+ const remote = run2(["remote", "get-url", "origin"]);
14611
15758
  if (remote) return remote.replace(/^git@[^:]+:/, "").replace(/^https?:\/\/[^/]+\//, "").replace(/\.git$/, "");
14612
15759
  return root.split("/").pop() || root;
14613
15760
  }
@@ -14616,10 +15763,10 @@ async function pushToCloud(cfg, repo) {
14616
15763
  while (gwBase.endsWith("/")) gwBase = gwBase.slice(0, -1);
14617
15764
  let jwt2 = "";
14618
15765
  try {
14619
- jwt2 = readFileSync33(join33(SYNKRO_DIR15, ".mcp-jwt"), "utf-8").trim();
15766
+ jwt2 = readFileSync33(join34(SYNKRO_DIR15, ".mcp-jwt"), "utf-8").trim();
14620
15767
  } catch {
14621
15768
  }
14622
- if (!jwt2 || !existsSync34(REACHABILITY_PATH)) return;
15769
+ if (!jwt2 || !existsSync35(REACHABILITY_PATH)) return;
14623
15770
  const body = readFileSync33(REACHABILITY_PATH, "utf-8");
14624
15771
  try {
14625
15772
  const resp = await fetch(gwBase + "/api/v1/reachability?repo=" + encodeURIComponent(repo), {
@@ -14634,7 +15781,7 @@ async function pushToCloud(cfg, repo) {
14634
15781
  }
14635
15782
  }
14636
15783
  async function reachabilityScanCommand(args2 = []) {
14637
- const root = repoRoot();
15784
+ const root = repoRoot2();
14638
15785
  const force = args2.includes("--force");
14639
15786
  const quiet = args2.includes("--quiet");
14640
15787
  const res = runReachabilityScan(root, { force });
@@ -14652,7 +15799,7 @@ var init_reachabilityScan2 = __esm({
14652
15799
  "cli/commands/reachabilityScan.ts"() {
14653
15800
  "use strict";
14654
15801
  init_reachabilityScan();
14655
- SYNKRO_DIR15 = join33(homedir33(), ".synkro");
15802
+ SYNKRO_DIR15 = join34(homedir34(), ".synkro");
14656
15803
  }
14657
15804
  });
14658
15805
 
@@ -14782,11 +15929,11 @@ var config_exports = {};
14782
15929
  __export(config_exports, {
14783
15930
  configCommand: () => configCommand
14784
15931
  });
14785
- import { readFileSync as readFileSync34, writeFileSync as writeFileSync23, existsSync as existsSync35 } from "fs";
14786
- import { join as join34 } from "path";
14787
- import { homedir as homedir34 } from "os";
15932
+ import { readFileSync as readFileSync34, writeFileSync as writeFileSync24, existsSync as existsSync36 } from "fs";
15933
+ import { join as join35 } from "path";
15934
+ import { homedir as homedir35 } from "os";
14788
15935
  function readConfigEnv5() {
14789
- if (!existsSync35(CONFIG_PATH9)) return {};
15936
+ if (!existsSync36(CONFIG_PATH9)) return {};
14790
15937
  const out = {};
14791
15938
  for (const line of readFileSync34(CONFIG_PATH9, "utf-8").split("\n")) {
14792
15939
  const t = line.trim();
@@ -14797,7 +15944,7 @@ function readConfigEnv5() {
14797
15944
  return out;
14798
15945
  }
14799
15946
  function updateConfigValue(key, value) {
14800
- if (!existsSync35(CONFIG_PATH9)) {
15947
+ if (!existsSync36(CONFIG_PATH9)) {
14801
15948
  console.error("No config found. Run `synkro install` first.");
14802
15949
  process.exit(1);
14803
15950
  }
@@ -14812,7 +15959,7 @@ function updateConfigValue(key, value) {
14812
15959
  return line;
14813
15960
  });
14814
15961
  if (!found) updated.splice(updated.length - 1, 0, `${key}='${value}'`);
14815
- writeFileSync23(CONFIG_PATH9, updated.join("\n"), "utf-8");
15962
+ writeFileSync24(CONFIG_PATH9, updated.join("\n"), "utf-8");
14816
15963
  }
14817
15964
  function resolveInferenceMode(cfg) {
14818
15965
  if ((cfg.SYNKRO_GRADING_MODE || "local") === "byok") return "byok";
@@ -14970,8 +16117,8 @@ var init_config = __esm({
14970
16117
  "use strict";
14971
16118
  init_stub();
14972
16119
  init_optout();
14973
- SYNKRO_DIR16 = join34(homedir34(), ".synkro");
14974
- CONFIG_PATH9 = join34(SYNKRO_DIR16, "config.env");
16120
+ SYNKRO_DIR16 = join35(homedir35(), ".synkro");
16121
+ CONFIG_PATH9 = join35(SYNKRO_DIR16, "config.env");
14975
16122
  }
14976
16123
  });
14977
16124
 
@@ -15035,11 +16182,11 @@ async function printTail(args2) {
15035
16182
  console.log("(no events \u2014 run `synkro start` if the container is down so JSONL pending events can drain)");
15036
16183
  return;
15037
16184
  }
15038
- for (const row of rows) {
15039
- const session = row.cc_session_id ? ` session=${String(row.cc_session_id).slice(0, 8)}` : "";
15040
- const ts = typeof row.occurred_at === "string" ? row.occurred_at : new Date(row.occurred_at).toISOString();
15041
- console.log(` ${ts} ${row.event_type.padEnd(20)} ${row.emitter}${session}`);
15042
- const contextStr = typeof row.context === "string" ? row.context : JSON.stringify(row.context);
16185
+ for (const row2 of rows) {
16186
+ const session = row2.cc_session_id ? ` session=${String(row2.cc_session_id).slice(0, 8)}` : "";
16187
+ const ts = typeof row2.occurred_at === "string" ? row2.occurred_at : new Date(row2.occurred_at).toISOString();
16188
+ console.log(` ${ts} ${row2.event_type.padEnd(20)} ${row2.emitter}${session}`);
16189
+ const contextStr = typeof row2.context === "string" ? row2.context : JSON.stringify(row2.context);
15043
16190
  console.log(` context: ${truncate2(contextStr, 200)}`);
15044
16191
  }
15045
16192
  }
@@ -15161,27 +16308,27 @@ Usage:
15161
16308
 
15162
16309
  // cli/inventory/identity.ts
15163
16310
  import { randomUUID as randomUUID5 } from "crypto";
15164
- import { existsSync as existsSync36, mkdirSync as mkdirSync19, readFileSync as readFileSync35, renameSync as renameSync9, writeFileSync as writeFileSync24 } from "fs";
15165
- import { homedir as homedir35 } from "os";
15166
- import { dirname as dirname9, join as join35 } from "path";
16311
+ import { existsSync as existsSync37, mkdirSync as mkdirSync20, readFileSync as readFileSync35, renameSync as renameSync9, writeFileSync as writeFileSync25 } from "fs";
16312
+ import { homedir as homedir36 } from "os";
16313
+ import { dirname as dirname9, join as join36 } from "path";
15167
16314
  function operationalIdentityPath() {
15168
- return process.env.SYNKRO_OPERATIONAL_IDENTITY_PATH || join35(homedir35(), ".synkro", "installation.json");
16315
+ return process.env.SYNKRO_OPERATIONAL_IDENTITY_PATH || join36(homedir36(), ".synkro", "installation.json");
15169
16316
  }
15170
16317
  function validIdentity(value) {
15171
16318
  if (!value || typeof value !== "object") return false;
15172
- const row = value;
15173
- return typeof row.installation_id === "string" && UUID_RE.test(row.installation_id) && typeof row.created_at === "string" && Number.isFinite(Date.parse(row.created_at));
16319
+ const row2 = value;
16320
+ return typeof row2.installation_id === "string" && UUID_RE.test(row2.installation_id) && typeof row2.created_at === "string" && Number.isFinite(Date.parse(row2.created_at));
15174
16321
  }
15175
16322
  function writeIdentity(path, identity) {
15176
- mkdirSync19(dirname9(path), { recursive: true, mode: 448 });
16323
+ mkdirSync20(dirname9(path), { recursive: true, mode: 448 });
15177
16324
  const temp = `${path}.${process.pid}.${randomUUID5()}.tmp`;
15178
- writeFileSync24(temp, JSON.stringify(identity, null, 2) + "\n", { encoding: "utf8", mode: 384 });
16325
+ writeFileSync25(temp, JSON.stringify(identity, null, 2) + "\n", { encoding: "utf8", mode: 384 });
15179
16326
  renameSync9(temp, path);
15180
16327
  }
15181
16328
  function getOperationalInstallationIdentity(path = operationalIdentityPath()) {
15182
16329
  const prior = cached4.get(path);
15183
16330
  if (prior) return prior;
15184
- if (existsSync36(path)) {
16331
+ if (existsSync37(path)) {
15185
16332
  try {
15186
16333
  const parsed = JSON.parse(readFileSync35(path, "utf8"));
15187
16334
  if (validIdentity(parsed)) {
@@ -15208,13 +16355,13 @@ var init_identity2 = __esm({
15208
16355
  // cli/inventory/collector.ts
15209
16356
  import { createHash as createHash5 } from "crypto";
15210
16357
  import {
15211
- existsSync as existsSync37,
16358
+ existsSync as existsSync38,
15212
16359
  readFileSync as readFileSync36,
15213
- readdirSync as readdirSync9,
16360
+ readdirSync as readdirSync10,
15214
16361
  statSync as statSync5
15215
16362
  } from "fs";
15216
- import { arch, homedir as homedir36, hostname as hostname2, platform as platform5, release } from "os";
15217
- import { basename as basename3, join as join36, relative, resolve as resolve5 } from "path";
16363
+ import { arch, homedir as homedir37, hostname as hostname2, platform as platform5, release } from "os";
16364
+ import { basename as basename3, join as join37, relative, resolve as resolve5 } from "path";
15218
16365
  import { fileURLToPath } from "url";
15219
16366
  function sha256(value) {
15220
16367
  return createHash5("sha256").update(value).digest("hex");
@@ -15224,14 +16371,14 @@ function pseudonymousHostnameHash(installationId, host) {
15224
16371
  }
15225
16372
  function cliVersion() {
15226
16373
  try {
15227
- return "1.8.0";
16374
+ return "1.10.2";
15228
16375
  } catch {
15229
16376
  return "0.0.0";
15230
16377
  }
15231
16378
  }
15232
16379
  function readJson(path) {
15233
16380
  try {
15234
- if (!existsSync37(path)) return null;
16381
+ if (!existsSync38(path)) return null;
15235
16382
  const parsed = JSON.parse(readFileSync36(path, "utf8"));
15236
16383
  return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
15237
16384
  } catch {
@@ -15240,7 +16387,7 @@ function readJson(path) {
15240
16387
  }
15241
16388
  function readText(path) {
15242
16389
  try {
15243
- if (!existsSync37(path)) return "";
16390
+ if (!existsSync38(path)) return "";
15244
16391
  return readFileSync36(path, "utf8");
15245
16392
  } catch {
15246
16393
  return "";
@@ -15327,16 +16474,16 @@ function mcpArtifactsFromJson(harness, config, configScope = "user") {
15327
16474
  }
15328
16475
  function claudeDesktopConfigCandidates(home, targetPlatform) {
15329
16476
  if (targetPlatform === "darwin") {
15330
- return [join36(home, "Library", "Application Support", "Claude", "claude_desktop_config.json")];
16477
+ return [join37(home, "Library", "Application Support", "Claude", "claude_desktop_config.json")];
15331
16478
  }
15332
16479
  if (targetPlatform === "linux") {
15333
16480
  return [
15334
- join36(home, ".config", "Claude", "claude_desktop_config.json"),
15335
- join36(home, ".config", "claude", "claude_desktop_config.json")
16481
+ join37(home, ".config", "Claude", "claude_desktop_config.json"),
16482
+ join37(home, ".config", "claude", "claude_desktop_config.json")
15336
16483
  ];
15337
16484
  }
15338
16485
  if (targetPlatform === "win32" && process.env.APPDATA) {
15339
- return [join36(process.env.APPDATA, "Claude", "claude_desktop_config.json")];
16486
+ return [join37(process.env.APPDATA, "Claude", "claude_desktop_config.json")];
15340
16487
  }
15341
16488
  return [];
15342
16489
  }
@@ -15344,7 +16491,7 @@ function claudeManagedMcpConfigCandidates(targetPlatform) {
15344
16491
  if (targetPlatform === "darwin") return ["/Library/Application Support/ClaudeCode/managed-mcp.json"];
15345
16492
  if (targetPlatform === "linux") return ["/etc/claude-code/managed-mcp.json"];
15346
16493
  if (targetPlatform === "win32" && process.env.ProgramFiles) {
15347
- return [join36(process.env.ProgramFiles, "ClaudeCode", "managed-mcp.json")];
16494
+ return [join37(process.env.ProgramFiles, "ClaudeCode", "managed-mcp.json")];
15348
16495
  }
15349
16496
  return [];
15350
16497
  }
@@ -15353,7 +16500,7 @@ function discoveredProjectRoots(claudeState, currentDirectory, explicit = [], cu
15353
16500
  const add = (value) => {
15354
16501
  if (typeof value !== "string" || !value.trim()) return;
15355
16502
  const path = resolve5(value);
15356
- if (existsSync37(path)) roots.add(path);
16503
+ if (existsSync38(path)) roots.add(path);
15357
16504
  };
15358
16505
  add(currentDirectory);
15359
16506
  for (const path of explicit) add(path);
@@ -15364,31 +16511,31 @@ function discoveredProjectRoots(claudeState, currentDirectory, explicit = [], cu
15364
16511
  return [...roots];
15365
16512
  }
15366
16513
  function cursorWorkspaceStorageCandidates(home, targetPlatform) {
15367
- if (targetPlatform === "darwin") return [join36(home, "Library", "Application Support", "Cursor", "User", "workspaceStorage")];
15368
- if (targetPlatform === "linux") return [join36(home, ".config", "Cursor", "User", "workspaceStorage")];
16514
+ if (targetPlatform === "darwin") return [join37(home, "Library", "Application Support", "Cursor", "User", "workspaceStorage")];
16515
+ if (targetPlatform === "linux") return [join37(home, ".config", "Cursor", "User", "workspaceStorage")];
15369
16516
  if (targetPlatform === "win32" && process.env.APPDATA) {
15370
- return [join36(process.env.APPDATA, "Cursor", "User", "workspaceStorage")];
16517
+ return [join37(process.env.APPDATA, "Cursor", "User", "workspaceStorage")];
15371
16518
  }
15372
16519
  return [];
15373
16520
  }
15374
16521
  function cursorWorkspaceRoots(home, targetPlatform) {
15375
16522
  const roots = /* @__PURE__ */ new Set();
15376
16523
  for (const storage of cursorWorkspaceStorageCandidates(home, targetPlatform)) {
15377
- if (!existsSync37(storage)) continue;
16524
+ if (!existsSync38(storage)) continue;
15378
16525
  let entries = [];
15379
16526
  try {
15380
- entries = readdirSync9(storage, { withFileTypes: true });
16527
+ entries = readdirSync10(storage, { withFileTypes: true });
15381
16528
  } catch {
15382
16529
  continue;
15383
16530
  }
15384
16531
  for (const entry of entries) {
15385
16532
  if (!entry.isDirectory() || entry.isSymbolicLink?.()) continue;
15386
- const state = readJson(join36(storage, entry.name, "workspace.json"));
16533
+ const state = readJson(join37(storage, entry.name, "workspace.json"));
15387
16534
  const raw = state?.folder;
15388
16535
  if (typeof raw !== "string" || !raw.trim()) continue;
15389
16536
  try {
15390
16537
  const path = raw.startsWith("file:") ? fileURLToPath(raw) : raw;
15391
- if (existsSync37(path)) roots.add(resolve5(path));
16538
+ if (existsSync38(path)) roots.add(resolve5(path));
15392
16539
  } catch {
15393
16540
  }
15394
16541
  }
@@ -15482,18 +16629,18 @@ function parseFrontmatter(content) {
15482
16629
  return { name: value("name"), version: value("version") };
15483
16630
  }
15484
16631
  function skillArtifacts(harness, root) {
15485
- if (!existsSync37(root)) return [];
16632
+ if (!existsSync38(root)) return [];
15486
16633
  const manifests = [];
15487
16634
  const visit = (dir) => {
15488
16635
  let entries;
15489
16636
  try {
15490
- entries = readdirSync9(dir, { withFileTypes: true });
16637
+ entries = readdirSync10(dir, { withFileTypes: true });
15491
16638
  } catch {
15492
16639
  return;
15493
16640
  }
15494
16641
  for (const entry of entries) {
15495
16642
  if (entry.isSymbolicLink?.()) continue;
15496
- const path = join36(dir, entry.name);
16643
+ const path = join37(dir, entry.name);
15497
16644
  if (entry.isFile() && entry.name === "SKILL.md") manifests.push(path);
15498
16645
  else if (entry.isDirectory()) visit(path);
15499
16646
  }
@@ -15503,7 +16650,7 @@ function skillArtifacts(harness, root) {
15503
16650
  const content = readText(path);
15504
16651
  const frontmatter = parseFrontmatter(content);
15505
16652
  const rel = relative(root, path).replaceAll("\\", "/");
15506
- const name = frontmatter.name || basename3(join36(path, "..")) || "skill";
16653
+ const name = frontmatter.name || basename3(join37(path, "..")) || "skill";
15507
16654
  return {
15508
16655
  harness,
15509
16656
  type: "skill",
@@ -15518,16 +16665,16 @@ function skillArtifacts(harness, root) {
15518
16665
  });
15519
16666
  }
15520
16667
  function cursorExtensionArtifacts(root) {
15521
- if (!existsSync37(root)) return [];
16668
+ if (!existsSync38(root)) return [];
15522
16669
  let dirs = [];
15523
16670
  try {
15524
- dirs = readdirSync9(root, { withFileTypes: true }).filter((entry) => entry.isDirectory() && !entry.isSymbolicLink());
16671
+ dirs = readdirSync10(root, { withFileTypes: true }).filter((entry) => entry.isDirectory() && !entry.isSymbolicLink());
15525
16672
  } catch {
15526
16673
  return [];
15527
16674
  }
15528
16675
  const artifacts = [];
15529
16676
  for (const dir of dirs) {
15530
- const pkg = readJson(join36(root, dir.name, "package.json"));
16677
+ const pkg = readJson(join37(root, dir.name, "package.json"));
15531
16678
  if (!pkg) continue;
15532
16679
  const publisher = typeof pkg.publisher === "string" ? pkg.publisher : void 0;
15533
16680
  const name = typeof pkg.name === "string" ? pkg.name : dir.name;
@@ -15547,18 +16694,18 @@ function cursorExtensionArtifacts(root) {
15547
16694
  return artifacts;
15548
16695
  }
15549
16696
  function deploymentMode2(home) {
15550
- const raw = readText(join36(home, ".synkro", "config.env"));
16697
+ const raw = readText(join37(home, ".synkro", "config.env"));
15551
16698
  const value = (key) => raw.match(new RegExp(`^${key}=['"]?([^'"\\n]*)`, "m"))?.[1]?.toLowerCase();
15552
16699
  if (value("SYNKRO_GRADING_MODE") === "byok") return "byok";
15553
16700
  if (value("SYNKRO_STORAGE_MODE") === "cloud") return "cloud";
15554
16701
  return "local";
15555
16702
  }
15556
16703
  function telemetryHealth(home) {
15557
- const meta = readJson(join36(home, ".synkro", "telemetry-meta.json"));
16704
+ const meta = readJson(join37(home, ".synkro", "telemetry-meta.json"));
15558
16705
  const health = {};
15559
16706
  if (meta?.last_flush_ok_at && Number.isFinite(Date.parse(meta.last_flush_ok_at))) health.telemetry_last_flush_at = meta.last_flush_ok_at;
15560
16707
  if (meta?.last_flush_error) health.telemetry_last_error = "flush_failed";
15561
- const queue = join36(home, ".synkro", "telemetry-pending.jsonl");
16708
+ const queue = join37(home, ".synkro", "telemetry-pending.jsonl");
15562
16709
  try {
15563
16710
  const size = statSync5(queue).size;
15564
16711
  health.telemetry_backlog = size <= 5 * 1024 * 1024 ? readFileSync36(queue, "utf8").split("\n").filter(Boolean).length : Math.ceil(size / 1024);
@@ -15602,7 +16749,7 @@ function harnessSnapshot(agent) {
15602
16749
  }
15603
16750
  const config = readJson(agent.settingsPath);
15604
16751
  const coverage = inspectCodexHooks(agent.settingsPath);
15605
- const toml = readText(join36(agent.configDir, "config.toml"));
16752
+ const toml = readText(join37(agent.configDir, "config.toml"));
15606
16753
  const permission = toml.match(/^\s*approval_policy\s*=\s*["']([^"']+)/m)?.[1];
15607
16754
  return {
15608
16755
  row: {
@@ -15619,19 +16766,19 @@ function harnessSnapshot(agent) {
15619
16766
  };
15620
16767
  }
15621
16768
  function collectOperationalInventory(options = {}) {
15622
- const home = options.homeDir ?? homedir36();
16769
+ const home = options.homeDir ?? homedir37();
15623
16770
  const detected = options.detectedAgents ?? detectAgents();
15624
16771
  const identity = getOperationalInstallationIdentity(options.identityPath);
15625
16772
  const targetPlatform = options.platformName ?? platform5();
15626
- const codexHome = options.homeDir ? join36(home, ".codex") : process.env.CODEX_HOME || join36(home, ".codex");
16773
+ const codexHome = options.homeDir ? join37(home, ".codex") : process.env.CODEX_HOME || join37(home, ".codex");
15627
16774
  const harnesses = [];
15628
16775
  const artifacts = [];
15629
16776
  for (const agent of detected) {
15630
- const { row, config } = harnessSnapshot(agent);
15631
- harnesses.push(row);
15632
- artifacts.push(...hookArtifacts(row.harness, config));
16777
+ const { row: row2, config } = harnessSnapshot(agent);
16778
+ harnesses.push(row2);
16779
+ artifacts.push(...hookArtifacts(row2.harness, config));
15633
16780
  }
15634
- const claudeJson = readJson(join36(home, ".claude.json"));
16781
+ const claudeJson = readJson(join37(home, ".claude.json"));
15635
16782
  artifacts.push(...mcpArtifactsFromJson("claude_code", claudeJson));
15636
16783
  if (claudeJson?.projects && typeof claudeJson.projects === "object") {
15637
16784
  for (const [projectPath, project] of Object.entries(claudeJson.projects)) {
@@ -15639,8 +16786,8 @@ function collectOperationalInventory(options = {}) {
15639
16786
  artifacts.push(...mcpArtifactsFromJson("claude_code", project, `local:${sha256(projectPath).slice(0, 16)}`));
15640
16787
  }
15641
16788
  }
15642
- artifacts.push(...mcpArtifactsFromJson("cursor", readJson(join36(home, ".cursor", "mcp.json"))));
15643
- artifacts.push(...codexMcpArtifacts(readText(join36(codexHome, "config.toml"))));
16789
+ artifacts.push(...mcpArtifactsFromJson("cursor", readJson(join37(home, ".cursor", "mcp.json"))));
16790
+ artifacts.push(...codexMcpArtifacts(readText(join37(codexHome, "config.toml"))));
15644
16791
  const projectRoots = discoveredProjectRoots(
15645
16792
  claudeJson,
15646
16793
  options.currentDirectory ?? process.cwd(),
@@ -15651,11 +16798,11 @@ function collectOperationalInventory(options = {}) {
15651
16798
  const scopeHash = sha256(projectRoot).slice(0, 16);
15652
16799
  artifacts.push(...mcpArtifactsFromJson(
15653
16800
  "claude_code",
15654
- readJson(join36(projectRoot, ".mcp.json")),
16801
+ readJson(join37(projectRoot, ".mcp.json")),
15655
16802
  `project:${scopeHash}`
15656
16803
  ));
15657
- const cursorProjectConfig = join36(projectRoot, ".cursor", "mcp.json");
15658
- if (resolve5(cursorProjectConfig) !== resolve5(join36(home, ".cursor", "mcp.json"))) {
16804
+ const cursorProjectConfig = join37(projectRoot, ".cursor", "mcp.json");
16805
+ if (resolve5(cursorProjectConfig) !== resolve5(join37(home, ".cursor", "mcp.json"))) {
15659
16806
  artifacts.push(...mcpArtifactsFromJson(
15660
16807
  "cursor",
15661
16808
  readJson(cursorProjectConfig),
@@ -15666,7 +16813,7 @@ function collectOperationalInventory(options = {}) {
15666
16813
  for (const managedPath of claudeManagedMcpConfigCandidates(targetPlatform)) {
15667
16814
  artifacts.push(...mcpArtifactsFromJson("claude_code", readJson(managedPath), "managed"));
15668
16815
  }
15669
- const desktopConfigPath = claudeDesktopConfigCandidates(home, targetPlatform).find((path) => existsSync37(path));
16816
+ const desktopConfigPath = claudeDesktopConfigCandidates(home, targetPlatform).find((path) => existsSync38(path));
15670
16817
  if (desktopConfigPath) {
15671
16818
  const desktopConfig = readJson(desktopConfigPath);
15672
16819
  harnesses.push({
@@ -15677,7 +16824,7 @@ function collectOperationalInventory(options = {}) {
15677
16824
  });
15678
16825
  artifacts.push(...mcpArtifactsFromJson("claude_desktop", desktopConfig));
15679
16826
  }
15680
- const claudeSettings = readJson(join36(home, ".claude", "settings.json"));
16827
+ const claudeSettings = readJson(join37(home, ".claude", "settings.json"));
15681
16828
  if (claudeSettings?.enabledPlugins && typeof claudeSettings.enabledPlugins === "object") {
15682
16829
  for (const [name, enabled] of Object.entries(claudeSettings.enabledPlugins)) {
15683
16830
  artifacts.push({
@@ -15691,19 +16838,19 @@ function collectOperationalInventory(options = {}) {
15691
16838
  });
15692
16839
  }
15693
16840
  }
15694
- artifacts.push(...skillArtifacts("claude_code", join36(home, ".claude", "skills")));
15695
- artifacts.push(...skillArtifacts("cursor", join36(home, ".cursor", "skills")));
15696
- artifacts.push(...skillArtifacts("codex", join36(codexHome, "skills")));
15697
- artifacts.push(...cursorExtensionArtifacts(join36(home, ".cursor", "extensions")));
16841
+ artifacts.push(...skillArtifacts("claude_code", join37(home, ".claude", "skills")));
16842
+ artifacts.push(...skillArtifacts("cursor", join37(home, ".cursor", "skills")));
16843
+ artifacts.push(...skillArtifacts("codex", join37(codexHome, "skills")));
16844
+ artifacts.push(...cursorExtensionArtifacts(join37(home, ".cursor", "extensions")));
15698
16845
  const uniqueArtifacts = /* @__PURE__ */ new Map();
15699
16846
  for (const artifact of artifacts) {
15700
16847
  const key = `${artifact.harness || "global"}:${artifact.type}:${artifact.canonical_id}`;
15701
16848
  uniqueArtifacts.set(key, artifact);
15702
16849
  }
15703
- const codingHarnesses = harnesses.filter((row) => row.harness === "claude_code" || row.harness === "cursor" || row.harness === "codex");
16850
+ const codingHarnesses = harnesses.filter((row2) => row2.harness === "claude_code" || row2.harness === "cursor" || row2.harness === "codex");
15704
16851
  const health = telemetryHealth(home) ?? {};
15705
16852
  health.scanners = {
15706
- hook_runtime: codingHarnesses.length === 0 ? "unknown" : codingHarnesses.every((row) => row.enabled) ? "ok" : "degraded"
16853
+ hook_runtime: codingHarnesses.length === 0 ? "unknown" : codingHarnesses.every((row2) => row2.enabled) ? "ok" : "degraded"
15707
16854
  };
15708
16855
  return {
15709
16856
  schema_version: 1,
@@ -15749,16 +16896,16 @@ __export(sync_exports2, {
15749
16896
  import { createHash as createHash6, randomUUID as randomUUID6 } from "crypto";
15750
16897
  import { spawn as spawn8 } from "child_process";
15751
16898
  import {
15752
- existsSync as existsSync38,
15753
- mkdirSync as mkdirSync20,
16899
+ existsSync as existsSync39,
16900
+ mkdirSync as mkdirSync21,
15754
16901
  readFileSync as readFileSync37,
15755
16902
  renameSync as renameSync10,
15756
- writeFileSync as writeFileSync25
16903
+ writeFileSync as writeFileSync26
15757
16904
  } from "fs";
15758
- import { homedir as homedir37 } from "os";
15759
- import { dirname as dirname10, join as join37 } from "path";
16905
+ import { homedir as homedir38 } from "os";
16906
+ import { dirname as dirname10, join as join38 } from "path";
15760
16907
  function syncStatePath() {
15761
- return process.env.SYNKRO_INVENTORY_SYNC_STATE_PATH || join37(homedir37(), ".synkro", "inventory-sync.json");
16908
+ return process.env.SYNKRO_INVENTORY_SYNC_STATE_PATH || join38(homedir38(), ".synkro", "inventory-sync.json");
15762
16909
  }
15763
16910
  function readState(path = syncStatePath()) {
15764
16911
  try {
@@ -15770,9 +16917,9 @@ function readState(path = syncStatePath()) {
15770
16917
  }
15771
16918
  function writeState(state, path = syncStatePath()) {
15772
16919
  try {
15773
- mkdirSync20(dirname10(path), { recursive: true, mode: 448 });
16920
+ mkdirSync21(dirname10(path), { recursive: true, mode: 448 });
15774
16921
  const temp = `${path}.${process.pid}.tmp`;
15775
- writeFileSync25(temp, JSON.stringify(state, null, 2) + "\n", { encoding: "utf8", mode: 384 });
16922
+ writeFileSync26(temp, JSON.stringify(state, null, 2) + "\n", { encoding: "utf8", mode: 384 });
15776
16923
  renameSync10(temp, path);
15777
16924
  } catch {
15778
16925
  }
@@ -15785,7 +16932,7 @@ function shouldSyncInventory(state, now = Date.now(), target) {
15785
16932
  return !Number.isFinite(lastAttempt) || lastAttempt <= 0 || now - lastAttempt >= FAILURE_RETRY_MS;
15786
16933
  }
15787
16934
  function readConfig() {
15788
- const path = join37(homedir37(), ".synkro", "config.env");
16935
+ const path = join38(homedir38(), ".synkro", "config.env");
15789
16936
  const out = {};
15790
16937
  try {
15791
16938
  for (const rawLine of readFileSync37(path, "utf8").split("\n")) {
@@ -15827,7 +16974,7 @@ function resolveInventoryGateway(raw) {
15827
16974
  }
15828
16975
  async function loadToken() {
15829
16976
  try {
15830
- const durable = readFileSync37(join37(homedir37(), ".synkro", ".mcp-jwt"), "utf8").trim();
16977
+ const durable = readFileSync37(join38(homedir38(), ".synkro", ".mcp-jwt"), "utf8").trim();
15831
16978
  if (durable) return durable;
15832
16979
  } catch {
15833
16980
  }
@@ -15945,7 +17092,7 @@ function syncOperationalInventoryDetached() {
15945
17092
  writeState({ ...state, last_attempt_at: (/* @__PURE__ */ new Date()).toISOString(), last_target: target }, path);
15946
17093
  try {
15947
17094
  const script = process.argv[1];
15948
- if (!script || !existsSync38(script)) return;
17095
+ if (!script || !existsSync39(script)) return;
15949
17096
  const child = spawn8(process.execPath, [script, "inventory-sync", "--detached"], {
15950
17097
  detached: true,
15951
17098
  stdio: "ignore",
@@ -15969,13 +17116,13 @@ var init_sync2 = __esm({
15969
17116
  });
15970
17117
 
15971
17118
  // cli/bootstrap.js
15972
- import { readFileSync as readFileSync38, existsSync as existsSync39 } from "fs";
17119
+ import { readFileSync as readFileSync38, existsSync as existsSync40 } from "fs";
15973
17120
  import { resolve as resolve6 } from "path";
15974
17121
  var envCandidates = [
15975
17122
  resolve6(process.env.HOME ?? "", ".synkro", "config.env")
15976
17123
  ];
15977
17124
  for (const envPath of envCandidates) {
15978
- if (!existsSync39(envPath)) continue;
17125
+ if (!existsSync40(envPath)) continue;
15979
17126
  const envContent = readFileSync38(envPath, "utf-8");
15980
17127
  for (const line of envContent.split("\n")) {
15981
17128
  const trimmed = line.trim();
@@ -15993,7 +17140,7 @@ var subArgs = args.slice(1);
15993
17140
  var isDetachedChild = process.env.SYNKRO_TELEMETRY_DETACHED === "1";
15994
17141
  var FLUSH_SKIP = /* @__PURE__ */ new Set(["grade", "inventory-sync", "version", "--version", "-v", "help", "--help", "-h", ""]);
15995
17142
  function printVersion() {
15996
- console.log("1.8.0");
17143
+ console.log("1.10.2");
15997
17144
  }
15998
17145
  function printHelp2() {
15999
17146
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents
@@ -16012,9 +17159,16 @@ Commands:
16012
17159
  claude-desktop Monitor Claude Desktop conversations (local, macOS)
16013
17160
  telemetry <sub> Inspect or flush local telemetry events
16014
17161
  whoami Show resolved identity + where grading runs
17162
+ workspace <sub> Answer the task-workspace question (stay/clear/status)
17163
+ ui Governed multiplexer: spaces + agents, real sessions in tabs
16015
17164
  refresh Refresh the login session (keeps you signed in; run on a schedule)
16016
17165
  version Show version
16017
17166
 
17167
+ workspace:
17168
+ synkro workspace stay <taskId> keep working in the current checkout
17169
+ synkro workspace clear <taskId> forget the choice (Synkro asks again)
17170
+ synkro workspace status [taskId] show recorded choices
17171
+
16018
17172
  config:
16019
17173
  synkro config show current settings
16020
17174
  synkro config grading <local|byok> where grading runs
@@ -16118,6 +17272,16 @@ async function main() {
16118
17272
  await whoamiCommand2(subArgs);
16119
17273
  break;
16120
17274
  }
17275
+ case "workspace": {
17276
+ const { workspaceCommand: workspaceCommand2 } = await Promise.resolve().then(() => (init_workspace(), workspace_exports));
17277
+ await workspaceCommand2(subArgs);
17278
+ break;
17279
+ }
17280
+ case "ui": {
17281
+ const { uiCommand: uiCommand2 } = await Promise.resolve().then(() => (init_ui(), ui_exports));
17282
+ await uiCommand2(subArgs);
17283
+ break;
17284
+ }
16121
17285
  case "refresh": {
16122
17286
  const { refreshCommand: refreshCommand2 } = await Promise.resolve().then(() => (init_refresh(), refresh_exports));
16123
17287
  await refreshCommand2(subArgs);
@@ -16230,7 +17394,7 @@ async function shutdown(code) {
16230
17394
  }, 200);
16231
17395
  force.unref();
16232
17396
  }
16233
- main().then(() => postDispatchFlush()).then(() => shutdown(0)).catch(async (err) => {
17397
+ main().then(() => postDispatchFlush()).then(() => shutdown(typeof process.exitCode === "number" ? process.exitCode : 0)).catch(async (err) => {
16234
17398
  try {
16235
17399
  const { emit: emit2 } = await Promise.resolve().then(() => (init_telemetry(), telemetry_exports));
16236
17400
  emit2("error", {