@imdeadpool/guardex 7.0.36 → 7.0.38

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,20 @@ 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.38
270
+ - Bumped `@imdeadpool/guardex` from `7.0.37` to `7.0.38` so the current
271
+ `main` payload can publish under a fresh npm version after `7.0.37` reached
272
+ the registry.
273
+ - No new CLI command behavior is introduced in this release lane.
274
+
275
+ ### v7.0.37
276
+ - Bumped `@imdeadpool/guardex` from `7.0.36` to `7.0.37` so the current
277
+ package can publish under a fresh npm version after `7.0.36` reached the
278
+ registry.
279
+ - Synced the shipped Active Agents template with the canonical VS Code
280
+ extension source so Colony task counts and details install with the package.
281
+ - No new CLI command behavior is introduced in this release lane.
282
+
269
283
  ### v7.0.36
270
284
  - Bumped `@imdeadpool/guardex` from `7.0.35` to `7.0.36` so the latest
271
285
  branch-finish cwd-prune fix can ship under a fresh npm version after PR #424.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imdeadpool/guardex",
3
- "version": "7.0.36",
3
+ "version": "7.0.38",
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
@@ -656,7 +656,7 @@ function runSetupInSandbox(options, blocked, repoLabel = '') {
656
656
  const nestedResult = run(
657
657
  process.execPath,
658
658
  [__filename, ...buildSandboxSetupArgs(options, sandboxTarget)],
659
- { cwd: metadata.worktreePath },
659
+ { cwd: metadata.worktreePath, env: { GUARDEX_DOCTOR_SANDBOX: '1' } },
660
660
  );
661
661
  if (isSpawnFailure(nestedResult)) {
662
662
  throw nestedResult.error;
@@ -701,6 +701,12 @@ function runSetupInSandbox(options, blocked, repoLabel = '') {
701
701
  console.log(`[${TOOL_NAME}] ${autoFinishSummary.details[0]}`);
702
702
  }
703
703
 
704
+ const prunePayload = doctorModule.pruneStaleAgentWorktrees(scanResult.repoRoot, {
705
+ baseBranch: currentBaseBranch,
706
+ dryRun: syncOptions.dryRun,
707
+ });
708
+ printWorktreePruneSummary(prunePayload, { baseBranch: currentBaseBranch });
709
+
704
710
  const cleanupResult = cleanupProtectedBaseSandbox(blocked.repoRoot, metadata);
705
711
  console.log(
706
712
  `[${TOOL_NAME}] Protected-base setup sandbox cleanup: ${cleanupResult.note} ` +
@@ -1755,6 +1761,26 @@ function runScanInternal(options) {
1755
1761
  };
1756
1762
  }
1757
1763
 
1764
+ function printWorktreePruneSummary(payload, options = {}) {
1765
+ if (!payload || payload.enabled === false) {
1766
+ if (payload && payload.details && payload.details[0]) {
1767
+ console.log(`[${TOOL_NAME}] ${payload.details[0]}`);
1768
+ }
1769
+ return;
1770
+ }
1771
+ if (!payload.ran) {
1772
+ return;
1773
+ }
1774
+ const baseLabel = options.baseBranch ? ` (base=${options.baseBranch})` : '';
1775
+ const tag = payload.status === 'failed' ? '⚠️' : (payload.status === 'dry-run' ? '🔍' : '🧹');
1776
+ console.log(
1777
+ `[${TOOL_NAME}] ${tag} Stale agent-worktree prune${baseLabel}: status=${payload.status}`,
1778
+ );
1779
+ for (const detail of payload.details || []) {
1780
+ console.log(`[${TOOL_NAME}] ${detail}`);
1781
+ }
1782
+ }
1783
+
1758
1784
  function printScanResult(scan, json = false) {
1759
1785
  if (json) {
1760
1786
  process.stdout.write(
@@ -2369,6 +2395,12 @@ function doctor(rawArgs) {
2369
2395
  configureHooks,
2370
2396
  autoFinishReadyAgentBranches: doctorModule.autoFinishReadyAgentBranches,
2371
2397
  });
2398
+ const primaryBaseBranch = currentBranchName(blocked.repoRoot);
2399
+ const prunePayload = doctorModule.pruneStaleAgentWorktrees(blocked.repoRoot, {
2400
+ baseBranch: primaryBaseBranch,
2401
+ dryRun: singleRepoOptions.dryRun,
2402
+ });
2403
+ printWorktreePruneSummary(prunePayload, { baseBranch: primaryBaseBranch });
2372
2404
  return;
2373
2405
  }
2374
2406
 
@@ -2390,6 +2422,12 @@ function doctor(rawArgs) {
2390
2422
  dryRun: singleRepoOptions.dryRun,
2391
2423
  waitForMerge: singleRepoOptions.waitForMerge,
2392
2424
  });
2425
+ const prunePayload = scanResult.guardexEnabled === false
2426
+ ? { enabled: false, ran: false, status: 'skipped', details: ['Guardex disabled for this repo.'] }
2427
+ : doctorModule.pruneStaleAgentWorktrees(scanResult.repoRoot, {
2428
+ baseBranch: currentBaseBranch,
2429
+ dryRun: singleRepoOptions.dryRun,
2430
+ });
2393
2431
  const safe = scanResult.guardexEnabled === false || (scanResult.errors === 0 && scanResult.warnings === 0);
2394
2432
  const musafe = safe;
2395
2433
 
@@ -2414,6 +2452,7 @@ function doctor(rawArgs) {
2414
2452
  findings: scanResult.findings,
2415
2453
  },
2416
2454
  autoFinish: autoFinishSummary,
2455
+ worktreePrune: prunePayload,
2417
2456
  },
2418
2457
  null,
2419
2458
  2,
@@ -2434,6 +2473,7 @@ function doctor(rawArgs) {
2434
2473
  baseBranch: currentBaseBranch,
2435
2474
  verbose: singleRepoOptions.verboseAutoFinish,
2436
2475
  });
