@imdeadpool/guardex 7.0.37 → 7.0.39

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/README.md CHANGED
@@ -266,6 +266,18 @@ Being honest about where this still has issues:
266
266
  <details open>
267
267
  <summary><strong>v7.x</strong></summary>
268
268
 
269
+ ### v7.0.39
270
+ - Bumped `@imdeadpool/guardex` from `7.0.38` to `7.0.39` so the current
271
+ `main` payload can publish under a fresh npm version after `7.0.38` reached
272
+ the registry.
273
+ - No new CLI command behavior is introduced in this release lane.
274
+
275
+ ### v7.0.38
276
+ - Bumped `@imdeadpool/guardex` from `7.0.37` to `7.0.38` so the current
277
+ `main` payload can publish under a fresh npm version after `7.0.37` reached
278
+ the registry.
279
+ - No new CLI command behavior is introduced in this release lane.
280
+
269
281
  ### v7.0.37
270
282
  - Bumped `@imdeadpool/guardex` from `7.0.36` to `7.0.37` so the current
271
283
  package can publish under a fresh npm version after `7.0.36` reached the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imdeadpool/guardex",
3
- "version": "7.0.37",
3
+ "version": "7.0.39",
4
4
  "description": "Guardian T-Rex for your multi-agent repo. Isolated worktrees, file locks, and PR-only merges stop parallel Codex & Claude agents from overwriting each other's work. Auto-wires Oh My Codex, Oh My Claude, OpenSpec, and Caveman.",
5
5
  "license": "MIT",
6
6
  "preferGlobal": true,
package/src/cli/main.js CHANGED
@@ -408,8 +408,9 @@ function runSetupBootstrapInternal(options) {
408
408
  }
409
409
 
