@synkro-sh/cli 1.10.4 → 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.4";
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.4";
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.4")}`
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.4",
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;
@@ -16705,11 +16834,14 @@ var init_events = __esm({
16705
16834
  });
16706
16835
 
16707
16836
  // cli/harness/cursor.ts
16837
+ function unwrapReplay(text) {
16838
+ return text.replace(/<\/?user_query>/gi, "").trim();
16839
+ }
16708
16840
  function textOf(message) {
16709
16841
  const content = message?.content;
16710
- if (typeof content === "string") return content.trim();
16842
+ if (typeof content === "string") return unwrapReplay(content);
16711
16843
  if (!Array.isArray(content)) return "";
16712
- return content.filter((part) => part && (part.type === "text" || typeof part.text === "string")).map((part) => String(part.text || "")).join("").trim();
16844
+ return unwrapReplay(content.filter((part) => part && (part.type === "text" || typeof part.text === "string")).map((part) => String(part.text || "")).join(""));
16713
16845
  }
16714
16846
  function toolPayload(toolCall) {
16715
16847
  if (!toolCall) return { kind: "other", body: {} };
@@ -16784,6 +16916,15 @@ function parseCursorLine(line) {
16784
16916
  }
16785
16917
  return [];
16786
16918
  }
16919
+ if (type === "retry") {
16920
+ if (subtype !== "starting") return [];
16921
+ const attempt = Number(frame.attempt || 0);
16922
+ return [{
16923
+ type: "notice",
16924
+ kind: "retry",
16925
+ text: "connection dropped, resuming" + (attempt ? " (attempt " + attempt + ")" : "") + "\u2026"
16926
+ }];
16927
+ }
16787
16928
  if (type === "result") {
16788
16929
  return [{
16789
16930
  type: "turn-end",
@@ -16793,6 +16934,15 @@ function parseCursorLine(line) {
16793
16934
  }
16794
16935
  return [];
16795
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
+ }
16796
16946
  function feed(buffer, chunk) {
16797
16947
  const combined = buffer + chunk;
16798
16948
  const parts = combined.split("\n");
@@ -16832,43 +16982,70 @@ var init_cursor = __esm({
16832
16982
  });
16833
16983
 
16834
16984
  // cli/harness/render.ts
16985
+ function spinnerFrame(tick) {
16986
+ return SPINNER[Math.abs(tick) % SPINNER.length];
16987
+ }
16835
16988
  function clip3(text, max) {
16836
16989
  const value = String(text || "").replace(/\s+/g, " ").trim();
16990
+ if (max <= 1) return value;
16837
16991
  return value.length <= max ? value : value.slice(0, max - 1) + "\u2026";
16838
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
+ }
16839
17004
  function renderEvent(event, width = 100) {
16840
17005
  const body = Math.max(30, width - 4);
16841
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.
16842
17010
  case "session-start":
16843
17011
  return [
16844
17012
  "",
16845
- 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,
16846
17014
  ""
16847
17015
  ];
16848
17016
  case "user-message":
16849
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.
16850
17019
  case "thinking":
16851
- return [S2.think + " \xB7 thinking\u2026" + S2.reset];
16852
- case "assistant-message":
16853
- 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
+ }
16854
17029
  case "tool-start": {
16855
- const glyph = GLYPH[event.kind] || GLYPH.other;
16856
- 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
+ ];
16857
17034
  }
16858
17035
  case "tool-end": {
16859
17036
  if (event.blocked) {
16860
17037
  return [
16861
- S2.blocked + " \u26D4 blocked" + S2.reset + S2.dim + " " + clip3(event.target, body - 14) + S2.reset,
16862
- ...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)
16863
17040
  ];
16864
17041
  }
16865
- const mark = event.ok ? S2.ok + " \u2713" : S2.blocked + " \u2717";
16866
- const code = event.exitCode === null || event.exitCode === 0 ? "" : " (exit " + event.exitCode + ")";
16867
- const out = event.output.trim() ? S2.dim + " " + clip3(event.output, body - 20) + S2.reset : "";
16868
- 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];
16869
17045
  }
17046
+ // A clean finish needs no announcement: the prompt returning IS the signal.
16870
17047
  case "turn-end":
16871
- 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, ""];
16872
17049
  case "notice":
