Repository navigation
feat(watch): external capability loading + PID tracker (#918, #921) - #922
Conversation
github#918) Add loadExternalCapabilities() that scans .squad/capabilities/ for .js files, validates WatchCapability interface conformance, and registers them in the CapabilityRegistry after built-in capabilities. - external-loader.ts: loader with graceful error handling per file - Wire into runWatch() after createDefaultRegistry() - Export from watch barrel for downstream consumers - Sample capability template: issue-pipeline.sample.js - 6 vitest tests covering all edge cases - Changeset for minor bump
Add PidTracker class that tracks child process PIDs spawned during watch rounds, kills them on exit/crash, and cleans up stale orphans from previous crashed runs on startup. - New pid-tracker.ts with cross-platform process tree kill - Wire into watch/index.ts for startup cleanup and exit handlers - Wire into execute.ts to track/untrack copilot session PIDs - Add pidTracker to WatchContext interface - Export from barrel for external consumers - Add .watch-pids to .gitignore (runtime state) - 11 vitest tests covering track/untrack, cleanup, and stale handling Closes github#921
🟡 Impact Analysis — PR #922Risk tier: 🟡 MEDIUM 📊 Summary
🎯 Risk Factors
📦 Modules Affectedroot (4 files)
squad-cli (6 files)
tests (2 files)
|
There was a problem hiding this comment.
Pull request overview
Adds two watch-mode enhancements: loading user-defined watch capabilities from .squad/capabilities/*.js, and tracking/killing spawned Copilot session PIDs to reduce orphaned-process leaks across watch exits/restarts.
Changes:
- Add external capability loader that dynamically imports
.jsmodules from.squad/capabilities/and registers them into the capability registry. - Add
PidTrackerto record spawned child PIDs in.squad/.watch-pids, clean up stale PIDs on startup, and register exit/crash handlers to clean up on shutdown. - Wire PID tracking into the
executecapability so Copilot child processes are tracked/untracked.
Reviewed changes
Copilot reviewed 10 out of 11 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
packages/squad-cli/src/cli/commands/watch/external-loader.ts |
Implements dynamic discovery/import/registration of external capabilities. |
packages/squad-cli/src/cli/commands/watch/index.ts |
Wires external loader + PID tracker into runWatch() startup/shutdown flow. |
packages/squad-cli/src/cli/commands/watch/pid-tracker.ts |
New PID tracking + cleanup logic with cross-platform kill behavior. |
packages/squad-cli/src/cli/commands/watch/capabilities/execute.ts |
Tracks Copilot child PIDs for cleanup on exit/crash. |
packages/squad-cli/src/cli/commands/watch/types.ts |
Extends WatchContext with optional pidTracker. |
packages/squad-cli/src/cli/templates/capabilities/issue-pipeline.sample.js |
Adds sample external capability template for users. |
test/cli/watch-external-loader.test.ts |
Adds tests for external loader behavior and failure handling. |
test/cli/watch-pid-tracker.test.ts |
Adds tests for PID file lifecycle, cleanup, and stale cleanup behavior. |
.gitignore |
Ignores the new .squad/.watch-pids tracking file. |
.changeset/pid-tracker-cleanup.md |
Changeset for PID tracking/orphan cleanup fix. |
.changeset/external-capability-loading.md |
Changeset for external capability loading feature. |
Comments suppressed due to low confidence (1)
packages/squad-cli/src/cli/commands/watch/capabilities/execute.ts:159
- The copilot child process is spawned without
detached: true, so on POSIXPidTracker.killTree()'sprocess.kill(-pid, ...)will usually not kill a process group (because the child is not a group leader). If the goal is to reliably kill descendants (MCP servers) on non-Windows too, consider spawning the child in a new process group and tracking/killing that group.
const cp: ChildProcess = execFile(
cmd,
args,
{ cwd: context.teamRoot, timeout: timeoutMs, maxBuffer: 50 * 1024 * 1024 },
(err) => {
| // Validate required fields | ||
| const missing = REQUIRED_FIELDS.filter(f => !(f in cap)); | ||
| if (missing.length > 0) { | ||
| console.log( | ||
| `${YELLOW}⚠️ Failed to load capability from ${filename}: missing fields: ${missing.join(', ')}${RESET}`, | ||
| ); | ||
| continue; | ||
| } |
There was a problem hiding this comment.
Validation currently only checks property presence via f in cap. This will treat non-callable preflight/execute values as valid and will accept any string for phase. To prevent runtime crashes later in the round loop, validate field types (e.g., typeof cap.preflight === 'function', typeof cap.execute === 'function', cap.phase is one of the supported phases).
| } else { | ||
| // Try process group kill first, fall back to single PID | ||
| try { | ||
| process.kill(-pid, 'SIGKILL'); // negative PID = process group | ||
| } catch { | ||
| process.kill(pid, 'SIGKILL'); | ||
| } | ||
| } |
There was a problem hiding this comment.
killTree() attempts process.kill(-pid, 'SIGKILL'), but the execFile() call that spawns the copilot process is not started in its own process group (detached: true), so -pid will typically not correspond to a process group. As a result, on non-Windows this often only kills the direct child PID and may leave grandchildren (e.g., MCP servers) running. Consider spawning the child in a new process group (e.g., detached: true) and then killing the group, or implement a real process-tree kill strategy for POSIX.
| it('kills alive processes and reports count', () => { | ||
| const tracker = new PidTracker(teamRoot); | ||
| tracker.track(111, 'alive-proc'); | ||
| tracker.track(222, 'dead-proc'); | ||
|
|
||
| // Mock process.kill: 111 is alive, 222 is dead | ||
| const killSpy = vi.spyOn(process, 'kill').mockImplementation(((pid: number, signal?: string | number) => { | ||
| if (signal === 0) { | ||
| // Existence check — 111 is alive, 222 is dead | ||
| if (Math.abs(pid) === 111) return true; | ||
| throw new Error('ESRCH'); | ||
| } | ||
| // Actual kill — succeed for alive process | ||
| return true; | ||
| }) as typeof process.kill); | ||
|
|
||
| const result = tracker.cleanup(); |
There was a problem hiding this comment.
On Windows, PidTracker.cleanup() will call taskkill via execSync for “alive” PIDs. These tests mock process.kill but do not mock execSync, so running the suite on Windows could execute real taskkill /PID 111 /T /F etc and potentially kill unrelated processes if those PIDs exist. Mock node:child_process (or refactor PidTracker to inject the killer) so tests never execute real OS kill commands.
| const entries = await readdir(capDir); | ||
| const jsFiles = entries.filter(f => f.endsWith('.js')); | ||
|
|
||
| let loaded = 0; | ||
|
|
||
| for (const filename of jsFiles) { | ||
| const filePath = join(capDir, filename); |
There was a problem hiding this comment.
readdir() order is filesystem-dependent. If load order matters (or to keep logs deterministic), consider sorting entries/jsFiles before iterating so capability load/override behavior is reproducible across platforms and filesystems.
P0 mitigations from Worf's security review: 1. Print security warning listing all external .js files before importing 2. Block external capabilities from overwriting built-in names 3. New test: hijack attempt is rejected, built-in preserved Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…922) * feat(watch): load external WatchCapabilities from .squad/capabilities/ (#918) Add loadExternalCapabilities() that scans .squad/capabilities/ for .js files, validates WatchCapability interface conformance, and registers them in the CapabilityRegistry after built-in capabilities. - external-loader.ts: loader with graceful error handling per file - Wire into runWatch() after createDefaultRegistry() - Export from watch barrel for downstream consumers - Sample capability template: issue-pipeline.sample.js - 6 vitest tests covering all edge cases - Changeset for minor bump * fix(watch): track child PIDs and cleanup orphans on exit (#921) Add PidTracker class that tracks child process PIDs spawned during watch rounds, kills them on exit/crash, and cleans up stale orphans from previous crashed runs on startup. - New pid-tracker.ts with cross-platform process tree kill - Wire into watch/index.ts for startup cleanup and exit handlers - Wire into execute.ts to track/untrack copilot session PIDs - Add pidTracker to WatchContext interface - Export from barrel for external consumers - Add .watch-pids to .gitignore (runtime state) - 11 vitest tests covering track/untrack, cleanup, and stale handling Closes #921 * fix: address Copilot review comments on PR #922 * security: add startup warning banner + block built-in name hijacking P0 mitigations from Worf's security review: 1. Print security warning listing all external .js files before importing 2. Block external capabilities from overwriting built-in names 3. New test: hijack attempt is rejected, built-in preserved Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <copilot@github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Summary
Two features that improve watch reliability and extensibility:
1. External WatchCapability loading (closes #918)
Users can now define custom watch capabilities as
.jsfiles in.squad/capabilities/. Each file default-exports aWatchCapabilityobject. The loader scans the directory at watch startup, validates required fields, and registers them alongside built-in capabilities.Files:
watch/external-loader.ts— loader function (78 lines)watch/index.ts— wired aftercreateDefaultRegistry()templates/capabilities/issue-pipeline.sample.js— sample capability2. PID tracker for orphan cleanup (closes #921)
Prevents MCP server process leaks during long-running watch sessions. Tracks PIDs of spawned copilot sessions in
.squad/.watch-pids, kills them on exit/crash, and cleans up stale orphans from previous crashed runs on startup.Problem: After ~12h of watch running on Windows, we found 85 orphaned enghub-mcp processes, 4 orphaned copilot.exe, and 37 orphaned pwsh.exe — all leaked because child processes aren't cleaned up when parents exit.
Files:
watch/pid-tracker.ts— PidTracker class with cross-platform killwatch/index.ts— stale cleanup on startup + exit handlerswatch/types.ts— pidTracker added to WatchContextcapabilities/execute.ts— tracks copilot child PIDsTesting
Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com