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
6 changes: 6 additions & 0 deletions .changeset/calm-bridges-exit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@bradygaster/squad-cli": patch
"@bradygaster/squad-sdk": patch
---

Generate resolvable versioned state bridge declarations, probe them in `squad doctor`, report bridge identity in health output, and terminate stdio servers when their host transport closes.
5 changes: 5 additions & 0 deletions .changeset/safe-state-root.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bradygaster/squad-sdk": patch
---

Allow backend root aliases only for list-shaped operations and reject file or destructive operations before they can access or erase the entire state tree.
37 changes: 37 additions & 0 deletions docs/src/content/docs/scenarios/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,43 @@ If below v22.5, upgrade to the latest LTS:

---

## `state-mcp` processes remain after Copilot sessions close

**Problem:** Many `state-mcp` processes remain on a long-running development
machine.

**Cause:** Copilot host sessions can remain alive after their visible UI session
closes. Every live host keeps its stdio MCP children alive. Older Squad
installations also used `npx`/shell wrapper chains and duplicate hashed user
declarations, which multiplied the process count.

**Fix:**

1. Run `squad upgrade --force`. The repository `.mcp.json` declaration takes
precedence, uses the installed CLI entry directly when available, and removes
only the matching legacy `squad_state_<hash>` user declaration after backing
up `~/.copilot/mcp-config.json`.
2. Restart the affected Copilot sessions so they load the new declaration.
3. If cleanup is still required, stop only confirmed parent-gone bridge PIDs.
Never kill processes by image/name:

```powershell
$all = Get-CimInstance Win32_Process
$byPid = @{}
$all | ForEach-Object { $byPid[[int]$_.ProcessId] = $_ }
$orphans = $all | Where-Object {
$_.CommandLine -match 'state-mcp' -and
-not $byPid.ContainsKey([int]$_.ParentProcessId)
}
$orphans | Select-Object ProcessId, ParentProcessId, CommandLine
$orphans | ForEach-Object { Stop-Process -Id ([int]$_.ProcessId) }
```

If the list is empty, do not kill anything. The live Copilot parent owns
those processes; close or restart that specific session instead.

---

## Upgrade doesn't change anything

**Problem:** Running `squad upgrade` completes but nothing changes.
Expand Down
80 changes: 80 additions & 0 deletions packages/squad-cli/src/cli/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ import { fatalNoSquad } from '../core/errors.js';
import { isMutableStateKey, MUTABLE_STATE_PATHS } from '@bradygaster/squad-sdk/tools';
import { resolveUpstreams } from '@bradygaster/squad-sdk/upstream';
import { effectiveSquadDir } from '../core/effective-squad-dir.js';
import { getPackageVersion } from '../core/version.js';
import {
probeSquadStateBridge,
resolveConfiguredSquadStateMcp,
} from '../core/mcp-probe.js';

const storage = new FSStorageProvider();

Expand Down Expand Up @@ -595,6 +600,80 @@ function checkCopilotCli(): Promise<DoctorCheck> {
});
}

export async function checkSquadStateBridge(cwd: string): Promise<DoctorCheck> {
const name = 'squad_state bridge';
const expectedVersion = getPackageVersion();
const configured = resolveConfiguredSquadStateMcp(cwd);
const fix = "Run 'squad upgrade --force' to rewrite the bridge declaration.";
if (!configured) {
return {
name,
status: 'warn',
message: `no squad_state declaration found in .mcp.json or the project-scoped user config. ${fix}`,
};
}

try {
const probe = await probeSquadStateBridge(cwd, configured);
if (probe.version !== expectedVersion) {
return {
name,
status: 'fail',
message: `version mismatch: configured bridge reports ${probe.version || 'unknown'}, running CLI is ${expectedVersion}. ${fix}`,
};
}
if (!probe.tools.includes('squad_state_health')) {
return {
name,
status: 'fail',
message: `bridge ${expectedVersion} exposes ${probe.tools.length} tool(s) but not squad_state_health. ${fix}`,
};
}

const expectedTeamDir = path.resolve(effectiveSquadDir(cwd).stateDir);
const healthVersion = `squad-state ${expectedVersion}`;
const teamLine = probe.healthText.match(/^Team squad dir:\s*(.+)$/m)?.[1]?.trim();
if (!probe.healthText.includes(healthVersion) || !teamLine) {
return {
name,
status: 'fail',
message: `bridge health is missing "${healthVersion}" or the resolved team directory. ${fix}`,
};
}
const reportedTeamDir = path.resolve(teamLine);
const sameTeamDir = process.platform === 'win32'
? reportedTeamDir.toLowerCase() === expectedTeamDir.toLowerCase()
: reportedTeamDir === expectedTeamDir;
if (!sameTeamDir) {
return {
name,
status: 'fail',
message: `bridge resolved ${reportedTeamDir}, expected ${expectedTeamDir}. ${fix}`,
};
}

return {
name,
status: 'pass',
message: `${probe.version}; ${probe.tools.length} tools; ${configured.precedence} config ${configured.configPath}`,
};
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
if (/ETARGET|No matching version found/i.test(detail)) {
return {
name,
status: 'fail',
message: `registry mirror cannot resolve the configured dist-tag. Pin @bradygaster/squad-cli@${expectedVersion} exactly, or ${fix.toLowerCase()}`,
};
}
return {
name,
status: 'fail',
message: `unreachable: ${detail}. ${fix}`,
};
}
}

