Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/external-capability-loading.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@bradygaster/squad-cli': minor
---

feat(watch): load external WatchCapabilities from .squad/capabilities/

Users can now define custom watch capabilities as .js files in `.squad/capabilities/`.
Each file default-exports a WatchCapability object (name, phase, preflight, execute).
Capabilities are loaded at watch startup and participate in the normal phase-based round cycle.
9 changes: 9 additions & 0 deletions .changeset/pid-tracker-cleanup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@bradygaster/squad-cli': patch
---

fix(watch): track child process PIDs and cleanup orphans on exit

Prevents MCP server process leaks during long-running watch sessions.
Tracks PIDs of spawned copilot sessions, kills them on exit/crash,
and cleans up stale orphans from previous crashed runs on startup.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ docs/src/content/docs/reference/api/
docs/tests/screenshots/
# Squad: SubSquad activation file (local to this machine)
.squad-workstream
.squad/.first-run
.squad/.first-run
.squad/.watch-pids

# Images folder (root only — don't ignore docs/public/images/)
/images/
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ async function executeAll(
const { cmd, args } = buildAgentCommand(prompt, context);

return new Promise<{ success: boolean; error?: string }>((resolve) => {
const _cp: ChildProcess = execFile(
const cp: ChildProcess = execFile(
cmd,
args,
{ cwd: context.teamRoot, timeout: timeoutMs, maxBuffer: 50 * 1024 * 1024 },
Expand All @@ -166,6 +166,18 @@ async function executeAll(
}
},
);

// Track child PID for cleanup on exit/crash
if (context.pidTracker && cp.pid) {
const issueNums = issues.map(i => `#${i.number}`).join(',');
context.pidTracker.track(cp.pid, `copilot-session-${issueNums}`);
}

cp.on('exit', () => {
if (context.pidTracker && cp.pid) {
context.pidTracker.untrack(cp.pid);
}
});
});
}

Expand Down
121 changes: 121 additions & 0 deletions packages/squad-cli/src/cli/commands/watch/external-loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* External capability loader — scans .squad/capabilities/ for user-defined
* WatchCapability modules and registers them alongside built-in capabilities.
*/