16873
17050
  return [S2.dim + " " + clip3(event.text, body) + S2.reset];
16874
17051
  default:
@@ -16891,7 +17068,7 @@ function wrap(text, width) {
16891
17068
  if (line) lines.push(line);
16892
17069
  return lines;
16893
17070
  }
16894
- var ESC2, S2, GLYPH;
17071
+ var ESC2, S2, CLEAR_LINE, BULLET, ELBOW, SPINNER, TOOL_LABEL;
16895
17072
  var init_render2 = __esm({
16896
17073
  "cli/harness/render.ts"() {
16897
17074
  "use strict";
@@ -16908,84 +17085,340 @@ var init_render2 = __esm({
16908
17085
  blocked: ESC2 + "38;5;203m",
16909
17086
  rule: ESC2 + "38;5;211m"
16910
17087
  };
16911
- GLYPH = {
16912
- shell: "\u276F",
16913
- read: "\u25C4",
16914
- edit: "\u270E",
16915
- write: "\u270E",
16916
- delete: "\u2716",
16917
- search: "\u2315",
16918
- list: "\u2630",
16919
- todo: "\u2611",
16920
- 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"
16921
17102
  };
16922
17103
  }
16923
17104
  });
16924
17105
 
16925
17106
  // cli/harness/run.ts
16926
17107
  import { spawn as spawn9 } from "child_process";
17108
+ function replayKey(event) {
17109
+ switch (event.type) {
17110
+ case "user-message":
17111
+ return "u:" + event.text;
17112
+ case "assistant-message":
17113
+ return "a:" + event.text;
17114
+ case "tool-start":
17115
+ return "s:" + event.id;
17116
+ case "tool-end":
17117
+ return "e:" + event.id;
17118
+ default:
17119
+ return "";
17120
+ }
17121
+ }
17122
+ function createTurnSink(opts) {
17123
+ const events = [];
17124
+ const now = opts.now || (() => Date.now());
17125
+ const showPrompt = opts.showPrompt !== false;
17126
+ let blocked = 0;
17127
+ let replaying = false;
17128
+ const seen = /* @__PURE__ */ new Set();
17129
+ let sawTurnEnd = false;
17130
+ let sawAnswer = false;
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
+ };
17204
+ const emit2 = (event) => {
17205
+ events.push(event);
17206
+ if (event.type === "tool-end" && event.blocked) blocked += 1;
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
+ }
17218
+ if (event.type === "thinking") {
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);
17228
+ }
17229
+ if (event.type === "turn-end") stopStatus();
17230
+ const rendered = renderEvent(event, opts.width);
17231
+ if (!rendered.length) return;
17232
+ clearStatus();
17233
+ opts.write(rendered.join("\n") + "\n");
17234
+ drawStatus();
17235
+ };
17236
+ const take = (event) => {
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
+ }
17246
+ const key = replayKey(event);
17247
+ if (key) {
17248
+ if (replaying && seen.has(key)) {
17249
+ lastWasAnswer = event.type === "assistant-message";
17250
+ settle();
17251
+ return;
17252
+ }
17253
+ seen.add(key);
17254
+ }
17255
+ if (key || event.type === "thinking") lastWasAnswer = event.type === "assistant-message";
17256
+ if (event.type === "turn-end") sawTurnEnd = true;
17257
+ if (event.type === "assistant-message") sawAnswer = true;
17258
+ emit2(event);
17259
+ };
17260
+ return {
17261
+ chunk(text) {
17262
+ const { lines, rest } = feed(buffer, text);
17263
+ buffer = rest;
17264
+ for (const line of lines) for (const event of parseCursorLine(line)) take(event);
17265
+ },
17266
+ notice(text) {
17267
+ emit2({ type: "notice", text });
17268
+ },
17269
+ finish(exit, interrupted = false) {
17270
+ disarmStall();
17271
+ stopStatus();
17272
+ if (!sawTurnEnd) {
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: "" });
17281
+ }
17282
+ }
17283
+ stopStatus();
17284
+ return { events, blocked, exitCode: interrupted ? 130 : sawAnswer ? 0 : exit };
17285
+ }
17286
+ };
17287
+ }
16927
17288
  async function runCursorTurn(opts) {
16928
17289
  const write2 = opts.write || ((text) => process.stdout.write(text));
16929
17290
  const width = opts.width || Number(process.stdout.columns || 100);
16930
- const events = [];
16931
- let blocked = 0;
16932
- let lastWasThinking = false;
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
+ });
16933
17302
  return new Promise((resolve7) => {
16934
17303
  const child = spawn9("cursor-agent", cursorArgs(opts.prompt), {
16935
17304
  cwd: opts.cwd,
16936
17305
  stdio: ["ignore", "pipe", "pipe"]
16937
17306
  });
16938
- let buffer = "";
16939
- child.stdout.on("data", (chunk) => {
16940
- const { lines, rest } = feed(buffer, chunk.toString("utf8"));
16941
- buffer = rest;
16942
- for (const line of lines) {
16943
- for (const event of parseCursorLine(line)) {
16944
- events.push(event);
16945
- if (event.type === "tool-end" && event.blocked) blocked += 1;
16946
- opts.onEvent?.(event);
16947
- if (event.type === "thinking") {
16948
- if (lastWasThinking) continue;
16949
- lastWasThinking = true;
16950
- } else {
16951
- lastWasThinking = false;
16952
- }
16953
- const rendered = renderEvent(event, width);
16954
- if (rendered.length) write2(rendered.join("\n") + "\n");
16955
- }
16956
- }
16957
- });
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 });
17320
+ child.stdout.on("data", (chunk) => sink.chunk(chunk.toString("utf8")));
17321
+ let lastNote = "";
16958
17322
  child.stderr.on("data", (chunk) => {
16959
- const text = chunk.toString("utf8").trim();
16960
- if (/error|fatal/i.test(text) && !/RetriableError/i.test(text)) {
16961
- write2(renderEvent({ type: "notice", text }, width).join("\n") + "\n");
17323
+ const note = stderrNotice(chunk.toString("utf8"));
17324
+ if (note && note !== lastNote) {
17325
+ lastNote = note;
17326
+ sink.notice(note);
16962
17327
  }
16963
17328
  });
16964
17329
  child.on("close", (code) => {
16965
- resolve7({ events, blocked, exitCode: code ?? 0 });
17330
+ opts.signal?.removeEventListener("abort", onAbort);
17331
+ resolve7(sink.finish(code ?? 0, interrupted));
16966
17332
  });
16967
17333
  child.on("error", (error) => {
16968
- write2(renderEvent({ type: "notice", text: "failed to start cursor-agent: " + String(error) }, width).join("\n") + "\n");
16969
- resolve7({ events, blocked, exitCode: 1 });
17334
+ sink.notice("failed to start cursor-agent: " + String(error));
17335
+ resolve7(sink.finish(1, interrupted));
16970
17336
  });
16971
17337
  });
