@synkro-sh/cli 1.8.0 → 1.9.0

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.9.0";
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);
@@ -1169,7 +1169,7 @@ async function getStats() {
1169
1169
  GROUP BY event_type
1170
1170
  ORDER BY c DESC
1171
1171
  `;
1172
- for (const row of types) base.by_type[String(row.event_type)] = Number(row.c);
1172
+ for (const row2 of types) base.by_type[String(row2.event_type)] = Number(row2.c);
1173
1173
  const newest = await sql`
1174
1174
  SELECT occurred_at FROM telemetry_events ORDER BY occurred_at DESC LIMIT 1
1175
1175
  `;
@@ -1224,8 +1224,8 @@ async function exportEvents(path) {
1224
1224
  const lines = [];
1225
1225
  try {
1226
1226
  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));
1227
+ for (const row2 of rows) {
1228
+ lines.push(JSON.stringify(row2));
1229
1229
  }
1230
1230
  } catch {
1231
1231
  }
@@ -2880,8 +2880,8 @@ Print a short readiness summary: rules now active, security scanning on, the gra
2880
2880
  // cli/installer/skillParser.ts
2881
2881
  import { existsSync as existsSync15 } from "fs";
2882
2882
  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));
2883
+ function resolveSkillPaths(skills, repoRoot3) {
2884
+ return skills.filter((s) => s.endsWith(".md")).map((s) => resolve3(repoRoot3, s)).filter((p) => existsSync15(p));
2885
2885
  }
2886
2886
  var init_skillParser = __esm({
2887
2887
  "cli/installer/skillParser.ts"() {
@@ -2897,7 +2897,7 @@ var STUB_COMMON_TS, STUB_EDIT_PRECHECK_TS, STUB_EDIT_FOLLOWUP_TS, STUB_CWE_PRECH
2897
2897
  var init_hookScriptsTs = __esm({
2898
2898
  "cli/installer/hookScriptsTs.ts"() {
2899
2899
  "use strict";
2900
- STUB_COMMON_TS = String.raw`import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync, appendFileSync, statSync, realpathSync, openSync, readSync, closeSync } from 'node:fs';
2900
+ STUB_COMMON_TS = String.raw`import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync, appendFileSync, statSync, realpathSync, openSync, readSync, closeSync, unlinkSync } from 'node:fs';
2901
2901
  import { execFileSync, execSync } from 'node:child_process';
2902
2902
  import { homedir } from 'node:os';
2903
2903
  import { basename, dirname, join, resolve, relative, isAbsolute } from 'node:path';
@@ -3136,6 +3136,28 @@ function gitRoot(cwd: string): string {
3136
3136
  } catch { return ''; }
3137
3137
  }
3138
3138
 
3139
+ // synkro.toml is per-machine and untracked, so a LINKED git worktree never has
3140
+ // its own copy — its root is a fresh checkout. Resolve the main checkout through
3141
+ // the worktree's .git pointer file (a linked worktree's .git is a FILE reading
3142
+ // "gitdir: <main>/.git/worktrees/<name>") so worktrees inherit the repo's
3143
+ // config instead of silently skipping all grading. Opt-in semantics survive: a
3144
+ // repo whose MAIN root has no synkro.toml stays dormant. Pure fs — no
3145
+ // subprocess on the hook hot path.
3146
+ function mainWorktreeRoot(root: string): string {
3147
+ try {
3148
+ const dotGit = join(root, '.git');
3149
+ if (statSync(dotGit).isDirectory()) return root;
3150
+ const pointer = readFileSync(dotGit, 'utf-8');
3151
+ const match = pointer.match(/^gitdir:\s*(.+?)\s*$/m);
3152
+ if (!match) return root;
3153
+ const gitDir = isAbsolute(match[1]) ? match[1] : join(root, match[1]);
3154
+ const marker = join('.git', 'worktrees') + '/';
3155
+ const at = gitDir.lastIndexOf('/' + marker);
3156
+ if (at === -1) return root;
3157
+ return gitDir.slice(0, at) || root;
3158
+ } catch { return root; }
3159
+ }
3160
+
3139
3161
  function taskRepoContext(root: string): any {
3140
3162
  if (!root) return undefined;
3141
3163
  try {
@@ -3182,16 +3204,144 @@ function taskRepoContext(root: string): any {
3182
3204
  } catch { return undefined; }
3183
3205
  }
3184
3206
 
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 });
3207
+ // One task-workspace message per TOOL CALL, not per hook. A single Bash call fans
3208
+ // out to install-scan + bash-judge + skill-judge (an Edit fans out to four), and
3209
+ // every hook is its own process, so the dedupe has to live on disk rather than in
3210
+ // a Map the way the server-side status-line dedupe does (see stableHookEventIdentity
3211
+ // in scanning/scanRouter). Atomic first-writer-wins via an exclusive create: the
3212
+ // hook that wins says it in full, the rest stay terse.
3213
+ const TOOL_CALL_MARK_DIR = join(HOME, '.synkro', 'tool-call-marks');
3214
+ const TOOL_CALL_MARK_WINDOW_MS = 15000;
3215
+
3216
+ function pruneToolCallMarks(now: number): void {
3217
+ try {
3218
+ const entries = readdirSync(TOOL_CALL_MARK_DIR);
3219
+ if (entries.length < 200) return;
3220
+ for (const name of entries) {
3221
+ const path = join(TOOL_CALL_MARK_DIR, name);
3222
+ try {
3223
+ if (now - statSync(path).mtimeMs > TOOL_CALL_MARK_WINDOW_MS) unlinkSync(path);
3224
+ } catch {}
3225
+ }
3226
+ } catch {}
3227
+ }
3228
+
3229
+ // The user's answer to "move into the task worktree, or stay here?". Written by the
3230
+ // "synkro workspace stay" command (the CLI owns ~/.synkro, so nothing hand-writes it)
3231
+ // and read here. Keyed by task alone: the decision resets when the active task
3232
+ // changes, which is the scope the workspace gate is asking about.
3233
+ const WORKSPACE_CHOICE_DIR = join(HOME, '.synkro', 'workspace-choice');
3234
+
3235
+ // The user answers the workspace question in plain language on their next
3236
+ // prompt. Writing the marker is exactly what the CLI's "workspace stay"
3237
+ // command does — but the consent matcher only accepts one literal spelling,
3238
+ // and on a machine where PATH shadows the binary (observed: a venv python
3239
+ // named synkro, and an older global without the command) that spelling cannot
3240
+ // succeed, which deadlocked the ask. Patterns are DIRECTIVES, never
3241
+ // questions: a trailing question mark rejects, and the bare verb matches only
3242
+ // as a short standalone reply. Callers gate on a PENDING ask, so ordinary
3243
+ // conversation containing "stay" is never scanned against this.
3244
+ function isWorkspaceStayIntent(prompt: string): boolean {
3245
+ const normalized = String(prompt || '').toLowerCase().replace(/\s+/g, ' ').trim();
3246
+ if (!normalized || normalized.length > 240) return false;
3247
+ if (/\?\s*$/.test(normalized)) return false;
3248
+ if (/^(?:yes[,.\s]+)?(?:please\s+)?stay(?:\s+(?:here|put))?(?:\s*(?:pls|please))?[.!]?$/.test(normalized)) return true;
3249
+ return [
3250
+ /\b(?:stay|remain|keep working|keep going|continue)\b[^?]{0,60}\b(?:current|same|this)\s+(?:worktree|workspace|checkout|directory)\b/,
3251
+ /\b(?:do not|don't|dont|no need to)\s+(?:move|switch|change)\b[^?]{0,40}\b(?:worktrees?|workspaces?|checkouts?)\b/,
3252
+ ].some((pattern) => pattern.test(normalized));
3253
+ }
3254
+
3255
+ function taskWorkspaceStayRecorded(taskId: string): boolean {
3256
+ if (!/^task_[a-z0-9]{8}$/i.test(String(taskId || ''))) return false;
3257
+ try { return existsSync(join(WORKSPACE_CHOICE_DIR, taskId + '.stay')); } catch { return false; }
3258
+ }
3259
+
3260
+ // Our own context string, so the shape is stable: '... task=<id> ...'.
3261
+ function taskIdFromScmContext(context: string): string {
3262
+ const marker = ' task=';
3263
+ const at = String(context || '').indexOf(marker);
3264
+ if (at === -1) return '';
3265
+ const rest = context.slice(at + marker.length);
3266
+ const end = rest.indexOf(' ');
3267
+ return (end === -1 ? rest : rest.slice(0, end)).trim();
3268
+ }
3269
+
3270
+ // Consent must never deadlock behind the block it resolves: the command that records
3271
+ // the answer is allowed through for the task currently being asked about, and nothing
3272
+ // else is.
3273
+ function isWorkspaceConsentCommand(payload: any, taskId: string): boolean {
3274
+ if (!taskId || String(payload?.tool_name || '') !== 'Bash') return false;
3275
+ const input = payload?.tool_input && typeof payload.tool_input === 'object' ? payload.tool_input : {};
3276
+ const command = String(input.command || input.cmd || '').trim();
3277
+ return command === 'synkro workspace stay ' + taskId;
3278
+ }
3279
+
3280
+ function firstHookForToolCall(sessionId: string, payload: any): boolean {
3281
+ const p = payload && typeof payload === 'object' ? payload : {};
3282
+ const id = String(p.tool_use_id || p.tool_call_id || p.call_id || p.event_id || '').trim();
3283
+ // Fallback when the harness omits a call id: tool name + input is identical across
3284
+ // the surfaces of one call and distinct across different calls. Identical
3285
+ // back-to-back commands can collide inside the window, which costs one repeated
3286
+ // line and nothing else.
3287
+ const material = id || (String(p.tool_name || '') + ':' + (p.tool_input ? JSON.stringify(p.tool_input) : ''));
3288
+ if (!sessionId || !material) return true;
3289
+ const key = createHash('sha256').update(sessionId + '\0' + material).digest('hex').slice(0, 24);
3290
+ const marker = join(TOOL_CALL_MARK_DIR, key);
3291
+ const now = Date.now();
3292
+ try { mkdirSync(TOOL_CALL_MARK_DIR, { recursive: true }); } catch {}
3293
+ pruneToolCallMarks(now);
3294
+ try {
3295
+ writeFileSync(marker, '', { flag: 'wx', mode: 0o600 });
3296
+ return true;
3297
+ } catch (err: any) {
3298
+ if (err && err.code === 'EEXIST') {
3299
+ // Outside the burst window this is a genuinely new call reusing the fallback
3300
+ // key, so re-arm the marker and let it speak.
3301
+ try {
3302
+ if (now - statSync(marker).mtimeMs > TOOL_CALL_MARK_WINDOW_MS) {
3303
+ writeFileSync(marker, '', { mode: 0o600 });
3304
+ return true;
3305
+ }
3306
+ } catch {}
3307
+ return false;
3308
+ }
3309
+ // Any other failure (read-only home, quota) must not silence the message.
3310
+ return true;
3311
+ }
3312
+ }
3313
+
3314
+ function taskScmBlockResponse(harness: string, reason: string, verbose = true): string {
3315
+ // Every fanned-out hook still denies — suppressing the decision itself would let the
3316
+ // tool through if the winning hook's response were ever dropped. Only the TEXT
3317
+ // collapses: the later surfaces of one tool call deny with no systemMessage and no
3318
+ // additionalContext, so the transcript carries one workspace message, not three.
3319
+ const tag = synkroOriginTag('synkro:scm', harness);
3320
+ const message = tag + ' ' + reason;
3321
+ if (harness === 'cursor') {
3322
+ return verbose
3323
+ ? JSON.stringify({ permission: 'deny', user_message: message, agent_message: message })
3324
+ : JSON.stringify({ permission: 'deny', user_message: '', agent_message: '' });
3325
+ }
3326
+ if (!verbose) {
3327
+ return JSON.stringify({
3328
+ systemMessage: '',
3329
+ hookSpecificOutput: {
3330
+ hookEventName: 'PreToolUse',
3331
+ permissionDecision: 'deny',
3332
+ // Only surfaces if this hook's denial is the one the harness reports.
3333
+ permissionDecisionReason: tag + ' blocked — see the workspace message above',
3334
+ additionalContext: '',
3335
+ },
3336
+ });
3337
+ }
3188
3338
  return JSON.stringify({
3189
3339
  systemMessage: message,
3190
3340
  hookSpecificOutput: {
3191
3341
  hookEventName: 'PreToolUse',
3192
3342
  permissionDecision: 'deny',
3193
3343
  permissionDecisionReason: message,
3194
- additionalContext: message + ' Resolve the repository state, then retry the tool call.',
3344
+ additionalContext: message,
3195
3345
  },
3196
3346
  });
3197
3347
  }
@@ -3228,8 +3378,36 @@ function sharedRepoRoot(root: string): string {
3228
3378
  return dirname(common);
3229
3379
  }
3230
3380
 
3231
- function taskWorktreePath(root: string, taskId: string): string {
3232
- return join(sharedRepoRoot(root), '.synkro-worktrees', taskId);
3381
+ // Claude Code only permits switching BETWEEN worktrees when the target lives under
3382
+ // <repo>/.claude/worktrees, so a cc session that activates a second task can never
3383
+ // reach another .synkro-worktrees path. Provisioning cc task worktrees under
3384
+ // .claude/worktrees keeps mid-session task switching working; every other harness
3385
+ // keeps the original location.
3386
+ const CC_MANAGED_WORKTREE_DIR = join('.claude', 'worktrees');
3387
+ const LEGACY_MANAGED_WORKTREE_DIR = '.synkro-worktrees';
3388
+
3389
+ function managedWorktreeDir(harness: string): string {
3390
+ return harness === 'cc' ? CC_MANAGED_WORKTREE_DIR : LEGACY_MANAGED_WORKTREE_DIR;
3391
+ }
3392
+
3393
+ function taskWorktreePath(root: string, taskId: string, harness: string): string {
3394
+ return join(sharedRepoRoot(root), managedWorktreeDir(harness), taskId);
3395
+ }
3396
+
3397
+ function taskWorktreeCandidatePaths(root: string, taskId: string): string[] {
3398
+ const shared = sharedRepoRoot(root);
3399
+ return [CC_MANAGED_WORKTREE_DIR, LEGACY_MANAGED_WORKTREE_DIR]
3400
+ .map((dir) => join(shared, dir, taskId));
3401
+ }
3402
+
3403
+ // A task bound before the cc relocation keeps the worktree it already owns: the
3404
+ // canonical branch is checked out there, so provisioning a second one would fail
3405
+ // on 'the canonical task branch already exists in another workspace'.
3406
+ function resolveTaskWorktreePath(root: string, taskId: string, harness: string): string {
3407
+ const records = taskWorktreeRecords(root);
3408
+ const registered = taskWorktreeCandidatePaths(root, taskId)
3409
+ .find((candidate) => records.some((item) => samePath(item.path, candidate)));
3410
+ return registered || taskWorktreePath(root, taskId, harness);
3233
3411
  }
3234
3412
 
3235
3413
  function codexDesktopOrigin(): boolean {
@@ -3238,10 +3416,13 @@ function codexDesktopOrigin(): boolean {
3238
3416
  return origin === 'codex desktop' || bundle === 'com.openai.codex';
3239
3417
  }
3240
3418
 
3241
- function ignoreManagedWorktreeDirectory(root: string): void {
3242
- const common = join(sharedRepoRoot(root), '.git');
3419
+ function ignoreManagedWorktreeDirectory(root: string, worktreePath: string): void {
3420
+ const shared = sharedRepoRoot(root);
3421
+ const common = join(shared, '.git');
3243
3422
  const excludePath = join(common, 'info', 'exclude');
3244
- const entry = '.synkro-worktrees/';
3423
+ // Ignore the directory the worktree actually landed in, which differs per harness.
3424
+ const entry = relative(shared, dirname(worktreePath)) + '/';
3425
+ if (entry.startsWith('..')) return;
3245
3426
  let existing = '';
3246
3427
  try { existing = readFileSync(excludePath, 'utf-8'); } catch {}
3247
3428
  if (existing.split(/\r?\n/).includes(entry)) return;
@@ -3279,9 +3460,11 @@ function gitOperationInProgress(root: string): boolean {
3279
3460
  }
3280
3461
 
3281
3462
  function validateTaskWorktree(root: string, currentRoot: string, op: any): string {
3282
- const expected = taskWorktreePath(root, String(op.taskId || ''));
3463
+ // Accept either managed location: a task bound before the cc relocation still
3464
+ // pushes from its legacy .synkro-worktrees path.
3465
+ const expected = taskWorktreeCandidatePaths(root, String(op.taskId || ''));
3283
3466
  const supplied = String(op.worktreePath || '');
3284
- const managed = Boolean(supplied) && samePath(supplied, expected);
3467
+ const managed = Boolean(supplied) && expected.some((candidate) => samePath(supplied, candidate));
3285
3468
  const nativeCurrent = Boolean(supplied) && samePath(supplied, currentRoot);
3286
3469
  if (!managed && !nativeCurrent) {
3287
3470
  throw new Error('task worktree path does not match the managed or current native workspace');
@@ -3307,7 +3490,7 @@ async function completeTaskScm(sessionId: string, op: any, ok: boolean, error: s
3307
3490
  } catch {}
3308
3491
  }
3309
3492
 
3310
- function executeTaskScm(root: string, op: any): string {
3493
+ function executeTaskScm(root: string, op: any, harness: string): string {
3311
3494
  if (!op || typeof op !== 'object') throw new Error('invalid SCM operation');
3312
3495
  if (!/^task_[a-z0-9]{8}$/i.test(String(op.taskId || ''))) throw new Error('invalid task id');
3313
3496
  const branch = String(op.branchName || '');
@@ -3372,7 +3555,7 @@ function executeTaskScm(root: string, op: any): string {
3372
3555
  }
3373
3556
  return root;
3374
3557
  }
3375
- const worktreePath = taskWorktreePath(boundRoot, String(op.taskId));
3558
+ const worktreePath = resolveTaskWorktreePath(boundRoot, String(op.taskId), harness);
3376
3559
  const existing = taskWorktreeRecords(boundRoot).find((item) => samePath(item.path, worktreePath));
3377
3560
  if (existing) {
3378
3561
  if (existing.branch !== branch || gitOutput(worktreePath, ['rev-parse', '--abbrev-ref', 'HEAD']) !== branch) {
@@ -3398,7 +3581,7 @@ function executeTaskScm(root: string, op: any): string {
3398
3581
  throw new Error('task base revision is unavailable in this repository');
3399
3582
  }
3400
3583
  const originalBranch = gitOutput(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
3401
- ignoreManagedWorktreeDirectory(boundRoot);
3584
+ ignoreManagedWorktreeDirectory(boundRoot, worktreePath);
3402
3585
  mkdirSync(dirname(worktreePath), { recursive: true });
3403
3586
  gitOutput(root, ['worktree', 'add', '-b', branch, worktreePath, String(op.baseSha)], 30000);
3404
3587
  if (gitOutput(root, ['rev-parse', 'HEAD']) !== head
@@ -3430,14 +3613,23 @@ function executeTaskScm(root: string, op: any): string {
3430
3613
 
3431
3614
  interface TaskScmReconcileResult { reason: string; context: string }
3432
3615
 
3433
- function taskScmWorkspaceContext(workspace: any): string {
3616
+ // Who acted and where it ran: a message in a shared terminal should say which
3617
+ // harness produced it and whether this install is grading locally or in the cloud.
3618
+ function synkroOriginTag(prefix: string, harness: string): string {
3619
+ return '[' + prefix
3620
+ + (harness ? ':' + harness : '')
3621
+ + ':' + (deployIsCloud() ? 'cloud' : 'local')
3622
+ + ']';
3623
+ }
3624
+
3625
+ function taskScmWorkspaceContext(workspace: any, harness = ''): string {
3434
3626
  if (!workspace || typeof workspace !== 'object') return '';
3435
3627
  const taskId = String(workspace.taskId || '');
3436
3628
  const linearRef = String(workspace.linearRef || '');
3437
3629
  const branchName = String(workspace.branchName || '');
3438
3630
  const worktreePath = String(workspace.worktreePath || '');
3439
3631
  if (!taskId || !branchName) return '';
3440
- return '[synkro:task-workspace] task=' + taskId
3632
+ return synkroOriginTag('synkro:task-workspace', harness) + ' task=' + taskId
3441
3633
  + (linearRef ? ' linear=' + linearRef : '')
3442
3634
  + ' branch=' + branchName
3443
3635
  + (worktreePath ? ' worktree=' + worktreePath : ' worktree=pending-native-handoff');
@@ -3448,7 +3640,7 @@ function shellTaskWorkspaceArg(value: string): string {
3448
3640
  }
3449
3641
 
3450
3642
  function taskScmWorkspaceInstruction(harness: string, sessionId: string, workspace: any): string {
3451
- const context = taskScmWorkspaceContext(workspace);
3643
+ const context = taskScmWorkspaceContext(workspace, harness);
3452
3644
  const worktreePath = String(workspace?.worktreePath || '');
3453
3645
  const branchName = String(workspace?.branchName || '');
3454
3646
  if (!context || !branchName) return '';
@@ -3456,9 +3648,14 @@ function taskScmWorkspaceInstruction(harness: string, sessionId: string, workspa
3456
3648
  + 'or ask the user to perform the transition. Synkro keeps substantive tools blocked until this exact session reports '
3457
3649
  + 'the task worktree and canonical branch.';
3458
3650
  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;
3651
+ if (!worktreePath) return context + '\nTask worktree creation failed closed; retry workspace preparation.';
3652
+ // Ask, do not command. The user may legitimately want to keep working where they
3653
+ // are, and Synkro should not move them without their say-so.
3654
+ return context + '\nAsk the user whether to move this task into its isolated worktree '
3655
+ + JSON.stringify(worktreePath) + ' on branch ' + JSON.stringify(branchName)
3656
+ + ', or keep working in the current workspace. Do not decide for them. '
3657
+ + 'To move: invoke EnterWorktree on that path. '
3658
+ + 'To stay: run "synkro workspace stay ' + String(workspace?.taskId || '') + '".';
3462
3659
  }
3463
3660
  if (harness === 'cursor') {
3464
3661
  if (!worktreePath) return context + '\n[synkro:workspace-handoff] Task worktree creation failed closed; retry workspace preparation.';
@@ -3539,11 +3736,11 @@ async function reconcileTaskScm(
3539
3736
  ? (taskScmWorkspaceInstruction(harness, sessionId, workspace)
3540
3737
  || String(result.reason || 'task source-control preparation is pending'))
3541
3738
  : '',
3542
- context: taskScmWorkspaceContext(workspace),
3739
+ context: taskScmWorkspaceContext(workspace, harness),
3543
3740
  };
3544
3741
  }
3545
3742
  try {
3546
- const worktreePath = executeTaskScm(root, result.op);
3743
+ const worktreePath = executeTaskScm(root, result.op, harness);
3547
3744
  await completeTaskScm(sessionId, result.op, true, '', worktreePath);
3548
3745
  if (result.op.kind === 'branch') {
3549
3746
  if (result.op.adoptExistingWorktree === true) {
@@ -3551,12 +3748,12 @@ async function reconcileTaskScm(
3551
3748
  if (rebound.ok) {
3552
3749
  const reboundResult = await rebound.json() as any;
3553
3750
  if (!reboundResult?.waiting && reboundResult?.workspace) {
3554
- return { reason: '', context: taskScmWorkspaceContext(reboundResult.workspace) };
3751
+ return { reason: '', context: taskScmWorkspaceContext(reboundResult.workspace, harness) };
3555
3752
  }
3556
3753
  if (reboundResult?.waiting) {
3557
3754
  return {
3558
3755
  reason: String(reboundResult.reason || 'Codex native worktree binding is pending'),
3559
- context: taskScmWorkspaceContext(reboundResult.workspace),
3756
+ context: taskScmWorkspaceContext(reboundResult.workspace, harness),
3560
3757
  };
3561
3758
  }
3562
3759
  }
@@ -4188,10 +4385,19 @@ export async function runStub(surface: string, opts: StubOpts = {}): Promise<voi
4188
4385
  // Dormancy: a repo is onboarded only if it has a synkro.toml FILE at its git
4189
4386
  // root. Guard root !== HOME — ~/.synkro is the config DIRECTORY, so without
4190
4387
  // this a home-rooted cwd (dotfiles in git, non-git dir under home) could
4191
- // look onboarded.
4388
+ // look onboarded. A linked worktree checked out at a commit predating the
4389
+ // tracked synkro.toml has no copy of its own — fall back to the main
4390
+ // checkout's config rather than silently skipping enforcement there.
4192
4391
  let synkroFileText = '';
4193
- if (root && root !== HOME && existsSync(join(root, 'synkro.toml'))) {
4194
- try { synkroFileText = readFileSync(join(root, 'synkro.toml'), 'utf-8'); } catch {}
4392
+ if (root && root !== HOME) {
4393
+ let configRoot = root;
4394
+ if (!existsSync(join(configRoot, 'synkro.toml'))) {
4395
+ const mainRoot = mainWorktreeRoot(root);
4396
+ if (mainRoot !== root && mainRoot !== HOME && existsSync(join(mainRoot, 'synkro.toml'))) {
4397
+ configRoot = mainRoot;
4398
+ }
4399
+ }
4400
+ try { synkroFileText = readFileSync(join(configRoot, 'synkro.toml'), 'utf-8'); } catch {}
4195
4401
  }
4196
4402
  if (!synkroFileText) {
4197
4403
  // Repo not onboarded — emit a minimal tool_call so usage analytics still
@@ -4220,8 +4426,26 @@ export async function runStub(surface: string, opts: StubOpts = {}): Promise<voi
4220
4426
 
4221
4427
  const scm = await reconcileTaskScm(root || cwd, sessionId, harness, payload);
4222
4428
  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));
4429
+ const scmTaskId = taskIdFromScmContext(scm.context);
4430
+ // The user can settle the workspace question in plain language: while the
4431
+ // ask is pending for this session's task, a stay-directive on the user's
4432
+ // prompt records the same durable marker the CLI command writes. Reconcile
4433
+ // just told us WHICH task is being asked, so no extra state is needed and
4434
+ // ordinary prompts outside a pending ask are never scanned.
4435
+ if (surface === 'prompt-submit' && scm.reason && scmTaskId && !taskWorkspaceStayRecorded(scmTaskId)) {
4436
+ const promptText = String(payload.prompt || payload.user_message || '');
4437
+ if (isWorkspaceStayIntent(promptText)) {
4438
+ try {
4439
+ mkdirSync(WORKSPACE_CHOICE_DIR, { recursive: true });
4440
+ writeFileSync(join(WORKSPACE_CHOICE_DIR, scmTaskId + '.stay'), new Date().toISOString() + '\n');
4441
+ } catch { /* fail-open: the CLI command and the exact-string path remain */ }
4442
+ }
4443
+ }
4444
+ // The user was asked and chose to keep working here, or is answering right now.
4445
+ const workspaceConsentSettled = Boolean(scmTaskId)
4446
+ && (taskWorkspaceStayRecorded(scmTaskId) || isWorkspaceConsentCommand(payload, scmTaskId));
4447
+ if (scm.reason && substantiveTool && !workspaceConsentSettled) {
4448
+ out(taskScmBlockResponse(harness, scm.reason, firstHookForToolCall(sessionId, payload)));
4225
4449
  return;
4226
4450
  }
4227
4451
 
@@ -4382,7 +4606,15 @@ export async function runStub(surface: string, opts: StubOpts = {}): Promise<voi
4382
4606
  const contractResponseText = opts.stopContract && harness === 'codex'
4383
4607
  ? codexStopResponse(rawResponseText)
4384
4608
  : rawResponseText;
4385
- const responseText = withTaskScmContext(contractResponseText, harness, scm.context);
4609
+ // Only the first hook of this tool call carries the workspace context; the other
4610
+ // surfaces of the same call would otherwise repeat it verbatim 2-4 times.
4611
+ let scmContext = scm.context && firstHookForToolCall(sessionId, payload) ? scm.context : '';
4612
+ // Say plainly that work is continuing outside the task worktree by the user's choice,
4613
+ // so the state is never mistaken for a binding that silently failed.
4614
+ if (scmContext && scmTaskId && taskWorkspaceStayRecorded(scmTaskId)) {
4615
+ scmContext += ' workspace=staying-here-by-user-choice';
4616
+ }
4617
+ const responseText = withTaskScmContext(contractResponseText, harness, scmContext);
4386
4618
  out(responseText);
4387
4619
  emitStubTelemetry(surface, harness, telemPayload, responseText, Date.now() - startedAt, telemCwd, telemSessionId);
4388
4620
  } catch (err) {
@@ -7281,8 +7513,8 @@ async function dockerInstall(opts = {}) {
7281
7513
  "SYNKRO_TELEMETRY_QUEUE=/data/synkro-host/telemetry-pending.jsonl",
7282
7514
  image
7283
7515
  ];
7284
- const run = spawnSync3("docker", args2, { encoding: "utf-8", stdio: "inherit", timeout: 6e4 });
7285
- if (run.status !== 0) {
7516
+ const run2 = spawnSync3("docker", args2, { encoding: "utf-8", stdio: "inherit", timeout: 6e4 });
7517
+ if (run2.status !== 0) {
7286
7518
  throw new DockerInstallError(`docker run failed (image ${image})`);
7287
7519
  }
7288
7520
  return {
@@ -7513,7 +7745,7 @@ var init_dockerInstall = __esm({
7513
7745
  HOST_PGLITE_PORT = parseInt(process.env.SYNKRO_HOST_PGLITE_PORT || "15433", 10);
7514
7746
  CONTAINER_NAME = resolveContainerName();
7515
7747
  defaultImageVersion = () => {
7516
- if (true) return "1.8.0";
7748
+ if (true) return "1.9.0";
7517
7749
  try {
7518
7750
  const pkg = JSON.parse(readFileSync17(new URL("../../package.json", import.meta.url), "utf8"));
7519
7751
  if (pkg.version) return pkg.version;
@@ -8221,7 +8453,7 @@ function isoDay(value, fallbackDay) {
8221
8453
  function parseClaudeTranscriptUsage(transcript, fallbackDay = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10), options = {}) {
8222
8454
  const seen = options.seenStableIds ?? /* @__PURE__ */ new Set();
8223
8455
  const rollups = /* @__PURE__ */ new Map();
8224
- const usage = {
8456
+ const usage2 = {
8225
8457
  input_tokens: 0,
8226
8458
  output_tokens: 0,
8227
8459
  cache_creation_input_tokens: 0,
@@ -8255,7 +8487,7 @@ function parseClaudeTranscriptUsage(transcript, fallbackDay = (/* @__PURE__ */ n
8255
8487
  if (entryModel !== "<synthetic>") model = entryModel;
8256
8488
  const day = isoDay(entry.timestamp, fallbackDay);
8257
8489
  const key = `${day}\0${entryModel}`;
8258
- const row = rollups.get(key) ?? {
8490
+ const row2 = rollups.get(key) ?? {
8259
8491
  day,
8260
8492
  model: entryModel,
8261
8493
  turns: 0,
@@ -8264,23 +8496,23 @@ function parseClaudeTranscriptUsage(transcript, fallbackDay = (/* @__PURE__ */ n
8264
8496
  cache_creation_input_tokens: 0,
8265
8497
  cache_read_input_tokens: 0
8266
8498
  };
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);
8499
+ row2.turns += 1;
8500
+ row2.input_tokens += counts.input_tokens;
8501
+ row2.output_tokens += counts.output_tokens;
8502
+ row2.cache_creation_input_tokens += counts.cache_creation_input_tokens;
8503
+ row2.cache_read_input_tokens += counts.cache_read_input_tokens;
8504
+ rollups.set(key, row2);
8273
8505
  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;
8506
+ usage2.input_tokens += counts.input_tokens;
8507
+ usage2.output_tokens += counts.output_tokens;
8508
+ usage2.cache_creation_input_tokens += counts.cache_creation_input_tokens;
8509
+ usage2.cache_read_input_tokens += counts.cache_read_input_tokens;
8278
8510
  } catch {
8279
8511
  }
8280
8512
  }
8281
8513
  if (turns === 0) return null;
8282
8514
  return {
8283
- usage,
8515
+ usage: usage2,
8284
8516
  model: model || "unknown",
8285
8517
  rollups: [...rollups.values()].sort(
8286
8518
  (a, b) => a.day.localeCompare(b.day) || a.model.localeCompare(b.model)
@@ -8570,7 +8802,7 @@ function writeConfigEnv(opts) {
8570
8802
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle2(credsPath)}`,
8571
8803
  `SYNKRO_TIER=${shellQuoteSingle2(safeTier)}`,
