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
4 changes: 4 additions & 0 deletions packages/squad-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@
"types": "./dist/agents/index.d.ts",
"import": "./dist/agents/index.js"
},
"./agents/personal": {
"types": "./dist/agents/personal.d.ts",
"import": "./dist/agents/personal.js"
},
"./adapter": {
"types": "./dist/adapter/types.d.ts",
"import": "./dist/adapter/types.js"
Expand Down
8 changes: 8 additions & 0 deletions packages/squad-sdk/src/agents/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@ export {
type OnboardResult,
} from './onboarding.js';

// --- Personal Squad Agents ---
export {
resolvePersonalAgents,
mergeSessionCast,
type PersonalAgentMeta,
type PersonalAgentManifest,
} from './personal.js';

// --- Charter Types ---

export interface AgentCharter {
Expand Down
96 changes: 96 additions & 0 deletions packages/squad-sdk/src/agents/personal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* Personal Squad Agent Resolution
*
* Discovers and merges personal agents from the user's personal squad directory
* into project session casts. Personal agents are ambient — they're automatically
* available in all project contexts with ghost protocol enforced.
*
* @module agents/personal
*/

import fs from 'node:fs';
import path from 'node:path';
import { resolvePersonalSquadDir } from '../resolution.js';
import { AgentManifest } from '../config/agent-source.js';

/** Metadata tag for personal agents in a session cast */
export interface PersonalAgentMeta {
/** Always 'personal' for personal squad agents */
origin: 'personal';
/** Absolute path to the personal agent's directory */
sourceDir: string;
/** Whether ghost protocol is enforced (always true in project context) */
ghostProtocol: boolean;
}

/** A project agent manifest augmented with personal origin info */
export type PersonalAgentManifest = AgentManifest & {
personal: PersonalAgentMeta;
};

/**
* Discover personal agents from the user's personal squad directory.
* Returns empty array if personal squad is disabled or doesn't exist.
*/
export async function resolvePersonalAgents(): Promise<PersonalAgentManifest[]> {
const personalDir = resolvePersonalSquadDir();
if (!personalDir) return [];

const agentsDir = path.join(personalDir, 'agents');
if (!fs.existsSync(agentsDir)) return [];

const entries = await fs.promises.readdir(agentsDir, { withFileTypes: true });
const agents: PersonalAgentManifest[] = [];

for (const entry of entries) {
if (!entry.isDirectory()) continue;

const charterPath = path.join(agentsDir, entry.name, 'charter.md');
if (!fs.existsSync(charterPath)) continue;

const charterContent = await fs.promises.readFile(charterPath, 'utf-8');
const meta = parseCharterMetadataBasic(charterContent);

agents.push({
name: entry.name,
role: meta.role || 'personal',
source: 'personal',
personal: {
origin: 'personal',
sourceDir: path.join(agentsDir, entry.name),
ghostProtocol: true,
},
});
}

return agents;
}

/** Basic charter metadata parser for personal agents */
function parseCharterMetadataBasic(content: string): { role?: string; name?: string } {
const roleMatch = content.match(/\*\*Role:\*\*\s*(.+)/);
const nameMatch = content.match(/\*\*Name:\*\*\s*(.+)/);
return {
role: roleMatch?.[1]?.trim(),
name: nameMatch?.[1]?.trim(),
};
}

/**
* Merge personal agents into a project session cast.
* Personal agents are tagged with origin: 'personal' and ghost protocol is enforced.
* Duplicate names: project agents take precedence over personal agents.
*/
export function mergeSessionCast(
projectAgents: AgentManifest[],
personalAgents: PersonalAgentManifest[]
): (AgentManifest | PersonalAgentManifest)[] {
const projectNames = new Set(projectAgents.map(a => a.name.toLowerCase()));

// Filter out personal agents that conflict with project agent names
const uniquePersonal = personalAgents.filter(
a => !projectNames.has(a.name.toLowerCase())
);

return [...projectAgents, ...uniquePersonal];
}
4 changes: 3 additions & 1 deletion packages/squad-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ const pkg = require('../package.json');
export const VERSION: string = pkg.version;

// Export public API
export { resolveSquad, resolveGlobalSquadPath, ensureSquadPath, loadDirConfig, isConsultMode } from './resolution.js';
export { resolveSquad, resolveGlobalSquadPath, resolvePersonalSquadDir, ensureSquadPath, ensureSquadPathTriple, loadDirConfig, isConsultMode } from './resolution.js';
export type { SquadDirConfig, ResolvedSquadPaths } from './resolution.js';
export * from './config/index.js';
export * from './agents/onboarding.js';
export { resolvePersonalAgents, mergeSessionCast } from './agents/personal.js';
export type { PersonalAgentMeta, PersonalAgentManifest } from './agents/personal.js';
export * from './casting/index.js';
export * from './skills/index.js';
export { selectResponseTier, getTier } from './coordinator/response-tiers.js';
Expand Down
50 changes: 50 additions & 0 deletions packages/squad-sdk/src/resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ export interface ResolvedSquadPaths {
projectDir: string;
/** Team identity root (agents, casting, skills) */
teamDir: string;
/** User's personal squad dir, null if not found or disabled */
personalDir: string | null;
config: SquadDirConfig | null;
name: '.squad' | '.ai-team';
isLegacy: boolean;
Expand Down Expand Up @@ -199,6 +201,7 @@ export function resolveSquadPaths(startDir?: string): ResolvedSquadPaths | null
mode: 'remote',
projectDir,
teamDir,
personalDir: resolvePersonalSquadDir(),
config,
name,
isLegacy,
Expand All @@ -210,6 +213,7 @@ export function resolveSquadPaths(startDir?: string): ResolvedSquadPaths | null
mode: 'local',
projectDir,
teamDir: projectDir,
personalDir: resolvePersonalSquadDir(),
config,
name,
isLegacy,
Expand Down Expand Up @@ -254,6 +258,25 @@ export function resolveGlobalSquadPath(): string {
return globalDir;
}

/**
* Resolves the user's personal squad directory.
* Returns null if SQUAD_NO_PERSONAL is set or directory doesn't exist.
*
* Platform paths:
* - Windows: %APPDATA%/squad/personal-squad
* - macOS: ~/Library/Application Support/squad/personal-squad
* - Linux: $XDG_CONFIG_HOME/squad/personal-squad or ~/.config/squad/personal-squad
*/
export function resolvePersonalSquadDir(): string | null {
if (process.env['SQUAD_NO_PERSONAL']) return null;

const globalDir = resolveGlobalSquadPath();
const personalDir = path.join(globalDir, 'personal-squad');

if (!fs.existsSync(personalDir)) return null;
return personalDir;
}

/**
* Validate that a file path is within `.squad/` or the system temp directory.
*
Expand Down Expand Up @@ -323,6 +346,33 @@ export function ensureSquadPathDual(filePath: string, projectDir: string, teamDi
);
}

/**
* Validates a file path is inside one of three allowed directories:
* projectDir, teamDir, personalDir, or system temp.
* Extends ensureSquadPathDual() for triple-root (project + team + personal).
*/
export function ensureSquadPathTriple(
filePath: string,
projectDir: string,
teamDir: string,
personalDir: string | null
): string {
const resolved = path.resolve(filePath);
const tmpDir = os.tmpdir();

const allowed = [projectDir, teamDir, personalDir, tmpDir].filter(Boolean) as string[];

for (const dir of allowed) {
if (resolved.startsWith(path.resolve(dir) + path.sep) || resolved === path.resolve(dir)) {
return resolved;
}
}

throw new Error(
`Path "${resolved}" is outside all allowed directories: ${allowed.join(', ')}`
);
}

/**
* ensureSquadPath that works with resolved dual-root paths.
* Convenience wrapper around ensureSquadPathDual.
Expand Down
Loading