import { existsSync } from 'node:fs';
import { readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import type { CapabilityRegistry } from './registry.js';
import type { WatchCapability } from './types.js';
import { GREEN, YELLOW, RED, BOLD, DIM, RESET } from '../../core/output.js';

/** Required fields on a valid WatchCapability. */
const REQUIRED_FIELDS: ReadonlyArray<keyof WatchCapability> = [
'name',
'description',
'configShape',
'requires',
'phase',
'preflight',
'execute',
];
Comment thread
diberry marked this conversation as resolved.
Resolved

/**
* Load external WatchCapability modules from `{teamRoot}/.squad/capabilities/`.
*
* - Skips silently when the directory does not exist.
* - Logs a warning and continues when a file fails to load.
* - Returns the count of successfully loaded capabilities.
*/
export async function loadExternalCapabilities(
teamRoot: string,
registry: CapabilityRegistry,
): Promise<number> {
const capDir = join(teamRoot, '.squad', 'capabilities');

if (!existsSync(capDir)) {
return 0;
}

const entries = await readdir(capDir);
const jsFiles = entries.filter(f => f.endsWith('.js')).sort();

if (jsFiles.length === 0) {
return 0;
}

// Security: warn user before executing external code
console.log(
`\n${YELLOW}${BOLD}⚠️ SECURITY: Loading ${jsFiles.length} external capability file(s) from .squad/capabilities/${RESET}`,
);
for (const f of jsFiles) {
console.log(`${DIM} • ${f}${RESET}`);
}
console.log(
`${YELLOW} External capabilities execute with FULL process permissions (file system, network, credentials).${RESET}`,
);
console.log(
`${YELLOW} Review these files if you did not author them.${RESET}\n`,
);

let loaded = 0;

for (const filename of jsFiles) {
const filePath = join(capDir, filename);
Comment on lines +42 to +66

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 0bcd04d.

try {
const mod = await import(pathToFileURL(filePath).href);
let cap: WatchCapability = mod.default ?? mod;

// Support class-based capabilities with a no-arg constructor
if (typeof cap === 'function') {
cap = new (cap as new () => WatchCapability)();
}

// 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;
}
Comment on lines +76 to +83

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 0bcd04d.


// Validate field types
if (typeof cap.preflight !== 'function' || typeof cap.execute !== 'function') {
console.log(
`${YELLOW}⚠️ Failed to load capability from ${filename}: preflight and execute must be functions${RESET}`,
);
continue;
}
const validPhases = ['pre-scan', 'post-triage', 'post-execute', 'housekeeping'];
if (!validPhases.includes(cap.phase)) {
console.log(
`${YELLOW}⚠️ Failed to load capability from ${filename}: invalid phase '${cap.phase}' (must be one of: ${validPhases.join(', ')})${RESET}`,
);
continue;
}

// Security: block hijacking of built-in capabilities
if (registry.get(cap.name)) {
console.log(
`${YELLOW}⚠️ External capability "${cap.name}" conflicts with built-in — skipped (potential hijack)${RESET}`,
);
continue;
}

registry.register(cap);
console.log(
`${GREEN}✅ Loaded external capability: ${cap.name} (phase: ${cap.phase})${RESET}`,
);
loaded++;
} catch (err) {
console.log(
`${YELLOW}⚠️ Failed to load capability from ${filename}: ${err instanceof Error ? err.message : String(err)}${RESET}`,
);
}
}

return loaded;
}
21 changes: 21 additions & 0 deletions packages/squad-cli/src/cli/commands/watch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ export type { WatchCapability, WatchContext, WatchPhase, PreflightResult, Capabi
export { CapabilityRegistry } from './registry.js';
export { createDefaultRegistry } from './capabilities/index.js';
export { createVerboseLogger, type VerboseLogger } from './verbose.js';
export { loadExternalCapabilities } from './external-loader.js';
export { PidTracker, type TrackedProcess } from './pid-tracker.js';
export { getWatchHealth, writePidFile, removePidFile, getPidPath, isProcessAlive, type WatchPidInfo } from './health.js';

// ── Watch Platform Abstraction ───────────────────────────────────
Expand Down Expand Up @@ -751,6 +753,24 @@ export async function runWatch(dest: string, options: WatchOptions | WatchConfig

// ── Capability system setup ────────────────────────────────────
const registry = createDefaultRegistry();

// Load external capabilities from .squad/capabilities/
const { loadExternalCapabilities } = await import('./external-loader.js');
await loadExternalCapabilities(teamRoot, registry);

// PID tracking for child process cleanup
const { PidTracker } = await import('./pid-tracker.js');
const pidTracker = new PidTracker(teamRoot);

// Clean up orphans from previous crashed run
const staleKilled = pidTracker.cleanupStale();
if (staleKilled > 0) {
console.log(`${YELLOW}⚠️ Cleaned up ${staleKilled} orphaned process(es) from previous run${RESET}`);
}

// Register exit handlers for graceful cleanup
pidTracker.registerExitHandlers();

const baseContext: WatchContext = {
teamRoot,
adapter,
Expand All @@ -760,6 +780,7 @@ export async function runWatch(dest: string, options: WatchOptions | WatchConfig
agentCmd: config.agentCmd,
copilotFlags: config.copilotFlags,
verbose: config.verbose,
pidTracker,
};

const enabledCapabilities = await preflightCapabilities(registry, config, baseContext);
Expand Down
141 changes: 141 additions & 0 deletions packages/squad-cli/src/cli/commands/watch/pid-tracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* Tracks child process PIDs spawned during watch rounds.
* Enables cleanup of orphaned processes on exit or restart.
*/

import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
import { YELLOW, DIM, RESET } from '../../core/output.js';

export interface TrackedProcess {
pid: number;
name: string;
spawnedAt: string; // ISO 8601
}

export class PidTracker {
private readonly pidFile: string;
private tracked: TrackedProcess[] = [];

constructor(teamRoot: string) {
this.pidFile = join(teamRoot, '.squad', '.watch-pids');
}

/** Add a child PID to tracking. */
track(pid: number, name: string): void {
this.tracked.push({ pid, name, spawnedAt: new Date().toISOString() });
this.save();
}

/** Remove a PID from tracking (process exited normally). */
untrack(pid: number): void {
this.tracked = this.tracked.filter(p => p.pid !== pid);
this.save();
}

/** Check if a process is still alive. */
private isAlive(pid: number): boolean {
try {
process.kill(pid, 0); // signal 0 = existence check, doesn't kill
return true;
} catch {
return false;
}
}

/** Kill a process tree. Cross-platform. */
private killTree(pid: number): boolean {
try {
if (process.platform === 'win32') {
execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', timeout: 5000 });
} else {
// Note: We only kill the direct PID. Grandchild cleanup requires
// spawning with detached:true, which is controlled by the Execute
// capability. For now, direct PID kill is the honest approach.
process.kill(pid, 'SIGKILL');
}
Comment on lines +52 to +57

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 0bcd04d.

return true;
} catch {
return false;
}
}

/** Kill all tracked processes and clean up the PID file. */
cleanup(): { killed: number; total: number } {
let killed = 0;
const total = this.tracked.length;

for (const proc of this.tracked) {
if (this.isAlive(proc.pid)) {
if (this.killTree(proc.pid)) {
killed++;
}
}
}

this.tracked = [];
this.deletePidFile();
return { killed, total };
}

/** On startup: check for stale PID file from a previous crashed run.
* Kill any orphans that are still alive. */
cleanupStale(): number {
if (!existsSync(this.pidFile)) return 0;

let staleKilled = 0;
try {
const content = readFileSync(this.pidFile, 'utf-8');
const stale: TrackedProcess[] = JSON.parse(content);

for (const proc of stale) {
if (this.isAlive(proc.pid)) {
if (this.killTree(proc.pid)) {
staleKilled++;
console.log(`${YELLOW}⚠️ Killed orphaned process: ${proc.name} (PID ${proc.pid}, spawned ${proc.spawnedAt})${RESET}`);
}
}
}
} catch {
// Corrupted PID file — just delete it
}

this.deletePidFile();
return staleKilled;
}

/** Register process exit handlers for cleanup. */
registerExitHandlers(): void {
const doCleanup = () => {
const result = this.cleanup();
if (result.killed > 0) {
// Can't use console.log in exit handler reliably, but try
try {
console.log(`${DIM}Cleaned up ${result.killed} child process(es)${RESET}`);
} catch { /* ignore */ }
}
};

process.on('exit', doCleanup);
process.on('SIGINT', () => { doCleanup(); process.exit(130); });
process.on('SIGTERM', () => { doCleanup(); process.exit(143); });
process.on('uncaughtException', (err) => {
console.error('Uncaught exception:', err);
doCleanup();
process.exit(1);
});
}

private save(): void {
try {
writeFileSync(this.pidFile, JSON.stringify(this.tracked, null, 2), 'utf-8');
} catch { /* ignore — best effort */ }
}

private deletePidFile(): void {
try {
if (existsSync(this.pidFile)) unlinkSync(this.pidFile);
} catch { /* ignore */ }
}
}
2 changes: 2 additions & 0 deletions packages/squad-cli/src/cli/commands/watch/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ export interface WatchContext {
copilotFlags?: string;
/** Verbose diagnostic output enabled. */
verbose?: boolean;
/** PID tracker for child process cleanup (optional — only set when watch is running). */
pidTracker?: { track(pid: number, name: string): void; untrack(pid: number): void };
}

/** Contract that every watch capability must implement. */
Expand Down
Loading
Loading