410
410
  function extractAgentBranchStartMetadata(output) {
411
- const branchMatch = String(output || '').match(/^\[agent-branch-start\] Created branch: (.+)$/m);
412
- const worktreeMatch = String(output || '').match(/^\[agent-branch-start\] Worktree: (.+)$/m);
411
+ const outputText = String(output || '');
412
+ const branchMatch = outputText.match(/^\[agent-branch-start\] (?:Created branch|Reusing existing branch): (.+)$/m);
413
+ const worktreeMatch = outputText.match(/^\[agent-branch-start\] Worktree: (.+)$/m);
413
414
  return {
414
415
  branch: branchMatch ? branchMatch[1].trim() : '',
415
416
  worktreePath: worktreeMatch ? worktreeMatch[1].trim() : '',
@@ -656,7 +657,7 @@ function runSetupInSandbox(options, blocked, repoLabel = '') {
656
657
  const nestedResult = run(
657
658
  process.execPath,
658
659
  [__filename, ...buildSandboxSetupArgs(options, sandboxTarget)],
659
- { cwd: metadata.worktreePath },
660
+ { cwd: metadata.worktreePath, env: { GUARDEX_DOCTOR_SANDBOX: '1' } },
660
661
  );
661
662
  if (isSpawnFailure(nestedResult)) {
662
663
  throw nestedResult.error;
@@ -701,6 +702,12 @@ function runSetupInSandbox(options, blocked, repoLabel = '') {
701
702
  console.log(`[${TOOL_NAME}] ${autoFinishSummary.details[0]}`);
702
703
  }
703
704
 
705
+ const prunePayload = doctorModule.pruneStaleAgentWorktrees(scanResult.repoRoot, {
706
+ baseBranch: currentBaseBranch,
707
+ dryRun: syncOptions.dryRun,
708
+ });
709
+ printWorktreePruneSummary(prunePayload, { baseBranch: currentBaseBranch });
710
+
704
711
  const cleanupResult = cleanupProtectedBaseSandbox(blocked.repoRoot, metadata);
705
712
  console.log(
706
713
  `[${TOOL_NAME}] Protected-base setup sandbox cleanup: ${cleanupResult.note} ` +
@@ -1755,6 +1762,26 @@ function runScanInternal(options) {
1755
1762
  };
1756
1763
  }
1757
1764
 
1765
+ function printWorktreePruneSummary(payload, options = {}) {
1766
+ if (!payload || payload.enabled === false) {
1767
+ if (payload && payload.details && payload.details[0]) {
1768
+ console.log(`[${TOOL_NAME}] ${payload.details[0]}`);
1769
+ }
1770
+ return;
1771
+ }
1772
+ if (!payload.ran) {
1773
+ return;
1774
+ }
1775
+ const baseLabel = options.baseBranch ? ` (base=${options.baseBranch})` : '';
1776
+ const tag = payload.status === 'failed' ? '⚠️' : (payload.status === 'dry-run' ? '🔍' : '🧹');
1777
+ console.log(
1778
+ `[${TOOL_NAME}] ${tag} Stale agent-worktree prune${baseLabel}: status=${payload.status}`,
1779
+ );
1780
+ for (const detail of payload.details || []) {
1781
+ console.log(`[${TOOL_NAME}] ${detail}`);
1782
+ }
1783
+ }
1784
+
1758
1785
  function printScanResult(scan, json = false) {
1759
1786
  if (json) {
1760
1787
  process.stdout.write(
@@ -2369,6 +2396,12 @@ function doctor(rawArgs) {
2369
2396
  configureHooks,
2370
2397
  autoFinishReadyAgentBranches: doctorModule.autoFinishReadyAgentBranches,
2371
2398
  });
2399
+ const primaryBaseBranch = currentBranchName(blocked.repoRoot);
2400
+ const prunePayload = doctorModule.pruneStaleAgentWorktrees(blocked.repoRoot, {
2401
+ baseBranch: primaryBaseBranch,
2402
+ dryRun: singleRepoOptions.dryRun,
2403
+ });
2404
+ printWorktreePruneSummary(prunePayload, { baseBranch: primaryBaseBranch });
2372
2405
  return;
2373
2406
  }
2374
2407
 
@@ -2390,6 +2423,12 @@ function doctor(rawArgs) {
2390
2423
  dryRun: singleRepoOptions.dryRun,
2391
2424
  waitForMerge: singleRepoOptions.waitForMerge,
2392
2425
  });
2426
+ const prunePayload = scanResult.guardexEnabled === false
2427
+ ? { enabled: false, ran: false, status: 'skipped', details: ['Guardex disabled for this repo.'] }
2428
+ : doctorModule.pruneStaleAgentWorktrees(scanResult.repoRoot, {
2429
+ baseBranch: currentBaseBranch,
2430
+ dryRun: singleRepoOptions.dryRun,
2431
+ });
2393
2432
  const safe = scanResult.guardexEnabled === false || (scanResult.errors === 0 && scanResult.warnings === 0);
2394
2433
  const musafe = safe;
2395
2434
 
@@ -2414,6 +2453,7 @@ function doctor(rawArgs) {
2414
2453
  findings: scanResult.findings,
2415
2454
  },
2416
2455
  autoFinish: autoFinishSummary,
2456
+ worktreePrune: prunePayload,
2417
2457
  },
2418
2458
  null,
2419
2459
  2,
@@ -2434,6 +2474,7 @@ function doctor(rawArgs) {
2434
2474
  baseBranch: currentBaseBranch,
2435
2475
  verbose: singleRepoOptions.verboseAutoFinish,
2436
2476
  });
2477
+ printWorktreePruneSummary(prunePayload, { baseBranch: currentBaseBranch });
2437
2478
  if (safe) {
2438
2479
  console.log(colorizeDoctorOutput(`[${TOOL_NAME}] ✅ Repo is fully safe.`, 'safe'));
2439
2480
  } else {
@@ -2967,6 +3008,12 @@ function setup(rawArgs) {
2967
3008
  aggregateErrors += sandboxResult.scanResult.errors;
2968
3009
  aggregateWarnings += sandboxResult.scanResult.warnings;
2969
3010
  lastScanResult = sandboxResult.scanResult;
3011
+ const primaryBaseBranch = currentBranchName(blocked.repoRoot);
3012
+ const prunePayload = doctorModule.pruneStaleAgentWorktrees(blocked.repoRoot, {
3013
+ baseBranch: primaryBaseBranch,
3014
+ dryRun: perRepoOptions.dryRun,
3015
+ });
3016
+ printWorktreePruneSummary(prunePayload, { baseBranch: primaryBaseBranch });
2970
3017
  continue;
2971
3018
  }
2972
3019
 
@@ -2992,6 +3039,13 @@ function setup(rawArgs) {
2992
3039
  printAutoFinishSummary(autoFinishSummary, {
2993
3040
  baseBranch: currentBaseBranch,
2994
3041
  });
3042
+ const prunePayload = scanResult.guardexEnabled === false
3043
+ ? { enabled: false, ran: false, status: 'skipped', details: ['Guardex disabled for this repo.'] }
3044
+ : doctorModule.pruneStaleAgentWorktrees(scanResult.repoRoot, {
3045
+ baseBranch: currentBaseBranch,
3046
+ dryRun: perRepoOptions.dryRun,
3047
+ });
3048
+ printWorktreePruneSummary(prunePayload, { baseBranch: currentBaseBranch });
2995
3049
  printSetupRepoHints(scanResult.repoRoot, currentBaseBranch, repoLabel);
2996
3050
 
2997
3051
  aggregateErrors += scanResult.errors;
@@ -3380,6 +3434,64 @@ function branch(rawArgs) {
3380
3434
  );
3381
3435
  }
3382
3436
 
3437
+ // `gx pivot` — single-tool-call escape from a protected branch into an isolated
3438
+ // agent worktree. AI agents (Claude Code / Codex) cannot set the bypass env
3439
+ // vars from inside a tool call, so they need a whitelisted command that does
3440
+ // the whole hop: branch+worktree creation, dirty-tree migration, and a clean
3441
+ // trailer (`WORKTREE_PATH=...`, `BRANCH=...`, `NEXT_STEP=cd ...`) the agent can
3442
+ // parse to know exactly where to `cd`.
3443
+ //
3444
+ // On an existing agent/* branch, `gx pivot` short-circuits and just prints the
3445
+ // current worktree path — safe to call as a no-op.
3446
+ function pivot(rawArgs) {
3447
+ const { target, passthrough } = extractTargetedArgs(rawArgs);
3448
+ const repoRoot = resolveRepoRoot(target);
3449
+ const headProc = run('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: repoRoot });
3450
+ const currentBranch = String(headProc.stdout || '').trim();
3451
+ if (currentBranch.startsWith('agent/')) {
3452
+ const wtProc = run('git', ['rev-parse', '--show-toplevel'], { cwd: repoRoot });
3453
+ const wtPath = String(wtProc.stdout || '').trim() || repoRoot;
3454
+ process.stdout.write(`[${TOOL_NAME} pivot] Already on agent branch '${currentBranch}'.\n`);
3455
+ process.stdout.write(`WORKTREE_PATH=${wtPath}\n`);
3456
+ process.stdout.write(`BRANCH=${currentBranch}\n`);
3457
+ process.stdout.write(`NEXT_STEP=cd "${wtPath}"\n`);
3458
+ process.exitCode = 0;
3459
+ return;
3460
+ }
3461
+ const result = runPackageAsset('branchStart', passthrough, { cwd: repoRoot });
3462
+ if (result.stdout) process.stdout.write(result.stdout);
3463
+ if (result.stderr) process.stderr.write(result.stderr);
3464
+ if (result.status !== 0) {
3465
+ process.exitCode = result.status || 1;
3466
+ return;
3467
+ }
3468
+ const stdoutText = String(result.stdout || '');
3469
+ const wtMatch = stdoutText.match(/^\[agent-branch-start\] Worktree:\s+(.+)$/m);
3470
+ const branchMatch = stdoutText.match(/^\[agent-branch-start\] (?:Created branch|Reusing existing branch):\s+(.+)$/m);
3471
+ if (wtMatch) {
3472
+ const wtPath = wtMatch[1].trim();
3473
+ process.stdout.write('\n');
3474
+ process.stdout.write(`WORKTREE_PATH=${wtPath}\n`);
3475
+ if (branchMatch) process.stdout.write(`BRANCH=${branchMatch[1].trim()}\n`);
3476
+ process.stdout.write(`NEXT_STEP=cd "${wtPath}"\n`);
3477
+ }
3478
+ process.exitCode = 0;
3479
+ }
3480
+
3481
+ // `gx ship` — alias for the canonical "I am done" command. Defaults to
3482
+ // `finish --via-pr --wait-for-merge --cleanup` so AI agents don't strand
3483
+ // commits or worktrees by accident. Any explicit user-supplied flags survive.
3484
+ function ship(rawArgs) {
3485
+ const args = Array.isArray(rawArgs) ? rawArgs.slice() : [];
3486
+ const ensureFlag = (flag) => {
3487
+ if (!args.includes(flag)) args.push(flag);
3488
+ };
3489
+ ensureFlag('--via-pr');
3490
+ ensureFlag('--wait-for-merge');
3491
+ ensureFlag('--cleanup');
3492
+ return finish(args);
3493
+ }
3494
+
3383
3495
  function locks(rawArgs) {
3384
3496
  const { target, passthrough } = extractTargetedArgs(rawArgs);
3385
3497
  const result = runPackageAsset('lockTool', passthrough, { cwd: resolveRepoRoot(target) });
@@ -3637,6 +3749,8 @@ async function main() {
3637
3749
  if (command === 'prompt') return prompt(rest);
3638
3750
  if (command === 'doctor') return doctor(rest);
3639
3751
  if (command === 'branch') return branch(rest);
3752
+ if (command === 'pivot') return pivot(rest);
3753
+ if (command === 'ship') return ship(rest);
3640
3754
  if (command === 'locks') return locks(rest);
3641
3755
  if (command === 'worktree') return worktree(rest);
3642
3756
  if (command === 'hook') return hook(rest);
package/src/context.js CHANGED
@@ -338,6 +338,8 @@ const SUGGESTIBLE_COMMANDS = [
338
338
  'setup',
339
339
  'doctor',
340
340
  'branch',
341
+ 'pivot',
342
+ 'ship',
341
343
  'locks',
342
344
  'worktree',
343
345
  'hook',
@@ -383,7 +385,9 @@ const CLI_COMMAND_GROUPS = [
383
385
  label: 'Branch workflow',
384
386
  description: 'The sandbox → commit → PR → merge loop for agent-owned branches.',
385
387
  commands: [
388
+ ['pivot', 'Auto-pivot from a protected branch into a fresh agent worktree (single tool call for AI agents)'],
386
389
  ['branch', 'CLI-owned branch workflow surface (start/finish/merge)'],
390
+ ['ship', 'Stage + commit + push + PR + auto-merge + cleanup (alias for `finish --via-pr --wait-for-merge --cleanup`)'],
387
391
  ['finish', 'Commit + PR + merge completed agent branches (--all, --branch)'],
388
392
  ['merge', 'Create/reuse an integration lane and merge overlapping agent branches'],
389
393
  ['sync', 'Sync agent branches with origin/<base>'],
@@ -1006,6 +1006,70 @@ function autoFinishReadyAgentBranches(repoRoot, options = {}) {
1006
1006
  return summary;
1007
1007
  }
1008
1008
 
1009
+ function pruneStaleAgentWorktrees(repoRoot, options = {}) {
1010
+ const summary = {
1011
+ enabled: true,
1012
+ ran: false,
1013
+ status: 'skipped',
1014
+ details: [],
1015
+ };
1016
+
1017
+ const dryRun = Boolean(options.dryRun);
1018
+ const baseBranch = String(options.baseBranch || '').trim();
1019
+
1020
+ if (String(process.env.GUARDEX_DOCTOR_SANDBOX || '') === '1') {
1021
+ summary.enabled = false;
1022
+ summary.details.push('Skipped stale-worktree prune inside doctor sandbox pass.');
1023
+ return summary;
1024
+ }
1025
+
1026
+ if (String(process.env.GUARDEX_SKIP_AUTO_WORKTREE_PRUNE || '') === '1') {
1027
+ summary.enabled = false;
1028
+ summary.details.push('Skipped stale-worktree prune (GUARDEX_SKIP_AUTO_WORKTREE_PRUNE=1).');
1029
+ return summary;
1030
+ }
1031
+
1032
+ const idleMinutesRaw = Number(options.idleMinutes);
1033
+ const idleMinutes = Number.isFinite(idleMinutesRaw) && idleMinutesRaw >= 0
1034
+ ? Math.floor(idleMinutesRaw)
1035
+ : 60;
1036
+
1037
+ const args = [
1038
+ '--idle-minutes', String(idleMinutes),
1039
+ '--delete-branches',
1040
+ '--delete-remote-branches',
1041
+ '--include-pr-merged',
1042
+ '--force-dirty',
1043
+ ];
1044
+ if (baseBranch && baseBranch !== 'HEAD' && !baseBranch.startsWith('agent/')) {
1045
+ args.push('--base', baseBranch);
1046
+ }
1047
+ if (dryRun) {
1048
+ args.push('--dry-run');
1049
+ }
1050
+
1051
+ const runResult = runPackageAsset('worktreePrune', args, { cwd: repoRoot });
1052
+ summary.ran = true;
1053
+ const stdout = String(runResult.stdout || '').trim();
1054
+ const stderr = String(runResult.stderr || '').trim();
1055
+ if (stdout) {
1056
+ for (const line of stdout.split('\n')) {
1057
+ if (line.trim()) summary.details.push(line);
1058
+ }
1059
+ }
1060
+ if (runResult.status === 0) {
1061
+ summary.status = dryRun ? 'dry-run' : 'pruned';
1062
+ } else {
1063
+ summary.status = 'failed';
1064
+ if (stderr) {
1065
+ summary.details.push(`[error] ${stderr.split('\n').slice(-2).join(' | ')}`);
1066
+ } else {
1067
+ summary.details.push(`[error] worktreePrune exited with status ${runResult.status}`);
1068
+ }
1069
+ }
1070
+ return summary;
1071
+ }
1072
+
1009
1073
  function executeDoctorSandboxLifecycle(options, blocked, metadata, integrations) {
1010
1074
  const execution = createDoctorSandboxExecutionState();
1011
1075
  const dryRun = Boolean(options.dryRun);
@@ -1206,7 +1270,7 @@ function runDoctorInSandbox(options, blocked, rawIntegrations = {}) {
1206
1270
  const nestedResult = run(
1207
1271
  process.execPath,
1208
1272
  [require.main?.filename || process.argv[1], ...buildSandboxDoctorArgs(options, sandboxTarget)],
1209
- { cwd: metadata.worktreePath },
1273
+ { cwd: metadata.worktreePath, env: { GUARDEX_DOCTOR_SANDBOX: '1' } },
1210
1274
  );
1211
1275
  if (isSpawnFailure(nestedResult)) {
1212
1276
  throw nestedResult.error;
@@ -1242,5 +1306,6 @@ module.exports = {
1242
1306
  emitDoctorSandboxJsonOutput,
1243
1307
  emitDoctorSandboxConsoleOutput,
1244
1308
  autoFinishReadyAgentBranches,
1309
+ pruneStaleAgentWorktrees,
1245
1310
  runDoctorInSandbox,
1246
1311
  };
@@ -65,8 +65,9 @@ function assertProtectedMainWriteAllowed(options, commandName) {
65
65
  }
66
66
 
67
67
  function extractAgentBranchStartMetadata(output) {
68
- const branchMatch = String(output || '').match(/^\[agent-branch-start\] Created branch: (.+)$/m);
69
- const worktreeMatch = String(output || '').match(/^\[agent-branch-start\] Worktree: (.+)$/m);
68
+ const outputText = String(output || '');
69
+ const branchMatch = outputText.match(/^\[agent-branch-start\] (?:Created branch|Reusing existing branch): (.+)$/m);
70
+ const worktreeMatch = outputText.match(/^\[agent-branch-start\] Worktree: (.+)$/m);
70
71
  return {
71
72
  branch: branchMatch ? branchMatch[1].trim() : '',
72
73
  worktreePath: worktreeMatch ? worktreeMatch[1].trim() : '',
@@ -508,7 +508,7 @@ is_local_branch_delete_error() {
508
508
 
509
509
  is_remote_branch_missing_error() {
510
510
  local output="$1"
511
- if [[ "$output" == *"remote ref does not exist"* ]] || [[ "$output" == *"failed to push some refs"* ]]; then
511
+ if [[ "$output" == *"remote ref does not exist"* ]]; then
512
512
  return 0
513
513
  fi
514
514
  return 1
@@ -893,8 +893,8 @@ if [[ "$CLEANUP_AFTER_MERGE" -eq 1 ]]; then
893
893
  if is_remote_branch_missing_error "$remote_delete_output"; then
894
894
  echo "[agent-branch-finish] Remote branch '${SOURCE_BRANCH}' was already deleted; continuing cleanup." >&2
895
895
  else
896
+ echo "[agent-branch-finish] Warning: remote branch cleanup failed for '${SOURCE_BRANCH}' after merge; continuing local cleanup." >&2
896
897
  echo "$remote_delete_output" >&2
897
- exit 1
898
898
  fi
899
899
  fi
900
900
  fi
@@ -288,7 +288,7 @@ if [[ -z "$TARGET_BRANCH" ]]; then
288
288
  fi
289
289
 
290
290
  printf '%s\n' "$start_output"
291
- TARGET_BRANCH="$(printf '%s\n' "$start_output" | sed -n 's/^\[agent-branch-start\] Created branch: //p' | head -n 1)"
291
+ TARGET_BRANCH="$(printf '%s\n' "$start_output" | sed -n -E 's/^\[agent-branch-start\] (Created branch|Reusing existing branch): //p' | head -n 1)"
292
292
  target_worktree="$(printf '%s\n' "$start_output" | sed -n 's/^\[agent-branch-start\] Worktree: //p' | head -n 1)"
293
293
  if [[ -z "$TARGET_BRANCH" || -z "$target_worktree" ]]; then
294
294
  echo "[agent-branch-merge] Unable to parse target branch/worktree from agent-branch-start output." >&2
@@ -15,6 +15,7 @@ OPENSPEC_CHANGE_SLUG_OVERRIDE="${GUARDEX_OPENSPEC_CHANGE_SLUG:-}"
15
15
  OPENSPEC_CAPABILITY_SLUG_OVERRIDE="${GUARDEX_OPENSPEC_CAPABILITY_SLUG:-}"
16
16
  OPENSPEC_MASTERPLAN_LABEL_RAW="${GUARDEX_OPENSPEC_MASTERPLAN_LABEL-masterplan}"
17
17
  OPENSPEC_TIER_RAW="${GUARDEX_OPENSPEC_TIER:-T3}"
18
+ REUSE_EXISTING_RAW="${GUARDEX_BRANCH_START_REUSE_EXISTING:-true}"
18
19
  PRINT_NAME_ONLY=0
19
20
  POSITIONAL_ARGS=()
20
21
 
@@ -58,6 +59,14 @@ while [[ $# -gt 0 ]]; do
58
59
  OPENSPEC_TIER_RAW="${2:-$OPENSPEC_TIER_RAW}"
59
60
  shift 2
60
61
  ;;
62
+ --reuse-existing|--reuse)
63
+ REUSE_EXISTING_RAW="true"
64
+ shift
65
+ ;;
66
+ --new|--no-reuse|--no-reuse-existing)
67
+ REUSE_EXISTING_RAW="false"
68
+ shift
69
+ ;;
61
70
  --in-place|--allow-in-place)
62
71
  echo "[agent-branch-start] In-place branch mode is disabled." >&2
63
72
  echo "[agent-branch-start] This command always creates an isolated worktree to keep your active checkout unchanged." >&2
@@ -78,7 +87,7 @@ while [[ $# -gt 0 ]]; do
78
87
  ;;
79
88
  -*)
80
89
  echo "[agent-branch-start] Unknown option: $1" >&2
81
- echo "Usage: $0 [task] [agent] [base] [--worktree-root <path>] [--print-name-only]" >&2
90
+ echo "Usage: $0 [task] [agent] [base] [--worktree-root <path>] [--reuse-existing|--new] [--print-name-only]" >&2
82
91
  exit 1
83
92
  ;;
84
93
  *)
@@ -90,7 +99,7 @@ done
90
99
 
91
100
  if [[ "${#POSITIONAL_ARGS[@]}" -gt 3 ]]; then
92
101
  echo "[agent-branch-start] Too many positional arguments." >&2
93
- echo "Usage: $0 [task] [agent] [base] [--worktree-root <path>]" >&2
102
+ echo "Usage: $0 [task] [agent] [base] [--worktree-root <path>] [--reuse-existing|--new]" >&2
94
103
  exit 1
95
104
  fi
96
105
 
@@ -254,6 +263,7 @@ normalize_bool() {
254
263
  }
255
264
 
256
265
  OPENSPEC_AUTO_INIT="$(normalize_bool "$OPENSPEC_AUTO_INIT_RAW" "1")"
266
+ REUSE_EXISTING_WORKTREE="$(normalize_bool "$REUSE_EXISTING_RAW" "1")"
257
267
 
258
268
  normalize_tier() {
259
269
  local raw="${1:-}"
@@ -370,6 +380,22 @@ resolve_worktree_leaf() {
370
380
  printf '%s' "${branch_name//\//__}"
371
381
  }
372
382
 
383
+ print_reused_agent_worktree() {
384
+ local branch_name="$1"
385
+ local worktree_path="$2"
386
+
387
+ echo "[agent-branch-start] Reusing existing branch: ${branch_name}"
388
+ echo "[agent-branch-start] Worktree: ${worktree_path}"
389
+ echo "[agent-branch-start] OpenSpec tier: ${OPENSPEC_TIER}"
390
+ echo "[agent-branch-start] OpenSpec change: existing worktree"
391
+ echo "[agent-branch-start] OpenSpec plan: existing worktree"
392
+ echo "[agent-branch-start] Next steps:"
393
+ echo " cd \"${worktree_path}\""
394
+ echo " gx locks claim --branch \"${branch_name}\" <file...>"
395
+ echo " # continue work in this existing sandbox"
396
+ echo " gx branch finish --branch \"${branch_name}\" --via-pr --wait-for-merge"
397
+ }
398
+
373
399
  has_local_changes() {
374
400
  local root="$1"
375
401
  if ! git -C "$root" diff --quiet; then
@@ -550,6 +576,12 @@ if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then
550
576
  exit 1
551
577
  fi
552
578
 
579
+ current_branch="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
580
+ if [[ "$REUSE_EXISTING_WORKTREE" -eq 1 && "$current_branch" == agent/* ]]; then
581
+ print_reused_agent_worktree "$current_branch" "$repo_root"
582
+ exit 0
583
+ fi
584
+
553
585
  task_slug="$(sanitize_slug "$TASK_NAME" "task")"
554
586
  agent_slug="$(normalize_role "$AGENT_NAME")"
555
587
  if [[ "$WORKTREE_ROOT_EXPLICIT" -eq 0 ]]; then
@@ -681,6 +713,7 @@ if [[ -n "$auto_transfer_stash_ref" ]]; then
681
713
  fi
682
714
  fi
683
715
 
716
+ hydrate_dependency_dir_symlink_in_worktree "$repo_root" "$worktree_path" ".venv"
684
717
  hydrate_dependency_dir_symlink_in_worktree "$repo_root" "$worktree_path" "node_modules"
685
718
  hydrate_dependency_dir_symlink_in_worktree "$repo_root" "$worktree_path" "apps/frontend/node_modules"
686
719
  hydrate_dependency_dir_symlink_in_worktree "$repo_root" "$worktree_path" "apps/backend/node_modules"
@@ -136,7 +136,7 @@ const MANAGED_REPO_SCAN_IGNORED_FOLDERS = [
136
136
  const SESSION_ACTIVITY_GROUPS = [
137
137
  { kind: 'blocked', label: 'BLOCKED' },
138
138
  { kind: 'working', label: 'WORKING NOW' },
139
- { kind: 'finished', label: 'FINISHED' },
139
+ { kind: 'finished', label: 'NEEDS CLEANUP' },
140
140
  { kind: 'idle', label: 'THINKING' },
141
141
  { kind: 'stalled', label: 'STALLED' },
142
142
  { kind: 'dead', label: 'DEAD' },
@@ -571,7 +571,7 @@ function buildActiveAgentsStatusSummary(summary) {
571
571
  if (workingCount > 0 || finishedCount > 0 || idleCount > 0) {
572
572
  const parts = [`${workingCount} working`];
573
573
  if (finishedCount > 0) {
574
- parts.push(`${finishedCount} finished`);
574
+ parts.push(`${finishedCount} needs cleanup`);
575
575
  }
576
576
  parts.push(`${idleCount} idle`);
577
577
  return `$(git-branch) ${parts.join(' · ')}`;
@@ -594,7 +594,7 @@ function buildActiveAgentsStatusTooltip(selectedSession, summary) {
594
594
  return [
595
595
  formatCountLabel(activeCount, 'active agent'),
596
596
  formatCountLabel(summary?.workingCount || 0, 'working now session', 'working now sessions'),
597
- formatCountLabel(summary?.finishedCount || 0, 'finished session'),
597
+ formatCountLabel(summary?.finishedCount || 0, 'needs cleanup session'),
598
598
  formatCountLabel(summary?.idleCount || 0, 'idle session'),
599
599
  formatCountLabel(summary?.unassignedChangeCount || 0, 'unassigned change'),
600
600
  formatCountLabel(summary?.lockedFileCount || 0, 'locked file'),
@@ -681,7 +681,7 @@ function sessionFreshnessLabel(session, now = Date.now()) {
681
681
  return 'Needs attention';
682
682
  }
683
683
  if (session.activityKind === 'finished') {
684
- return 'Finished';
684
+ return 'Needs cleanup';
685
685
  }
686
686
  if (session.activityKind === 'stalled') {
687
687
  return 'Possibly stale';
@@ -711,7 +711,7 @@ function sessionStatusLabel(session) {
711
711
  case 'working':
712
712
  return 'Working';
713
713
  case 'finished':
714
- return 'Finished';
714
+ return 'Needs cleanup';
715
715
  case 'idle':
716
716
  return 'Idle';
717
717
  case 'stalled':
@@ -918,7 +918,7 @@ function buildWorktreeBranchDescription(sessions) {
918
918
  function buildOverviewDescription(summary) {
919
919
  return [
920
920
  formatCountLabel(summary?.workingCount || 0, 'working agent'),
921
- formatCountLabel(summary?.finishedCount || 0, 'finished agent'),
921
+ formatCountLabel(summary?.finishedCount || 0, 'needs cleanup agent'),
922
922
  formatCountLabel(summary?.idleCount || 0, 'idle agent'),
923
923
  summary?.colonyTaskCount
924
924
  ? formatCountLabel(summary.colonyTaskCount, 'colony task')
@@ -2395,6 +2395,74 @@ function isPathWithin(parentPath, targetPath) {
2395
2395
  return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath));
2396
2396
  }
2397
2397
 
2398
+ function normalizeAbsolutePath(value) {
2399
+ return typeof value === 'string' && value.trim() ? path.resolve(value) : '';
2400
+ }
2401
+
2402
+ function isManagedWorktreePath(worktreePath) {
2403
+ const normalizedWorktreePath = normalizeAbsolutePath(worktreePath);
2404
+ if (!normalizedWorktreePath) {
2405
+ return false;
2406
+ }
2407
+
2408
+ return MANAGED_WORKTREE_RELATIVE_ROOTS.some((relativeRoot) => {
2409
+ const normalizedRelativeRoot = path.normalize(relativeRoot);
2410
+ const marker = `${path.sep}${normalizedRelativeRoot}${path.sep}`;
2411
+ return normalizedWorktreePath.includes(marker);
2412
+ });
2413
+ }
2414
+
2415
+ function removeDeletedWorktreeWorkspaceFolder(worktreePath) {
2416
+ if (typeof vscode.workspace.updateWorkspaceFolders !== 'function') {
2417
+ return false;
2418
+ }
2419
+
2420
+ const normalizedWorktreePath = normalizeAbsolutePath(worktreePath);
2421
+ if (!normalizedWorktreePath) {
2422
+ return false;
2423
+ }
2424
+
2425
+ const workspaceFolders = vscode.workspace.workspaceFolders || [];
2426
+ const folderIndex = workspaceFolders.findIndex((folder) => (
2427
+ normalizeAbsolutePath(folder?.uri?.fsPath) === normalizedWorktreePath
2428
+ ));
2429
+ if (folderIndex < 0) {
2430
+ return false;
2431
+ }
2432
+
2433
+ try {
2434
+ return vscode.workspace.updateWorkspaceFolders(folderIndex, 1) === true;
2435
+ } catch (_error) {
2436
+ return false;
2437
+ }
2438
+ }
2439
+
2440
+ async function closeDeletedWorktreeRepository(worktreePath) {
2441
+ const normalizedWorktreePath = normalizeAbsolutePath(worktreePath);
2442
+ if (!normalizedWorktreePath || fs.existsSync(normalizedWorktreePath)) {
2443
+ return false;
2444
+ }
2445
+
2446
+ try {
2447
+ await vscode.commands.executeCommand('git.close', vscode.Uri.file(normalizedWorktreePath));
2448
+ } catch (_error) {
2449
+ // The Git extension may have already removed this repository.
2450
+ }
2451
+
2452
+ removeDeletedWorktreeWorkspaceFolder(normalizedWorktreePath);
2453
+ return true;
2454
+ }
2455
+
2456
+ function findDeletedManagedWorkspaceFolders() {
2457
+ return (vscode.workspace.workspaceFolders || [])
2458
+ .map((folder) => normalizeAbsolutePath(folder?.uri?.fsPath))
2459
+ .filter((workspacePath) => (
2460
+ workspacePath
2461
+ && !fs.existsSync(workspacePath)
2462
+ && isManagedWorktreePath(workspacePath)
2463
+ ));
2464
+ }
2465
+
2398
2466
  function localizeChangeForSession(session, change) {
2399
2467
  if (!change?.absolutePath || !isPathWithin(session.worktreePath, change.absolutePath)) {
2400
2468
  return null;
@@ -2925,7 +2993,9 @@ function buildWorkingNowNodes(sessions) {
2925
2993
  function buildIdleThinkingNodes(sessions) {
2926
2994
  const sessionEntries = sortSessionsForIdleThinking(
2927
2995
  sessions.filter((session) => !(
2928
- session.activityKind === 'working' || session.activityKind === 'blocked'
2996
+ session.activityKind === 'working'
2997
+ || session.activityKind === 'blocked'
2998
+ || session.activityKind === 'finished'
2929
2999
  )),
2930
3000
  ).map((session) => ({
2931
3001
  projectRelativePath: resolveSessionProjectRelativePath(session),
@@ -2935,6 +3005,17 @@ function buildIdleThinkingNodes(sessions) {
2935
3005
  return buildProjectScopedItems(sessionEntries, { rootLabel: 'Repo root' });
2936
3006
  }
2937
3007
 
3008
+ function buildNeedsCleanupNodes(sessions) {
3009
+ const sessionEntries = sessions
3010
+ .filter((session) => session.activityKind === 'finished')
3011
+ .map((session) => ({
3012
+ projectRelativePath: resolveSessionProjectRelativePath(session),
3013
+ sessions: [session],
3014
+ item: new SessionItem(session, buildSessionDetailItems(session)),
3015
+ }));
3016
+ return buildProjectScopedItems(sessionEntries, { rootLabel: 'Repo root' });
3017
+ }
3018
+
2938
3019
  function buildUnassignedChangeNodes(changes) {
2939
3020
  return sortUnassignedChanges(changes).map((change) => new ChangeItem(change, {
2940
3021
  label: compactRelativePath(change.relativePath),
@@ -3205,6 +3286,15 @@ class ActiveAgentsProvider {
3205
3286
  }));
3206
3287
  }
3207
3288
 
3289
+ const needsCleanupItems = buildNeedsCleanupNodes(element.sessions);
3290
+ if (needsCleanupItems.length > 0) {
3291
+ sectionItems.push(new SectionItem('Needs cleanup', needsCleanupItems, {
3292
+ description: String(needsCleanupItems.length),
3293
+ collapsedState: vscode.TreeItemCollapsibleState.Collapsed,
3294
+ iconId: 'pass-filled',
3295
+ }));
3296
+ }
3297
+
3208
3298
  const idleThinkingItems = buildIdleThinkingNodes(element.sessions);
3209
3299
  if (idleThinkingItems.length > 0) {
3210
3300
  sectionItems.push(new SectionItem('Idle / thinking', idleThinkingItems, {
@@ -3412,6 +3502,8 @@ class ActiveAgentsRefreshController {
3412
3502
  this.inspectPanelManager = inspectPanelManager;
3413
3503
  this.refreshTimer = null;
3414
3504
  this.sessionWatchers = new Map();
3505
+ this.closedMissingWorktreeRepositories = new Set();
3506
+ this.observedWorktreePaths = new Set();
3415
3507
  }
3416
3508
 
3417
3509
  scheduleRefresh() {
@@ -3434,8 +3526,23 @@ class ActiveAgentsRefreshController {
3434
3526
  const repoEntries = await findRepoSessionEntries();
3435
3527
  const liveSessionKeys = new Set();
3436
3528
 
3529
+ for (const workspacePath of findDeletedManagedWorkspaceFolders()) {
3530
+ await this.closeMissingWorktreeRepository(workspacePath);
3531
+ }
3532
+
3437
3533
  for (const entry of repoEntries) {
3438
3534
  for (const session of entry.sessions) {
3535
+ const worktreePath = sessionWorktreePath(session);
3536
+ const normalizedWorktreePath = normalizeAbsolutePath(worktreePath);
3537
+ if (normalizedWorktreePath && !fs.existsSync(normalizedWorktreePath)) {
3538
+ await this.closeMissingWorktreeRepository(normalizedWorktreePath);
3539
+ continue;
3540
+ }
3541
+ if (normalizedWorktreePath) {
3542
+ this.closedMissingWorktreeRepositories.delete(normalizedWorktreePath);
3543
+ this.observedWorktreePaths.add(normalizedWorktreePath);
3544
+ }
3545
+
3439
3546
  const sessionKey = resolveSessionWatcherKey(session);
3440
3547
  liveSessionKeys.add(sessionKey);
3441
3548
  if (this.sessionWatchers.has(sessionKey)) {
@@ -3446,21 +3553,46 @@ class ActiveAgentsRefreshController {
3446
3553
  resolveSessionGitIndexPath(session.worktreePath),
3447
3554
  );
3448
3555
  const disposables = bindRefreshWatcher(watcher, () => this.scheduleRefresh());
3449
- this.sessionWatchers.set(sessionKey, { watcher, disposables });
3556
+ this.sessionWatchers.set(sessionKey, {
3557
+ watcher,
3558
+ disposables,
3559
+ worktreePath: normalizedWorktreePath,
3560
+ });
3450
3561
  }
3451
3562
  }
3452
3563
 
3564
+ for (const observedWorktreePath of this.observedWorktreePaths) {
3565
+ if (fs.existsSync(observedWorktreePath)) {
3566
+ this.closedMissingWorktreeRepositories.delete(observedWorktreePath);
3567
+ continue;
3568
+ }
3569
+ await this.closeMissingWorktreeRepository(observedWorktreePath);
3570
+ }
3571
+
3453
3572
  for (const [sessionKey, entry] of this.sessionWatchers) {
3454
3573
  if (liveSessionKeys.has(sessionKey)) {
3455
3574
  continue;
3456
3575
  }
3457
3576
 
3577
+ if (entry.worktreePath && !fs.existsSync(entry.worktreePath)) {
3578
+ await this.closeMissingWorktreeRepository(entry.worktreePath);
3579
+ }
3458
3580
  disposeAll(entry.disposables);
3459
3581
  entry.watcher.dispose();
3460
3582
  this.sessionWatchers.delete(sessionKey);
3461
3583
  }
3462
3584
  }
3463
3585
 
3586
+ async closeMissingWorktreeRepository(worktreePath) {
3587
+ const normalizedWorktreePath = normalizeAbsolutePath(worktreePath);
3588
+ if (!normalizedWorktreePath || this.closedMissingWorktreeRepositories.has(normalizedWorktreePath)) {
3589
+ return;
3590
+ }
3591
+
3592
+ this.closedMissingWorktreeRepositories.add(normalizedWorktreePath);
3593
+ await closeDeletedWorktreeRepository(normalizedWorktreePath);
3594
+ }
3595
+
3464
3596
  dispose() {
3465
3597
  if (this.refreshTimer) {
3466
3598
  clearTimeout(this.refreshTimer);
@@ -3587,7 +3719,7 @@ function activate(context) {
3587
3719
  vscode.commands.registerCommand('gitguardex.activeAgents.refresh', refresh),
3588
3720
  vscode.commands.registerCommand('gitguardex.activeAgents.restart', restartActiveAgents),
3589
3721
  vscode.commands.registerCommand('gitguardex.activeAgents.focus', async () => {
3590
- await vscode.commands.executeCommand('workbench.view.extension.gitguardex.activeAgentsContainer');
3722
+ await vscode.commands.executeCommand('workbench.view.extension.gitguardex-active-agents-container');
3591
3723
  }),
3592
3724
  vscode.commands.registerCommand('gitguardex.activeAgents.commitSelectedSession', commitSelectedSession),
3593
3725
  vscode.commands.registerCommand('gitguardex.activeAgents.openWorktree', async (session) => {
@@ -3,7 +3,7 @@
3
3
  "displayName": "GitGuardex Active Agents",
4
4
  "description": "Shows live Guardex sandbox sessions and repo changes in a dedicated VS Code Active Agents sidebar.",
5
5
  "publisher": "Recodee",
6
- "version": "0.0.19",
6
+ "version": "0.0.21",
7
7
  "license": "MIT",
8
8
  "icon": "icon.png",
9
9
  "engines": {
@@ -79,14 +79,14 @@
79
79
  "viewsContainers": {
80
80
  "activitybar": [
81
81
  {
82
- "id": "gitguardex.activeAgentsContainer",
82
+ "id": "gitguardex-active-agents-container",
83
83
  "title": "Active Agents",
84
84
  "icon": "media/active-agents-hivemind.svg"
85
85
  }
86
86
  ]
87
87
  },
88
88
  "views": {
89
- "gitguardex.activeAgentsContainer": [
89
+ "gitguardex-active-agents-container": [
90
90
  {
91
91
  "id": "gitguardex.activeAgents",
92
92
  "name": "Active Agents",