16972
17338
  }
17339
+ var TICK_MS;
16973
17340
  var init_run = __esm({
16974
17341
  "cli/harness/run.ts"() {
16975
17342
  "use strict";
16976
17343
  init_cursor();
16977
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();
16978
17411
  }
16979
17412
  });
16980
17413
 
16981
17414
  // cli/harness/session.ts
16982
17415
  import { createInterface as createInterface5 } from "readline";
16983
- async function runOnce(harness, cwd, prompt) {
17416
+ async function runOnce(harness, cwd, prompt, echoPrompt = true, showHeader = true, signal) {
16984
17417
  if (harness !== "cursor") {
16985
17418
  process.stdout.write(S2.dim + " " + harness + " sessions are not embedded yet\n" + S2.reset);
16986
17419
  return 1;
16987
17420
  }
16988
- const result = await runCursorTurn({ prompt, cwd });
17421
+ const result = await runCursorTurn({ prompt, cwd, showPrompt: echoPrompt, showHeader, signal });
16989
17422
  if (result.blocked > 0) {
16990
17423
  process.stdout.write(
16991
17424
  S2.blocked + " " + result.blocked + " action" + (result.blocked === 1 ? "" : "s") + " blocked by policy" + S2.reset + "\n"
@@ -16995,31 +17428,46 @@ async function runOnce(harness, cwd, prompt) {
16995
17428
  }
16996
17429
  async function runGovernedSession(harness, cwd, prompt) {
16997
17430
  if (prompt) return runOnce(harness, cwd, prompt);
16998
- process.stdout.write(BANNER.join("\n") + "\n");
17431
+ process.stdout.write(identityHeader({ cwd, harness, identity: readIdentity(harness) }).join("\n") + "\n");
16999
17432
  const rl = createInterface5({ input: process.stdin, output: process.stdout });
17000
- 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;
17001
17451
  for (; ; ) {
17002
- const line = (await ask3()).trim();
17452
+ const answer = await ask3();
17453
+ if (answer === null) break;
17454
+ const line = answer.trim();
17003
17455
  if (!line) continue;
17004
17456
  if (line === "exit" || line === "quit") break;
17005
- 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;
17006
17461
  }
17007
17462
  rl.close();
17008
17463
  return 0;
17009
17464
  }
17010
- var BANNER;
17011
17465
  var init_session = __esm({
17012
17466
  "cli/harness/session.ts"() {
17013
17467
  "use strict";
17014
17468
  init_run();
17469
+ init_identity2();
17015
17470
  init_render2();
17016
- BANNER = [
17017
- "",
17018
- S2.bold + " synkro" + S2.reset + S2.dim + " governed session" + S2.reset,
17019
- S2.dim + " every tool call passes Synkro policy before it runs" + S2.reset,
17020
- S2.dim + " ctrl-c to leave" + S2.reset,
17021
- ""
17022
- ];
17023
17471
  }
17024
17472
  });
17025
17473
 
@@ -17212,7 +17660,7 @@ __export(linear_exports, {
17212
17660
  linearCommand: () => linearCommand
17213
17661
  });
17214
17662
  import { readFileSync as readFileSync33 } from "fs";
17215
- import { homedir as homedir38 } from "os";
17663
+ import { homedir as homedir39 } from "os";
17216
17664
  import { join as join37 } from "path";
17217
17665
  function mcpJwt() {
17218
17666
  try {
@@ -17255,7 +17703,7 @@ var SYNKRO_DIR14, PORT2, BASE;
17255
17703
  var init_linear = __esm({
17256
17704
  "cli/commands/linear.ts"() {
17257
17705
  "use strict";
17258
- SYNKRO_DIR14 = join37(homedir38(), ".synkro");
17706
+ SYNKRO_DIR14 = join37(homedir39(), ".synkro");
17259
17707
  PORT2 = process.env.SYNKRO_MCP_PORT || "18931";
17260
17708
  BASE = `http://127.0.0.1:${PORT2}`;
17261
17709
  }
@@ -17404,10 +17852,10 @@ var init_cveReachability = __esm({
17404
17852
  });
17405
17853
 
17406
17854
  // cli/reachability/reachabilityScan.ts
17407
- import { spawnSync as spawnSync12, execFileSync as execFileSync5 } from "child_process";
17855
+ import { spawnSync as spawnSync12, execFileSync as execFileSync6 } from "child_process";
17408
17856
  import { readFileSync as readFileSync35, writeFileSync as writeFileSync27, existsSync as existsSync38, readdirSync as readdirSync10 } from "fs";
17409
17857
  import { join as join38 } from "path";
17410
- import { homedir as homedir39 } from "os";
17858
+ import { homedir as homedir40 } from "os";
17411
17859
  import { createRequire } from "module";
17412
17860
  function walkSourceFiles(repoRoot3, maxFiles = 4e3, maxBytes = 5e5) {
17413
17861
  const SKIP2 = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", "coverage", ".next", ".turbo", "out", ".cache", ".synkro", ".claude", "vendor", "__tests__", "test-results"]);
@@ -17531,7 +17979,7 @@ function findEntries(repoRoot3) {
17531
17979
  }
17532
17980
  function currentCommit(repoRoot3) {
17533
17981
  try {
17534
- return execFileSync5("git", ["rev-parse", "HEAD"], { cwd: repoRoot3, encoding: "utf8" }).trim();
17982
+ return execFileSync6("git", ["rev-parse", "HEAD"], { cwd: repoRoot3, encoding: "utf8" }).trim();
17535
17983
  } catch {
17536
17984
  return "";
17537
17985
  }
@@ -17655,7 +18103,7 @@ var init_reachabilityScan = __esm({
17655
18103
  "use strict";
17656
18104
  init_cveReachability();
17657
18105
  require2 = createRequire(import.meta.url);
17658
- REACHABILITY_PATH = join38(homedir39(), ".synkro", "reachability.json");
18106
+ REACHABILITY_PATH = join38(homedir40(), ".synkro", "reachability.json");
17659
18107
  }
17660
18108
  });
17661
18109
 
@@ -17666,8 +18114,8 @@ __export(reachabilityScan_exports, {
17666
18114
  });
17667
18115
  import { readFileSync as readFileSync36, existsSync as existsSync39 } from "fs";
17668
18116
  import { join as join39 } from "path";
17669
- import { homedir as homedir40 } from "os";
17670
- import { execFileSync as execFileSync6 } from "child_process";
18117
+ import { homedir as homedir41 } from "os";
18118
+ import { execFileSync as execFileSync7 } from "child_process";
17671
18119
  function readConfigEnv4() {
17672
18120
  const p = join39(SYNKRO_DIR15, "config.env");
17673
18121
  if (!existsSync39(p)) return {};
@@ -17682,7 +18130,7 @@ function readConfigEnv4() {
17682
18130
  }
17683
18131
  function repoRoot2() {
17684
18132
  try {
17685
- return execFileSync6("git", ["rev-parse", "--show-toplevel"], { encoding: "utf-8" }).trim();
18133
+ return execFileSync7("git", ["rev-parse", "--show-toplevel"], { encoding: "utf-8" }).trim();
17686
18134
  } catch {
17687
18135
  return process.cwd();
17688
18136
  }
@@ -17690,7 +18138,7 @@ function repoRoot2() {
17690
18138
  function repoSlug(root) {
17691
18139
  const run2 = (a) => {
17692
18140
  try {
17693
- return execFileSync6("git", a, { encoding: "utf-8" }).trim();
18141
+ return execFileSync7("git", a, { encoding: "utf-8" }).trim();
17694
18142
  } catch {
17695
18143
  return "";
17696
18144
  }
@@ -17740,7 +18188,7 @@ var init_reachabilityScan2 = __esm({
17740
18188
  "cli/commands/reachabilityScan.ts"() {
17741
18189
  "use strict";
17742
18190
  init_reachabilityScan();
17743
- SYNKRO_DIR15 = join39(homedir40(), ".synkro");
18191
+ SYNKRO_DIR15 = join39(homedir41(), ".synkro");
17744
18192
  }
17745
18193
  });
17746
18194
 
@@ -17872,7 +18320,7 @@ __export(config_exports, {
17872
18320
  });
17873
18321
  import { readFileSync as readFileSync37, writeFileSync as writeFileSync28, existsSync as existsSync40 } from "fs";
17874
18322
  import { join as join40 } from "path";
17875
- import { homedir as homedir41 } from "os";
18323
+ import { homedir as homedir42 } from "os";
17876
18324
  function readConfigEnv5() {
17877
18325
  if (!existsSync40(CONFIG_PATH9)) return {};
17878
18326
  const out = {};
@@ -18058,7 +18506,7 @@ var init_config = __esm({
18058
18506
  "use strict";
18059
18507
  init_stub();
18060
18508
  init_optout();
18061
- SYNKRO_DIR16 = join40(homedir41(), ".synkro");
18509
+ SYNKRO_DIR16 = join40(homedir42(), ".synkro");
18062
18510
  CONFIG_PATH9 = join40(SYNKRO_DIR16, "config.env");
18063
18511
  }
18064
18512
  });
@@ -18250,10 +18698,10 @@ Usage:
18250
18698
  // cli/inventory/identity.ts
18251
18699
  import { randomUUID as randomUUID5 } from "crypto";
18252
18700
  import { existsSync as existsSync41, mkdirSync as mkdirSync24, readFileSync as readFileSync38, renameSync as renameSync9, writeFileSync as writeFileSync29 } from "fs";
18253
- import { homedir as homedir42 } from "os";
18701
+ import { homedir as homedir43 } from "os";
18254
18702
  import { dirname as dirname12, join as join41 } from "path";
18255
18703
  function operationalIdentityPath() {
18256
- 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");
18257
18705
  }
18258
18706
  function validIdentity(value) {
18259
18707
  if (!value || typeof value !== "object") return false;
@@ -18285,7 +18733,7 @@ function getOperationalInstallationIdentity(path = operationalIdentityPath()) {
18285
18733
  return identity;
18286
18734
  }
18287
18735
  var UUID_RE, cached4;
18288
- var init_identity2 = __esm({
18736
+ var init_identity3 = __esm({
18289
18737
  "cli/inventory/identity.ts"() {
18290
18738
  "use strict";
18291
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;
@@ -18301,7 +18749,7 @@ import {
18301
18749
  readdirSync as readdirSync11,
18302
18750
  statSync as statSync5
18303
18751
  } from "fs";
18304
- 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";
18305
18753
  import { basename as basename3, join as join42, relative, resolve as resolve5 } from "path";
18306
18754
  import { fileURLToPath } from "url";
18307
18755
  function sha256(value) {
@@ -18312,7 +18760,7 @@ function pseudonymousHostnameHash(installationId, host) {
18312
18760
  }
18313
18761
  function cliVersion() {
18314
18762
  try {
18315
- return "1.10.4";
18763
+ return "1.10.6";
18316
18764
  } catch {
18317
18765
  return "0.0.0";
18318
18766
  }
@@ -18707,7 +19155,7 @@ function harnessSnapshot(agent) {
18707
19155
  };
18708
19156
  }
18709
19157
  function collectOperationalInventory(options = {}) {
18710
- const home = options.homeDir ?? homedir43();
19158
+ const home = options.homeDir ?? homedir44();
18711
19159
  const detected = options.detectedAgents ?? detectAgents();
18712
19160
  const identity = getOperationalInstallationIdentity(options.identityPath);
18713
19161
  const targetPlatform = options.platformName ?? platform6();
@@ -18819,7 +19267,7 @@ var init_collector = __esm({
18819
19267
  init_ccHookConfig();
18820
19268
  init_cursorHookConfig();
18821
19269
  init_codexHookConfig();
18822
- init_identity2();
19270
+ init_identity3();
18823
19271
  }
18824
19272
  });
18825
19273
 
@@ -18843,10 +19291,10 @@ import {
18843
19291
  renameSync as renameSync10,
18844
19292
  writeFileSync as writeFileSync30
18845
19293
  } from "fs";
18846
- import { homedir as homedir44 } from "os";
19294
+ import { homedir as homedir45 } from "os";
18847
19295
  import { dirname as dirname13, join as join43 } from "path";
18848
19296
  function syncStatePath() {
18849
- 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");
18850
19298
  }
18851
19299
  function readState(path = syncStatePath()) {
18852
19300
  try {
@@ -18873,7 +19321,7 @@ function shouldSyncInventory(state, now = Date.now(), target) {
18873
19321
  return !Number.isFinite(lastAttempt) || lastAttempt <= 0 || now - lastAttempt >= FAILURE_RETRY_MS;
18874
19322
  }
18875
19323
  function readConfig() {
18876
- const path = join43(homedir44(), ".synkro", "config.env");
19324
+ const path = join43(homedir45(), ".synkro", "config.env");
18877
19325
  const out = {};
18878
19326
  try {
18879
19327
  for (const rawLine of readFileSync40(path, "utf8").split("\n")) {
@@ -18915,7 +19363,7 @@ function resolveInventoryGateway(raw) {
18915
19363
  }
18916
19364
  async function loadToken() {
18917
19365
  try {
18918
- const durable = readFileSync40(join43(homedir44(), ".synkro", ".mcp-jwt"), "utf8").trim();
19366
+ const durable = readFileSync40(join43(homedir45(), ".synkro", ".mcp-jwt"), "utf8").trim();
18919
19367
  if (durable) return durable;
18920
19368
  } catch {
18921
19369
  }
@@ -19082,7 +19530,7 @@ var subArgs = args.slice(1);
19082
19530
  var isDetachedChild = process.env.SYNKRO_TELEMETRY_DETACHED === "1";
19083
19531
  var FLUSH_SKIP = /* @__PURE__ */ new Set(["grade", "inventory-sync", "version", "--version", "-v", "help", "--help", "-h", ""]);
19084
19532
  function printVersion() {
19085
- console.log("1.10.4");
19533
+ console.log("1.10.6");
19086
19534
  }
19087
19535
  function printHelp2() {
19088
19536
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents