@synkro-sh/cli 1.10.5 → 1.10.6

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.10.5";
150
+ cliVersion2 = "1.10.6";
151
151
  } catch {
152
152
  }
153
153
  const creds = loadCredentialsIdentity();
@@ -3255,10 +3255,15 @@ function pruneToolCallMarks(now: number): void {
3255
3255
 
3256
3256
  // The user's answer to "move into the task worktree, or stay here?". Written by the
3257
3257
  // "synkro workspace stay" command (the CLI owns ~/.synkro, so nothing hand-writes it)
3258
- // and read here. Keyed by task alone: the decision resets when the active task
3259
- // changes, which is the scope the workspace gate is asking about.
3258
+ // and read here. The filename includes a one-way session key so consent in one
3259
+ // Claude/Codex chat can never silently authorize another chat working the task.
3260
3260
  const WORKSPACE_CHOICE_DIR = join(HOME, '.synkro', 'workspace-choice');
3261
3261
 
3262
+ function taskWorkspaceChoicePath(taskId: string, sessionId: string): string {
3263
+ const sessionKey = createHash('sha256').update(String(sessionId || '')).digest('hex').slice(0, 16);
3264
+ return join(WORKSPACE_CHOICE_DIR, taskId + '.' + sessionKey + '.stay');
3265
+ }
3266
+
3262
3267
  // The user answers the workspace question in plain language on their next
3263
3268
  // prompt. Writing the marker is exactly what the CLI's "workspace stay"
3264
3269
  // command does — but the consent matcher only accepts one literal spelling,
@@ -3279,14 +3284,23 @@ function isWorkspaceStayIntent(prompt: string): boolean {
3279
3284
  ].some((pattern) => pattern.test(normalized));
3280
3285
  }
3281
3286
 