// ── git sync hooks check ─────────────────────────────────────────────

const SQUAD_SYNC_HOOK_MARKER = '# --- squad-sync-hook ---';
Expand Down Expand Up @@ -842,6 +921,7 @@ export async function runDoctor(cwd?: string): Promise<DoctorCheck[]> {
const upstreamCheck = checkUpstreams(squadDir);
if (upstreamCheck) checks.push(upstreamCheck);

checks.push(await checkSquadStateBridge(resolvedCwd));

// Hook presence check (only for two-layer / orphan backends)
const hookCheck = checkGitSyncHooks(resolvedCwd, squadDir);
Expand Down
40 changes: 39 additions & 1 deletion packages/squad-cli/src/cli/commands/state-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export function createStateMcpToolRegistry(startDir: string): ToolRegistry {
return new ToolRegistry(context.paths.teamSquadDir, undefined, context.storage, undefined, undefined, undefined, {
backendName: context.backend.name,
upstreamDir: context.paths.projectDir,
runtimeVersion: SERVER_INFO.version,
});
}

Expand Down Expand Up @@ -265,7 +266,44 @@ export async function runStateMcp(startDir = process.cwd(), options: StateMcpOpt
return normalizeToolResult(result);
});

await server.connect(new StdioServerTransport());
const transport = new StdioServerTransport();
let stopping = false;
let forceExit: NodeJS.Timeout | undefined;

const stop = async (): Promise<void> => {
if (stopping) return;
stopping = true;
forceExit = setTimeout(() => process.exit(0), 500);
try {
await server.close();
} catch {
// Transport teardown is best-effort; the grace timer guarantees exit.
}
};

const onStdinEnd = (): void => { void stop(); };
const onDisconnect = (): void => { void stop(); };
const onSigpipe = (): void => { void stop(); };
const onStdoutError = (error: NodeJS.ErrnoException): void => {
if (error.code === 'EPIPE') void stop();
};

process.stdin.once('end', onStdinEnd);
process.stdin.once('close', onStdinEnd);
process.once('disconnect', onDisconnect);
process.once('SIGPIPE', onSigpipe);
process.stdout.on('error', onStdoutError);

try {
await server.connect(transport);
} finally {
process.stdin.off('end', onStdinEnd);
process.stdin.off('close', onStdinEnd);
process.off('disconnect', onDisconnect);
process.off('SIGPIPE', onSigpipe);
process.stdout.off('error', onStdoutError);
if (forceExit && !stopping) clearTimeout(forceExit);
}
}