8572
8804
  `SYNKRO_INFERENCE=${shellQuoteSingle2(safeInference)}`,
8573
- `SYNKRO_VERSION=${shellQuoteSingle2("1.8.0")}`
8805
+ `SYNKRO_VERSION=${shellQuoteSingle2("1.9.0")}`
8574
8806
  ];
8575
8807
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle2(safeSynkroBin)}`);
8576
8808
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle2(safeUserId)}`);
@@ -9317,7 +9549,7 @@ async function installCommand(opts = {}) {
9317
9549
  await setTelemetryState({ enabled: true, remoteFlushEnabled: telemetryConsent });
9318
9550
  emit("install", {
9319
9551
  phase: "started",
9320
- cli_version_to: "1.8.0",
9552
+ cli_version_to: "1.9.0",
9321
9553
  agents_detected: agents.map((a) => a.kind),
9322
9554
  with_github: false,
9323
9555
  with_local_cc: false,
@@ -10178,7 +10410,7 @@ async function syncSkillFiles() {
10178
10410
  function normSkillName(name) {
10179
10411
  return name.toLowerCase().replace(/\.mdx?$/, "");
10180
10412
  }
10181
- function discoverSkillFiles(repoRoot2, excludeHashes, ingestedHashes, ingestedNames) {
10413
+ function discoverSkillFiles(repoRoot3, excludeHashes, ingestedHashes, ingestedNames) {
10182
10414
  const roots = [];
10183
10415
  const add = (p) => {
10184
10416
  try {
@@ -10188,9 +10420,9 @@ function discoverSkillFiles(repoRoot2, excludeHashes, ingestedHashes, ingestedNa
10188
10420
  };
10189
10421
  add(join19(homedir21(), ".claude", "skills"));
10190
10422
  add(join19(homedir21(), ".agents", "skills"));
10191
- if (repoRoot2) {
10192
- add(join19(repoRoot2, ".claude", "skills"));
10193
- add(join19(repoRoot2, ".agents", "skills"));
10423
+ if (repoRoot3) {
10424
+ add(join19(repoRoot3, ".claude", "skills"));
10425
+ add(join19(repoRoot3, ".agents", "skills"));
10194
10426
  }
10195
10427
  const out = [];
10196
10428
  const seen = /* @__PURE__ */ new Set();
@@ -10273,7 +10505,7 @@ Found ${found.length} skill${found.length === 1 ? "" : "s"} in your Claude Code
10273
10505
  async function discoverAndIngestSkills() {
10274
10506
  try {
10275
10507
  const sf = readFullSynkroFile();
10276
- const repoRoot2 = sf?._repoRoot || detectGitRepo2();
10508
+ const repoRoot3 = sf?._repoRoot || detectGitRepo2();
10277
10509
  const mcpPort = process.env.SYNKRO_MCP_PORT || "18931";
10278
10510
  const excludeHashes = /* @__PURE__ */ new Set();
10279
10511
  if (sf?.skills?.length) {
@@ -10297,7 +10529,7 @@ async function discoverAndIngestSkills() {
10297
10529
  }
10298
10530
  } catch {
10299
10531
  }
10300
- const found = discoverSkillFiles(repoRoot2, excludeHashes, ingestedHashes, ingestedNames);
10532
+ const found = discoverSkillFiles(repoRoot3, excludeHashes, ingestedHashes, ingestedNames);
10301
10533
  if (found.length === 0) return;
10302
10534
  const selectable = found.filter((f) => !f.ingested);
10303
10535
  if (selectable.length === 0) {
@@ -10333,18 +10565,18 @@ async function discoverAndIngestSkills() {
10333
10565
  }
10334
10566
  }
10335
10567
  function resolveSynkroBinPath() {
10336
- const run = (cmd3) => {
10568
+ const run2 = (cmd3) => {
10337
10569
  try {
10338
10570
  return execSync4(cmd3, { encoding: "utf-8", timeout: 5e3, stdio: ["pipe", "pipe", "pipe"] }).trim();
10339
10571
  } catch {
10340
10572
  return "";
10341
10573
  }
10342
10574
  };
10343
- const p = run("command -v synkro").split("\n")[0].trim();
10575
+ const p = run2("command -v synkro").split("\n")[0].trim();
10344
10576
  return p && isAbsolute(p) ? p : "";
10345
10577
  }
10346
10578
  function ensureReachabilityGitHook() {
10347
- const run = (cmd3) => {
10579
+ const run2 = (cmd3) => {
10348
10580
  try {
10349
10581
  return execSync4(cmd3, { encoding: "utf-8", timeout: 5e3, stdio: ["pipe", "pipe", "pipe"] }).trim();
10350
10582
  } catch {
@@ -10352,9 +10584,9 @@ function ensureReachabilityGitHook() {
10352
10584
  }
10353
10585
  };
10354
10586
  try {
10355
- const root = run("git rev-parse --show-toplevel");
10587
+ const root = run2("git rev-parse --show-toplevel");
10356
10588
  if (!root) return null;
10357
- let hooksDir = run("git config --get core.hooksPath");
10589
+ let hooksDir = run2("git config --get core.hooksPath");
10358
10590
  hooksDir = hooksDir ? isAbsolute(hooksDir) ? hooksDir : join19(root, hooksDir) : join19(root, ".git", "hooks");
10359
10591
  if (!existsSync22(hooksDir)) mkdirSync15(hooksDir, { recursive: true });
10360
10592
  const hookPath = join19(hooksDir, "post-commit");
@@ -10396,18 +10628,18 @@ function ensureReachabilityGitHook() {
10396
10628
  }
10397
10629
  }
10398
10630
  function detectGitRepo2() {
10399
- const run = (cmd3) => {
10631
+ const run2 = (cmd3) => {
10400
10632
  try {
10401
10633
  return execSync4(cmd3, { encoding: "utf-8", timeout: 5e3, stdio: ["pipe", "pipe", "pipe"] }).trim();
10402
10634
  } catch {
10403
10635
  return "";
10404
10636
  }
10405
10637
  };
10406
- const remoteUrl = run("git remote get-url origin");
10638
+ const remoteUrl = run2("git remote get-url origin");
10407
10639
  if (remoteUrl) {
10408
10640
  return remoteUrl.replace(/^git@[^:]+:/, "").replace(/^https?:\/\/[^/]+\//, "").replace(/\.git$/, "");
10409
10641
  }
10410
- const root = run("git rev-parse --show-toplevel");
10642
+ const root = run2("git rev-parse --show-toplevel");
10411
10643
  return root ? root.split("/").pop() || null : null;
10412
10644
  }
10413
10645
  function getClaudeProjectsFolder() {
@@ -13856,10 +14088,10 @@ var init_packVerify = __esm({
13856
14088
  // cli/installer/lockfile.ts
13857
14089
  import { existsSync as existsSync30, readFileSync as readFileSync28, writeFileSync as writeFileSync20 } from "fs";
13858
14090
  import { join as join28 } from "path";
13859
- function lockPath(repoRoot2) {
13860
- return join28(repoRoot2, LOCK_FILE);
14091
+ function lockPath(repoRoot3) {
14092
+ return join28(repoRoot3, LOCK_FILE);
13861
14093
  }
13862
- function writeLockfile(repoRoot2, entries) {
14094
+ function writeLockfile(repoRoot3, entries) {
13863
14095
  const sorted = [...entries].sort((a, b) => a.ref.localeCompare(b.ref));
13864
14096
  const body = [
13865
14097
  "# synkro.lock \u2014 generated by `synkro sync`. Commit this file.",
@@ -13875,7 +14107,7 @@ function writeLockfile(repoRoot2, entries) {
13875
14107
  ""
13876
14108
  ])
13877
14109
  ].join("\n");
13878
- writeFileSync20(lockPath(repoRoot2), body, "utf-8");
14110
+ writeFileSync20(lockPath(repoRoot3), body, "utf-8");
13879
14111
  }
13880
14112
  var LOCK_FILE;
13881
14113
  var init_lockfile = __esm({
@@ -14087,6 +14319,822 @@ var init_whoami = __esm({
14087
14319
  }
14088
14320
  });
14089
14321
 
14322
+ // cli/commands/workspace.ts
14323
+ var workspace_exports = {};
14324
+ __export(workspace_exports, {
14325
+ workspaceCommand: () => workspaceCommand
14326
+ });
14327
+ import { existsSync as existsSync33, mkdirSync as mkdirSync19, readdirSync as readdirSync8, rmSync as rmSync5, writeFileSync as writeFileSync22 } from "fs";
14328
+ import { join as join31 } from "path";
14329
+ import { homedir as homedir31 } from "os";
14330
+ function markerPath(taskId) {
14331
+ return join31(WORKSPACE_CHOICE_DIR, `${taskId}.stay`);
14332
+ }
14333
+ function usage() {
14334
+ console.log(`synkro workspace \u2014 record where a task's work happens
14335
+
14336
+ Usage:
14337
+ synkro workspace stay <taskId> keep working in the current checkout
14338
+ synkro workspace clear <taskId> forget the choice (the gate asks again)
14339
+ synkro workspace status [taskId] show recorded choices
14340
+
14341
+ The gate asks once per task. "stay" is remembered until the choice is cleared or
14342
+ the active task changes.`);
14343
+ }
14344
+ async function workspaceCommand(args2) {
14345
+ const sub = String(args2[0] || "").trim();
14346
+ const taskId = String(args2[1] || "").trim();
14347
+ if (!sub || sub === "help" || sub === "--help" || sub === "-h") {
14348
+ usage();
14349
+ return;
14350
+ }
14351
+ if (sub === "status") {
14352
+ let recorded = [];
14353
+ try {
14354
+ recorded = existsSync33(WORKSPACE_CHOICE_DIR) ? readdirSync8(WORKSPACE_CHOICE_DIR).filter((name) => name.endsWith(".stay")) : [];
14355
+ } catch {
14356
+ recorded = [];
14357
+ }
14358
+ if (taskId) {
14359
+ const on = recorded.includes(`${taskId}.stay`);
14360
+ console.log(`${taskId}: ${on ? "stay recorded" : "no choice recorded"}`);
14361
+ return;
14362
+ }
14363
+ if (recorded.length === 0) {
14364
+ console.log("No workspace choices recorded.");
14365
+ return;
14366
+ }
14367
+ console.log("Staying in the current checkout for:");
14368
+ for (const name of recorded.sort()) console.log(` ${name.replace(/\.stay$/, "")}`);
14369
+ return;
14370
+ }
14371
+ if (sub !== "stay" && sub !== "clear") {
14372
+ console.error(`Unknown workspace subcommand: ${sub}`);
14373
+ usage();
14374
+ process.exitCode = 2;
14375
+ return;
14376
+ }
14377
+ if (!TASK_ID.test(taskId)) {
14378
+ console.error(taskId ? `Not a task id: ${taskId} (expected task_ followed by 8 characters)` : `Usage: synkro workspace ${sub} <taskId>`);
14379
+ process.exitCode = 2;
14380
+ return;
14381
+ }
14382
+ if (sub === "clear") {
14383
+ try {
14384
+ rmSync5(markerPath(taskId), { force: true });
14385
+ } catch {
14386
+ }
14387
+ console.log(`Cleared the workspace choice for ${taskId}.`);
14388
+ return;
14389
+ }
14390
+ try {
14391
+ mkdirSync19(WORKSPACE_CHOICE_DIR, { recursive: true });
14392
+ writeFileSync22(markerPath(taskId), `${(/* @__PURE__ */ new Date()).toISOString()}
14393
+ `, "utf-8");
14394
+ } catch (error) {
14395
+ console.error(`Could not record the workspace choice: ${error?.message || error}`);
14396
+ process.exitCode = 1;
14397
+ return;
14398
+ }
14399
+ console.log(`Staying in the current checkout for ${taskId}. Synkro will not ask again for this task.`);
14400
+ }
14401
+ var WORKSPACE_CHOICE_DIR, TASK_ID;
14402
+ var init_workspace = __esm({
14403
+ "cli/commands/workspace.ts"() {
14404
+ "use strict";
14405
+ WORKSPACE_CHOICE_DIR = join31(homedir31(), ".synkro", "workspace-choice");
14406
+ TASK_ID = /^task_[a-z0-9]{8}$/i;
14407
+ }
14408
+ });
14409
+
14410
+ // cli/ui/tmux.ts
14411
+ import { execFile as execFile2, spawnSync as spawnSync11 } from "child_process";
14412
+ import { promisify } from "util";
14413
+ function runnerArgs(runner, argv) {
14414
+ return runner.kind === "container" ? ["docker", "exec", "-u", CONTAINER_USER, runner.container, ...argv] : argv;
14415
+ }
14416
+ function runnerInteractiveArgs(runner, argv) {
14417
+ return runner.kind === "container" ? ["docker", "exec", "-it", "-u", CONTAINER_USER, runner.container, ...argv] : argv;
14418
+ }
14419
+ async function run(runner, argv) {
14420
+ const [cmd3, ...args2] = runnerArgs(runner, argv);
14421
+ try {
14422
+ const { stdout, stderr } = await execFileAsync(cmd3, args2, { timeout: 8e3, maxBuffer: 1024 * 1024 });
14423
+ return { ok: true, stdout: String(stdout || ""), stderr: String(stderr || "") };
14424
+ } catch (error) {
14425
+ return { ok: false, stdout: String(error?.stdout || ""), stderr: String(error?.stderr || error?.message || "") };
14426
+ }
14427
+ }
14428
+ function runInherit(argv) {
14429
+ const [cmd3, ...args2] = argv;
14430
+ const result = spawnSync11(cmd3, args2, { stdio: "inherit" });
14431
+ return result.status ?? 1;
14432
+ }
14433
+ function slugify(name) {
14434
+ return String(name || "").toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "agent";
14435
+ }
14436
+ function agentSession(name) {
14437
+ return AGENT_PREFIX + slugify(name);
14438
+ }
14439
+ function buildSpawnAgent(opts) {
14440
+ const session = agentSession(opts.name);
14441
+ return [
14442
+ ["tmux", "new-session", "-d", "-s", session, "-c", opts.cwd, opts.command],
14443
+ ["tmux", "set-option", "-t", session, "status", "off"],
14444
+ ["tmux", "set-option", "-t", session, "-q", "@synkro_harness", opts.harness],
14445
+ ["tmux", "set-option", "-t", session, "-q", "@synkro_space", opts.space],
14446
+ ["tmux", "set-option", "-t", session, "-q", "@synkro_backend", opts.backend],
14447
+ // Keep the pane visible after exit so the sidebar can render 'done'
14448
+ // instead of the agent silently vanishing.
14449
+ ["tmux", "set-option", "-t", session, "remain-on-exit", "on"]
14450
+ ];
14451
+ }
14452
+ function buildListAgents() {
14453
+ return ["tmux", "list-sessions", "-F", ["#{session_name}", "#{?pane_dead,dead,alive}", "#{@synkro_harness}", "#{@synkro_space}", "#{@synkro_backend}", "#{@synkro_pueue}"].join(FIELD_SEP)];
14454
+ }
14455
+ function buildCapture(session, lines = 14) {
14456
+ return ["tmux", "capture-pane", "-p", "-t", session, "-S", String(-lines)];
14457
+ }
14458
+ function buildKillSession(session) {
14459
+ return ["tmux", "kill-session", "-t", session];
14460
+ }
14461
+ function buildSetOption(session, option, value) {
14462
+ return ["tmux", "set-option", "-t", session, "-q", option, value];
14463
+ }
14464
+ function buildSendText(session, text) {
14465
+ return [
14466
+ ["tmux", "send-keys", "-t", session, "-l", text],
14467
+ ["tmux", "send-keys", "-t", session, "Enter"]
14468
+ ];
14469
+ }
14470
+ function buildInterrupt(session) {
14471
+ return ["tmux", "send-keys", "-t", session, "Escape"];
14472
+ }
14473
+ function buildCenterAttachCommand(runner, session) {
14474
+ const argv = runnerInteractiveArgs(runner, ["tmux", "attach-session", "-t", session]);
14475
+ return "env TMUX= " + argv.map(shellQuote3).join(" ");
14476
+ }
14477
+ function shellQuote3(value) {
14478
+ return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : "'" + value.replace(/'/g, "'\\''") + "'";
14479
+ }
14480
+ function parseAgentSessions(output, backend) {
14481
+ return String(output || "").split("\n").map((line) => line.split(FIELD_SEP)).filter((cols) => cols[0]?.startsWith(AGENT_PREFIX)).map((cols) => ({
14482
+ session: cols[0],
14483
+ name: cols[0].slice(AGENT_PREFIX.length),
14484
+ dead: cols[1] === "dead",
14485
+ harness: cols[2] || "claude",
14486
+ space: cols[3] || "",
14487
+ backend: cols[4] || backend,
14488
+ pueue: cols[5] || ""
14489
+ }));
14490
+ }
14491
+ function parseWorktrees(porcelain) {
14492
+ const rows = [];
14493
+ let current = {};
14494
+ for (const line of String(porcelain || "").split("\n")) {
14495
+ if (line.startsWith("worktree ")) current = { path: line.slice(9).trim() };
14496
+ else if (line.startsWith("branch ")) current.branch = line.slice(7).replace("refs/heads/", "").trim();
14497
+ else if (line.trim() === "" && current.path) {
14498
+ rows.push({
14499
+ path: current.path,
14500
+ branch: current.branch || "detached",
14501
+ name: current.path.split("/").filter(Boolean).pop() || current.path
14502
+ });
14503
+ current = {};
14504
+ }
14505
+ }
14506
+ if (current.path) {
14507
+ rows.push({
14508
+ path: current.path,
14509
+ branch: current.branch || "detached",
14510
+ name: current.path.split("/").filter(Boolean).pop() || current.path
14511
+ });
14512
+ }
14513
+ return rows;
14514
+ }
14515
+ var execFileAsync, AGENT_PREFIX, UI_SESSION, CONTAINER_USER, FIELD_SEP;
14516
+ var init_tmux = __esm({
14517
+ "cli/ui/tmux.ts"() {
14518
+ "use strict";
14519
+ execFileAsync = promisify(execFile2);
14520
+ AGENT_PREFIX = "synkro-agent-";
14521
+ UI_SESSION = "synkro-ui";
14522
+ CONTAINER_USER = "synkro";
14523
+ FIELD_SEP = "|";
14524
+ }
14525
+ });
14526
+
14527
+ // cli/ui/launch.ts
14528
+ function welcomeCommand() {
14529
+ const banner = [
14530
+ "",
14531
+ " synkro ui",
14532
+ " governed agents, one screen",
14533
+ "",
14534
+ " enter attach selected agent",
14535
+ " n new agent in selected space",
14536
+ " c new container agent",
14537
+ " g/s/y consent: track / skip / stay",
14538
+ " T new tab q quit",
14539
+ ""
14540
+ ].join("\\n");
14541
+ return "printf " + shellQuote3(banner + "\\n") + "; tail -f /dev/null";
14542
+ }
14543
+ function sidebarCommand(bootPath, centerPane, repoCwd) {
14544
+ const env = [
14545
+ "SYNKRO_UI_CENTER=" + shellQuote3(centerPane),
14546
+ "SYNKRO_UI_OUTER=" + UI_SESSION,
14547
+ "SYNKRO_UI_BOOT=" + shellQuote3(bootPath),
14548
+ "SYNKRO_UI_REPO=" + shellQuote3(repoCwd)
14549
+ ].join(" ");
14550
+ return "env " + env + " node " + shellQuote3(bootPath) + " ui --sidebar";
14551
+ }
14552
+ async function styleOuterSession() {
14553
+ const style = [
14554
+ ["set-option", "-t", UI_SESSION, "status-position", "top"],
14555
+ ["set-option", "-t", UI_SESSION, "status-style", "bg=colour233,fg=colour245"],
14556
+ ["set-option", "-t", UI_SESSION, "status-left", " synkro "],
14557
+ ["set-option", "-t", UI_SESSION, "status-left-style", "fg=colour135,bold"],
14558
+ ["set-option", "-t", UI_SESSION, "status-right", " + (T new tab) "],
14559
+ ["set-option", "-t", UI_SESSION, "status-right-style", "fg=colour240"],
14560
+ ["set-option", "-t", UI_SESSION, "-w", "window-status-format", " #W "],
14561
+ ["set-option", "-t", UI_SESSION, "-w", "window-status-current-format", "#[bg=colour135,fg=colour233,bold] #W #[default]"],
14562
+ ["set-option", "-t", UI_SESSION, "pane-border-style", "fg=colour236"],
14563
+ ["set-option", "-t", UI_SESSION, "pane-active-border-style", "fg=colour135"],
14564
+ // Tab keys without the prefix: Alt+t new tab, Alt+arrows to move.
14565
+ ["bind-key", "-n", "M-t", "new-window"],
14566
+ ["bind-key", "-n", "M-Right", "next-window"],
14567
+ ["bind-key", "-n", "M-Left", "previous-window"]
14568
+ ];
14569
+ for (const argv of style) await run(HOST, ["tmux", ...argv]);
14570
+ }
14571
+ async function buildTab(bootPath, repoCwd, windowTarget) {
14572
+ if (windowTarget === void 0) {
14573
+ await run(HOST, ["tmux", "new-session", "-d", "-s", UI_SESSION, "-x", "220", "-y", "55", welcomeCommand()]);
14574
+ windowTarget = UI_SESSION + ":0";
14575
+ } else {
14576
+ const created = await run(HOST, ["tmux", "new-window", "-t", UI_SESSION, "-P", "-F", "#{window_id}", welcomeCommand()]);
14577
+ windowTarget = created.stdout.trim() || windowTarget;
14578
+ }
14579
+ await run(HOST, ["tmux", "rename-window", "-t", windowTarget, "space"]);
14580
+ const split = await run(HOST, [
14581
+ "tmux",
14582
+ "split-window",
14583
+ "-hb",
14584
+ "-t",
14585
+ windowTarget,
14586
+ "-l",
14587
+ SIDEBAR_WIDTH,
14588
+ "-P",
14589
+ "-F",
14590
+ "#{pane_id}",
14591
+ "tail -f /dev/null"
14592
+ ]);
14593
+ const sidebarPane = split.stdout.trim();
14594
+ const panes = await run(HOST, ["tmux", "list-panes", "-t", windowTarget, "-F", "#{pane_id}"]);
14595
+ const centerPane = panes.stdout.split("\n").map((line) => line.trim()).filter(Boolean).find((id) => id !== sidebarPane) || "";
14596
+ await run(HOST, ["tmux", "respawn-pane", "-k", "-t", sidebarPane, sidebarCommand(bootPath, centerPane, repoCwd)]);
14597
+ }
14598
+ async function uiSessionExists() {
14599
+ const result = await run(HOST, ["tmux", "has-session", "-t", UI_SESSION]);
14600
+ return result.ok;
14601
+ }
14602
+ async function launchUi(bootPath, repoCwd) {
14603
+ if (!await uiSessionExists()) {
14604
+ await buildTab(bootPath, repoCwd);
14605
+ await styleOuterSession();
14606
+ }
14607
+ return runInherit(process.env.TMUX ? ["tmux", "switch-client", "-t", UI_SESSION] : ["tmux", "attach-session", "-t", UI_SESSION]);
14608
+ }
14609
+ var SIDEBAR_WIDTH, HOST;
14610
+ var init_launch = __esm({
14611
+ "cli/ui/launch.ts"() {
14612
+ "use strict";
14613
+ init_tmux();
14614
+ SIDEBAR_WIDTH = "34";
14615
+ HOST = { kind: "host" };
14616
+ }
14617
+ });
14618
+
14619
+ // cli/ui/model.ts
14620
+ function detectAsk(tail2) {
14621
+ for (const marker of BLOCK_MARKERS) {
14622
+ if (marker.pattern.test(String(tail2 || ""))) return marker.ask;
14623
+ }
14624
+ return null;
14625
+ }
14626
+ function deriveStatus(input) {
14627
+ if (input.dead) return { status: "done" };
14628
+ const ask3 = detectAsk(input.tail);
14629
+ if (ask3) return { status: "blocked", ask: ask3 };
14630
+ return { status: input.changed ? "working" : "idle" };
14631
+ }
14632
+ function hashTail(tail2) {
14633
+ let hash = 0;
14634
+ const text = String(tail2 || "");
14635
+ for (let index = 0; index < text.length; index += 1) {
14636
+ hash = (hash << 5) - hash + text.charCodeAt(index) | 0;
14637
+ }
14638
+ return String(hash);
14639
+ }
14640
+ function mapAgentToTask(spacePath, tasks) {
14641
+ const normalized = String(spacePath || "").replace(/\/+$/, "");
14642
+ if (!normalized) return void 0;
14643
+ const hit = tasks.find((task) => task.worktree && task.worktree.replace(/\/+$/, "") === normalized);
14644
+ return hit?.linear || void 0;
14645
+ }
14646
+ async function fetchConductorTasks(baseUrl) {
14647
+ try {
14648
+ const controller = new AbortController();
14649
+ const timer = setTimeout(() => controller.abort(), 900);
14650
+ const response = await fetch(baseUrl + "/api/local/conductor/repositories", { signal: controller.signal });
14651
+ clearTimeout(timer);
14652
+ if (!response.ok) return [];
14653
+ const payload = await response.json().catch(() => null);
14654
+ return Array.isArray(payload?.tasks) ? payload.tasks.map((task) => ({
14655
+ worktree: task?.worktree || null,
14656
+ linear: task?.linear?.key || null,
14657
+ status: String(task?.status || "")
14658
+ })) : [];
14659
+ } catch {
14660
+ return [];
14661
+ }
14662
+ }
14663
+ async function discoverHostSpaces(repoCwd) {
14664
+ const result = await run({ kind: "host" }, ["git", "-C", repoCwd, "worktree", "list", "--porcelain"]);
14665
+ if (!result.ok) return [];
14666
+ return parseWorktrees(result.stdout).map((row2) => ({
14667
+ name: row2.name,
14668
+ branch: row2.branch,
14669
+ path: row2.path,
14670
+ backend: "host"
14671
+ }));
14672
+ }
14673
+ async function discoverContainerSpaces(runner) {
14674
+ if (runner.kind !== "container") return [];
14675
+ const result = await run(runner, ["sh", "-c", "ls -1 /home/synkro/work 2>/dev/null"]);
14676
+ if (!result.ok) return [];
14677
+ return result.stdout.split("\n").map((line) => line.trim()).filter((name) => /^ui-/.test(name)).map((name) => ({
14678
+ name,
14679
+ branch: "container",
14680
+ path: "/home/synkro/work/" + name,
14681
+ backend: "container"
14682
+ }));
14683
+ }
14684
+ async function discoverAgents(runner, backend, previousHashes) {
14685
+ const listed = await run(runner, buildListAgents());
14686
+ const rows = listed.ok ? parseAgentSessions(listed.stdout, backend) : [];
14687
+ const hashes = /* @__PURE__ */ new Map();
14688
+ const agents = [];
14689
+ for (const row2 of rows) {
14690
+ const capture = row2.dead ? { ok: true, stdout: "" } : await run(runner, buildCapture(row2.session));
14691
+ const tail2 = capture.ok ? capture.stdout : "";
14692
+ const nextHash = hashTail(tail2);
14693
+ const changed = previousHashes.has(row2.session) && previousHashes.get(row2.session) !== nextHash;
14694
+ hashes.set(row2.session, nextHash);
14695
+ const derived = deriveStatus({ dead: row2.dead, tail: tail2, changed });
14696
+ agents.push({
14697
+ name: row2.name,
14698
+ session: row2.session,
14699
+ harness: row2.harness,
14700
+ space: row2.space,
14701
+ backend: row2.backend,
14702
+ status: derived.status,
14703
+ ask: derived.ask
14704
+ });
14705
+ }
14706
+ return { agents, hashes };
14707
+ }
14708
+ var BLOCK_MARKERS;
14709
+ var init_model = __esm({
14710
+ "cli/ui/model.ts"() {
14711
+ "use strict";
14712
+ init_tmux();
14713
+ BLOCK_MARKERS = [
14714
+ { pattern: /needs a tracking decision|Task tracking suggestion|tracking decision for "/i, ask: "tracking" },
14715
+ { pattern: /\[synkro:task-workspace|\[synkro:scm|keep working in the current workspace/i, ask: "workspace" },
14716
+ { pattern: /⛔/, ask: "tracking" }
14717
+ ];
14718
+ }
14719
+ });
14720
+
14721
+ // cli/ui/consent.ts
14722
+ function actionsForAsk(ask3) {
14723
+ return ask3 === "workspace" ? ["stay"] : ["track", "skip"];
14724
+ }
14725
+ var CONSENT_PHRASES;
14726
+ var init_consent = __esm({
14727
+ "cli/ui/consent.ts"() {
14728
+ "use strict";
14729
+ CONSENT_PHRASES = {
14730
+ /** Settles the conductor tracking ask by durable decline. */
14731
+ skip: "skip the task tracking, continue without a task",
14732
+ /** Settles the task-workspace ask in place. */
14733
+ stay: "stay in the current worktree",
14734
+ /** Asks the agent to run the two-phase create flow (draft → approve). */
14735
+ track: "yes, track this work \u2014 draft the requirements and create the task"
14736
+ };
14737
+ }
14738
+ });
14739
+
14740
+ // cli/ui/render.ts
14741
+ function pad(text, width) {
14742
+ return text.length >= width ? text.slice(0, width) : text + " ".repeat(width - text.length);
14743
+ }
14744
+ function row(selected, width, content) {
14745
+ const body = stripForPad(" " + content, width);
14746
+ return selected ? STYLE.select + body + STYLE.reset : body;
14747
+ }
14748
+ function stripForPad(text, width) {
14749
+ let visible = 0;
14750
+ let out = "";
14751
+ let index = 0;
14752
+ while (index < text.length && visible < width) {
14753
+ if (text.startsWith(ESC, index)) {
14754
+ const end = text.indexOf("m", index);
14755
+ if (end === -1) break;
14756
+ out += text.slice(index, end + 1);
14757
+ index = end + 1;
14758
+ } else {
14759
+ out += text[index];
14760
+ index += 1;
14761
+ visible += 1;
14762
+ }
14763
+ }
14764
+ return out + " ".repeat(Math.max(0, width - visible));
14765
+ }
14766
+ function renderSidebar(state, width = 32, height = 40) {
14767
+ const lines = [];
14768
+ lines.push("");
14769
+ lines.push(" " + STYLE.header + "spaces" + STYLE.reset);
14770
+ lines.push("");
14771
+ state.spaces.forEach((space, index) => {
14772
+ const selected = state.section === "spaces" && index === state.spaceIndex;
14773
+ const badge = space.backend === "container" ? STYLE.accent + "\u25A3 " + STYLE.reset : "";
14774
+ lines.push(row(selected, width, STYLE.done + "\u25CF" + STYLE.reset + " " + badge + STYLE.bold + space.name + STYLE.reset));
14775
+ lines.push(row(selected, width, " " + STYLE.branch + space.branch + STYLE.reset));
14776
+ });
14777
+ if (state.spaces.length === 0) lines.push(row(false, width, STYLE.dim + "no spaces found" + STYLE.reset));
14778
+ lines.push("");
14779
+ lines.push(" " + STYLE.header + "agents" + STYLE.reset);
14780
+ lines.push("");
14781
+ state.agents.forEach((agent, index) => {
14782
+ const selected = state.section === "agents" && index === state.agentIndex;
14783
+ const dot = DOT[agent.status] || DOT.idle;
14784
+ const badge = agent.backend === "container" ? STYLE.accent + "\u25A3 " + STYLE.reset : "";
14785
+ const linear = agent.linear ? " " + STYLE.dim + agent.linear + STYLE.reset : "";
14786
+ lines.push(row(selected, width, dot + " " + badge + STYLE.bold + agent.name + STYLE.reset + linear));
14787
+ const statusStyle = agent.status === "blocked" ? STYLE.blocked : STYLE.dim;
14788
+ lines.push(row(selected, width, " " + statusStyle + agent.status + STYLE.reset + STYLE.dim + " \xB7 " + agent.harness + STYLE.reset));
14789
+ });
14790
+ if (state.agents.length === 0) lines.push(row(false, width, STYLE.dim + "no agents \u2014 n to spawn" + STYLE.reset));
14791
+ const selectedAgent = state.section === "agents" ? state.agents[state.agentIndex] : void 0;
14792
+ if (selectedAgent?.status === "blocked" && selectedAgent.ask) {
14793
+ lines.push("");
14794
+ lines.push(row(false, width, STYLE.blocked + "\u26D4 needs consent" + STYLE.reset));
14795
+ for (const action of actionsForAsk(selectedAgent.ask)) {
14796
+ const key = action === "track" ? "g" : action === "skip" ? "s" : "y";
14797
+ lines.push(row(false, width, STYLE.dim + " " + key + " \u2014 " + action + STYLE.reset));
14798
+ }
14799
+ }
14800
+ while (lines.length < height - 4) lines.push(pad("", width));
14801
+ lines.push(pad("", width));
14802
+ if (state.message) lines.push(row(false, width, STYLE.accent + state.message.slice(0, width - 2) + STYLE.reset));
14803
+ else lines.push(row(false, width, STYLE.dim + "n new \xB7 enter attach \xB7 x kill" + STYLE.reset));
14804
+ lines.push(row(false, width, STYLE.dim + "T tab \xB7 i interrupt \xB7 q quit" + STYLE.reset));
14805
+ lines.push(row(false, width, STYLE.dim + state.backendNote + STYLE.reset));
14806
+ return lines.slice(0, height).join("\n");
14807
+ }
14808
+ var ESC, STYLE, DOT;
14809
+ var init_render = __esm({
14810
+ "cli/ui/render.ts"() {
14811
+ "use strict";
14812
+ init_consent();
14813
+ ESC = "\x1B[";
14814
+ STYLE = {
14815
+ reset: ESC + "0m",
14816
+ dim: ESC + "2m",
14817
+ bold: ESC + "1m",
14818
+ header: ESC + "38;5;245m",
14819
+ select: ESC + "48;5;236m",
14820
+ working: ESC + "38;5;214m",
14821
+ idle: ESC + "38;5;244m",
14822
+ blocked: ESC + "38;5;203m",
14823
+ done: ESC + "38;5;114m",
14824
+ accent: ESC + "38;5;135m",
14825
+ branch: ESC + "38;5;140m"
14826
+ };
14827
+ DOT = {
14828
+ working: STYLE.working + "\u25CF" + STYLE.reset,
14829
+ idle: STYLE.idle + "\u25CB" + STYLE.reset,
14830
+ blocked: STYLE.blocked + "\u25CF" + STYLE.reset,
14831
+ done: STYLE.done + "\u25CF" + STYLE.reset
14832
+ };
14833
+ }
14834
+ });
14835
+
14836
+ // cli/ui/pueue.ts
14837
+ function buildEnsureGroup() {
14838
+ return ["pueue", "group", "add", PUEUE_GROUP];
14839
+ }
14840
+ function buildGroupParallel() {
14841
+ return ["pueue", "parallel", "-g", PUEUE_GROUP, "32"];
14842
+ }
14843
+ function buildSentinel(session) {
14844
+ const loop = "while tmux has-session -t " + shellQuote3(session) + " 2>/dev/null; do sleep 10; done";
14845
+ return ["pueue", "add", "-g", PUEUE_GROUP, "-l", session, "--", loop];
14846
+ }
14847
+ function buildRemove(taskId) {
14848
+ return ["pueue", "remove", taskId];
14849
+ }
14850
+ async function pueueAvailable2(runner) {
14851
+ const result = await run(runner, ["sh", "-c", "command -v pueue >/dev/null 2>&1 && pueue status >/dev/null 2>&1 && echo ok"]);
14852
+ return result.ok && result.stdout.includes("ok");
14853
+ }
14854
+ async function registerAgent(runner, session) {
14855
+ if (!await pueueAvailable2(runner)) return "";
14856
+ await run(runner, buildEnsureGroup());
14857
+ await run(runner, buildGroupParallel());
14858
+ const added = await run(runner, buildSentinel(session));
14859
+ const match = added.stdout.match(/id\s+(\d+)/i) || added.stderr.match(/id\s+(\d+)/i);
14860
+ return match ? match[1] : "";
14861
+ }
14862
+ async function releaseAgent(runner, pueueId) {
14863
+ if (!pueueId) return;
14864
+ await run(runner, buildRemove(pueueId)).catch?.(() => {
14865
+ });
14866
+ }
14867
+ var PUEUE_GROUP;
14868
+ var init_pueue2 = __esm({
14869
+ "cli/ui/pueue.ts"() {
14870
+ "use strict";
14871
+ init_tmux();
14872
+ PUEUE_GROUP = "synkro-ui";
14873
+ }
14874
+ });
14875
+
14876
+ // cli/ui/backend.ts
14877
+ async function detectContainerBackend() {
14878
+ const runner = { kind: "container", container: CONTAINER_NAME2 };
14879
+ const probe = await run({ kind: "host" }, [
14880
+ "docker",
14881
+ "exec",
14882
+ CONTAINER_NAME2,
14883
+ "sh",
14884
+ "-c",
14885
+ "command -v tmux >/dev/null && command -v claude >/dev/null && ls " + shellQuote3(AUTH_SEED) + " >/dev/null 2>&1 && echo ready"
14886
+ ]);
14887
+ if (probe.ok && probe.stdout.includes("ready")) {
14888
+ return { runner, backend: "container", note: "runtime: container (" + CONTAINER_NAME2 + ")" };
14889
+ }
14890
+ return { runner: { kind: "host" }, backend: "host", note: "runtime: host (container unavailable)" };
14891
+ }
14892
+ async function provisionContainerWorkspace(runner, slug) {
14893
+ const dir = CONTAINER_WORK + "/ui-" + slug;
14894
+ await run(runner, [
14895
+ "sh",
14896
+ "-c",
14897
+ "mkdir -p " + shellQuote3(dir) + " && cp -n " + shellQuote3(AUTH_SEED) + " " + shellQuote3(dir + "/.claude.json") + " 2>/dev/null; true"
14898
+ ]);
14899
+ return dir;
14900
+ }
14901
+ async function spawnAgent(info, request) {
14902
+ const slug = slugify(request.name);
14903
+ const session = agentSession(slug);
14904
+ const runner = request.backend === "container" ? { kind: "container", container: CONTAINER_NAME2 } : { kind: "host" };
14905
+ let cwd = request.cwd;
14906
+ if (request.backend === "container") {
14907
+ cwd = request.cwd.startsWith(CONTAINER_WORK) ? request.cwd : await provisionContainerWorkspace(runner, slug);
14908
+ }
14909
+ const command = request.harness === "codex" ? "codex" : "claude";
14910
+ for (const argv of buildSpawnAgent({ name: slug, cwd, command, harness: request.harness, space: request.spaceName, backend: request.backend })) {
14911
+ const result = await run(runner, argv);
14912
+ if (!result.ok && argv[1] === "new-session") {
14913
+ return { ok: false, session, error: result.stderr.trim() || "tmux new-session failed" };
14914
+ }
14915
+ }
14916
+ const pueueId = await registerAgent(runner, session);
14917
+ if (pueueId) await run(runner, buildSetOption(session, "@synkro_pueue", pueueId));
14918
+ return { ok: true, session };
14919
+ }
14920
+ async function killAgent(backend, session, pueueId) {
14921
+ const runner = backend === "container" ? { kind: "container", container: CONTAINER_NAME2 } : { kind: "host" };
14922
+ await run(runner, buildKillSession(session));
14923
+ await releaseAgent(runner, pueueId);
14924
+ }
14925
+ var CONTAINER_NAME2, CONTAINER_WORK, AUTH_SEED;
14926
+ var init_backend = __esm({
14927
+ "cli/ui/backend.ts"() {
14928
+ "use strict";
14929
+ init_tmux();
14930
+ init_pueue2();
14931
+ CONTAINER_NAME2 = "synkro-server";
14932
+ CONTAINER_WORK = "/home/synkro/work";
14933
+ AUTH_SEED = CONTAINER_WORK + "/claude-1/.claude.json";
14934
+ }
14935
+ });
14936
+
14937
+ // cli/ui/sidebar.ts
14938
+ function runnerFor(backend) {
14939
+ return backend === "container" ? { kind: "container", container: CONTAINER_NAME2 } : { kind: "host" };
14940
+ }
14941
+ async function runSidebar() {
14942
+ const centerPane = process.env.SYNKRO_UI_CENTER || "";
14943
+ const outerSession = process.env.SYNKRO_UI_OUTER || "synkro-ui";
14944
+ const bootPath = process.env.SYNKRO_UI_BOOT || process.argv[1];
14945
+ const repoCwd = process.env.SYNKRO_UI_REPO || process.cwd();
14946
+ const info = await detectContainerBackend();
14947
+ const state = {
14948
+ spaces: [],
14949
+ agents: [],
14950
+ section: "agents",
14951
+ spaceIndex: 0,
14952
+ agentIndex: 0,
14953
+ backendNote: info.note,
14954
+ message: ""
14955
+ };
14956
+ let hashes = /* @__PURE__ */ new Map();
14957
+ let lastFrame = "";
14958
+ let spawnCounter = 1;
14959
+ const host = { kind: "host" };
14960
+ const containerRunner = { kind: "container", container: CONTAINER_NAME2 };
14961
+ async function refresh() {
14962
+ const [hostSpaces, containerSpaces, conductorTasks] = await Promise.all([
14963
+ discoverHostSpaces(repoCwd),
14964
+ info.backend === "container" ? discoverContainerSpaces(containerRunner) : Promise.resolve([]),
14965
+ fetchConductorTasks(CONDUCTOR_URL)
14966
+ ]);
14967
+ state.spaces = [...hostSpaces, ...containerSpaces];
14968
+ const hostAgents = await discoverAgents(host, "host", hashes);
14969
+ const containerAgents = info.backend === "container" ? await discoverAgents(containerRunner, "container", hashes) : { agents: [], hashes: /* @__PURE__ */ new Map() };
14970
+ hashes = new Map([...hostAgents.hashes, ...containerAgents.hashes]);
14971
+ state.agents = [...hostAgents.agents, ...containerAgents.agents].map((agent) => ({
14972
+ ...agent,
14973
+ linear: mapAgentToTask(agent.space, conductorTasks)
14974
+ }));
14975
+ state.spaceIndex = Math.min(state.spaceIndex, Math.max(0, state.spaces.length - 1));
14976
+ state.agentIndex = Math.min(state.agentIndex, Math.max(0, state.agents.length - 1));
14977
+ }
14978
+ function draw() {
14979
+ const rows = Number(process.stdout.rows || 42);
14980
+ const cols = Number(process.stdout.columns || 32);
14981
+ const frame = renderSidebar(state, cols, rows);
14982
+ if (frame === lastFrame) return;
14983
+ lastFrame = frame;
14984
+ process.stdout.write("\x1B[2J\x1B[H" + frame);
14985
+ }
14986
+ async function attachSelected() {
14987
+ const agent = state.agents[state.agentIndex];
14988
+ if (!agent || !centerPane) return;
14989
+ const command = buildCenterAttachCommand(runnerFor(agent.backend), agent.session);
14990
+ await run(host, ["tmux", "respawn-pane", "-k", "-t", centerPane, command]);
14991
+ await run(host, ["tmux", "rename-window", "-t", outerSession, agent.name]);
14992
+ state.message = "attached " + agent.name;
14993
+ }
14994
+ async function spawnInSelectedSpace() {
14995
+ const space = state.spaces[state.spaceIndex] || state.spaces[0];
14996
+ if (!space) {
14997
+ state.message = "no space selected";
14998
+ return;
14999
+ }
15000
+ const name = space.name + "-" + spawnCounter++;
15001
+ const result = await spawnAgent(info, {
15002
+ name,
15003
+ harness: "claude",
15004
+ spaceName: space.name,
15005
+ cwd: space.path,
15006
+ backend: space.backend === "container" && info.backend === "container" ? "container" : "host"
15007
+ });
15008
+ state.message = result.ok ? "spawned " + name : "spawn failed: " + (result.error || "").slice(0, 24);
15009
+ }
15010
+ async function spawnContainerAgent() {
15011
+ if (info.backend !== "container") {
15012
+ state.message = "container runtime unavailable";
15013
+ return;
15014
+ }
15015
+ const name = "box-" + spawnCounter++;
15016
+ const result = await spawnAgent(info, { name, harness: "claude", spaceName: name, cwd: "", backend: "container" });
15017
+ state.message = result.ok ? "spawned \u25A3 " + name : "spawn failed: " + (result.error || "").slice(0, 24);
15018
+ }
15019
+ async function consent(action) {
15020
+ const agent = state.agents[state.agentIndex];
15021
+ if (!agent || agent.status !== "blocked" || !agent.ask) return;
15022
+ if (!actionsForAsk(agent.ask).includes(action)) return;
15023
+ const runner = runnerFor(agent.backend);
15024
+ for (const argv of buildSendText(agent.session, CONSENT_PHRASES[action])) await run(runner, argv);
15025
+ state.message = action + " \u2192 " + agent.name;
15026
+ }
15027
+ async function newTab() {
15028
+ await run(host, ["node", String(bootPath), "ui", "--new-tab", outerSession]);
15029
+ }
15030
+ process.stdin.setRawMode?.(true);
15031
+ process.stdin.resume();
15032
+ process.stdin.on("data", (chunk) => {
15033
+ const key = chunk.toString("utf8");
15034
+ void (async () => {
15035
+ const list = state.section === "spaces" ? state.spaces : state.agents;
15036
+ if (key === " ") state.section = state.section === "spaces" ? "agents" : "spaces";
15037
+ else if (key === "j" || key === "\x1B[B") {
15038
+ if (state.section === "spaces") state.spaceIndex = Math.min(state.spaceIndex + 1, Math.max(0, list.length - 1));
15039
+ else state.agentIndex = Math.min(state.agentIndex + 1, Math.max(0, list.length - 1));
15040
+ } else if (key === "k" || key === "\x1B[A") {
15041
+ if (state.section === "spaces") state.spaceIndex = Math.max(0, state.spaceIndex - 1);
15042
+ else state.agentIndex = Math.max(0, state.agentIndex - 1);
15043
+ } else if (key === "\r") {
15044
+ if (state.section === "agents") await attachSelected();
15045
+ else state.message = "space: " + (state.spaces[state.spaceIndex]?.name || "");
15046
+ } else if (key === "n") await spawnInSelectedSpace();
15047
+ else if (key === "c") await spawnContainerAgent();
15048
+ else if (key === "x") {
15049
+ const agent = state.agents[state.agentIndex];
15050
+ if (agent) {
15051
+ await killAgent(agent.backend, agent.session, "");
15052
+ state.message = "killed " + agent.name;
15053
+ }
15054
+ } else if (key === "i") {
15055
+ const agent = state.agents[state.agentIndex];
15056
+ if (agent) await run(runnerFor(agent.backend), buildInterrupt(agent.session));
15057
+ } else if (key === "g") await consent("track");
15058
+ else if (key === "s") await consent("skip");
15059
+ else if (key === "y") await consent("stay");
15060
+ else if (key === "T") await newTab();
15061
+ else if (key === "q" || key === "") {
15062
+ await run(host, ["tmux", "kill-session", "-t", outerSession]);
15063
+ process.exit(0);
15064
+ }
15065
+ await refresh();
15066
+ draw();
15067
+ })();
15068
+ });
15069
+ await refresh();
15070
+ draw();
15071
+ setInterval(() => {
15072
+ void refresh().then(draw);
15073
+ }, POLL_MS);
15074
+ }
15075
+ var POLL_MS, CONDUCTOR_URL;
15076
+ var init_sidebar = __esm({
15077
+ "cli/ui/sidebar.ts"() {
15078
+ "use strict";
15079
+ init_model();
15080
+ init_render();
15081
+ init_consent();
15082
+ init_backend();
15083
+ init_tmux();
15084
+ POLL_MS = 2e3;
15085
+ CONDUCTOR_URL = "http://127.0.0.1:" + (process.env.SYNKRO_HOST_MCP_PORT || "18931");
15086
+ }
15087
+ });
15088
+
15089
+ // cli/commands/ui.ts
15090
+ var ui_exports = {};
15091
+ __export(ui_exports, {
15092
+ uiCommand: () => uiCommand
15093
+ });
15094
+ import { execSync as execSync7 } from "child_process";
15095
+ function repoRoot() {
15096
+ try {
15097
+ return execSync7("git rev-parse --show-toplevel", { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim() || process.cwd();
15098
+ } catch {
15099
+ return process.cwd();
15100
+ }
15101
+ }
15102
+ function tmuxPresent() {
15103
+ try {
15104
+ execSync7("tmux -V", { stdio: ["pipe", "pipe", "pipe"] });
15105
+ return true;
15106
+ } catch {
15107
+ return false;
15108
+ }
15109
+ }
15110
+ async function uiCommand(args2) {
15111
+ const bootPath = String(process.argv[1] || "");
15112
+ if (args2.includes("--sidebar")) {
15113
+ await runSidebar();
15114
+ await new Promise(() => {
15115
+ });
15116
+ return;
15117
+ }
15118
+ if (args2.includes("--new-tab")) {
15119
+ await buildTab(bootPath, repoRoot(), "new");
15120
+ return;
15121
+ }
15122
+ if (!tmuxPresent()) {
15123
+ console.error("synkro ui needs tmux. Install it (brew install tmux) and rerun.");
15124
+ process.exitCode = 1;
15125
+ return;
15126
+ }
15127
+ const code = await launchUi(bootPath, repoRoot());
15128
+ process.exitCode = code;
15129
+ }
15130
+ var init_ui = __esm({
15131
+ "cli/commands/ui.ts"() {
15132
+ "use strict";
15133
+ init_launch();
15134
+ init_sidebar();
15135
+ }
15136
+ });
15137
+
14090
15138
  // cli/commands/refresh.ts
14091
15139
  var refresh_exports = {};
14092
15140
  __export(refresh_exports, {
@@ -14124,11 +15172,11 @@ __export(linear_exports, {
14124
15172
  linearCommand: () => linearCommand
14125
15173
  });
14126
15174
  import { readFileSync as readFileSync30 } from "fs";
14127
- import { homedir as homedir31 } from "os";
14128
- import { join as join31 } from "path";
15175
+ import { homedir as homedir32 } from "os";
15176
+ import { join as join32 } from "path";
14129
15177
  function mcpJwt() {
14130
15178
  try {
14131
- return readFileSync30(join31(SYNKRO_DIR14, ".mcp-jwt"), "utf-8").trim();
15179
+ return readFileSync30(join32(SYNKRO_DIR14, ".mcp-jwt"), "utf-8").trim();
14132
15180
  } catch {
14133
15181
  return "";
14134
15182
  }
@@ -14167,7 +15215,7 @@ var SYNKRO_DIR14, PORT2, BASE;
14167
15215
  var init_linear = __esm({
14168
15216
  "cli/commands/linear.ts"() {
14169
15217
  "use strict";
14170
- SYNKRO_DIR14 = join31(homedir31(), ".synkro");
15218
+ SYNKRO_DIR14 = join32(homedir32(), ".synkro");
14171
15219
  PORT2 = process.env.SYNKRO_MCP_PORT || "18931";
14172
15220
  BASE = `http://127.0.0.1:${PORT2}`;
14173
15221
  }
@@ -14316,33 +15364,33 @@ var init_cveReachability = __esm({
14316
15364
  });
14317
15365
 
14318
15366
  // 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";
15367
+ import { spawnSync as spawnSync12, execFileSync as execFileSync5 } from "child_process";
15368
+ import { readFileSync as readFileSync32, writeFileSync as writeFileSync23, existsSync as existsSync34, readdirSync as readdirSync9 } from "fs";
15369
+ import { join as join33 } from "path";
15370
+ import { homedir as homedir33 } from "os";
14323
15371
  import { createRequire } from "module";
14324
- function walkSourceFiles(repoRoot2, maxFiles = 4e3, maxBytes = 5e5) {
15372
+ function walkSourceFiles(repoRoot3, maxFiles = 4e3, maxBytes = 5e5) {
14325
15373
  const SKIP = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", "coverage", ".next", ".turbo", "out", ".cache", ".synkro", ".claude", "vendor", "__tests__", "test-results"]);
14326
15374
  const EXT = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
14327
15375
  const files = [];
14328
- const stack = [repoRoot2];
15376
+ const stack = [repoRoot3];
14329
15377
  while (stack.length && files.length < maxFiles) {
14330
15378
  const dir = stack.pop();
14331
15379
  let ents;
14332
15380
  try {
14333
- ents = readdirSync8(dir, { withFileTypes: true });
15381
+ ents = readdirSync9(dir, { withFileTypes: true });
14334
15382
  } catch {
14335
15383
  continue;
14336
15384
  }
14337
15385
  for (const e of ents) {
14338
15386
  if (files.length >= maxFiles) break;
14339
- const full = join32(dir, e.name);
15387
+ const full = join33(dir, e.name);
14340
15388
  if (e.isDirectory()) {
14341
15389
  if (!SKIP.has(e.name) && !e.name.startsWith(".")) stack.push(full);
14342
15390
  continue;
14343
15391
  }
14344
15392
  if (!EXT.test(e.name) || e.name.endsWith(".d.ts")) continue;
14345
- const rel = full.startsWith(repoRoot2 + "/") ? full.slice(repoRoot2.length + 1) : full;
15393
+ const rel = full.startsWith(repoRoot3 + "/") ? full.slice(repoRoot3.length + 1) : full;
14346
15394
  try {
14347
15395
  const content = readFileSync32(full, "utf8");
14348
15396
  if (content.length <= maxBytes) files.push({ path: rel, content });
@@ -14360,15 +15408,15 @@ function cleanVersion(spec) {
14360
15408
  const c = s.replace(/^[\^~>=<\s]+/, "");
14361
15409
  return /^\d[\w.\-+]*$/.test(c) ? c : null;
14362
15410
  }
14363
- function gatherManifestVersions(repoRoot2) {
15411
+ function gatherManifestVersions(repoRoot3) {
14364
15412
  const out = {};
14365
- const dirs = [repoRoot2];
14366
- const pkgsDir = join32(repoRoot2, "packages");
14367
- if (existsSync33(pkgsDir)) {
15413
+ const dirs = [repoRoot3];
15414
+ const pkgsDir = join33(repoRoot3, "packages");
15415
+ if (existsSync34(pkgsDir)) {
14368
15416
  try {
14369
- for (const d of readdirSync8(pkgsDir)) {
14370
- const pd = join32(pkgsDir, d);
14371
- if (existsSync33(join32(pd, "package.json"))) dirs.push(pd);
15417
+ for (const d of readdirSync9(pkgsDir)) {
15418
+ const pd = join33(pkgsDir, d);
15419
+ if (existsSync34(join33(pd, "package.json"))) dirs.push(pd);
14372
15420
  }
14373
15421
  } catch {
14374
15422
  }
@@ -14377,7 +15425,7 @@ function gatherManifestVersions(repoRoot2) {
14377
15425
  for (const dir of dirs) {
14378
15426
  let pkg;
14379
15427
  try {
14380
- pkg = JSON.parse(readFileSync32(join32(dir, "package.json"), "utf8"));
15428
+ pkg = JSON.parse(readFileSync32(join33(dir, "package.json"), "utf8"));
14381
15429
  } catch {
14382
15430
  continue;
14383
15431
  }
@@ -14393,32 +15441,32 @@ function gatherManifestVersions(repoRoot2) {
14393
15441
  }
14394
15442
  return out;
14395
15443
  }
14396
- function findJelly(repoRoot2) {
15444
+ function findJelly(repoRoot3) {
14397
15445
  try {
14398
15446
  const pkgJson = require2.resolve("@cs-au-dk/jelly/package.json");
14399
15447
  const dir = pkgJson.slice(0, pkgJson.length - "package.json".length);
14400
15448
  const pkg = JSON.parse(readFileSync32(pkgJson, "utf8"));
14401
15449
  const bin = typeof pkg.bin === "string" ? pkg.bin : pkg.bin && (pkg.bin.jelly || pkg.bin[Object.keys(pkg.bin)[0]]);
14402
15450
  if (bin) {
14403
- const p = join32(dir, bin);
14404
- if (existsSync33(p)) return p;
15451
+ const p = join33(dir, bin);
15452
+ if (existsSync34(p)) return p;
14405
15453
  }
14406
15454
  } catch {
14407
15455
  }
14408
- for (const base of [repoRoot2, process.cwd()]) {
14409
- const b = join32(base, "node_modules", ".bin", "jelly");
14410
- if (existsSync33(b)) return b;
15456
+ for (const base of [repoRoot3, process.cwd()]) {
15457
+ const b = join33(base, "node_modules", ".bin", "jelly");
15458
+ if (existsSync34(b)) return b;
14411
15459
  }
14412
15460
  return null;
14413
15461
  }
14414
- function findEntries(repoRoot2) {
14415
- const dirs = [repoRoot2];
14416
- const pkgsDir = join32(repoRoot2, "packages");
14417
- if (existsSync33(pkgsDir)) {
15462
+ function findEntries(repoRoot3) {
15463
+ const dirs = [repoRoot3];
15464
+ const pkgsDir = join33(repoRoot3, "packages");
15465
+ if (existsSync34(pkgsDir)) {
14418
15466
  try {
14419
- for (const d of readdirSync8(pkgsDir)) {
14420
- const pd = join32(pkgsDir, d);
14421
- if (existsSync33(join32(pd, "package.json"))) dirs.push(pd);
15467
+ for (const d of readdirSync9(pkgsDir)) {
15468
+ const pd = join33(pkgsDir, d);
15469
+ if (existsSync34(join33(pd, "package.json"))) dirs.push(pd);
14422
15470
  }
14423
15471
  } catch {
14424
15472
  }
@@ -14426,12 +15474,12 @@ function findEntries(repoRoot2) {
14426
15474
  const entries = [];
14427
15475
  for (const dir of dirs) {
14428
15476
  try {
14429
- const pkg = JSON.parse(readFileSync32(join32(dir, "package.json"), "utf8"));
15477
+ const pkg = JSON.parse(readFileSync32(join33(dir, "package.json"), "utf8"));
14430
15478
  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
15479
  for (const c of cands) {
14432
15480
  if (typeof c !== "string") continue;
14433
- const f = join32(dir, c);
14434
- if (existsSync33(f)) {
15481
+ const f = join33(dir, c);
15482
+ if (existsSync34(f)) {
14435
15483
  entries.push(f);
14436
15484
  break;
14437
15485
  }
@@ -14441,9 +15489,9 @@ function findEntries(repoRoot2) {
14441
15489
  }
14442
15490
  return entries.slice(0, 40);
14443
15491
  }
14444
- function currentCommit(repoRoot2) {
15492
+ function currentCommit(repoRoot3) {
14445
15493
  try {
14446
- return execFileSync5("git", ["rev-parse", "HEAD"], { cwd: repoRoot2, encoding: "utf8" }).trim();
15494
+ return execFileSync5("git", ["rev-parse", "HEAD"], { cwd: repoRoot3, encoding: "utf8" }).trim();
14447
15495
  } catch {
14448
15496
  return "";
14449
15497
  }
@@ -14462,9 +15510,9 @@ function parseApiUsage(log) {
14462
15510
  for (const k of Object.keys(map)) out[k] = { reachableApis: [...map[k]].slice(0, 60) };
14463
15511
  return out;
14464
15512
  }
14465
- function runReachabilityScan(repoRoot2, opts = {}) {
14466
- const commit = currentCommit(repoRoot2);
14467
- if (!opts.force && commit && existsSync33(REACHABILITY_PATH)) {
15513
+ function runReachabilityScan(repoRoot3, opts = {}) {
15514
+ const commit = currentCommit(repoRoot3);
15515
+ if (!opts.force && commit && existsSync34(REACHABILITY_PATH)) {
14468
15516
  try {
14469
15517
  const prev = JSON.parse(readFileSync32(REACHABILITY_PATH, "utf8"));
14470
15518
  if (prev.commit === commit) return { ok: true, cached: true, packages: Object.keys(prev.packages || {}).length };
@@ -14513,13 +15561,13 @@ function runReachabilityScan(repoRoot2, opts = {}) {
14513
15561
  }
14514
15562
  };
14515
15563
  let tool = "ast";
14516
- const jelly = findJelly(repoRoot2);
15564
+ const jelly = findJelly(repoRoot3);
14517
15565
  if (jelly) {
14518
- const entries = findEntries(repoRoot2);
15566
+ const entries = findEntries(repoRoot3);
14519
15567
  if (entries.length > 0) {
14520
- const r = spawnSync11(
15568
+ const r = spawnSync12(
14521
15569
  process.execPath,
14522
- [jelly, "-b", repoRoot2, "--api-usage", ...entries],
15570
+ [jelly, "-b", repoRoot3, "--api-usage", ...entries],
14523
15571
  { encoding: "utf8", timeout: opts.timeoutMs ?? 18e4, maxBuffer: 2e8 }
14524
15572
  );
14525
15573
  const jp = parseApiUsage((r.stdout || "") + (r.stderr || ""));
@@ -14529,7 +15577,7 @@ function runReachabilityScan(repoRoot2, opts = {}) {
14529
15577
  }
14530
15578
  }
14531
15579
  }
14532
- const astPackages = extractAllPackageUsage(walkSourceFiles(repoRoot2));
15580
+ const astPackages = extractAllPackageUsage(walkSourceFiles(repoRoot3));
14533
15581
  for (const [k, v] of Object.entries(astPackages)) {
14534
15582
  addAll(k, v.apis);
14535
15583
  addSites(k, v.sites);
@@ -14553,9 +15601,9 @@ function runReachabilityScan(repoRoot2, opts = {}) {
14553
15601
  packages[k] = entry;
14554
15602
  }
14555
15603
  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) };
15604
+ const file = { generatedAt: (/* @__PURE__ */ new Date()).toISOString(), commit, tool, packages, versions: gatherManifestVersions(repoRoot3) };
14557
15605
  try {
14558
- writeFileSync22(REACHABILITY_PATH, JSON.stringify(file, null, 2));
15606
+ writeFileSync23(REACHABILITY_PATH, JSON.stringify(file, null, 2));
14559
15607
  } catch (e) {
14560
15608
  return { ok: false, reason: "write failed: " + String(e.message || e) };
14561
15609
  }
@@ -14567,7 +15615,7 @@ var init_reachabilityScan = __esm({
14567
15615
  "use strict";
14568
15616
  init_cveReachability();
14569
15617
  require2 = createRequire(import.meta.url);
14570
- REACHABILITY_PATH = join32(homedir32(), ".synkro", "reachability.json");
15618
+ REACHABILITY_PATH = join33(homedir33(), ".synkro", "reachability.json");
14571
15619
  }
14572
15620
  });
14573
15621
 
@@ -14576,13 +15624,13 @@ var reachabilityScan_exports = {};
14576
15624
  __export(reachabilityScan_exports, {
14577
15625
  reachabilityScanCommand: () => reachabilityScanCommand
14578
15626
  });
14579
- import { readFileSync as readFileSync33, existsSync as existsSync34 } from "fs";
14580
- import { join as join33 } from "path";
14581
- import { homedir as homedir33 } from "os";
15627
+ import { readFileSync as readFileSync33, existsSync as existsSync35 } from "fs";
15628
+ import { join as join34 } from "path";
15629
+ import { homedir as homedir34 } from "os";
14582
15630
  import { execFileSync as execFileSync6 } from "child_process";
14583
15631
  function readConfigEnv4() {
14584
- const p = join33(SYNKRO_DIR15, "config.env");
14585
- if (!existsSync34(p)) return {};
15632
+ const p = join34(SYNKRO_DIR15, "config.env");
15633
+ if (!existsSync35(p)) return {};
14586
15634
  const out = {};
14587
15635
  for (const line of readFileSync33(p, "utf-8").split("\n")) {
14588
15636
  const t = line.trim();
@@ -14592,7 +15640,7 @@ function readConfigEnv4() {
14592
15640
  }
14593
15641
  return out;
14594
15642
  }
14595
- function repoRoot() {
15643
+ function repoRoot2() {
14596
15644
  try {
14597
15645
  return execFileSync6("git", ["rev-parse", "--show-toplevel"], { encoding: "utf-8" }).trim();
14598
15646
  } catch {
@@ -14600,14 +15648,14 @@ function repoRoot() {
14600
15648
  }
14601
15649
  }
14602
15650
  function repoSlug(root) {
14603
- const run = (a) => {
15651
+ const run2 = (a) => {
14604
15652
  try {
14605
15653
  return execFileSync6("git", a, { encoding: "utf-8" }).trim();
14606
15654
  } catch {
14607
15655
  return "";
14608
15656
  }
14609
15657
  };
14610
- const remote = run(["remote", "get-url", "origin"]);
15658
+ const remote = run2(["remote", "get-url", "origin"]);
14611
15659
  if (remote) return remote.replace(/^git@[^:]+:/, "").replace(/^https?:\/\/[^/]+\//, "").replace(/\.git$/, "");
14612
15660
  return root.split("/").pop() || root;
14613
15661
  }
@@ -14616,10 +15664,10 @@ async function pushToCloud(cfg, repo) {
14616
15664
  while (gwBase.endsWith("/")) gwBase = gwBase.slice(0, -1);
14617
15665
  let jwt2 = "";
14618
15666
  try {
14619
- jwt2 = readFileSync33(join33(SYNKRO_DIR15, ".mcp-jwt"), "utf-8").trim();
15667
+ jwt2 = readFileSync33(join34(SYNKRO_DIR15, ".mcp-jwt"), "utf-8").trim();
14620
15668
  } catch {
14621
15669
  }
14622
- if (!jwt2 || !existsSync34(REACHABILITY_PATH)) return;
15670
+ if (!jwt2 || !existsSync35(REACHABILITY_PATH)) return;
14623
15671
  const body = readFileSync33(REACHABILITY_PATH, "utf-8");
14624
15672
  try {
14625
15673
  const resp = await fetch(gwBase + "/api/v1/reachability?repo=" + encodeURIComponent(repo), {
@@ -14634,7 +15682,7 @@ async function pushToCloud(cfg, repo) {
14634
15682
  }
14635
15683
  }
14636
15684
  async function reachabilityScanCommand(args2 = []) {
14637
- const root = repoRoot();
15685
+ const root = repoRoot2();
14638
15686
  const force = args2.includes("--force");
14639
15687
  const quiet = args2.includes("--quiet");
14640
15688
  const res = runReachabilityScan(root, { force });
@@ -14652,7 +15700,7 @@ var init_reachabilityScan2 = __esm({
14652
15700
  "cli/commands/reachabilityScan.ts"() {
14653
15701
  "use strict";
14654
15702
  init_reachabilityScan();
14655
- SYNKRO_DIR15 = join33(homedir33(), ".synkro");
15703
+ SYNKRO_DIR15 = join34(homedir34(), ".synkro");
14656
15704
  }
14657
15705
  });
14658
15706
 
@@ -14782,11 +15830,11 @@ var config_exports = {};
14782
15830
  __export(config_exports, {
14783
15831
  configCommand: () => configCommand
14784
15832
  });
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";
15833
+ import { readFileSync as readFileSync34, writeFileSync as writeFileSync24, existsSync as existsSync36 } from "fs";
15834
+ import { join as join35 } from "path";
15835
+ import { homedir as homedir35 } from "os";
14788
15836
  function readConfigEnv5() {
14789
- if (!existsSync35(CONFIG_PATH9)) return {};
15837
+ if (!existsSync36(CONFIG_PATH9)) return {};
14790
15838
  const out = {};
14791
15839
  for (const line of readFileSync34(CONFIG_PATH9, "utf-8").split("\n")) {
14792
15840
  const t = line.trim();
@@ -14797,7 +15845,7 @@ function readConfigEnv5() {
14797
15845
  return out;
14798
15846
  }
14799
15847
  function updateConfigValue(key, value) {
14800
- if (!existsSync35(CONFIG_PATH9)) {
15848
+ if (!existsSync36(CONFIG_PATH9)) {
14801
15849
  console.error("No config found. Run `synkro install` first.");
14802
15850
  process.exit(1);
14803
15851
  }
@@ -14812,7 +15860,7 @@ function updateConfigValue(key, value) {
14812
15860
  return line;
14813
15861
  });
14814
15862
  if (!found) updated.splice(updated.length - 1, 0, `${key}='${value}'`);
14815
- writeFileSync23(CONFIG_PATH9, updated.join("\n"), "utf-8");
15863
+ writeFileSync24(CONFIG_PATH9, updated.join("\n"), "utf-8");
14816
15864
  }
14817
15865
  function resolveInferenceMode(cfg) {
14818
15866
  if ((cfg.SYNKRO_GRADING_MODE || "local") === "byok") return "byok";
@@ -14970,8 +16018,8 @@ var init_config = __esm({
14970
16018
  "use strict";
14971
16019
  init_stub();
14972
16020
  init_optout();
14973
- SYNKRO_DIR16 = join34(homedir34(), ".synkro");
14974
- CONFIG_PATH9 = join34(SYNKRO_DIR16, "config.env");
16021
+ SYNKRO_DIR16 = join35(homedir35(), ".synkro");
16022
+ CONFIG_PATH9 = join35(SYNKRO_DIR16, "config.env");
14975
16023
  }
14976
16024
  });
14977
16025
 
@@ -15035,11 +16083,11 @@ async function printTail(args2) {
15035
16083
  console.log("(no events \u2014 run `synkro start` if the container is down so JSONL pending events can drain)");
15036
16084
  return;
15037
16085
  }
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);
16086
+ for (const row2 of rows) {
16087
+ const session = row2.cc_session_id ? ` session=${String(row2.cc_session_id).slice(0, 8)}` : "";
16088
+ const ts = typeof row2.occurred_at === "string" ? row2.occurred_at : new Date(row2.occurred_at).toISOString();
16089
+ console.log(` ${ts} ${row2.event_type.padEnd(20)} ${row2.emitter}${session}`);
16090
+ const contextStr = typeof row2.context === "string" ? row2.context : JSON.stringify(row2.context);
15043
16091
  console.log(` context: ${truncate2(contextStr, 200)}`);
15044
16092
  }
15045
16093
  }
@@ -15161,27 +16209,27 @@ Usage:
15161
16209
 
15162
16210
  // cli/inventory/identity.ts
15163
16211
  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";
16212
+ import { existsSync as existsSync37, mkdirSync as mkdirSync20, readFileSync as readFileSync35, renameSync as renameSync9, writeFileSync as writeFileSync25 } from "fs";
16213
+ import { homedir as homedir36 } from "os";
16214
+ import { dirname as dirname9, join as join36 } from "path";
15167
16215
  function operationalIdentityPath() {
15168
- return process.env.SYNKRO_OPERATIONAL_IDENTITY_PATH || join35(homedir35(), ".synkro", "installation.json");
16216
+ return process.env.SYNKRO_OPERATIONAL_IDENTITY_PATH || join36(homedir36(), ".synkro", "installation.json");
15169
16217
  }
15170
16218
  function validIdentity(value) {
15171
16219
  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));
16220
+ const row2 = value;
16221
+ 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
16222
  }
15175
16223
  function writeIdentity(path, identity) {
15176
- mkdirSync19(dirname9(path), { recursive: true, mode: 448 });
16224
+ mkdirSync20(dirname9(path), { recursive: true, mode: 448 });
15177
16225
  const temp = `${path}.${process.pid}.${randomUUID5()}.tmp`;
15178
- writeFileSync24(temp, JSON.stringify(identity, null, 2) + "\n", { encoding: "utf8", mode: 384 });
16226
+ writeFileSync25(temp, JSON.stringify(identity, null, 2) + "\n", { encoding: "utf8", mode: 384 });
15179
16227
  renameSync9(temp, path);
15180
16228
  }
15181
16229
  function getOperationalInstallationIdentity(path = operationalIdentityPath()) {
15182
16230
  const prior = cached4.get(path);
15183
16231
  if (prior) return prior;
15184
- if (existsSync36(path)) {
16232
+ if (existsSync37(path)) {
15185
16233
  try {
15186
16234
  const parsed = JSON.parse(readFileSync35(path, "utf8"));
15187
16235
  if (validIdentity(parsed)) {
@@ -15208,13 +16256,13 @@ var init_identity2 = __esm({
15208
16256
  // cli/inventory/collector.ts
15209
16257
  import { createHash as createHash5 } from "crypto";
15210
16258
  import {
15211
- existsSync as existsSync37,
16259
+ existsSync as existsSync38,
15212
16260
  readFileSync as readFileSync36,
15213
- readdirSync as readdirSync9,
16261
+ readdirSync as readdirSync10,
15214
16262
  statSync as statSync5
15215
16263
  } 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";
16264
+ import { arch, homedir as homedir37, hostname as hostname2, platform as platform5, release } from "os";
16265
+ import { basename as basename3, join as join37, relative, resolve as resolve5 } from "path";
15218
16266
  import { fileURLToPath } from "url";
15219
16267
  function sha256(value) {
15220
16268
  return createHash5("sha256").update(value).digest("hex");
@@ -15224,14 +16272,14 @@ function pseudonymousHostnameHash(installationId, host) {
15224
16272
  }
15225
16273
  function cliVersion() {
15226
16274
  try {
15227
- return "1.8.0";
16275
+ return "1.9.0";
15228
16276
  } catch {
15229
16277
  return "0.0.0";
15230
16278
  }
15231
16279
  }
15232
16280
  function readJson(path) {
15233
16281
  try {
15234
- if (!existsSync37(path)) return null;
16282
+ if (!existsSync38(path)) return null;
15235
16283
  const parsed = JSON.parse(readFileSync36(path, "utf8"));
15236
16284
  return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
15237
16285
  } catch {
@@ -15240,7 +16288,7 @@ function readJson(path) {
15240
16288
  }
15241
16289
  function readText(path) {
15242
16290
  try {
15243
- if (!existsSync37(path)) return "";
16291
+ if (!existsSync38(path)) return "";
15244
16292
  return readFileSync36(path, "utf8");
15245
16293
  } catch {
15246
16294
  return "";
@@ -15327,16 +16375,16 @@ function mcpArtifactsFromJson(harness, config, configScope = "user") {
15327
16375
  }
15328
16376
  function claudeDesktopConfigCandidates(home, targetPlatform) {
15329
16377
  if (targetPlatform === "darwin") {
15330
- return [join36(home, "Library", "Application Support", "Claude", "claude_desktop_config.json")];
16378
+ return [join37(home, "Library", "Application Support", "Claude", "claude_desktop_config.json")];
15331
16379
  }
15332
16380
  if (targetPlatform === "linux") {
15333
16381
  return [
15334
- join36(home, ".config", "Claude", "claude_desktop_config.json"),
15335
- join36(home, ".config", "claude", "claude_desktop_config.json")
16382
+ join37(home, ".config", "Claude", "claude_desktop_config.json"),
16383
+ join37(home, ".config", "claude", "claude_desktop_config.json")
15336
16384
  ];
15337
16385
  }
15338
16386
  if (targetPlatform === "win32" && process.env.APPDATA) {
15339
- return [join36(process.env.APPDATA, "Claude", "claude_desktop_config.json")];
16387
+ return [join37(process.env.APPDATA, "Claude", "claude_desktop_config.json")];
15340
16388
  }
15341
16389
  return [];
15342
16390
  }
@@ -15344,7 +16392,7 @@ function claudeManagedMcpConfigCandidates(targetPlatform) {
15344
16392
  if (targetPlatform === "darwin") return ["/Library/Application Support/ClaudeCode/managed-mcp.json"];
15345
16393
  if (targetPlatform === "linux") return ["/etc/claude-code/managed-mcp.json"];
15346
16394
  if (targetPlatform === "win32" && process.env.ProgramFiles) {
15347
- return [join36(process.env.ProgramFiles, "ClaudeCode", "managed-mcp.json")];
16395
+ return [join37(process.env.ProgramFiles, "ClaudeCode", "managed-mcp.json")];
15348
16396
  }
15349
16397
  return [];
15350
16398
  }
@@ -15353,7 +16401,7 @@ function discoveredProjectRoots(claudeState, currentDirectory, explicit = [], cu
15353
16401
  const add = (value) => {
15354
16402
  if (typeof value !== "string" || !value.trim()) return;
15355
16403
  const path = resolve5(value);
15356
- if (existsSync37(path)) roots.add(path);
16404
+ if (existsSync38(path)) roots.add(path);
15357
16405
  };
15358
16406
  add(currentDirectory);
15359
16407
  for (const path of explicit) add(path);
@@ -15364,31 +16412,31 @@ function discoveredProjectRoots(claudeState, currentDirectory, explicit = [], cu
15364
16412
  return [...roots];
15365
16413
  }
15366
16414
  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")];
16415
+ if (targetPlatform === "darwin") return [join37(home, "Library", "Application Support", "Cursor", "User", "workspaceStorage")];
16416
+ if (targetPlatform === "linux") return [join37(home, ".config", "Cursor", "User", "workspaceStorage")];
15369
16417
  if (targetPlatform === "win32" && process.env.APPDATA) {
15370
- return [join36(process.env.APPDATA, "Cursor", "User", "workspaceStorage")];
16418
+ return [join37(process.env.APPDATA, "Cursor", "User", "workspaceStorage")];
15371
16419
  }
15372
16420
  return [];
15373
16421
  }
15374
16422
  function cursorWorkspaceRoots(home, targetPlatform) {
15375
16423
  const roots = /* @__PURE__ */ new Set();
15376
16424
  for (const storage of cursorWorkspaceStorageCandidates(home, targetPlatform)) {
15377
- if (!existsSync37(storage)) continue;
16425
+ if (!existsSync38(storage)) continue;
15378
16426
  let entries = [];
15379
16427
  try {
15380
- entries = readdirSync9(storage, { withFileTypes: true });
16428
+ entries = readdirSync10(storage, { withFileTypes: true });
15381
16429
  } catch {
15382
16430
  continue;
15383
16431
  }
15384
16432
  for (const entry of entries) {
15385
16433
  if (!entry.isDirectory() || entry.isSymbolicLink?.()) continue;
15386
- const state = readJson(join36(storage, entry.name, "workspace.json"));
16434
+ const state = readJson(join37(storage, entry.name, "workspace.json"));
15387
16435
  const raw = state?.folder;
15388
16436
  if (typeof raw !== "string" || !raw.trim()) continue;
15389
16437
  try {
15390
16438
  const path = raw.startsWith("file:") ? fileURLToPath(raw) : raw;
15391
- if (existsSync37(path)) roots.add(resolve5(path));
16439
+ if (existsSync38(path)) roots.add(resolve5(path));
15392
16440
  } catch {
15393
16441
  }
15394
16442
  }
@@ -15482,18 +16530,18 @@ function parseFrontmatter(content) {
15482
16530
  return { name: value("name"), version: value("version") };
15483
16531
  }
15484
16532
  function skillArtifacts(harness, root) {
15485
- if (!existsSync37(root)) return [];
16533
+ if (!existsSync38(root)) return [];
15486
16534
  const manifests = [];
15487
16535
  const visit = (dir) => {
15488
16536
  let entries;
15489
16537
  try {
15490
- entries = readdirSync9(dir, { withFileTypes: true });
16538
+ entries = readdirSync10(dir, { withFileTypes: true });
15491
16539
  } catch {
15492
16540
  return;
15493
16541
  }
15494
16542
  for (const entry of entries) {
15495
16543
  if (entry.isSymbolicLink?.()) continue;
15496
- const path = join36(dir, entry.name);
16544
+ const path = join37(dir, entry.name);
15497
16545
  if (entry.isFile() && entry.name === "SKILL.md") manifests.push(path);
15498
16546
  else if (entry.isDirectory()) visit(path);
15499
16547
  }
@@ -15503,7 +16551,7 @@ function skillArtifacts(harness, root) {
15503
16551
  const content = readText(path);
15504
16552
  const frontmatter = parseFrontmatter(content);
15505
16553
  const rel = relative(root, path).replaceAll("\\", "/");
15506
- const name = frontmatter.name || basename3(join36(path, "..")) || "skill";
16554
+ const name = frontmatter.name || basename3(join37(path, "..")) || "skill";
15507
16555
  return {
15508
16556
  harness,
15509
16557
  type: "skill",
@@ -15518,16 +16566,16 @@ function skillArtifacts(harness, root) {
15518
16566
  });
15519
16567
  }
15520
16568
  function cursorExtensionArtifacts(root) {
15521
- if (!existsSync37(root)) return [];
16569
+ if (!existsSync38(root)) return [];
15522
16570
  let dirs = [];
15523
16571
  try {
15524
- dirs = readdirSync9(root, { withFileTypes: true }).filter((entry) => entry.isDirectory() && !entry.isSymbolicLink());
16572
+ dirs = readdirSync10(root, { withFileTypes: true }).filter((entry) => entry.isDirectory() && !entry.isSymbolicLink());
15525
16573
  } catch {
15526
16574
  return [];
15527
16575
  }
15528
16576
  const artifacts = [];
15529
16577
  for (const dir of dirs) {
15530
- const pkg = readJson(join36(root, dir.name, "package.json"));
16578
+ const pkg = readJson(join37(root, dir.name, "package.json"));
15531
16579
  if (!pkg) continue;
15532
16580
  const publisher = typeof pkg.publisher === "string" ? pkg.publisher : void 0;
15533
16581
  const name = typeof pkg.name === "string" ? pkg.name : dir.name;
@@ -15547,18 +16595,18 @@ function cursorExtensionArtifacts(root) {
15547
16595
  return artifacts;
15548
16596
  }
15549
16597
  function deploymentMode2(home) {
15550
- const raw = readText(join36(home, ".synkro", "config.env"));
16598
+ const raw = readText(join37(home, ".synkro", "config.env"));
15551
16599
  const value = (key) => raw.match(new RegExp(`^${key}=['"]?([^'"\\n]*)`, "m"))?.[1]?.toLowerCase();
15552
16600
  if (value("SYNKRO_GRADING_MODE") === "byok") return "byok";
15553
16601
  if (value("SYNKRO_STORAGE_MODE") === "cloud") return "cloud";
15554
16602
  return "local";
15555
16603
  }
15556
16604
  function telemetryHealth(home) {
15557
- const meta = readJson(join36(home, ".synkro", "telemetry-meta.json"));
16605
+ const meta = readJson(join37(home, ".synkro", "telemetry-meta.json"));
15558
16606
  const health = {};
15559
16607
  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
16608
  if (meta?.last_flush_error) health.telemetry_last_error = "flush_failed";
15561
- const queue = join36(home, ".synkro", "telemetry-pending.jsonl");
16609
+ const queue = join37(home, ".synkro", "telemetry-pending.jsonl");
15562
16610
  try {
15563
16611
  const size = statSync5(queue).size;
15564
16612
  health.telemetry_backlog = size <= 5 * 1024 * 1024 ? readFileSync36(queue, "utf8").split("\n").filter(Boolean).length : Math.ceil(size / 1024);
@@ -15602,7 +16650,7 @@ function harnessSnapshot(agent) {
15602
16650
  }
15603
16651
  const config = readJson(agent.settingsPath);
15604
16652
  const coverage = inspectCodexHooks(agent.settingsPath);
15605
- const toml = readText(join36(agent.configDir, "config.toml"));
16653
+ const toml = readText(join37(agent.configDir, "config.toml"));
15606
16654
  const permission = toml.match(/^\s*approval_policy\s*=\s*["']([^"']+)/m)?.[1];
15607
16655
  return {
15608
16656
  row: {
@@ -15619,19 +16667,19 @@ function harnessSnapshot(agent) {
15619
16667
  };
15620
16668
  }
15621
16669
  function collectOperationalInventory(options = {}) {
15622
- const home = options.homeDir ?? homedir36();
16670
+ const home = options.homeDir ?? homedir37();
15623
16671
  const detected = options.detectedAgents ?? detectAgents();
15624
16672
  const identity = getOperationalInstallationIdentity(options.identityPath);
15625
16673
  const targetPlatform = options.platformName ?? platform5();
15626
- const codexHome = options.homeDir ? join36(home, ".codex") : process.env.CODEX_HOME || join36(home, ".codex");
16674
+ const codexHome = options.homeDir ? join37(home, ".codex") : process.env.CODEX_HOME || join37(home, ".codex");
15627
16675
  const harnesses = [];
15628
16676
  const artifacts = [];
15629
16677
  for (const agent of detected) {
15630
- const { row, config } = harnessSnapshot(agent);
15631
- harnesses.push(row);
15632
- artifacts.push(...hookArtifacts(row.harness, config));
16678
+ const { row: row2, config } = harnessSnapshot(agent);
16679
+ harnesses.push(row2);
16680
+ artifacts.push(...hookArtifacts(row2.harness, config));
15633
16681
  }
15634
- const claudeJson = readJson(join36(home, ".claude.json"));
16682
+ const claudeJson = readJson(join37(home, ".claude.json"));
15635
16683
  artifacts.push(...mcpArtifactsFromJson("claude_code", claudeJson));
15636
16684
  if (claudeJson?.projects && typeof claudeJson.projects === "object") {
15637
16685
  for (const [projectPath, project] of Object.entries(claudeJson.projects)) {
@@ -15639,8 +16687,8 @@ function collectOperationalInventory(options = {}) {
15639
16687
  artifacts.push(...mcpArtifactsFromJson("claude_code", project, `local:${sha256(projectPath).slice(0, 16)}`));
15640
16688
  }
15641
16689
  }
15642
- artifacts.push(...mcpArtifactsFromJson("cursor", readJson(join36(home, ".cursor", "mcp.json"))));
15643
- artifacts.push(...codexMcpArtifacts(readText(join36(codexHome, "config.toml"))));
16690
+ artifacts.push(...mcpArtifactsFromJson("cursor", readJson(join37(home, ".cursor", "mcp.json"))));
16691
+ artifacts.push(...codexMcpArtifacts(readText(join37(codexHome, "config.toml"))));
15644
16692
  const projectRoots = discoveredProjectRoots(
15645
16693
  claudeJson,
15646
16694
  options.currentDirectory ?? process.cwd(),
@@ -15651,11 +16699,11 @@ function collectOperationalInventory(options = {}) {
15651
16699
  const scopeHash = sha256(projectRoot).slice(0, 16);
15652
16700
  artifacts.push(...mcpArtifactsFromJson(
15653
16701
  "claude_code",
15654
- readJson(join36(projectRoot, ".mcp.json")),
16702
+ readJson(join37(projectRoot, ".mcp.json")),
15655
16703
  `project:${scopeHash}`
15656
16704
  ));
15657
- const cursorProjectConfig = join36(projectRoot, ".cursor", "mcp.json");
15658
- if (resolve5(cursorProjectConfig) !== resolve5(join36(home, ".cursor", "mcp.json"))) {
16705
+ const cursorProjectConfig = join37(projectRoot, ".cursor", "mcp.json");
16706
+ if (resolve5(cursorProjectConfig) !== resolve5(join37(home, ".cursor", "mcp.json"))) {
15659
16707
  artifacts.push(...mcpArtifactsFromJson(
15660
16708
  "cursor",
15661
16709
  readJson(cursorProjectConfig),
@@ -15666,7 +16714,7 @@ function collectOperationalInventory(options = {}) {
15666
16714
  for (const managedPath of claudeManagedMcpConfigCandidates(targetPlatform)) {
15667
16715
  artifacts.push(...mcpArtifactsFromJson("claude_code", readJson(managedPath), "managed"));
15668
16716
  }
15669
- const desktopConfigPath = claudeDesktopConfigCandidates(home, targetPlatform).find((path) => existsSync37(path));
16717
+ const desktopConfigPath = claudeDesktopConfigCandidates(home, targetPlatform).find((path) => existsSync38(path));
15670
16718
  if (desktopConfigPath) {
15671
16719
  const desktopConfig = readJson(desktopConfigPath);
15672
16720
  harnesses.push({
@@ -15677,7 +16725,7 @@ function collectOperationalInventory(options = {}) {
15677
16725
  });
15678
16726
  artifacts.push(...mcpArtifactsFromJson("claude_desktop", desktopConfig));
15679
16727
  }
15680
- const claudeSettings = readJson(join36(home, ".claude", "settings.json"));
16728
+ const claudeSettings = readJson(join37(home, ".claude", "settings.json"));
15681
16729
  if (claudeSettings?.enabledPlugins && typeof claudeSettings.enabledPlugins === "object") {
15682
16730
  for (const [name, enabled] of Object.entries(claudeSettings.enabledPlugins)) {
15683
16731
  artifacts.push({
@@ -15691,19 +16739,19 @@ function collectOperationalInventory(options = {}) {
15691
16739
  });
15692
16740
  }
15693
16741
  }
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")));
16742
+ artifacts.push(...skillArtifacts("claude_code", join37(home, ".claude", "skills")));
16743
+ artifacts.push(...skillArtifacts("cursor", join37(home, ".cursor", "skills")));
16744
+ artifacts.push(...skillArtifacts("codex", join37(codexHome, "skills")));
16745
+ artifacts.push(...cursorExtensionArtifacts(join37(home, ".cursor", "extensions")));
15698
16746
  const uniqueArtifacts = /* @__PURE__ */ new Map();
15699
16747
  for (const artifact of artifacts) {
15700
16748
  const key = `${artifact.harness || "global"}:${artifact.type}:${artifact.canonical_id}`;
15701
16749
  uniqueArtifacts.set(key, artifact);
15702
16750
  }
15703
- const codingHarnesses = harnesses.filter((row) => row.harness === "claude_code" || row.harness === "cursor" || row.harness === "codex");
16751
+ const codingHarnesses = harnesses.filter((row2) => row2.harness === "claude_code" || row2.harness === "cursor" || row2.harness === "codex");
15704
16752
  const health = telemetryHealth(home) ?? {};
15705
16753
  health.scanners = {
15706
- hook_runtime: codingHarnesses.length === 0 ? "unknown" : codingHarnesses.every((row) => row.enabled) ? "ok" : "degraded"
16754
+ hook_runtime: codingHarnesses.length === 0 ? "unknown" : codingHarnesses.every((row2) => row2.enabled) ? "ok" : "degraded"
15707
16755
  };
15708
16756
  return {
15709
16757
  schema_version: 1,
@@ -15749,16 +16797,16 @@ __export(sync_exports2, {
15749
16797
  import { createHash as createHash6, randomUUID as randomUUID6 } from "crypto";
15750
16798
  import { spawn as spawn8 } from "child_process";
15751
16799
  import {
15752
- existsSync as existsSync38,
15753
- mkdirSync as mkdirSync20,
16800
+ existsSync as existsSync39,
16801
+ mkdirSync as mkdirSync21,
15754
16802
  readFileSync as readFileSync37,
15755
16803
  renameSync as renameSync10,
15756
- writeFileSync as writeFileSync25
16804
+ writeFileSync as writeFileSync26
15757
16805
  } from "fs";
15758
- import { homedir as homedir37 } from "os";
15759
- import { dirname as dirname10, join as join37 } from "path";
16806
+ import { homedir as homedir38 } from "os";
16807
+ import { dirname as dirname10, join as join38 } from "path";
15760
16808
  function syncStatePath() {
15761
- return process.env.SYNKRO_INVENTORY_SYNC_STATE_PATH || join37(homedir37(), ".synkro", "inventory-sync.json");
16809
+ return process.env.SYNKRO_INVENTORY_SYNC_STATE_PATH || join38(homedir38(), ".synkro", "inventory-sync.json");
15762
16810
  }
15763
16811
  function readState(path = syncStatePath()) {
15764
16812
  try {
@@ -15770,9 +16818,9 @@ function readState(path = syncStatePath()) {
15770
16818
  }
15771
16819
  function writeState(state, path = syncStatePath()) {
15772
16820
  try {
15773
- mkdirSync20(dirname10(path), { recursive: true, mode: 448 });
16821
+ mkdirSync21(dirname10(path), { recursive: true, mode: 448 });
15774
16822
  const temp = `${path}.${process.pid}.tmp`;
15775
- writeFileSync25(temp, JSON.stringify(state, null, 2) + "\n", { encoding: "utf8", mode: 384 });
16823
+ writeFileSync26(temp, JSON.stringify(state, null, 2) + "\n", { encoding: "utf8", mode: 384 });
15776
16824
  renameSync10(temp, path);
15777
16825
  } catch {
15778
16826
  }
@@ -15785,7 +16833,7 @@ function shouldSyncInventory(state, now = Date.now(), target) {
15785
16833
  return !Number.isFinite(lastAttempt) || lastAttempt <= 0 || now - lastAttempt >= FAILURE_RETRY_MS;
15786
16834
  }
15787
16835
  function readConfig() {
15788
- const path = join37(homedir37(), ".synkro", "config.env");
16836
+ const path = join38(homedir38(), ".synkro", "config.env");
15789
16837
  const out = {};
15790
16838
  try {
15791
16839
  for (const rawLine of readFileSync37(path, "utf8").split("\n")) {
@@ -15827,7 +16875,7 @@ function resolveInventoryGateway(raw) {
15827
16875
  }
15828
16876
  async function loadToken() {
15829
16877
  try {
15830
- const durable = readFileSync37(join37(homedir37(), ".synkro", ".mcp-jwt"), "utf8").trim();
16878
+ const durable = readFileSync37(join38(homedir38(), ".synkro", ".mcp-jwt"), "utf8").trim();
15831
16879
  if (durable) return durable;
15832
16880
  } catch {
15833
16881
  }
@@ -15945,7 +16993,7 @@ function syncOperationalInventoryDetached() {
15945
16993
  writeState({ ...state, last_attempt_at: (/* @__PURE__ */ new Date()).toISOString(), last_target: target }, path);
15946
16994
  try {
15947
16995
  const script = process.argv[1];
15948
- if (!script || !existsSync38(script)) return;
16996
+ if (!script || !existsSync39(script)) return;
15949
16997
  const child = spawn8(process.execPath, [script, "inventory-sync", "--detached"], {
15950
16998
  detached: true,
15951
16999
  stdio: "ignore",
@@ -15969,13 +17017,13 @@ var init_sync2 = __esm({
15969
17017
  });
15970
17018
 
15971
17019
  // cli/bootstrap.js
15972
- import { readFileSync as readFileSync38, existsSync as existsSync39 } from "fs";
17020
+ import { readFileSync as readFileSync38, existsSync as existsSync40 } from "fs";
15973
17021
  import { resolve as resolve6 } from "path";
15974
17022
  var envCandidates = [
15975
17023
  resolve6(process.env.HOME ?? "", ".synkro", "config.env")
15976
17024
  ];
15977
17025
  for (const envPath of envCandidates) {
15978
- if (!existsSync39(envPath)) continue;
17026
+ if (!existsSync40(envPath)) continue;
15979
17027
  const envContent = readFileSync38(envPath, "utf-8");
15980
17028
  for (const line of envContent.split("\n")) {
15981
17029
  const trimmed = line.trim();
@@ -15993,7 +17041,7 @@ var subArgs = args.slice(1);
15993
17041
  var isDetachedChild = process.env.SYNKRO_TELEMETRY_DETACHED === "1";
15994
17042
  var FLUSH_SKIP = /* @__PURE__ */ new Set(["grade", "inventory-sync", "version", "--version", "-v", "help", "--help", "-h", ""]);
15995
17043
  function printVersion() {
15996
- console.log("1.8.0");
17044
+ console.log("1.9.0");
15997
17045
  }
15998
17046
  function printHelp2() {
15999
17047
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents
@@ -16012,9 +17060,16 @@ Commands:
16012
17060
  claude-desktop Monitor Claude Desktop conversations (local, macOS)
16013
17061
  telemetry <sub> Inspect or flush local telemetry events
16014
17062
  whoami Show resolved identity + where grading runs
17063
+ workspace <sub> Answer the task-workspace question (stay/clear/status)
17064
+ ui Governed multiplexer: spaces + agents, real sessions in tabs
16015
17065
  refresh Refresh the login session (keeps you signed in; run on a schedule)
16016
17066
  version Show version
16017
17067
 
17068
+ workspace:
17069
+ synkro workspace stay <taskId> keep working in the current checkout
17070
+ synkro workspace clear <taskId> forget the choice (Synkro asks again)
17071
+ synkro workspace status [taskId] show recorded choices
17072
+
16018
17073
  config:
16019
17074
  synkro config show current settings
16020
17075
  synkro config grading <local|byok> where grading runs
@@ -16118,6 +17173,16 @@ async function main() {
16118
17173
  await whoamiCommand2(subArgs);
16119
17174
  break;
16120
17175
  }
17176
+ case "workspace": {
17177
+ const { workspaceCommand: workspaceCommand2 } = await Promise.resolve().then(() => (init_workspace(), workspace_exports));
17178
+ await workspaceCommand2(subArgs);
17179
+ break;
17180
+ }
17181
+ case "ui": {
17182
+ const { uiCommand: uiCommand2 } = await Promise.resolve().then(() => (init_ui(), ui_exports));
17183
+ await uiCommand2(subArgs);
17184
+ break;
17185
+ }
16121
17186
  case "refresh": {
16122
17187
  const { refreshCommand: refreshCommand2 } = await Promise.resolve().then(() => (init_refresh(), refresh_exports));
16123
17188
  await refreshCommand2(subArgs);
@@ -16230,7 +17295,7 @@ async function shutdown(code) {
16230
17295
  }, 200);
16231
17296
  force.unref();
16232
17297
  }
16233
- main().then(() => postDispatchFlush()).then(() => shutdown(0)).catch(async (err) => {
17298
+ main().then(() => postDispatchFlush()).then(() => shutdown(typeof process.exitCode === "number" ? process.exitCode : 0)).catch(async (err) => {
16234
17299
  try {
16235
17300
  const { emit: emit2 } = await Promise.resolve().then(() => (init_telemetry(), telemetry_exports));
16236
17301
  emit2("error", {