Repository navigation
feat(watch): external capability loading + PID tracker (#918, #921) #922
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5df461c
e257e12
0bcd04d
9e70513
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| 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. |
| 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', | ||
| ]; | ||
|
|
||
| /** | ||
| * 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
|
||
| 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
|
||
|
|
||
| // 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; | ||
| } | ||
| 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
|
||
| 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 */ } | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.