Skip to content
Closed
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
10 changes: 10 additions & 0 deletions .changeset/watch-health.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@bradygaster/squad-cli": minor
---

feat(watch): health check — show running watch instance status (#808)

Adds `squad watch --health` to display the status of a running watch
instance: PID, uptime, auth account, capabilities, and auth drift
detection. Writes `.squad/.watch-pid.json` at startup for instance
Comment on lines +5 to +9

Copilot AI Apr 4, 2026

Copy link

Choose a reason for hiding this comment

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

This changeset claims the CLI adds squad watch --health, but there is currently no argument parsing/dispatch for --health in packages/squad-cli/src/cli-entry.ts (and no other CLI router references it). Either wire up the flag/command as described, or adjust the release note to match the implemented API surface.

Copilot uses AI. Check for mistakes.
tracking. Detects and cleans up stale PID files from crashed instances.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ coverage/
.squad/decisions/inbox/
.squad/sessions/
.squad/config.json

# Test temp dirs (created in cwd by vitest tests, cleaned in afterEach)
.test-*
# Docs site generated files
Expand Down
4 changes: 4 additions & 0 deletions packages/squad-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,10 @@
"types": "./dist/cli/commands/watch/index.d.ts",
"import": "./dist/cli/commands/watch/index.js"
},
"./commands/watch/health": {
"types": "./dist/cli/commands/watch/health.d.ts",
"import": "./dist/cli/commands/watch/health.js"
},
"./commands/start": {
"types": "./dist/cli/commands/start.d.ts",
"import": "./dist/cli/commands/start.js"
Expand Down
8 changes: 8 additions & 0 deletions packages/squad-cli/src/cli-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ async function main(): Promise<void> {
console.log(` ${BOLD}--global${RESET} Use personal (global) squad path (for init, upgrade)`);
console.log(` ${BOLD}--economy${RESET} Activate economy mode for this session (cheaper models)`);
console.log(` ${BOLD}--team-root${RESET} Override team root path for resolution`);
console.log(` ${BOLD}--state-backend${RESET} State storage: worktree | external | git-notes | orphan`);
console.log(`\nInstallation:`);
console.log(` npm install --save-dev @bradygaster/squad-cli`);
console.log(`\nInsider channel:`);
Expand Down Expand Up @@ -338,6 +339,13 @@ async function main(): Promise<void> {
return;
}

// --health flag: show watch instance status and exit
if (cmd === 'watch' && args.includes('--health')) {
const { getWatchHealth } = await import('./cli/commands/watch/health.js');
console.log(getWatchHealth(process.cwd()));
return;
}

if (cmd === 'triage' || cmd === 'watch') {
const { runWatch, loadWatchConfig, createDefaultRegistry } = await import('./cli/commands/watch/index.js');

Expand Down
153 changes: 153 additions & 0 deletions packages/squad-cli/src/cli/commands/watch/health.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/**
* Watch health check — reports status of a running watch instance.
*
* Reads the PID file (stored in OS temp dir) written at watch startup to
* determine whether a watch process is alive, its uptime, auth account, and
* whether auth has drifted since launch.
*/

import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import crypto from 'node:crypto';
import { execFileSync } from 'node:child_process';

/** Shape of the PID file written by runWatch at startup. */
export interface WatchPidInfo {
pid: number;
startedAt: string;
user: string;
interval: number;
capabilities: string[];
repo: string;
}

/**
* Path to the PID file in the OS temp directory.
* Uses an MD5 hash of the repo path so different clones/worktrees get
* unique PID files without polluting the repo (avoids accidental commits).
*/
export function getPidPath(teamRoot: string): string {
const hash = crypto.createHash('md5').update(teamRoot).digest('hex').slice(0, 12);
return path.join(os.tmpdir(), `squad-watch-${hash}.json`);
}

/**
* Write the PID file at watch startup.
* The caller is responsible for registering exit handlers to clean up.
*/
export function writePidFile(teamRoot: string, info: WatchPidInfo): void {
const pidPath = getPidPath(teamRoot);
fs.writeFileSync(pidPath, JSON.stringify(info, null, 2));
}

/** Remove the PID file (best-effort, swallows errors). */
export function removePidFile(teamRoot: string): void {
try { fs.unlinkSync(getPidPath(teamRoot)); } catch { /* already gone */ }
}

/**
* Check if a process with the given PID is still alive.
* Uses `process.kill(pid, 0)` — signal 0 tests existence without killing.
*/
export function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
Comment on lines +52 to +58

Copilot AI Apr 4, 2026

Copy link

Choose a reason for hiding this comment

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

isProcessAlive() treats all errors from process.kill(pid, 0) as "dead". On some platforms this can throw EPERM for a live process you don't have permission to signal, which would incorrectly mark the PID as stale and trigger PID-file cleanup. Handle EPERM as "alive" and only treat ESRCH as "not running".

Suggested change
*/
export function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
* `EPERM` means the process exists but cannot be signaled by this user.
*/
export function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error: unknown) {
const err = error as NodeJS.ErrnoException;
if (err.code === 'EPERM') {
return true;
}
if (err.code === 'ESRCH') {
return false;
}
throw error;

Copilot uses AI. Check for mistakes.
}
}

/** Format a duration in milliseconds to a human-readable string. */
function formatUptime(ms: number): string {
const totalMinutes = Math.floor(ms / 60_000);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}

/**
* Returns the currently active `gh` account, or undefined.
* Duplicated from index.ts to avoid circular imports — the canonical
* version lives in `getActiveGhUser()` in the watch index.
*/
/** Probes gh auth status — captures both stdout and stderr since gh may write to either. */
function probeCurrentGhUser(): string | undefined {
try {
const result = execFileSync('gh', ['auth', 'status', '--active'], {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
});
const match = result.match(/account\s+(\S+)/);
return match?.[1];
Comment on lines +77 to +84

Copilot AI Apr 4, 2026

Copy link

Choose a reason for hiding this comment

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

probeCurrentGhUser() has the same stdout/stderr issue as getActiveGhUser(): execFileSync() does not include stderr when the command succeeds. If gh auth status --active prints the active account to stderr with exit code 0, auth drift detection here will silently fail. Consider switching to spawnSync() and merging outputs (or using a more reliable gh api query).

Copilot uses AI. Check for mistakes.
} catch (e) {
const stderr = (e as { stderr?: string }).stderr ?? '';
const match = stderr.match(/account\s+(\S+)/);
return match?.[1];
}
}

/**
* Run the watch health check and return a formatted status string.
*
* @param teamRoot - The repository root (directory containing `.squad/`).
*/
export function getWatchHealth(teamRoot: string): string {
const pidPath = getPidPath(teamRoot);

if (!fs.existsSync(pidPath)) {
return '📋 No watch instance detected.\n Start one with: squad watch --execute --interval 5';
}

let info: WatchPidInfo;
try {
info = JSON.parse(fs.readFileSync(pidPath, 'utf-8')) as WatchPidInfo;
} catch {
return `⚠ Corrupt PID file — cannot read watch status.\n Delete ${pidPath} and restart.`;
}

// Validate minimal fields
if (typeof info.pid !== 'number') {
return `⚠ Invalid PID file (missing pid).\n Delete ${pidPath} and restart.`;
}

// Check if the process is actually running
if (!isProcessAlive(info.pid)) {
// Stale PID file — process died without cleanup
removePidFile(teamRoot);
return `⚠ Stale watch detected (PID ${info.pid} is dead). Cleaned up.\n Restart with: squad watch --execute --interval 5`;
}

// Process is alive — build status report
// Validate startedAt before computing uptime
if (!info.startedAt || isNaN(new Date(info.startedAt).getTime())) {
return `⚠ Invalid PID file (bad startedAt).
Delete ${pidPath} and restart.`;
}
const uptime = formatUptime(Date.now() - new Date(info.startedAt).getTime());

Comment on lines +123 to +130

Copilot AI Apr 4, 2026

Copy link

Choose a reason for hiding this comment

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

startedAt is not validated before computing uptime. If the PID file is missing startedAt or contains an invalid timestamp, new Date(info.startedAt).getTime() becomes NaN and the output will show NaNm. Consider validating startedAt and falling back to an "unknown" uptime (or treating the PID file as invalid).

Copilot uses AI. Check for mistakes.
const lines = [
'🔄 Watch Instance — RUNNING',
'━━━━━━━━━━━━━━━━━━━━━━━━━━━━',
` PID: ${info.pid}`,
` Uptime: ${uptime}`,
` Auth: ${info.user}`,
` Interval: ${info.interval}m`,
` Repo: ${info.repo}`,
` Capabilities: ${(info.capabilities ?? []).join(', ') || '(none)'}`,
];

// Auth drift detection — compare current gh user vs recorded
const currentUser = probeCurrentGhUser();
if (currentUser && currentUser !== info.user) {
lines.push(` ⚠ AUTH DRIFT: Expected ${info.user}, current is ${currentUser}`);
} else if (currentUser) {
lines.push(' Auth status: ✓ matches expected');
} else {
lines.push(' Auth status: ? could not verify');
}

return lines.join('\n');
}
77 changes: 77 additions & 0 deletions packages/squad-cli/src/cli/commands/watch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* always runs — it is not an opt-in capability.
*/

import fs from 'node:fs';

Copilot AI Apr 4, 2026

Copy link

Choose a reason for hiding this comment

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

fs is imported but never used in this module, which adds noise and can trip lint rules in some setups. Remove the unused import (or start using it) to keep the file clean.

Suggested change
import fs from 'node:fs';

Copilot uses AI. Check for mistakes.
import path from 'node:path';
import { execFile, execFileSync } from 'node:child_process';
import { promisify } from 'node:util';
Expand Down Expand Up @@ -40,13 +41,39 @@ import type { WatchCapability, WatchContext, WatchPhase, CapabilityResult } from
import { CapabilityRegistry } from './registry.js';
import { createDefaultRegistry } from './capabilities/index.js';

// ── Auth Guard ───────────────────────────────────────────────────

/**
* Returns the currently active `gh` account, or undefined if it
* cannot be determined (e.g. gh not installed, not logged in).
*/
export function getActiveGhUser(): string | undefined {
try {
const result = execFileSync('gh', ['auth', 'status', '--active'], {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
});
// gh auth status outputs: "Logged in to github.com account XXXXX ..."
// Capture stderr too — gh writes status info to both streams
const match = result.match(/account\s+(\S+)/);
return match?.[1];
Comment on lines +50 to +59

Copilot AI Apr 4, 2026

Copy link

Choose a reason for hiding this comment

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

execFileSync() only returns stdout; the comment says it captures stderr too, but stderr output from a successful gh auth status invocation won't be included in result. If gh writes the account info to stderr with exit code 0, this will fail to detect the active user—consider using spawnSync() and combining stdout+stderr (or call gh api user -q .login).

Copilot uses AI. Check for mistakes.
} catch (e) {
// gh auth status exits non-zero when not logged in, but still
// writes account info to stderr — try to parse it
const stderr = (e as { stderr?: string }).stderr ?? '';
const match = stderr.match(/account\s+(\S+)/);
return match?.[1];
}
}

// ── Re-exports for backward compatibility ────────────────────────

export type { WatchConfig } from './config.js';
export { loadWatchConfig } from './config.js';
export type { WatchCapability, WatchContext, WatchPhase, PreflightResult, CapabilityResult } from './types.js';
export { CapabilityRegistry } from './registry.js';
export { createDefaultRegistry } from './capabilities/index.js';
export { getWatchHealth, writePidFile, removePidFile, getPidPath, isProcessAlive, type WatchPidInfo } from './health.js';

// ── Watch Platform Abstraction ───────────────────────────────────

Expand Down Expand Up @@ -644,6 +671,12 @@ export async function runWatch(dest: string, options: WatchOptions | WatchConfig
try { await execFileAsync('az', ['account', 'show']); } catch { fatal('az CLI not authenticated — run: az login'); }
}

// Auth guard — capture active account at startup for drift detection
let expectedGhUser: string | undefined;
if (adapter.type === 'github') {
expectedGhUser = getActiveGhUser();
}

// Parse team.md
const content = storage.readSync(teamMd) ?? '';
const roster = parseRoster(content);
Expand All @@ -663,6 +696,8 @@ export async function runWatch(dest: string, options: WatchOptions | WatchConfig
}

// Pre-create squad member labels so addTag never fails on missing labels
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const adapterAny = adapter as any;
if (adapter.ensureTag) {
for (const member of roster) {
try {
Expand Down Expand Up @@ -705,6 +740,31 @@ export async function runWatch(dest: string, options: WatchOptions | WatchConfig

const enabledCapabilities = await preflightCapabilities(registry, config, baseContext);

// ── PID file for health checks (#808) ──────────────────────────
{
const { writePidFile, removePidFile } = await import('./health.js');
let repoSlug = 'unknown';
try {
repoSlug = execFileSync('gh', ['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner'], {
encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
} catch { /* non-GitHub or offline — use fallback */ }

writePidFile(teamRoot, {
pid: process.pid,
startedAt: new Date().toISOString(),
user: expectedGhUser ?? 'unknown',
interval: config.interval,
capabilities: enabledCapabilities.map(c => c.name),
repo: repoSlug,
});

const cleanupPidFile = () => removePidFile(teamRoot);
process.on('exit', cleanupPidFile);
process.on('SIGINT', () => { cleanupPidFile(); process.exit(128 + 2); });
process.on('SIGTERM', () => { cleanupPidFile(); process.exit(128 + 15); });
}

// Print startup banner
const modeTag = config.execute ? ` ${BOLD}(Execute)${RESET}` : '';
const platformTag = ` [${adapter.type}]`;
Expand All @@ -727,6 +787,23 @@ export async function runWatch(dest: string, options: WatchOptions | WatchConfig
async function executeRound(): Promise<void> {
const ts = new Date().toLocaleTimeString();

// Auth guard — detect if another process switched gh auth mid-run
if (adapter.type === 'github' && expectedGhUser) {
const currentUser = getActiveGhUser();
if (currentUser && currentUser !== expectedGhUser) {
console.error(`${YELLOW}⚠${RESET} Auth switched! Expected ${BOLD}${expectedGhUser}${RESET}, got ${BOLD}${currentUser}${RESET}. Attempting restore...`);
try {
execFileSync('gh', ['auth', 'switch', '--user', expectedGhUser], {
encoding: 'utf-8',
stdio: 'pipe',
});
console.log(`${GREEN}✓${RESET} Auth restored to ${expectedGhUser}`);
} catch {
console.error(`${RED}✗${RESET} Could not restore auth to ${expectedGhUser}. Watch results may be incorrect.`);
}
}
}

// Circuit breaker gate
if (cbState.status === 'open') {
const elapsed = Date.now() - new Date(cbState.openedAt!).getTime();
Expand Down
58 changes: 58 additions & 0 deletions packages/squad-cli/src/cli/core/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -607,3 +607,61 @@ export async function runUpgrade(dest: string, options: UpgradeOptions = {}): Pr
migrationsRun: migrationsApplied,
};
}

// ============================================================================
// Self-upgrade: upgrade the CLI package itself
// ============================================================================

/**
* Detect the package manager that installed the CLI.
* Checks npm_execpath and npm_config_user_agent env vars.
*/
function detectPackageManager(): 'npm' | 'pnpm' | 'yarn' {
const execPath = process.env['npm_execpath'] ?? '';
if (execPath.includes('pnpm')) return 'pnpm';
if (execPath.includes('yarn')) return 'yarn';
const userAgent = process.env['npm_config_user_agent'] ?? '';
if (userAgent.startsWith('pnpm')) return 'pnpm';
if (userAgent.startsWith('yarn')) return 'yarn';
return 'npm';
}

/**
* Self-upgrade the Squad CLI package via the detected package manager.
*
* Detects whether the CLI was installed via npm, pnpm, or yarn and runs the
* appropriate global install command. Only suggests `sudo` for npm (pnpm and
* yarn typically do not require elevated permissions for global installs).
*/
export async function selfUpgradeCli(options: { insider?: boolean; force?: boolean } = {}): Promise<void> {
const { execSync } = await import('node:child_process');
const tag = options.insider ? 'insider' : 'latest';
const pkg = `@bradygaster/squad-cli@${tag}`;
const pm = detectPackageManager();

let cmd: string;
switch (pm) {
case 'pnpm':
cmd = `pnpm add -g ${pkg}`;
break;
case 'yarn':
cmd = `yarn global add ${pkg}`;
break;
default:
cmd = `npm install -g ${pkg}`;
break;
}

info(`Self-upgrading via ${pm}: ${cmd}`);

try {
execSync(cmd, { stdio: 'inherit' });
} catch {
// Only suggest sudo for npm — pnpm/yarn rarely need it
if (pm === 'npm') {
warn(`Permission denied. Try: sudo ${cmd}`);
} else {
warn(`Upgrade failed. Check ${pm} permissions or try running manually: ${cmd}`);
}
}
}
Loading
Loading