export function printStateMcpHelp(): void {
Expand Down
16 changes: 15 additions & 1 deletion packages/squad-cli/src/cli/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ import { projectSquadStateMcpSpec, resolveSquadStateMcpSpec } from './mcp-spec.j
import { describeMcpSpec, ensureUserOwnedTemplates } from './upgrade.js';
import { syncCopilotInstructionsFile } from './managed-file.js';
import { getTemplatesDir } from './templates.js';
import { ensureSquadStateMcpInRoot, tombstoneStaleSquadStateInProjectMcp } from './mcp-root.js';
import {
ensureSquadStateMcpInRoot,
reconcileSquadStateUserConfig,
tombstoneStaleSquadStateInProjectMcp,
} from './mcp-root.js';
import { applyClientProjectionsForInit, reportClientResults, type ClientId } from './clients.js';
import {
readTeamMd,
Expand Down Expand Up @@ -454,6 +458,11 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi
if (tomb.removed) {
success(`removed stale squad_state from ${tomb.path} (now lives in .mcp.json)`);
}
const userConfig = reconcileSquadStateUserConfig(dest);
if (userConfig.action === 'removed') {
success(`removed duplicate ${(userConfig.keys ?? [userConfig.key]).join(', ')} from ${userConfig.path} (.mcp.json takes precedence)`);
console.log(`${DIM} backup: ${userConfig.backupPath}${RESET}`);
}
} catch (err) {
console.warn(`${YELLOW}⚠ Could not install squad_state MCP entry in .mcp.json: ${err instanceof Error ? err.message : err}${RESET}`);
}
Expand Down Expand Up @@ -483,6 +492,11 @@ export async function runInit(dest: string, options: RunInitOptions = {}): Promi
if (tomb.removed) {
success(`removed stale squad_state from ${tomb.path} (now lives in .mcp.json)`);
}
const userConfig = reconcileSquadStateUserConfig(dest);
if (userConfig.action === 'removed') {
success(`removed duplicate ${(userConfig.keys ?? [userConfig.key]).join(', ')} from ${userConfig.path} (.mcp.json takes precedence)`);
console.log(`${DIM} backup: ${userConfig.backupPath}${RESET}`);
}
} catch {
// best-effort: .mcp.json write failure does not block init
}
Expand Down
41 changes: 35 additions & 6 deletions packages/squad-cli/src/cli/core/managed-file.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { readFileSync, writeFileSync, existsSync, copyFileSync } from 'node:fs';
import fs, { readFileSync, writeFileSync, existsSync, copyFileSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { extname } from 'node:path';

export const COPILOT_INSTRUCTIONS_START = '<!-- squad:copilot-instructions:start -->';
Expand All @@ -20,17 +21,46 @@ function readExisting(filePath: string): string | undefined {
}
}

function managedTextUpdate(existing: string | undefined, content: string): string | undefined {
const normalize = (text: string) => text.replace(/\r\n?|\n/g, '\n');
if (existing !== undefined && normalize(existing) === normalize(content)) return undefined;
const eol = existing?.match(/\r\n|\n|\r/)?.[0];
return eol ? normalize(content).replace(/\n/g, eol) : content;
}

/** Keep destination newlines, and avoid rewriting a newline-only difference. */
export function writeManagedText(filePath: string, content: string): boolean {
const existing = readExisting(filePath);
const normalize = (text: string) => text.replace(/\r\n?|\n/g, '\n');
if (existing !== undefined && normalize(existing) === normalize(content)) return false;
const eol = existing?.match(/\r\n|\n|\r/)?.[0];
const next = eol ? normalize(content).replace(/\n/g, eol) : content;
const next = managedTextUpdate(existing, content);
if (next === undefined) return false;
writeFileSync(filePath, next, 'utf8');
return true;
}

/** Write through a flushed sibling file, then atomically replace the destination. */
export function writeManagedTextAtomic(filePath: string, content: string): boolean {
const existing = readExisting(filePath);
const next = managedTextUpdate(existing, content);
if (next === undefined) return false;

const tempPath = `${filePath}.squad-tmp-${process.pid}-${randomUUID()}`;
const mode = fs.existsSync(filePath) ? fs.statSync(filePath).mode : 0o666;
try {
const descriptor = fs.openSync(tempPath, 'wx', mode);
try {
fs.writeFileSync(descriptor, next, 'utf8');
fs.fsyncSync(descriptor);
} finally {
fs.closeSync(descriptor);
}
fs.renameSync(tempPath, filePath);
} catch (error) {
fs.rmSync(tempPath, { force: true });
throw error;
}
return true;
}

export function copyManagedFile(source: string, destination: string): void {
if (!MANAGED_TEXT_EXTENSIONS.has(extname(source).toLowerCase()) || !existsSync(destination)) {
copyFileSync(source, destination);
Expand Down Expand Up @@ -149,4 +179,3 @@ export function syncCopilotInstructionsFile(sourceTemplatePath: string, destinat
}
return writeManagedText(destinationPath, nextContent);
}

Loading
Loading