2476
+ printWorktreePruneSummary(prunePayload, { baseBranch: currentBaseBranch });
2437
2477
  if (safe) {
2438
2478
  console.log(colorizeDoctorOutput(`[${TOOL_NAME}] ✅ Repo is fully safe.`, 'safe'));
2439
2479
  } else {
@@ -2967,6 +3007,12 @@ function setup(rawArgs) {
2967
3007
  aggregateErrors += sandboxResult.scanResult.errors;
2968
3008
  aggregateWarnings += sandboxResult.scanResult.warnings;
2969
3009
  lastScanResult = sandboxResult.scanResult;
3010
+ const primaryBaseBranch = currentBranchName(blocked.repoRoot);
3011
+ const prunePayload = doctorModule.pruneStaleAgentWorktrees(blocked.repoRoot, {
3012
+ baseBranch: primaryBaseBranch,
3013
+ dryRun: perRepoOptions.dryRun,
3014
+ });
3015
+ printWorktreePruneSummary(prunePayload, { baseBranch: primaryBaseBranch });
2970
3016
  continue;
2971
3017
  }
2972
3018
 
@@ -2992,6 +3038,13 @@ function setup(rawArgs) {
2992
3038
  printAutoFinishSummary(autoFinishSummary, {
2993
3039
  baseBranch: currentBaseBranch,
2994
3040
  });
3041
+ const prunePayload = scanResult.guardexEnabled === false
3042
+ ? { enabled: false, ran: false, status: 'skipped', details: ['Guardex disabled for this repo.'] }
3043
+ : doctorModule.pruneStaleAgentWorktrees(scanResult.repoRoot, {
3044
+ baseBranch: currentBaseBranch,
3045
+ dryRun: perRepoOptions.dryRun,
3046
+ });
3047
+ printWorktreePruneSummary(prunePayload, { baseBranch: currentBaseBranch });
2995
3048
  printSetupRepoHints(scanResult.repoRoot, currentBaseBranch, repoLabel);
2996
3049
 
2997
3050
  aggregateErrors += scanResult.errors;
@@ -3380,6 +3433,64 @@ function branch(rawArgs) {
3380
3433
  );
3381
3434
  }
3382
3435
 
3436
+ // `gx pivot` — single-tool-call escape from a protected branch into an isolated
3437
+ // agent worktree. AI agents (Claude Code / Codex) cannot set the bypass env
3438
+ // vars from inside a tool call, so they need a whitelisted command that does
3439
+ // the whole hop: branch+worktree creation, dirty-tree migration, and a clean
3440
+ // trailer (`WORKTREE_PATH=...`, `BRANCH=...`, `NEXT_STEP=cd ...`) the agent can
3441
+ // parse to know exactly where to `cd`.
3442
+ //
3443
+ // On an existing agent/* branch, `gx pivot` short-circuits and just prints the
3444
+ // current worktree path — safe to call as a no-op.
3445
+ function pivot(rawArgs) {
3446
+ const { target, passthrough } = extractTargetedArgs(rawArgs);
3447
+ const repoRoot = resolveRepoRoot(target);
3448
+ const headProc = run('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: repoRoot });
3449
+ const currentBranch = String(headProc.stdout || '').trim();
3450
+ if (currentBranch.startsWith('agent/')) {
3451
+ const wtProc = run('git', ['rev-parse', '--show-toplevel'], { cwd: repoRoot });
3452
+ const wtPath = String(wtProc.stdout || '').trim() || repoRoot;
3453
+ process.stdout.write(`[${TOOL_NAME} pivot] Already on agent branch '${currentBranch}'.\n`);
3454
+ process.stdout.write(`WORKTREE_PATH=${wtPath}\n`);
3455
+ process.stdout.write(`BRANCH=${currentBranch}\n`);
3456
+ process.stdout.write(`NEXT_STEP=cd "${wtPath}"\n`);
3457
+ process.exitCode = 0;
3458
+ return;
3459
+ }
3460
+ const result = runPackageAsset('branchStart', passthrough, { cwd: repoRoot });
3461
+ if (result.stdout) process.stdout.write(result.stdout);
3462
+ if (result.stderr) process.stderr.write(result.stderr);
3463
+ if (result.status !== 0) {
3464
+ process.exitCode = result.status || 1;
3465
+ return;
3466
+ }
3467
+ const stdoutText = String(result.stdout || '');
3468
+ const wtMatch = stdoutText.match(/^\[agent-branch-start\] Worktree:\s+(.+)$/m);
3469
+ const branchMatch = stdoutText.match(/^\[agent-branch-start\] Created branch:\s+(.+)$/m);
3470
+ if (wtMatch) {
3471
+ const wtPath = wtMatch[1].trim();
3472
+ process.stdout.write('\n');
3473
+ process.stdout.write(`WORKTREE_PATH=${wtPath}\n`);
3474
+ if (branchMatch) process.stdout.write(`BRANCH=${branchMatch[1].trim()}\n`);
3475
+ process.stdout.write(`NEXT_STEP=cd "${wtPath}"\n`);
3476
+ }
3477
+ process.exitCode = 0;
3478
+ }
3479
+
3480
+ // `gx ship` — alias for the canonical "I am done" command. Defaults to
3481
+ // `finish --via-pr --wait-for-merge --cleanup` so AI agents don't strand
3482
+ // commits or worktrees by accident. Any explicit user-supplied flags survive.
3483
+ function ship(rawArgs) {
3484
+ const args = Array.isArray(rawArgs) ? rawArgs.slice() : [];
3485
+ const ensureFlag = (flag) => {
3486
+ if (!args.includes(flag)) args.push(flag);
3487
+ };
3488
+ ensureFlag('--via-pr');
3489
+ ensureFlag('--wait-for-merge');
3490
+ ensureFlag('--cleanup');
3491
+ return finish(args);
3492
+ }
3493
+
3383
3494
  function locks(rawArgs) {
3384
3495
  const { target, passthrough } = extractTargetedArgs(rawArgs);
3385
3496
  const result = runPackageAsset('lockTool', passthrough, { cwd: resolveRepoRoot(target) });
@@ -3637,6 +3748,8 @@ async function main() {
3637
3748
  if (command === 'prompt') return prompt(rest);
3638
3749
  if (command === 'doctor') return doctor(rest);
3639
3750
  if (command === 'branch') return branch(rest);
3751
+ if (command === 'pivot') return pivot(rest);
3752
+ if (command === 'ship') return ship(rest);
3640
3753
  if (command === 'locks') return locks(rest);
3641
3754
  if (command === 'worktree') return worktree(rest);
3642
3755
  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
  };
@@ -165,7 +165,11 @@ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
165
165
  fi
166
166
 
167
167
  repo_root="$(git rev-parse --show-toplevel)"
168
- current_worktree="$(pwd -P)"
168
+ # The physical cwd may be a subdirectory inside the source worktree. Cleanup
169
+ # decisions need the enclosing worktree root, otherwise finishing from `src/`
170
+ # can delete the caller's cwd and turn a successful merge into a false shell
171
+ # failure.
172
+ current_worktree="$repo_root"
169
173
  common_git_dir_raw="$(git -C "$repo_root" rev-parse --git-common-dir)"
170
174
  if [[ "$common_git_dir_raw" == /* ]]; then
171
175
  common_git_dir="$common_git_dir_raw"
@@ -223,7 +227,68 @@ if [[ "$SOURCE_BRANCH" == "$BASE_BRANCH" ]]; then
223
227
  exit 1
224
228
  fi
225
229
 
230
+ cleanup_missing_merged_source_branch() {
231
+ local state_line=""
232
+ local parsed_state=""
233
+ local parsed_merged_at=""
234
+ local parsed_url=""
235
+ local remote_delete_output=""
236
+ local prune_args=()
237
+
238
+ if [[ "$MERGE_MODE" != "pr" || "$CLEANUP_AFTER_MERGE" -ne 1 ]]; then
239
+ return 1
240
+ fi
241
+ if ! command -v "$GH_BIN" >/dev/null 2>&1; then
242
+ return 1
243
+ fi
244
+
245
+ state_line="$("$GH_BIN" pr list \
246
+ --state merged \
247
+ --head "$SOURCE_BRANCH" \
248
+ --base "$BASE_BRANCH" \
249
+ --json state,mergedAt,url \
250
+ --jq 'sort_by(.mergedAt // "") | reverse | (.[0] // {}) | [(.state // ""), (.mergedAt // ""), (.url // "")] | join("\u001f")' \
251
+ 2>/dev/null || true)"
252
+ if [[ -z "$state_line" ]]; then
253
+ return 1
254
+ fi
255
+
256
+ IFS=$'\x1f' read -r parsed_state parsed_merged_at parsed_url <<< "$state_line"
257
+ if [[ "$parsed_state" != "MERGED" && -z "$parsed_merged_at" ]]; then
258
+ return 1
259
+ fi
260
+
261
+ echo "[agent-branch-finish] Local source branch '${SOURCE_BRANCH}' is already absent, but a merged PR exists; continuing cleanup." >&2
262
+ if [[ -n "$parsed_url" ]]; then
263
+ echo "[agent-branch-finish] Merged PR: ${parsed_url}" >&2
264
+ fi
265
+
266
+ run_guardex_cli locks release --branch "$SOURCE_BRANCH" >/dev/null 2>&1 || true
267
+
268
+ if [[ "$PUSH_ENABLED" -eq 1 && "$DELETE_REMOTE_BRANCH" -eq 1 ]]; then
269
+ if git -C "$repo_root" ls-remote --exit-code --heads origin "$SOURCE_BRANCH" >/dev/null 2>&1; then
270
+ if ! remote_delete_output="$(git -C "$repo_root" push origin --delete "$SOURCE_BRANCH" 2>&1)"; then
271
+ echo "[agent-branch-finish] Warning: remote branch cleanup failed for '${SOURCE_BRANCH}'." >&2
272
+ [[ -n "$remote_delete_output" ]] && echo "$remote_delete_output" >&2
273
+ fi
274
+ fi
275
+ fi
276
+
277
+ prune_args=(worktree prune --base "$BASE_BRANCH" --only-dirty-worktrees --delete-branches)
278
+ if [[ "$DELETE_REMOTE_BRANCH" -eq 1 ]]; then
279
+ prune_args+=(--delete-remote-branches)
280
+ fi
281
+ if ! run_guardex_cli "${prune_args[@]}"; then
282
+ echo "[agent-branch-finish] Warning: automatic worktree prune failed." >&2
283
+ echo "[agent-branch-finish] You can run manual cleanup: gx cleanup --base ${BASE_BRANCH}" >&2
284
+ fi
285
+
286
+ echo "[agent-branch-finish] Merged '${SOURCE_BRANCH}' into '${BASE_BRANCH}' via pr flow and found source branch/worktree already cleaned."
287
+ exit 0
288
+ }
289
+
226
290
  if ! git -C "$repo_root" show-ref --verify --quiet "refs/heads/${SOURCE_BRANCH}"; then
291
+ cleanup_missing_merged_source_branch
227
292
  echo "[agent-branch-finish] Local source branch does not exist: ${SOURCE_BRANCH}" >&2
228
293
  exit 1
229
294
  fi
@@ -846,10 +911,12 @@ if [[ "$CLEANUP_AFTER_MERGE" -eq 1 ]]; then
846
911
  echo "[agent-branch-finish] You can run manual cleanup: gx cleanup --base ${BASE_BRANCH}" >&2
847
912
  fi
848
913
 
849
- echo "[agent-branch-finish] Merged '${SOURCE_BRANCH}' into '${BASE_BRANCH}' via ${merge_status} flow and cleaned source branch/worktree."
850
914
  if [[ "$source_worktree" == "$current_worktree" && "$source_worktree" == "${agent_worktree_root}"/* && -d "$source_worktree" ]]; then
915
+ echo "[agent-branch-finish] Merged '${SOURCE_BRANCH}' into '${BASE_BRANCH}' via ${merge_status} flow and cleaned source branch/remote."
851
916
  echo "[agent-branch-finish] Current worktree '${source_worktree}' still exists because it is the active shell cwd." >&2
852
917
  echo "[agent-branch-finish] Leave this directory, then run: gx cleanup --base ${BASE_BRANCH}" >&2
918
+ else
919
+ echo "[agent-branch-finish] Merged '${SOURCE_BRANCH}' into '${BASE_BRANCH}' via ${merge_status} flow and cleaned source branch/worktree."
853
920
  fi
854
921
  else
855
922
  pivot_to_repo_root_before_prune
@@ -1,6 +1,8 @@
1
1
  const fs = require('node:fs');
2
2
  const path = require('node:path');
3
3
  const cp = require('node:child_process');
4
+ const http = require('node:http');
5
+ const os = require('node:os');
4
6
  const vscode = require('vscode');
5
7
  const {
6
8
  clearWorktreeActivityCache,
@@ -40,6 +42,84 @@ const ACTIVE_AGENTS_EXTENSION_ID = 'Recodee.gitguardex-active-agents';
40
42
  const RESTART_EXTENSION_HOST_COMMAND = 'workbench.action.restartExtensionHost';
41
43
  const REFRESH_POLL_INTERVAL_MS = 30_000;
42
44
  const INSPECT_PANEL_VIEW_TYPE = 'gitguardex.activeAgents.inspect';
45
+ const COLONY_DEFAULT_PORT = 37777;
46
+ const COLONY_SNAPSHOT_TTL_MS = 5_000;
47
+ const COLONY_FETCH_TIMEOUT_MS = 800;
48
+
49
+ function colonyDataDir() {
50
+ return process.env.COLONY_HOME
51
+ || process.env.CAVEMEM_HOME
52
+ || path.join(os.homedir(), '.colony');
53
+ }
54
+
55
+ function readColonyPort() {
56
+ try {
57
+ const raw = fs.readFileSync(path.join(colonyDataDir(), 'settings.json'), 'utf8');
58
+ const parsed = JSON.parse(raw);
59
+ const port = Number(parsed?.workerPort);
60
+ return Number.isFinite(port) && port > 0 ? port : COLONY_DEFAULT_PORT;
61
+ } catch (_error) {
62
+ return COLONY_DEFAULT_PORT;
63
+ }
64
+ }
65
+
66
+ function fetchColonyJson(urlPath) {
67
+ return new Promise((resolve) => {
68
+ const req = http.get(
69
+ {
70
+ hostname: '127.0.0.1',
71
+ port: readColonyPort(),
72
+ path: urlPath,
73
+ timeout: COLONY_FETCH_TIMEOUT_MS,
74
+ },
75
+ (res) => {
76
+ if (res.statusCode !== 200) {
77
+ res.resume();
78
+ resolve(null);
79
+ return;
80
+ }
81
+ let body = '';
82
+ res.setEncoding('utf8');
83
+ res.on('data', (chunk) => {
84
+ body += chunk;
85
+ });
86
+ res.on('end', () => {
87
+ try {
88
+ resolve(JSON.parse(body));
89
+ } catch (_error) {
90
+ resolve(null);
91
+ }
92
+ });
93
+ },
94
+ );
95
+ req.on('error', () => resolve(null));
96
+ req.on('timeout', () => {
97
+ req.destroy();
98
+ resolve(null);
99
+ });
100
+ });
101
+ }
102
+
103
+ const colonyTasksCache = new Map();
104
+
105
+ async function readColonyTasksForRepo(repoRoot) {
106
+ const cached = colonyTasksCache.get(repoRoot);
107
+ if (cached && Date.now() - cached.at < COLONY_SNAPSHOT_TTL_MS) {
108
+ return cached.tasks;
109
+ }
110
+ const tasks = await fetchColonyJson(
111
+ `/api/colony/tasks?repo_root=${encodeURIComponent(repoRoot)}`,
112
+ );
113
+ const resolved = Array.isArray(tasks) ? tasks : [];
114
+ colonyTasksCache.set(repoRoot, { at: Date.now(), tasks: resolved });
115
+ return resolved;
116
+ }
117
+
118
+ function compactColonyBranchLabel(branch) {
119
+ if (typeof branch !== 'string' || !branch) return 'unknown';
120
+ const parts = branch.split('/').filter(Boolean);
121
+ return parts.length > 2 ? parts.slice(-2).join('/') : branch;
122
+ }
43
123
  const GIT_CONFIGURATION_SECTION = 'git';
44
124
  const REPO_SCAN_IGNORED_FOLDERS_SETTING = 'repositoryScanIgnoredFolders';
45
125
  const BUNDLED_FILE_ICONS_MANIFEST_RELATIVE = path.join('fileicons', 'gitguardex-fileicons.json');
@@ -56,7 +136,7 @@ const MANAGED_REPO_SCAN_IGNORED_FOLDERS = [
56
136
  const SESSION_ACTIVITY_GROUPS = [
57
137
  { kind: 'blocked', label: 'BLOCKED' },
58
138
  { kind: 'working', label: 'WORKING NOW' },
59
- { kind: 'finished', label: 'FINISHED' },
139
+ { kind: 'finished', label: 'NEEDS CLEANUP' },
60
140
  { kind: 'idle', label: 'THINKING' },
61
141
  { kind: 'stalled', label: 'STALLED' },
62
142
  { kind: 'dead', label: 'DEAD' },
@@ -491,7 +571,7 @@ function buildActiveAgentsStatusSummary(summary) {
491
571
  if (workingCount > 0 || finishedCount > 0 || idleCount > 0) {
492
572
  const parts = [`${workingCount} working`];
493
573
  if (finishedCount > 0) {
494
- parts.push(`${finishedCount} finished`);
574
+ parts.push(`${finishedCount} needs cleanup`);
495
575
  }
496
576
  parts.push(`${idleCount} idle`);
497
577
  return `$(git-branch) ${parts.join(' · ')}`;
@@ -514,7 +594,7 @@ function buildActiveAgentsStatusTooltip(selectedSession, summary) {
514
594
  return [
515
595
  formatCountLabel(activeCount, 'active agent'),
516
596
  formatCountLabel(summary?.workingCount || 0, 'working now session', 'working now sessions'),
517
- formatCountLabel(summary?.finishedCount || 0, 'finished session'),
597
+ formatCountLabel(summary?.finishedCount || 0, 'needs cleanup session'),
518
598
  formatCountLabel(summary?.idleCount || 0, 'idle session'),
519
599
  formatCountLabel(summary?.unassignedChangeCount || 0, 'unassigned change'),
520
600
  formatCountLabel(summary?.lockedFileCount || 0, 'locked file'),
@@ -601,7 +681,7 @@ function sessionFreshnessLabel(session, now = Date.now()) {
601
681
  return 'Needs attention';
602
682
  }
603
683
  if (session.activityKind === 'finished') {
604
- return 'Finished';
684
+ return 'Needs cleanup';
605
685
  }
606
686
  if (session.activityKind === 'stalled') {
607
687
  return 'Possibly stale';
@@ -631,7 +711,7 @@ function sessionStatusLabel(session) {
631
711
  case 'working':
632
712
  return 'Working';
633
713
  case 'finished':
634
- return 'Finished';
714
+ return 'Needs cleanup';
635
715
  case 'idle':
636
716
  return 'Idle';
637
717
  case 'stalled':
@@ -838,12 +918,20 @@ function buildWorktreeBranchDescription(sessions) {
838
918
  function buildOverviewDescription(summary) {
839
919
  return [
840
920
  formatCountLabel(summary?.workingCount || 0, 'working agent'),
841
- formatCountLabel(summary?.finishedCount || 0, 'finished agent'),
921
+ formatCountLabel(summary?.finishedCount || 0, 'needs cleanup agent'),
842
922
  formatCountLabel(summary?.idleCount || 0, 'idle agent'),
923
+ summary?.colonyTaskCount
924
+ ? formatCountLabel(summary.colonyTaskCount, 'colony task')
925
+ : '',
926
+ summary?.pendingHandoffCount
927
+ ? formatCountLabel(summary.pendingHandoffCount, 'pending handoff')
928
+ : '',
843
929
  formatCountLabel(summary?.unassignedChangeCount || 0, 'unassigned change'),
844
930
  formatCountLabel(summary?.lockedFileCount || 0, 'locked file'),
845
931
  formatCountLabel(summary?.conflictCount || 0, 'conflict'),
846
- ].join(' · ');
932
+ ]
933
+ .filter(Boolean)
934
+ .join(' · ');
847
935
  }
848
936
 
849
937
  function buildRepoDescription(summary) {
@@ -1252,7 +1340,9 @@ class RepoItem extends vscode.TreeItem {
1252
1340
  this.changes = changes;
1253
1341
  this.unassignedChanges = options.unassignedChanges || [];
1254
1342
  this.lockEntries = options.lockEntries || [];
1255
- this.overview = options.overview || buildRepoOverview(sessions, this.unassignedChanges, this.lockEntries);
1343
+ this.colonyTasks = Array.isArray(options.colonyTasks) ? options.colonyTasks : [];
1344
+ this.overview = options.overview
1345
+ || buildRepoOverview(sessions, this.unassignedChanges, this.lockEntries, this.colonyTasks);
1256
1346
  this.description = buildRepoDescription(this.overview);
1257
1347
  this.tooltip = buildRepoTooltip(repoRoot, this.overview);
1258
1348
  this.iconPath = themeIcon('repo');
@@ -2524,7 +2614,8 @@ function countChangedPaths(repoRoot, sessions, changes) {
2524
2614
  return changedKeys.size;
2525
2615
  }
2526
2616
 
2527
- function buildRepoOverview(sessions, unassignedChanges, lockEntries) {
2617
+ function buildRepoOverview(sessions, unassignedChanges, lockEntries, colonyTasks = []) {
2618
+ const colonyTaskList = Array.isArray(colonyTasks) ? colonyTasks : [];
2528
2619
  return {
2529
2620
  sessionCount: sessions.length,
2530
2621
  workingCount: countWorkingSessions(sessions),
@@ -2536,6 +2627,11 @@ function buildRepoOverview(sessions, unassignedChanges, lockEntries) {
2536
2627
  (total, session) => total + (session.conflictCount || 0),
2537
2628
  0,
2538
2629
  ) + (unassignedChanges || []).filter((change) => change.hasForeignLock).length,
2630
+ colonyTaskCount: colonyTaskList.length,
2631
+ pendingHandoffCount: colonyTaskList.reduce(
2632
+ (total, task) => total + (task.pending_handoff_count || 0),
2633
+ 0,
2634
+ ),
2539
2635
  };
2540
2636
  }
2541
2637
 
@@ -2829,7 +2925,9 @@ function buildWorkingNowNodes(sessions) {
2829
2925
  function buildIdleThinkingNodes(sessions) {
2830
2926
  const sessionEntries = sortSessionsForIdleThinking(
2831
2927
  sessions.filter((session) => !(
2832
- session.activityKind === 'working' || session.activityKind === 'blocked'
2928
+ session.activityKind === 'working'
2929
+ || session.activityKind === 'blocked'
2930
+ || session.activityKind === 'finished'
2833
2931
  )),
2834
2932
  ).map((session) => ({
2835
2933
  projectRelativePath: resolveSessionProjectRelativePath(session),
@@ -2839,6 +2937,17 @@ function buildIdleThinkingNodes(sessions) {
2839
2937
  return buildProjectScopedItems(sessionEntries, { rootLabel: 'Repo root' });
2840
2938
  }
2841
2939
 
2940
+ function buildNeedsCleanupNodes(sessions) {
2941
+ const sessionEntries = sessions
2942
+ .filter((session) => session.activityKind === 'finished')
2943
+ .map((session) => ({
2944
+ projectRelativePath: resolveSessionProjectRelativePath(session),
2945
+ sessions: [session],
2946
+ item: new SessionItem(session, buildSessionDetailItems(session)),
2947
+ }));
2948
+ return buildProjectScopedItems(sessionEntries, { rootLabel: 'Repo root' });
2949
+ }
2950
+
2842
2951
  function buildUnassignedChangeNodes(changes) {
2843
2952
  return sortUnassignedChanges(changes).map((change) => new ChangeItem(change, {
2844
2953
  label: compactRelativePath(change.relativePath),
@@ -3023,12 +3132,14 @@ class ActiveAgentsProvider {
3023
3132
 
3024
3133
  const { repoRootChanges } = partitionChangesByOwnership(sessions, changes);
3025
3134
  const unassignedChanges = sortUnassignedChanges(repoRootChanges);
3135
+ const colonyTasks = Array.isArray(entry.colonyTasks) ? entry.colonyTasks : [];
3026
3136
  return {
3027
3137
  ...entry,
3028
3138
  sessions,
3029
3139
  changes,
3030
3140
  unassignedChanges,
3031
- overview: buildRepoOverview(sessions, unassignedChanges, entry.lockEntries),
3141
+ colonyTasks,
3142
+ overview: buildRepoOverview(sessions, unassignedChanges, entry.lockEntries, colonyTasks),
3032
3143
  };
3033
3144
  });
3034
3145
 
@@ -3107,6 +3218,15 @@ class ActiveAgentsProvider {
3107
3218
  }));
3108
3219
  }
3109
3220
 
3221
+ const needsCleanupItems = buildNeedsCleanupNodes(element.sessions);
3222
+ if (needsCleanupItems.length > 0) {
3223
+ sectionItems.push(new SectionItem('Needs cleanup', needsCleanupItems, {
3224
+ description: String(needsCleanupItems.length),
3225
+ collapsedState: vscode.TreeItemCollapsibleState.Collapsed,
3226
+ iconId: 'pass-filled',
3227
+ }));
3228
+ }
3229
+
3110
3230
  const idleThinkingItems = buildIdleThinkingNodes(element.sessions);
3111
3231
  if (idleThinkingItems.length > 0) {
3112
3232
  sectionItems.push(new SectionItem('Idle / thinking', idleThinkingItems, {
@@ -3140,6 +3260,37 @@ class ActiveAgentsProvider {
3140
3260
  iconId: 'file-directory',
3141
3261
  }));
3142
3262
  }
3263
+ const colonyTaskList = Array.isArray(element.colonyTasks) ? element.colonyTasks : [];
3264
+ if (colonyTaskList.length > 0) {
3265
+ const colonyItems = colonyTaskList.map((task) => {
3266
+ const pendingLabel = task.pending_handoff_count > 0
3267
+ ? formatCountLabel(task.pending_handoff_count, 'pending handoff')
3268
+ : 'quiet';
3269
+ const participantLabel =
3270
+ (task.participants || []).map((p) => p.agent).filter(Boolean).join(', ')
3271
+ || 'no participants';
3272
+ return new DetailItem(
3273
+ `#${task.id} · ${compactColonyBranchLabel(task.branch)}`,
3274
+ `${participantLabel} · ${pendingLabel}`,
3275
+ {
3276
+ iconId: task.pending_handoff_count > 0 ? 'warning' : 'comment-discussion',
3277
+ tooltip: [
3278
+ task.branch,
3279
+ `task #${task.id}`,
3280
+ participantLabel,
3281
+ task.pending_handoff_count > 0
3282
+ ? formatCountLabel(task.pending_handoff_count, 'pending handoff')
3283
+ : '',
3284
+ ].filter(Boolean).join('\n'),
3285
+ },
3286
+ );
3287
+ });
3288
+ advancedItems.push(new SectionItem('Colony tasks', colonyItems, {
3289
+ description: String(colonyItems.length),
3290
+ collapsedState: vscode.TreeItemCollapsibleState.Collapsed,
3291
+ iconId: 'organization',
3292
+ }));
3293
+ }
3143
3294
  if (advancedItems.length > 0) {
3144
3295
  sectionItems.push(new SectionItem('Advanced details', advancedItems, {
3145
3296
  description: String(advancedItems.length),
@@ -3166,24 +3317,29 @@ class ActiveAgentsProvider {
3166
3317
  overview: entry.overview,
3167
3318
  unassignedChanges: entry.unassignedChanges,
3168
3319
  lockEntries: entry.lockEntries,
3320
+ colonyTasks: entry.colonyTasks,
3169
3321
  }));
3170
3322
  }
3171
3323
 
3172
3324
  async loadRepoEntries() {
3173
3325
  const repoEntries = await findRepoSessionEntries();
3174
- return repoEntries.map((entry) => {
3175
- const repoRoot = entry.repoRoot;
3176
- const lockRegistry = this.getLockRegistryForRepo(repoRoot);
3177
- const currentBranch = readCurrentBranch(repoRoot);
3178
- return {
3179
- repoRoot,
3180
- sessions: entry.sessions.map((session) => decorateSession(session, lockRegistry)),
3181
- changes: readRepoChanges(repoRoot).map((change) => (
3182
- decorateChange(change, lockRegistry, currentBranch)
3183
- )),
3184
- lockEntries: Array.from(lockRegistry.entriesByPath.entries()),
3185
- };
3186
- });
3326
+ return Promise.all(
3327
+ repoEntries.map(async (entry) => {
3328
+ const repoRoot = entry.repoRoot;
3329
+ const lockRegistry = this.getLockRegistryForRepo(repoRoot);
3330
+ const currentBranch = readCurrentBranch(repoRoot);
3331
+ const colonyTasks = await readColonyTasksForRepo(repoRoot);
3332
+ return {
3333
+ repoRoot,
3334
+ sessions: entry.sessions.map((session) => decorateSession(session, lockRegistry)),
3335
+ changes: readRepoChanges(repoRoot).map((change) => (
3336
+ decorateChange(change, lockRegistry, currentBranch)
3337
+ )),
3338
+ lockEntries: Array.from(lockRegistry.entriesByPath.entries()),
3339
+ colonyTasks,
3340
+ };
3341
+ }),
3342
+ );
3187
3343
  }
3188
3344
  }
3189
3345
 
@@ -3453,7 +3609,7 @@ function activate(context) {
3453
3609
  vscode.commands.registerCommand('gitguardex.activeAgents.refresh', refresh),
3454
3610
  vscode.commands.registerCommand('gitguardex.activeAgents.restart', restartActiveAgents),
3455
3611
  vscode.commands.registerCommand('gitguardex.activeAgents.focus', async () => {
3456
- await vscode.commands.executeCommand('workbench.view.extension.gitguardex.activeAgentsContainer');
3612
+ await vscode.commands.executeCommand('workbench.view.extension.gitguardex-active-agents-container');
3457
3613
  }),
3458
3614
  vscode.commands.registerCommand('gitguardex.activeAgents.commitSelectedSession', commitSelectedSession),
3459
3615
  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.20",
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",