3282
- function taskWorkspaceStayRecorded(taskId: string): boolean {
3283
- if (!/^task_[a-z0-9]{8}$/i.test(String(taskId || ''))) return false;
3284
- try { return existsSync(join(WORKSPACE_CHOICE_DIR, taskId + '.stay')); } catch { return false; }
3287
+ function taskWorkspaceStayRecorded(taskId: string, sessionId: string): boolean {
3288
+ if (!/^task_[a-z0-9]{8}$/i.test(String(taskId || '')) || !sessionId) return false;
3289
+ try { return existsSync(taskWorkspaceChoicePath(taskId, sessionId)); } catch { return false; }
3285
3290
  }
3286
3291
 
3287
- // Our own context string, so the shape is stable: '... task=<id> ...'.
3288
- function taskIdFromScmContext(context: string): string {
3289
- const marker = ' task=';
3292
+ function recordTaskWorkspaceStay(taskId: string, sessionId: string): boolean {
3293
+ if (!/^task_[a-z0-9]{8}$/i.test(String(taskId || '')) || !sessionId) return false;
3294
+ try {
3295
+ mkdirSync(WORKSPACE_CHOICE_DIR, { recursive: true });
3296
+ writeFileSync(taskWorkspaceChoicePath(taskId, sessionId), new Date().toISOString() + '\n', { mode: 0o600 });
3297
+ return true;
3298
+ } catch { return false; }
3299
+ }
3300
+
3301
+ // Our own context string, so the shape is stable: '... task=<id> branch=<name> ...'.
3302
+ function fieldFromScmContext(context: string, field: string): string {
3303
+ const marker = ' ' + field + '=';
3290
3304
  const at = String(context || '').indexOf(marker);
3291
3305
  if (at === -1) return '';
3292
3306
  const rest = context.slice(at + marker.length);
@@ -3294,14 +3308,73 @@ function taskIdFromScmContext(context: string): string {
3294
3308
  return (end === -1 ? rest : rest.slice(0, end)).trim();
3295
3309
  }
3296
3310
 
3311
+ function taskIdFromScmContext(context: string): string {
3312
+ return fieldFromScmContext(context, 'task');
3313
+ }
3314
+
3315
+ // An exact-command allowance must not be defeated by the output plumbing agents
3316
+ // reflexively append ('2>&1 | tail -2') — requiring the bare spelling made the
3317
+ // gate's own escape hatch block itself (observed live). Only harmless trailing
3318
+ // redirection plus a single display filter pass; anything that could chain a
3319
+ // second command (&&, ;, extra pipes, substitution) still rejects.
3320
+ function isBareCommandWithPlumbing(command: string, head: string): boolean {
3321
+ if (!command.startsWith(head)) return false;
3322
+ const rest = command.slice(head.length);
3323
+ return /^(?:\s+2>&1)?(?:\s*\|\s*(?:tail|head|cat)(?:\s+[-\w.+]+)*)?\s*$/.test(rest);
3324
+ }
3325
+
3326
+ function bashCommandOf(payload: any): string {
3327
+ if (String(payload?.tool_name || '') !== 'Bash') return '';
3328
+ const input = payload?.tool_input && typeof payload.tool_input === 'object' ? payload.tool_input : {};
3329
+ return String(input.command || input.cmd || '').trim();
3330
+ }
3331
+
3297
3332
  // Consent must never deadlock behind the block it resolves: the command that records
3298
3333
  // the answer is allowed through for the task currently being asked about, and nothing
3299
3334
  // else is.
3300
- function isWorkspaceConsentCommand(payload: any, taskId: string): boolean {
3301
- if (!taskId || String(payload?.tool_name || '') !== 'Bash') return false;
3335
+ function workspaceReadOnlyShellSegment(segment: string): boolean {
3336
+ const value = String(segment || '').trim();
3337
+ if (!value || /[;&><$]/.test(value) || value.includes(String.fromCharCode(96))) return false;
3338
+ const tokens = value.split(/\s+/);
3339
+ const verb = String(tokens[0] || '').toLowerCase();
3340
+ const writeOptions = new Set(['-i', '--in-place', '-o', '--output', '--output-document']);
3341
+ if (tokens.some((token) => writeOptions.has(token) || token.startsWith('--output='))) return false;
3342
+ const reads = new Set(['cat', 'head', 'tail', 'grep', 'egrep', 'fgrep', 'rg', 'ag', 'find', 'fd', 'ls', 'wc', 'cmp', 'diff', 'file', 'stat', 'pwd', 'sort', 'uniq', 'cut', 'tr', 'jq', 'yq']);
3343
+ if (reads.has(verb)) {
3344
+ if ((verb === 'find' || verb === 'fd') && tokens.some((token) => new Set(['-exec', '-execdir', '-ok', '-okdir', '-delete', '-fprint', '-fprint0', '-fls', '--exec', '--exec-batch']).has(token))) return false;
3345
+ return true;
3346
+ }
3347
+ if (verb !== 'git') return false;
3348
+ return new Set(['log', 'show', 'diff', 'blame', 'status', 'rev-parse', 'ls-files', 'ls-tree', 'cat-file', 'shortlog', 'describe']).has(String(tokens[1] || '').toLowerCase());
3349
+ }
3350
+
3351
+ function isWorkspaceReadOnlyDiagnostic(payload: any): boolean {
3352
+ const toolName = String(payload?.tool_name || '');
3353
+ if (/^(?:Read|ReadFile|read_file|Grep|grep_search|codebase_search|file_search|Glob|list_dir)$/i.test(toolName)) return true;
3354
+ if (!/^(?:Bash|Shell|terminal|run_terminal_cmd|execute_command)$/i.test(toolName)) return false;
3302
3355
  const input = payload?.tool_input && typeof payload.tool_input === 'object' ? payload.tool_input : {};
3303
3356
  const command = String(input.command || input.cmd || '').trim();
3304
- return command === 'synkro workspace stay ' + taskId;
3357
+ if (!command || /\|\||(^|[^&])&([^&]|$)|;/.test(command)) return false;
3358
+ return command.split('|').every(workspaceReadOnlyShellSegment);
3359
+ }
3360
+
3361
+ function isWorkspaceConsentCommand(payload: any, taskId: string): boolean {
3362
+ if (!taskId) return false;
3363
+ const command = bashCommandOf(payload);
3364
+ return Boolean(command) && isBareCommandWithPlumbing(command, 'synkro workspace stay ' + taskId);
3365
+ }
3366
+
3367
+ // A session that is already inside the task worktree but on the wrong branch needs
3368
+ // exactly one repair: checking out the canonical branch. The gate demands that
3369
+ // branch, so blocking the checkout that reaches it would deadlock (observed live
3370
+ // after an agent created a side branch in the task worktree). Elsewhere the command
3371
+ // is harmless: git itself refuses to check out a branch held by another worktree.
3372
+ function isWorkspaceRepairCommand(payload: any, branchName: string): boolean {
3373
+ if (!branchName) return false;
3374
+ const command = bashCommandOf(payload);
3375
+ if (!command) return false;
3376
+ return isBareCommandWithPlumbing(command, 'git checkout ' + branchName)
3377
+ || isBareCommandWithPlumbing(command, 'git switch ' + branchName);
3305
3378
  }
3306
3379
 
3307
3380
  function firstHookForToolCall(sessionId: string, payload: any): boolean {
@@ -3666,7 +3739,7 @@ function shellTaskWorkspaceArg(value: string): string {
3666
3739
  return "'" + value.replace(/'/g, "'\"'\"'") + "'";
3667
3740
  }
3668
3741
 
3669
- function taskScmWorkspaceInstruction(harness: string, sessionId: string, workspace: any): string {
3742
+ function taskScmWorkspaceInstruction(harness: string, sessionId: string, workspace: any, root = ''): string {
3670
3743
  const context = taskScmWorkspaceContext(workspace, harness);
3671
3744
  const worktreePath = String(workspace?.worktreePath || '');
3672
3745
  const branchName = String(workspace?.branchName || '');
@@ -3676,6 +3749,15 @@ function taskScmWorkspaceInstruction(harness: string, sessionId: string, workspa
3676
3749
  + 'the task worktree and canonical branch.';
3677
3750
  if (harness === 'cc') {
3678
3751
  if (!worktreePath) return context + '\nTask worktree creation failed closed; retry workspace preparation.';
3752
+ // Already inside the task worktree, just not on its branch: the move/stay
3753
+ // question was answered the moment the session got here, and re-asking it
3754
+ // offers two exits that both fail. Name the one command that repairs it —
3755
+ // the gate lets exactly that command through.
3756
+ if (root && samePath(root, worktreePath)) {
3757
+ return context + '\nThis session is already in the task worktree but not on its canonical branch. '
3758
+ + 'Run "git checkout ' + branchName + '" to continue; that command is allowed through this gate, '
3759
+ + 'and substantive tools stay blocked until the branch matches.';
3760
+ }
3679
3761
  // Ask, do not command. The user may legitimately want to keep working where they
3680
3762
  // are, and Synkro should not move them without their say-so.
3681
3763
  return context + '\nAsk the user whether to move this task into its isolated worktree '
@@ -3691,14 +3773,18 @@ function taskScmWorkspaceInstruction(harness: string, sessionId: string, workspa
3691
3773
  + '. Confirm the resumed workspace is on branch ' + JSON.stringify(branchName) + '. ' + requirement;
3692
3774
  }
3693
3775
  if (!worktreePath) {
3694
- return context + '\n[synkro:workspace-handoff] Invoke Codex\'s native task/worktree Handoff now for this exact task. '
3695
- + 'Check out branch ' + JSON.stringify(branchName) + ' in a new worktree and continue this same chat there. '
3696
- + 'Do not switch the shared Local checkout or ask the user to switch branches. '
3697
- + 'Synkro keeps substantive tools blocked until this exact session reports the new worktree path and canonical branch.';
3776
+ return context + '\nAsk the user whether to move this task into a generated isolated Codex worktree on branch '
3777
+ + JSON.stringify(branchName) + ', or keep working in the current workspace. Do not decide for them. '
3778
+ + 'To move: use Codex\'s Environment control or native Handoff for this task; never hand off a different task as a workaround. '
3779
+ + 'To stay: run "synkro workspace stay ' + String(workspace?.taskId || '') + '". '
3780
+ + 'Never switch the shared Local checkout.';
3698
3781
  }
3699
- return context + '\n[synkro:workspace-handoff] Invoke Codex\'s native task/worktree Handoff now for this exact task, '
3700
- + 'using the existing worktree ' + JSON.stringify(worktreePath) + ' on branch ' + JSON.stringify(branchName) + '. '
3701
- + 'The Codex Environment panel must show that worktree and branch before retrying the blocked action. ' + requirement;
3782
+ return context + '\nAsk the user whether to move this task to its prepared worktree '
3783
+ + JSON.stringify(worktreePath) + ' on branch ' + JSON.stringify(branchName)
3784
+ + ', or keep working in the current isolated Codex worktree. Do not decide for them. '
3785
+ + 'To move: use Codex\'s Environment control or native Handoff for this task; never hand off a different task as a workaround. '
3786
+ + 'To stay: run "synkro workspace stay ' + String(workspace?.taskId || '') + '". '
3787
+ + 'Never switch the shared Local checkout.';
3702
3788
  }
3703
3789
 
3704
3790
  // CC validates hookSpecificOutput.hookEventName against the event that actually fired.
@@ -3780,7 +3866,7 @@ async function reconcileTaskScm(
3780
3866
  : result?.workspace;
3781
3867
  return {
3782
3868
  reason: result?.waiting
3783
- ? (taskScmWorkspaceInstruction(harness, sessionId, workspace)
3869
+ ? (taskScmWorkspaceInstruction(harness, sessionId, workspace, root)
3784
3870
  || String(result.reason || 'task source-control preparation is pending'))
3785
3871
  : '',
3786
3872
  context: taskScmWorkspaceContext(workspace, harness),
@@ -4479,19 +4565,21 @@ export async function runStub(surface: string, opts: StubOpts = {}): Promise<voi
4479
4565
  // prompt records the same durable marker the CLI command writes. Reconcile
4480
4566
  // just told us WHICH task is being asked, so no extra state is needed and
4481
4567
  // ordinary prompts outside a pending ask are never scanned.
4482
- if (surface === 'prompt-submit' && scm.reason && scmTaskId && !taskWorkspaceStayRecorded(scmTaskId)) {
4568
+ if (surface === 'prompt-submit' && scm.reason && scmTaskId && !taskWorkspaceStayRecorded(scmTaskId, sessionId)) {
4483
4569
  const promptText = String(payload.prompt || payload.user_message || '');
4484
4570
  if (isWorkspaceStayIntent(promptText)) {
4485
- try {
4486
- mkdirSync(WORKSPACE_CHOICE_DIR, { recursive: true });
4487
- writeFileSync(join(WORKSPACE_CHOICE_DIR, scmTaskId + '.stay'), new Date().toISOString() + '\n');
4488
- } catch { /* fail-open: the CLI command and the exact-string path remain */ }
4571
+ recordTaskWorkspaceStay(scmTaskId, sessionId);
4489
4572
  }
4490
4573
  }
4491
- // The user was asked and chose to keep working here, or is answering right now.
4574
+ const workspaceConsentCommand = Boolean(scmTaskId) && isWorkspaceConsentCommand(payload, scmTaskId);
4575
+ if (workspaceConsentCommand) recordTaskWorkspaceStay(scmTaskId, sessionId);
4576
+ // The user was asked and chose to keep working here, is answering right now,
4577
+ // or the tool call is the one git command that repairs the workspace itself.
4492
4578
  const workspaceConsentSettled = Boolean(scmTaskId)
4493
- && (taskWorkspaceStayRecorded(scmTaskId) || isWorkspaceConsentCommand(payload, scmTaskId));
4494
- if (scm.reason && substantiveTool && !workspaceConsentSettled) {
4579
+ && (taskWorkspaceStayRecorded(scmTaskId, sessionId)
4580
+ || workspaceConsentCommand
4581
+ || isWorkspaceRepairCommand(payload, fieldFromScmContext(scm.context, 'branch')));
4582
+ if (scm.reason && substantiveTool && !workspaceConsentSettled && !isWorkspaceReadOnlyDiagnostic(payload)) {
4495
4583
  out(taskScmBlockResponse(harness, scm.reason, firstHookForToolCall(sessionId, payload)));
4496
4584
  return;
4497
4585
  }
@@ -4680,7 +4768,7 @@ export async function runStub(surface: string, opts: StubOpts = {}): Promise<voi
4680
4768
  let scmContext = scm.context && surface === 'prompt-submit' && firstHookForToolCall(sessionId, payload) ? scm.context : '';
4681
4769
  // Say plainly that work is continuing outside the task worktree by the user's choice,
4682
4770
  // so the state is never mistaken for a binding that silently failed.
4683
- if (scmContext && scmTaskId && taskWorkspaceStayRecorded(scmTaskId)) {
4771
+ if (scmContext && scmTaskId && taskWorkspaceStayRecorded(scmTaskId, sessionId)) {
4684
4772
  scmContext += ' workspace=staying-here-by-user-choice';
4685
4773
  }
4686
4774
  const responseText = withActualHookEvent(withTaskScmContext(contractResponseText, harness, scmContext), payload);
@@ -5023,6 +5111,7 @@ if (process.env.SYNKRO_HOOK_FORMAT === 'codex' && hookEventName === 'PermissionR
5023
5111
  // cli/auth/stub.ts
5024
5112
  var stub_exports = {};
5025
5113
  __export(stub_exports, {
5114
+ accessTokenExpired: () => accessTokenExpired,
5026
5115
  authedFetch: () => authedFetch,
5027
5116
  authenticate: () => authenticate,
5028
5117
  clearCredentials: () => clearCredentials,
@@ -5280,19 +5369,23 @@ function getAccessToken() {
5280
5369
  const creds = loadCredentials();
5281
5370
  return creds?.access_token || null;
5282
5371
  }
5283
- function isTokenExpired() {
5284
- const creds = loadCredentials();
5285
- if (!creds) return true;
5372
+ function accessTokenExpired(accessToken, now = Date.now()) {
5286
5373
  try {
5287
- const decoded = jwt.decode(creds.access_token);
5374
+ const decoded = jwt.decode(accessToken);
5288
5375
  if (!decoded?.exp) return true;
5289
5376
  const expiresAt = decoded.exp * 1e3;
5290
- const buffer = 5 * 60 * 1e3;
5291
- return Date.now() > expiresAt - buffer;
5377
+ const lifetimeMs = decoded.iat ? (decoded.exp - decoded.iat) * 1e3 : 0;
5378
+ const buffer = lifetimeMs > 0 ? Math.min(MAX_EXPIRY_BUFFER_MS, Math.floor(lifetimeMs / 5)) : MAX_EXPIRY_BUFFER_MS;
5379
+ return now > expiresAt - buffer;
5292
5380
  } catch {
5293
5381
  return true;
5294
5382
  }
5295
5383
  }
5384
+ function isTokenExpired() {
5385
+ const creds = loadCredentials();
5386
+ if (!creds?.access_token) return true;
5387
+ return accessTokenExpired(creds.access_token);
5388
+ }
5296
5389
  async function refreshToken() {
5297
5390
  const creds = loadCredentials();
5298
5391
  if (!creds?.refresh_token) return false;
@@ -5360,7 +5453,7 @@ async function getSecrets(userId, integrationId) {
5360
5453
  LANGSMITH_API_KEY: process.env.USER_LANGSMITH_KEY || ""
5361
5454
  };
5362
5455
  }
5363
- var PORT, RAW_WEB_AUTH_URL, SYNKRO_WEB_AUTH_URL, AUTH_FILE, RAW_API_URL, SYNKRO_API_URL, ERROR_HTML, refreshPromise;
5456
+ var PORT, RAW_WEB_AUTH_URL, SYNKRO_WEB_AUTH_URL, AUTH_FILE, RAW_API_URL, SYNKRO_API_URL, ERROR_HTML, MAX_EXPIRY_BUFFER_MS, refreshPromise;
5364
5457
  var init_stub = __esm({
5365
5458
  "cli/auth/stub.ts"() {
5366
5459
  "use strict";
@@ -5410,6 +5503,7 @@ var init_stub = __esm({
5410
5503
  </body>
5411
5504
  </html>
5412
5505
  `;
5506
+ MAX_EXPIRY_BUFFER_MS = 60 * 1e3;
5413
5507
  refreshPromise = null;
5414
5508
  }
5415
5509
  });
@@ -7840,7 +7934,7 @@ var init_dockerInstall = __esm({
7840
7934
  HOST_PGLITE_PORT = parseInt(process.env.SYNKRO_HOST_PGLITE_PORT || "15433", 10);
7841
7935
  CONTAINER_NAME = resolveContainerName();
7842
7936
  defaultImageVersion = () => {
7843
- if (true) return "1.10.5";
7937
+ if (true) return "1.10.6";
7844
7938
  try {
7845
7939
  const pkg = JSON.parse(readFileSync17(new URL("../../package.json", import.meta.url), "utf8"));
7846
7940
  if (pkg.version) return pkg.version;
@@ -8900,7 +8994,7 @@ function writeConfigEnv(opts) {
8900
8994
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle2(credsPath)}`,
8901
8995
  `SYNKRO_TIER=${shellQuoteSingle2(safeTier)}`,
8902
8996
  `SYNKRO_INFERENCE=${shellQuoteSingle2(safeInference)}`,
8903
- `SYNKRO_VERSION=${shellQuoteSingle2("1.10.5")}`
8997
+ `SYNKRO_VERSION=${shellQuoteSingle2("1.10.6")}`
8904
8998
  ];
8905
8999
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle2(safeSynkroBin)}`);
8906
9000
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle2(safeUserId)}`);
@@ -9647,7 +9741,7 @@ async function installCommand(opts = {}) {
9647
9741
  await setTelemetryState({ enabled: true, remoteFlushEnabled: telemetryConsent });
9648
9742
  emit("install", {
9649
9743
  phase: "started",
9650
- cli_version_to: "1.10.5",
9744
+ cli_version_to: "1.10.6",
9651
9745
  agents_detected: agents.map((a) => a.kind),
9652
9746
  with_github: false,
9653
9747
  with_local_cc: false,
@@ -14429,6 +14523,16 @@ import { homedir as homedir31 } from "os";
14429
14523
  function markerPath(taskId) {
14430
14524
  return join31(WORKSPACE_CHOICE_DIR, `${taskId}.stay`);
14431
14525
  }
14526
+ function markerNamesForTask(taskId) {
14527
+ try {
14528
+ return existsSync33(WORKSPACE_CHOICE_DIR) ? readdirSync8(WORKSPACE_CHOICE_DIR).filter((name) => name === `${taskId}.stay` || name.startsWith(`${taskId}.`) && name.endsWith(".stay")) : [];
14529
+ } catch {
14530
+ return [];
14531
+ }
14532
+ }
14533
+ function taskIdFromMarker(name) {
14534
+ return (name.match(/^(task_[a-z0-9]{8})(?:\.[a-f0-9]{16})?\.stay$/i) || [])[1] || "";
14535
+ }
14432
14536
  function usage() {
14433
14537
  console.log(`synkro workspace \u2014 record where a task's work happens
14434
14538
 
@@ -14437,8 +14541,8 @@ Usage:
14437
14541
  synkro workspace clear <taskId> forget the choice (the gate asks again)
14438
14542
  synkro workspace status [taskId] show recorded choices
14439
14543
 
14440
- The gate asks once per task. "stay" is remembered until the choice is cleared or
14441
- the active task changes.`);
14544
+ The gate asks once per task session. "stay" is remembered in that chat until the
14545
+ choice is cleared or the active task changes.`);
14442
14546
  }
14443
14547
  async function workspaceCommand(args2) {
14444
14548
  const sub = String(args2[0] || "").trim();
@@ -14455,7 +14559,7 @@ async function workspaceCommand(args2) {
14455
14559
  recorded = [];
14456
14560
  }
14457
14561
  if (taskId) {
14458
- const on = recorded.includes(`${taskId}.stay`);
14562
+ const on = markerNamesForTask(taskId).length > 0;
14459
14563
  console.log(`${taskId}: ${on ? "stay recorded" : "no choice recorded"}`);
14460
14564
  return;
14461
14565
  }
@@ -14464,7 +14568,7 @@ async function workspaceCommand(args2) {
14464
14568
  return;
14465
14569
  }
14466
14570
  console.log("Staying in the current checkout for:");
14467
- for (const name of recorded.sort()) console.log(` ${name.replace(/\.stay$/, "")}`);
14571
+ for (const id of [...new Set(recorded.map(taskIdFromMarker).filter(Boolean))].sort()) console.log(` ${id}`);
14468
14572
  return;
14469
14573
  }
14470
14574
  if (sub !== "stay" && sub !== "clear") {
@@ -14479,9 +14583,11 @@ async function workspaceCommand(args2) {
14479
14583
  return;
14480
14584
  }
14481
14585
  if (sub === "clear") {
14482
- try {
14483
- rmSync5(markerPath(taskId), { force: true });
14484
- } catch {
14586
+ for (const name of markerNamesForTask(taskId)) {
14587
+ try {
14588
+ rmSync5(join31(WORKSPACE_CHOICE_DIR, name), { force: true });
14589
+ } catch {
14590
+ }
14485
14591
  }
14486
14592
  console.log(`Cleared the workspace choice for ${taskId}.`);
14487
14593
  return;
@@ -15039,6 +15145,10 @@ async function styleOuterSession(bootPath, repoCwd, sidebarWidth) {
15039
15145
  // focus it, click rows/chips. Without this the fixed split reads as
15040
15146
  // "blocked in".
15041
15147
  ["set-option", "-t", UI_SESSION, "mouse", "on"],
15148
+ // Scrollback deep enough to hold a real working session. tmux defaults to
15149
+ // 2000 lines, which a long harness session blows through — the start of the
15150
+ // conversation would silently fall off the top with no way back to it.
15151
+ ["set-option", "-t", UI_SESSION, "history-limit", "50000"],
15042
15152
  ["set-option", "-t", UI_SESSION, "status-position", "top"],
15043
15153
  ["set-option", "-t", UI_SESSION, "status-style", "bg=colour233,fg=colour245"],
15044
15154
  ["set-option", "-t", UI_SESSION, "status-format[0]", chipStrip(sidebarWidth)],
@@ -15064,6 +15174,7 @@ async function styleOuterSession(bootPath, repoCwd, sidebarWidth) {
15064
15174
  for (const argv of style) await run(HOST, ["tmux", ...argv]);
15065
15175
  }
15066
15176
  async function buildTab(bootPath, repoCwd, spec) {
15177
+ await run(HOST, ["tmux", "set-option", "-g", "history-limit", "50000"]);
15067
15178
  let windowTarget;
15068
15179
  if (!await uiSessionExists()) {
15069
15180
  const cols = String(Number(process.stdout.columns || 0) || 220);
@@ -15132,11 +15243,29 @@ async function uiSessionExists() {
15132
15243
  const result = await run(HOST, ["tmux", "has-session", "-t", UI_SESSION]);
15133
15244
  return result.ok;
15134
15245
  }
15246
+ function parseCollapsedClients(listOutput) {
15247
+ return String(listOutput || "").split("\n").map((line) => line.trim().split("|")).filter(([name, width, height]) => Boolean(name) && Number.isFinite(Number(width)) && Number.isFinite(Number(height)) && (Number(width) < 20 || Number(height) < 5)).map(([name]) => name);
15248
+ }
15249
+ async function pruneCollapsedClients() {
15250
+ const listed = await run(HOST, [
15251
+ "tmux",
15252
+ "list-clients",
15253
+ "-t",
15254
+ UI_SESSION,
15255
+ "-F",
15256
+ "#{client_name}|#{client_width}|#{client_height}"
15257
+ ]);
15258
+ if (!listed.ok) return 0;
15259
+ const collapsed = parseCollapsedClients(listed.stdout);
15260
+ for (const name of collapsed) await run(HOST, ["tmux", "detach-client", "-t", name]);
15261
+ return collapsed.length;
15262
+ }
15135
15263
  async function launchUi(bootPath, repoCwd) {
15136
15264
  rememberSpace(repoCwd);
15137
15265
  if (!await uiSessionExists()) {
15138
15266
  await buildTab(bootPath, repoCwd, { cwd: repoCwd, center: makeTerminalCommand(bootPath), focus: "sidebar" });
15139
15267
  }
15268
+ await pruneCollapsedClients();
15140
15269
  return runInherit(process.env.TMUX ? ["tmux", "switch-client", "-t", UI_SESSION] : ["tmux", "attach-session", "-t", UI_SESSION]);
15141
15270
  }
15142
15271
  var HOST, TAB_GLYPHS;
@@ -16805,6 +16934,15 @@ function parseCursorLine(line) {
16805
16934
  }
16806
16935
  return [];
16807
16936
  }
16937
+ function stderrNotice(raw) {
16938
+ const text = String(raw || "").trim();
16939
+ if (!text) return "";
16940
+ if (/ActionRequiredError/i.test(text) && /usage limit/i.test(text)) {
16941
+ return "Cursor usage limit reached \u2014 replies still land, but turns will keep stalling until the plan resets or is topped up";
16942
+ }
16943
+ if (/RetriableError/i.test(text)) return "";
16944
+ return /error|fatal/i.test(text) ? text : "";
16945
+ }
16808
16946
  function feed(buffer, chunk) {
16809
16947
  const combined = buffer + chunk;
16810
16948
  const parts = combined.split("\n");
@@ -16844,43 +16982,70 @@ var init_cursor = __esm({
16844
16982
  });
16845
16983
 
16846
16984
  // cli/harness/render.ts
16985
+ function spinnerFrame(tick) {
16986
+ return SPINNER[Math.abs(tick) % SPINNER.length];
16987
+ }
16847
16988
  function clip3(text, max) {
16848
16989
  const value = String(text || "").replace(/\s+/g, " ").trim();
16990
+ if (max <= 1) return value;
16849
16991
  return value.length <= max ? value : value.slice(0, max - 1) + "\u2026";
16850
16992
  }
16993
+ function seconds(ms) {
16994
+ const total = Math.max(0, Math.round(ms / 1e3));
16995
+ if (total < 60) return total + "s";
16996
+ return Math.floor(total / 60) + "m" + String(total % 60).padStart(2, "0") + "s";
16997
+ }
16998
+ function statusLine(opts) {
16999
+ const hint = opts.hint ? " \xB7 " + opts.hint : "";
17000
+ const body = opts.text + " (" + seconds(opts.elapsedMs) + hint + ")";
17001
+ const width = Math.max(20, (opts.width || 100) - 4);
17002
+ return S2.think + " " + spinnerFrame(opts.tick) + " " + clip3(body, width) + S2.reset;
17003
+ }
16851
17004
  function renderEvent(event, width = 100) {
16852
17005
  const body = Math.max(30, width - 4);
16853
17006
  switch (event.type) {
17007
+ // The workspace and both accounts are already in the session header, so
17008
+ // this line carries only what the header could not know before the harness
17009
+ // started: which model answered, and whether a subscription or a key paid.
16854
17010
  case "session-start":
16855
17011
  return [
16856
17012
  "",
16857
- S2.dim + " " + event.model + " \xB7 " + clip3(event.cwd, body - 30) + (event.authSource === "login" ? " \xB7 subscription" : "") + S2.reset,
17013
+ S2.dim + " " + clip3(event.model, body - 20) + (event.authSource === "login" ? " \xB7 subscription" : " \xB7 api key") + S2.reset,
16858
17014
  ""
16859
17015
  ];
16860
17016
  case "user-message":
16861
17017
  return ["", S2.user + " \u276F " + S2.reset + S2.bold + clip3(event.text, body) + S2.reset, ""];
17018
+ // Live state, not transcript. The runner promotes these to the status line.
16862
17019
  case "thinking":
16863
- return [S2.think + " \xB7 thinking\u2026" + S2.reset];
16864
- case "assistant-message":
16865
- return ["", ...wrap(event.text, body).map((line) => S2.agent + " " + line + S2.reset), ""];
17020
+ return [];
17021
+ case "assistant-message": {
17022
+ const lines = wrap(event.text, body - 2);
17023
+ return [
17024
+ "",
17025
+ ...lines.map((line, index) => index === 0 ? S2.agent + " " + BULLET + " " + line + S2.reset : S2.agent + " " + line + S2.reset),
17026
+ ""
17027
+ ];
17028
+ }
16866
17029
  case "tool-start": {
16867
- const glyph = GLYPH[event.kind] || GLYPH.other;
16868
- return [S2.tool + " " + glyph + " " + S2.reset + S2.dim + clip3(event.target, body - 6) + S2.reset];
17030
+ const label = TOOL_LABEL[event.kind] || TOOL_LABEL.other;
17031
+ return [
17032
+ S2.tool + " " + BULLET + " " + label + S2.reset + S2.dim + "(" + clip3(event.target, body - label.length - 8) + ")" + S2.reset
17033
+ ];
16869
17034
  }
16870
17035
  case "tool-end": {
16871
17036
  if (event.blocked) {
16872
17037
  return [
16873
- S2.blocked + " \u26D4 blocked" + S2.reset + S2.dim + " " + clip3(event.target, body - 14) + S2.reset,
16874
- ...wrap(event.reason, body - 6).map((line) => " " + S2.rule + line + S2.reset)
17038
+ S2.blocked + " " + BULLET + " Blocked" + S2.reset + S2.dim + " " + clip3(event.target, body - 14) + S2.reset,
17039
+ ...wrap(event.reason, body - 6).map((line, index) => index === 0 ? S2.blocked + " " + ELBOW + " " + S2.reset + S2.rule + line + S2.reset : S2.rule + " " + line + S2.reset)
16875
17040
  ];
16876
17041
  }
16877
- const mark = event.ok ? S2.ok + " \u2713" : S2.blocked + " \u2717";
16878
- const code = event.exitCode === null || event.exitCode === 0 ? "" : " (exit " + event.exitCode + ")";
16879
- const out = event.output.trim() ? S2.dim + " " + clip3(event.output, body - 20) + S2.reset : "";
16880
- return [mark + S2.reset + code + out];
17042
+ const detail = event.ok ? event.output.trim() ? clip3(event.output, body - 10) : "done" : "failed" + (event.exitCode === null ? "" : " (exit " + event.exitCode + ")");
17043
+ const tint = event.ok ? S2.ok : S2.blocked;
17044
+ return [tint + " " + ELBOW + " " + S2.reset + S2.dim + detail + S2.reset];
16881
17045
  }
17046
+ // A clean finish needs no announcement: the prompt returning IS the signal.
16882
17047
  case "turn-end":
16883
- return ["", S2.dim + " " + (event.ok ? "done" : "ended with an error") + S2.reset, ""];
17048
+ return event.ok ? [] : ["", S2.blocked + " " + BULLET + " turn ended with an error" + S2.reset, ""];
16884
17049
  case "notice":
16885
17050
  return [S2.dim + " " + clip3(event.text, body) + S2.reset];
16886
17051
  default:
@@ -16903,7 +17068,7 @@ function wrap(text, width) {
16903
17068
  if (line) lines.push(line);
16904
17069
  return lines;
16905
17070
  }
16906
- var ESC2, S2, GLYPH;
17071
+ var ESC2, S2, CLEAR_LINE, BULLET, ELBOW, SPINNER, TOOL_LABEL;
16907
17072
  var init_render2 = __esm({
16908
17073
  "cli/harness/render.ts"() {
16909
17074
  "use strict";
@@ -16920,16 +17085,20 @@ var init_render2 = __esm({
16920
17085
  blocked: ESC2 + "38;5;203m",
16921
17086
  rule: ESC2 + "38;5;211m"
16922
17087
  };
16923
- GLYPH = {
16924
- shell: "\u276F",
16925
- read: "\u25C4",
16926
- edit: "\u270E",
16927
- write: "\u270E",
16928
- delete: "\u2716",
16929
- search: "\u2315",
16930
- list: "\u2630",
16931
- todo: "\u2611",
16932
- other: "\u2022"
17088
+ CLEAR_LINE = "\r" + ESC2 + "2K";
17089
+ BULLET = "\u23FA";
17090
+ ELBOW = "\u23BF";
17091
+ SPINNER = ["\xB7", "\u2722", "\u2733", "\u2217", "\u273B", "\u273D"];
17092
+ TOOL_LABEL = {
17093
+ shell: "Shell",
17094
+ read: "Read",
17095
+ edit: "Edit",
17096
+ write: "Write",
17097
+ delete: "Delete",
17098
+ search: "Search",
17099
+ list: "List",
17100
+ todo: "Todo",
17101
+ other: "Tool"
16933
17102
  };
16934
17103
  }
16935
17104
  });
@@ -16952,33 +17121,138 @@ function replayKey(event) {
16952
17121
  }
16953
17122
  function createTurnSink(opts) {
16954
17123
  const events = [];
17124
+ const now = opts.now || (() => Date.now());
17125
+ const showPrompt = opts.showPrompt !== false;
16955
17126
  let blocked = 0;
16956
- let lastWasThinking = false;
16957
17127
  let replaying = false;
16958
17128
  const seen = /* @__PURE__ */ new Set();
16959
17129
  let sawTurnEnd = false;
16960
17130
  let sawAnswer = false;
16961
17131
  let buffer = "";
17132
+ let reconnecting = false;
17133
+ let lastWasAnswer = false;
17134
+ let statusText = "";
17135
+ let statusKind = "progress";
17136
+ let statusShown = false;
17137
+ let tick = 0;
17138
+ const startedAt = now();
17139
+ let timer = null;
17140
+ const drawStatus = () => {
17141
+ if (!opts.animate || !statusText) return;
17142
+ opts.write(statusLine({
17143
+ tick,
17144
+ text: statusText,
17145
+ elapsedMs: now() - startedAt,
17146
+ width: opts.width,
17147
+ hint: "ctrl-c to interrupt"
17148
+ }));
17149
+ statusShown = true;
17150
+ };
17151
+ const clearStatus = () => {
17152
+ if (!statusShown) return;
17153
+ opts.write(CLEAR_LINE);
17154
+ statusShown = false;
17155
+ };
17156
+ const setStatus = (text, kind = "progress") => {
17157
+ statusText = text;
17158
+ statusKind = kind;
17159
+ if (!opts.animate) return;
17160
+ if (!timer) {
17161
+ timer = setInterval(() => {
17162
+ tick += 1;
17163
+ clearStatus();
17164
+ drawStatus();
17165
+ }, TICK_MS);
17166
+ timer.unref?.();
17167
+ }
17168
+ clearStatus();
17169
+ drawStatus();
17170
+ };
17171
+ const stopStatus = () => {
17172
+ statusText = "";
17173
+ statusKind = "progress";
17174
+ if (timer) {
17175
+ clearInterval(timer);
17176
+ timer = null;
17177
+ }
17178
+ clearStatus();
17179
+ };
17180
+ let gaveUp = false;
17181
+ let stall = null;
17182
+ const graceMs = opts.postAnswerGraceMs ?? 15e3;
17183
+ const disarmStall = () => {
17184
+ if (stall) {
17185
+ clearTimeout(stall);
17186
+ stall = null;
17187
+ }
17188
+ };
17189
+ const giveUp = () => {
17190
+ if (gaveUp) return;
17191
+ gaveUp = true;
17192
+ disarmStall();
17193
+ opts.onGiveUp?.();
17194
+ };
17195
+ const armStall = () => {
17196
+ disarmStall();
17197
+ stall = setTimeout(giveUp, graceMs);
17198
+ stall.unref?.();
17199
+ };
17200
+ const settle = () => {
17201
+ reconnecting = false;
17202
+ if (statusKind === "retry") setStatus("Thinking");
17203
+ };
16962
17204
  const emit2 = (event) => {
16963
17205
  events.push(event);
16964
17206
  if (event.type === "tool-end" && event.blocked) blocked += 1;
16965
17207
  opts.onEvent?.(event);
17208
+ if (event.type === "notice" && event.kind === "retry") {
17209
+ reconnecting = true;
17210
+ if (sawAnswer && lastWasAnswer) {
17211
+ setStatus("finishing up", "retry");
17212
+ armStall();
17213
+ } else {
17214
+ setStatus(event.text, "retry");
17215
+ }
17216
+ return;
17217
+ }
16966
17218
  if (event.type === "thinking") {
16967
- if (lastWasThinking) return;
16968
- lastWasThinking = true;
16969
- } else {
16970
- lastWasThinking = false;
17219
+ if (!reconnecting) setStatus("Thinking");
17220
+ return;
17221
+ }
17222
+ if (event.type === "user-message" && !showPrompt) return;
17223
+ if (event.type === "session-start" && opts.showHeader === false) return;
17224
+ if (event.type === "assistant-message" || event.type === "tool-end") settle();
17225
+ if (event.type === "tool-start") {
17226
+ reconnecting = false;
17227
+ setStatus("Running " + event.kind);
16971
17228
  }
17229
+ if (event.type === "turn-end") stopStatus();
16972
17230
  const rendered = renderEvent(event, opts.width);
16973
- if (rendered.length) opts.write(rendered.join("\n") + "\n");
17231
+ if (!rendered.length) return;
17232
+ clearStatus();
17233
+ opts.write(rendered.join("\n") + "\n");
17234
+ drawStatus();
16974
17235
  };
16975
17236
  const take = (event) => {
16976
- if (event.type === "notice" && event.kind === "retry") replaying = true;
17237
+ const isRetry = event.type === "notice" && event.kind === "retry";
17238
+ if (!isRetry) disarmStall();
17239
+ if (isRetry) {
17240
+ if (reconnecting && sawAnswer && lastWasAnswer) {
17241
+ giveUp();
17242
+ return;
17243
+ }
17244
+ replaying = true;
17245
+ }
16977
17246
  const key = replayKey(event);
16978
17247
  if (key) {
16979
- if (replaying && seen.has(key)) return;
17248
+ if (replaying && seen.has(key)) {
17249
+ lastWasAnswer = event.type === "assistant-message";
17250
+ settle();
17251
+ return;
17252
+ }
16980
17253
  seen.add(key);
16981
17254
  }
17255
+ if (key || event.type === "thinking") lastWasAnswer = event.type === "assistant-message";
16982
17256
  if (event.type === "turn-end") sawTurnEnd = true;
16983
17257
  if (event.type === "assistant-message") sawAnswer = true;
16984
17258
  emit2(event);
@@ -16992,57 +17266,159 @@ function createTurnSink(opts) {
16992
17266
  notice(text) {
16993
17267
  emit2({ type: "notice", text });
16994
17268
  },
16995
- finish(exit) {
17269
+ finish(exit, interrupted = false) {
17270
+ disarmStall();
17271
+ stopStatus();
16996
17272
  if (!sawTurnEnd) {
16997
- if (exit !== 0) {
16998
- emit2({
16999
- type: "notice",
17000
- text: sawAnswer ? "cursor-agent exited " + exit + " after retrying; the reply above is complete" : "cursor-agent exited " + exit + " without completing the turn"
17001
- });
17273
+ if (interrupted) {
17274
+ emit2({ type: "notice", text: "interrupted" });
17275
+ emit2({ type: "turn-end", ok: true, text: "" });
17276
+ } else {
17277
+ if (exit !== 0 && !sawAnswer) {
17278
+ emit2({ type: "notice", text: "cursor-agent exited " + exit + " without completing the turn" });
17279
+ }
17280
+ emit2({ type: "turn-end", ok: sawAnswer, text: "" });
17002
17281
  }
17003
- emit2({ type: "turn-end", ok: sawAnswer, text: "" });
17004
17282
  }
17005
- return { events, blocked, exitCode: sawAnswer ? 0 : exit };
17283
+ stopStatus();
17284
+ return { events, blocked, exitCode: interrupted ? 130 : sawAnswer ? 0 : exit };
17006
17285
  }
17007
17286
  };
17008
17287
  }
17009
17288
  async function runCursorTurn(opts) {
17010
17289
  const write2 = opts.write || ((text) => process.stdout.write(text));
17011
17290
  const width = opts.width || Number(process.stdout.columns || 100);
17012
- const sink = createTurnSink({ write: write2, onEvent: opts.onEvent, width });
17291
+ let stopHarness = () => {
17292
+ };
17293
+ const sink = createTurnSink({
17294
+ write: write2,
17295
+ onEvent: opts.onEvent,
17296
+ width,
17297
+ animate: Boolean(process.stdout.isTTY),
17298
+ showPrompt: opts.showPrompt,
17299
+ showHeader: opts.showHeader,
17300
+ onGiveUp: () => stopHarness()
17301
+ });
17013
17302
  return new Promise((resolve7) => {
17014
17303
  const child = spawn9("cursor-agent", cursorArgs(opts.prompt), {
17015
17304
  cwd: opts.cwd,
17016
17305
  stdio: ["ignore", "pipe", "pipe"]
17017
17306
  });
17307
+ stopHarness = () => {
17308
+ child.kill();
17309
+ const hard = setTimeout(() => child.kill("SIGKILL"), 3e3);
17310
+ hard.unref?.();
17311
+ child.once("close", () => clearTimeout(hard));
17312
+ };
17313
+ let interrupted = false;
17314
+ const onAbort = () => {
17315
+ interrupted = true;
17316
+ stopHarness();
17317
+ };
17318
+ if (opts.signal?.aborted) onAbort();
17319
+ else opts.signal?.addEventListener("abort", onAbort, { once: true });
17018
17320
  child.stdout.on("data", (chunk) => sink.chunk(chunk.toString("utf8")));
17321
+ let lastNote = "";
17019
17322
  child.stderr.on("data", (chunk) => {
17020
- const text = chunk.toString("utf8").trim();
17021
- if (/error|fatal/i.test(text) && !/RetriableError/i.test(text)) sink.notice(text);
17323
+ const note = stderrNotice(chunk.toString("utf8"));
17324
+ if (note && note !== lastNote) {
17325
+ lastNote = note;
17326
+ sink.notice(note);
17327
+ }
17328
+ });
17329
+ child.on("close", (code) => {
17330
+ opts.signal?.removeEventListener("abort", onAbort);
17331
+ resolve7(sink.finish(code ?? 0, interrupted));
17022
17332
  });
17023
- child.on("close", (code) => resolve7(sink.finish(code ?? 0)));
17024
17333
  child.on("error", (error) => {
17025
17334
  sink.notice("failed to start cursor-agent: " + String(error));
17026
- resolve7(sink.finish(1));
17335
+ resolve7(sink.finish(1, interrupted));
17027
17336
  });
17028
17337
  });
17029
17338
  }
17339
+ var TICK_MS;
17030
17340
  var init_run = __esm({
17031
17341
  "cli/harness/run.ts"() {
17032
17342
  "use strict";
17033
17343
  init_cursor();
17034
17344
  init_render2();
17345
+ TICK_MS = 120;
17346
+ }
17347
+ });
17348
+
17349
+ // cli/harness/identity.ts
17350
+ import { execFileSync as execFileSync5 } from "child_process";
17351
+ import { homedir as homedir38 } from "os";
17352
+ function shortenPath(path, home = homedir38()) {
17353
+ const value = String(path || "");
17354
+ if (home && value === home) return "~";
17355
+ if (home && value.startsWith(home + "/")) return "~" + value.slice(home.length);
17356
+ return value;
17357
+ }
17358
+ function parseCursorAccount(output) {
17359
+ const match = String(output || "").match(/logged in as\s+(\S+)/i);
17360
+ return match ? match[1].trim() : "";
17361
+ }
17362
+ function cursorAccount() {
17363
+ try {
17364
+ const out = execFileSync5("cursor-agent", ["status"], {
17365
+ encoding: "utf8",
17366
+ timeout: 5e3,
17367
+ stdio: ["ignore", "pipe", "pipe"]
17368
+ });
17369
+ return parseCursorAccount(out);
17370
+ } catch {
17371
+ return "";
17372
+ }
17373
+ }
17374
+ function synkroAccount() {
17375
+ let email = "";
17376
+ try {
17377
+ email = String(getUserInfo().email || "");
17378
+ } catch {
17379
+ return { email: "", needsLogin: false };
17380
+ }
17381
+ let needsLogin = false;
17382
+ try {
17383
+ needsLogin = isTokenExpired() && !loadCredentials()?.refresh_token;
17384
+ } catch {
17385
+ }
17386
+ return { email, needsLogin };
17387
+ }
17388
+ function readIdentity(harness) {
17389
+ const synkro = synkroAccount();
17390
+ return {
17391
+ harness: harness === "cursor" ? cursorAccount() : "",
17392
+ synkro: synkro.email,
17393
+ needsLogin: synkro.needsLogin
17394
+ };
17395
+ }
17396
+ function identityHeader(opts) {
17397
+ const lines = ["", S2.bold + " " + shortenPath(opts.cwd, opts.home) + S2.reset];
17398
+ const label = (name, value, note = "") => S2.dim + " " + name.padEnd(7) + S2.reset + S2.think + value + S2.reset + note;
17399
+ if (opts.identity.harness) lines.push(label(opts.harness, opts.identity.harness));
17400
+ if (opts.identity.synkro) {
17401
+ lines.push(label("synkro", opts.identity.synkro, opts.identity.needsLogin ? S2.blocked + " \xB7 session ended, run synkro login" + S2.reset : ""));
17402
+ }
17403
+ lines.push("");
17404
+ return lines;
17405
+ }
17406
+ var init_identity2 = __esm({
17407
+ "cli/harness/identity.ts"() {
17408
+ "use strict";
17409
+ init_auth();
17410
+ init_render2();
17035
17411
  }
17036
17412
  });
17037
17413
 
17038
17414
  // cli/harness/session.ts
17039
17415
  import { createInterface as createInterface5 } from "readline";
17040
- async function runOnce(harness, cwd, prompt) {
17416
+ async function runOnce(harness, cwd, prompt, echoPrompt = true, showHeader = true, signal) {
17041
17417
  if (harness !== "cursor") {
17042
17418
  process.stdout.write(S2.dim + " " + harness + " sessions are not embedded yet\n" + S2.reset);
17043
17419
  return 1;
17044
17420
  }
17045
- const result = await runCursorTurn({ prompt, cwd });
17421
+ const result = await runCursorTurn({ prompt, cwd, showPrompt: echoPrompt, showHeader, signal });
17046
17422
  if (result.blocked > 0) {
17047
17423
  process.stdout.write(
17048
17424
  S2.blocked + " " + result.blocked + " action" + (result.blocked === 1 ? "" : "s") + " blocked by policy" + S2.reset + "\n"
@@ -17052,31 +17428,46 @@ async function runOnce(harness, cwd, prompt) {
17052
17428
  }
17053
17429
  async function runGovernedSession(harness, cwd, prompt) {
17054
17430
  if (prompt) return runOnce(harness, cwd, prompt);
17055
- process.stdout.write(BANNER.join("\n") + "\n");
17431
+ process.stdout.write(identityHeader({ cwd, harness, identity: readIdentity(harness) }).join("\n") + "\n");
17056
17432
  const rl = createInterface5({ input: process.stdin, output: process.stdout });
17057
- const ask3 = () => new Promise((resolve7) => rl.question(S2.user + " \u276F " + S2.reset, resolve7));
17433
+ const ask3 = () => new Promise((resolve7) => {
17434
+ const onClose = () => resolve7(null);
17435
+ rl.once("close", onClose);
17436
+ rl.question(S2.user + " \u276F " + S2.reset, (answer) => {
17437
+ rl.removeListener("close", onClose);
17438
+ resolve7(answer);
17439
+ });
17440
+ });
17441
+ let turn = null;
17442
+ rl.on("SIGINT", () => {
17443
+ if (turn) {
17444
+ turn.abort();
17445
+ return;
17446
+ }
17447
+ process.stdout.write("\n");
17448
+ rl.close();
17449
+ });
17450
+ let first = true;
17058
17451
  for (; ; ) {
17059
- const line = (await ask3()).trim();
17452
+ const answer = await ask3();
17453
+ if (answer === null) break;
17454
+ const line = answer.trim();
17060
17455
  if (!line) continue;
17061
17456
  if (line === "exit" || line === "quit") break;
17062
- await runOnce(harness, cwd, line);
17457
+ turn = new AbortController();
17458
+ await runOnce(harness, cwd, line, false, first, turn.signal);
17459
+ turn = null;
17460
+ first = false;
17063
17461
  }
17064
17462
  rl.close();
17065
17463
  return 0;
17066
17464
  }
17067
- var BANNER;
17068
17465
  var init_session = __esm({
17069
17466
  "cli/harness/session.ts"() {
17070
17467
  "use strict";
17071
17468
  init_run();
17469
+ init_identity2();
17072
17470
  init_render2();
17073
- BANNER = [
17074
- "",
17075
- S2.bold + " synkro" + S2.reset + S2.dim + " governed session" + S2.reset,
17076
- S2.dim + " every tool call passes Synkro policy before it runs" + S2.reset,
17077
- S2.dim + " ctrl-c to leave" + S2.reset,
17078
- ""
17079
- ];
17080
17471
  }
17081
17472
  });
17082
17473
 
@@ -17269,7 +17660,7 @@ __export(linear_exports, {
17269
17660
  linearCommand: () => linearCommand
17270
17661
  });
17271
17662
  import { readFileSync as readFileSync33 } from "fs";
17272
- import { homedir as homedir38 } from "os";
17663
+ import { homedir as homedir39 } from "os";
17273
17664
  import { join as join37 } from "path";
17274
17665
  function mcpJwt() {
17275
17666
  try {
@@ -17312,7 +17703,7 @@ var SYNKRO_DIR14, PORT2, BASE;
17312
17703
  var init_linear = __esm({
17313
17704
  "cli/commands/linear.ts"() {
17314
17705
  "use strict";
17315
- SYNKRO_DIR14 = join37(homedir38(), ".synkro");
17706
+ SYNKRO_DIR14 = join37(homedir39(), ".synkro");
17316
17707
  PORT2 = process.env.SYNKRO_MCP_PORT || "18931";
17317
17708
  BASE = `http://127.0.0.1:${PORT2}`;
17318
17709
  }
@@ -17461,10 +17852,10 @@ var init_cveReachability = __esm({
17461
17852
  });
17462
17853
 
17463
17854
  // cli/reachability/reachabilityScan.ts
17464
- import { spawnSync as spawnSync12, execFileSync as execFileSync5 } from "child_process";
17855
+ import { spawnSync as spawnSync12, execFileSync as execFileSync6 } from "child_process";
17465
17856
  import { readFileSync as readFileSync35, writeFileSync as writeFileSync27, existsSync as existsSync38, readdirSync as readdirSync10 } from "fs";
17466
17857
  import { join as join38 } from "path";
17467
- import { homedir as homedir39 } from "os";
17858
+ import { homedir as homedir40 } from "os";
17468
17859
  import { createRequire } from "module";
17469
17860
  function walkSourceFiles(repoRoot3, maxFiles = 4e3, maxBytes = 5e5) {
17470
17861
  const SKIP2 = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", "coverage", ".next", ".turbo", "out", ".cache", ".synkro", ".claude", "vendor", "__tests__", "test-results"]);
@@ -17588,7 +17979,7 @@ function findEntries(repoRoot3) {
17588
17979
  }
17589
17980
  function currentCommit(repoRoot3) {
17590
17981
  try {
17591
- return execFileSync5("git", ["rev-parse", "HEAD"], { cwd: repoRoot3, encoding: "utf8" }).trim();
17982
+ return execFileSync6("git", ["rev-parse", "HEAD"], { cwd: repoRoot3, encoding: "utf8" }).trim();
17592
17983
  } catch {
17593
17984
  return "";
17594
17985
  }
@@ -17712,7 +18103,7 @@ var init_reachabilityScan = __esm({
17712
18103
  "use strict";
17713
18104
  init_cveReachability();
17714
18105
  require2 = createRequire(import.meta.url);
17715
- REACHABILITY_PATH = join38(homedir39(), ".synkro", "reachability.json");
18106
+ REACHABILITY_PATH = join38(homedir40(), ".synkro", "reachability.json");
17716
18107
  }
17717
18108
  });
17718
18109
 
@@ -17723,8 +18114,8 @@ __export(reachabilityScan_exports, {
17723
18114
  });
17724
18115
  import { readFileSync as readFileSync36, existsSync as existsSync39 } from "fs";
17725
18116
  import { join as join39 } from "path";
17726
- import { homedir as homedir40 } from "os";
17727
- import { execFileSync as execFileSync6 } from "child_process";
18117
+ import { homedir as homedir41 } from "os";
18118
+ import { execFileSync as execFileSync7 } from "child_process";
17728
18119
  function readConfigEnv4() {
17729
18120
  const p = join39(SYNKRO_DIR15, "config.env");
17730
18121
  if (!existsSync39(p)) return {};
@@ -17739,7 +18130,7 @@ function readConfigEnv4() {
17739
18130
  }
17740
18131
  function repoRoot2() {
17741
18132
  try {
17742
- return execFileSync6("git", ["rev-parse", "--show-toplevel"], { encoding: "utf-8" }).trim();
18133
+ return execFileSync7("git", ["rev-parse", "--show-toplevel"], { encoding: "utf-8" }).trim();
17743
18134
  } catch {
17744
18135
  return process.cwd();
17745
18136
  }
@@ -17747,7 +18138,7 @@ function repoRoot2() {
17747
18138
  function repoSlug(root) {
17748
18139
  const run2 = (a) => {
17749
18140
  try {
17750
- return execFileSync6("git", a, { encoding: "utf-8" }).trim();
18141
+ return execFileSync7("git", a, { encoding: "utf-8" }).trim();
17751
18142
  } catch {
17752
18143
  return "";
17753
18144
  }
@@ -17797,7 +18188,7 @@ var init_reachabilityScan2 = __esm({
17797
18188
  "cli/commands/reachabilityScan.ts"() {
17798
18189
  "use strict";
17799
18190
  init_reachabilityScan();
17800
- SYNKRO_DIR15 = join39(homedir40(), ".synkro");
18191
+ SYNKRO_DIR15 = join39(homedir41(), ".synkro");
17801
18192
  }
17802
18193
  });
17803
18194
 
@@ -17929,7 +18320,7 @@ __export(config_exports, {
17929
18320
  });
17930
18321
  import { readFileSync as readFileSync37, writeFileSync as writeFileSync28, existsSync as existsSync40 } from "fs";
17931
18322
  import { join as join40 } from "path";
17932
- import { homedir as homedir41 } from "os";
18323
+ import { homedir as homedir42 } from "os";
17933
18324
  function readConfigEnv5() {
17934
18325
  if (!existsSync40(CONFIG_PATH9)) return {};
17935
18326
  const out = {};
@@ -18115,7 +18506,7 @@ var init_config = __esm({
18115
18506
  "use strict";
18116
18507
  init_stub();
18117
18508
  init_optout();
18118
- SYNKRO_DIR16 = join40(homedir41(), ".synkro");
18509
+ SYNKRO_DIR16 = join40(homedir42(), ".synkro");
18119
18510
  CONFIG_PATH9 = join40(SYNKRO_DIR16, "config.env");
18120
18511
  }
18121
18512
  });
@@ -18307,10 +18698,10 @@ Usage:
18307
18698
  // cli/inventory/identity.ts
18308
18699
  import { randomUUID as randomUUID5 } from "crypto";
18309
18700
  import { existsSync as existsSync41, mkdirSync as mkdirSync24, readFileSync as readFileSync38, renameSync as renameSync9, writeFileSync as writeFileSync29 } from "fs";
18310
- import { homedir as homedir42 } from "os";
18701
+ import { homedir as homedir43 } from "os";
18311
18702
  import { dirname as dirname12, join as join41 } from "path";
18312
18703
  function operationalIdentityPath() {
18313
- return process.env.SYNKRO_OPERATIONAL_IDENTITY_PATH || join41(homedir42(), ".synkro", "installation.json");
18704
+ return process.env.SYNKRO_OPERATIONAL_IDENTITY_PATH || join41(homedir43(), ".synkro", "installation.json");
18314
18705
  }
18315
18706
  function validIdentity(value) {
18316
18707
  if (!value || typeof value !== "object") return false;
@@ -18342,7 +18733,7 @@ function getOperationalInstallationIdentity(path = operationalIdentityPath()) {
18342
18733
  return identity;
18343
18734
  }
18344
18735
  var UUID_RE, cached4;
18345
- var init_identity2 = __esm({
18736
+ var init_identity3 = __esm({
18346
18737
  "cli/inventory/identity.ts"() {
18347
18738
  "use strict";
18348
18739
  UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@@ -18358,7 +18749,7 @@ import {
18358
18749
  readdirSync as readdirSync11,
18359
18750
  statSync as statSync5
18360
18751
  } from "fs";
18361
- import { arch, homedir as homedir43, hostname as hostname2, platform as platform6, release as release2 } from "os";
18752
+ import { arch, homedir as homedir44, hostname as hostname2, platform as platform6, release as release2 } from "os";
18362
18753
  import { basename as basename3, join as join42, relative, resolve as resolve5 } from "path";
18363
18754
  import { fileURLToPath } from "url";
18364
18755
  function sha256(value) {
@@ -18369,7 +18760,7 @@ function pseudonymousHostnameHash(installationId, host) {
18369
18760
  }
18370
18761
  function cliVersion() {
18371
18762
  try {
18372
- return "1.10.5";
18763
+ return "1.10.6";
18373
18764
  } catch {
18374
18765
  return "0.0.0";
18375
18766
  }
@@ -18764,7 +19155,7 @@ function harnessSnapshot(agent) {
18764
19155
  };
18765
19156
  }
18766
19157
  function collectOperationalInventory(options = {}) {
18767
- const home = options.homeDir ?? homedir43();
19158
+ const home = options.homeDir ?? homedir44();
18768
19159
  const detected = options.detectedAgents ?? detectAgents();
18769
19160
  const identity = getOperationalInstallationIdentity(options.identityPath);
18770
19161
  const targetPlatform = options.platformName ?? platform6();
@@ -18876,7 +19267,7 @@ var init_collector = __esm({
18876
19267
  init_ccHookConfig();
18877
19268
  init_cursorHookConfig();
18878
19269
  init_codexHookConfig();
18879
- init_identity2();
19270
+ init_identity3();
18880
19271
  }
18881
19272
  });
18882
19273
 
@@ -18900,10 +19291,10 @@ import {
18900
19291
  renameSync as renameSync10,
18901
19292
  writeFileSync as writeFileSync30
18902
19293
  } from "fs";
18903
- import { homedir as homedir44 } from "os";
19294
+ import { homedir as homedir45 } from "os";
18904
19295
  import { dirname as dirname13, join as join43 } from "path";
18905
19296
  function syncStatePath() {
18906
- return process.env.SYNKRO_INVENTORY_SYNC_STATE_PATH || join43(homedir44(), ".synkro", "inventory-sync.json");
19297
+ return process.env.SYNKRO_INVENTORY_SYNC_STATE_PATH || join43(homedir45(), ".synkro", "inventory-sync.json");
18907
19298
  }
18908
19299
  function readState(path = syncStatePath()) {
18909
19300
  try {
@@ -18930,7 +19321,7 @@ function shouldSyncInventory(state, now = Date.now(), target) {
18930
19321
  return !Number.isFinite(lastAttempt) || lastAttempt <= 0 || now - lastAttempt >= FAILURE_RETRY_MS;
18931
19322
  }
18932
19323
  function readConfig() {
18933
- const path = join43(homedir44(), ".synkro", "config.env");
19324
+ const path = join43(homedir45(), ".synkro", "config.env");
18934
19325
  const out = {};
18935
19326
  try {
18936
19327
  for (const rawLine of readFileSync40(path, "utf8").split("\n")) {
@@ -18972,7 +19363,7 @@ function resolveInventoryGateway(raw) {
18972
19363
  }
18973
19364
  async function loadToken() {
18974
19365
  try {
18975
- const durable = readFileSync40(join43(homedir44(), ".synkro", ".mcp-jwt"), "utf8").trim();
19366
+ const durable = readFileSync40(join43(homedir45(), ".synkro", ".mcp-jwt"), "utf8").trim();
18976
19367
  if (durable) return durable;
18977
19368
  } catch {
18978
19369
  }
@@ -19139,7 +19530,7 @@ var subArgs = args.slice(1);
19139
19530
  var isDetachedChild = process.env.SYNKRO_TELEMETRY_DETACHED === "1";
19140
19531
  var FLUSH_SKIP = /* @__PURE__ */ new Set(["grade", "inventory-sync", "version", "--version", "-v", "help", "--help", "-h", ""]);
19141
19532
  function printVersion() {
19142
- console.log("1.10.5");
19533
+ console.log("1.10.6");
19143
19534
  }
19144
19535
  function printHelp2() {
19145
19536
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents