Skip to content
Open
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
47 changes: 39 additions & 8 deletions src/commands/spawn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { readManifest, resolveWorktree, updateManifest } from '../core/manifest.
import { spawnAgent } from '../core/agent.js';
import { getRepoRoot } from '../core/worktree.js';
import { agentId, sessionId } from '../lib/id.js';
import { spawnAgentBatch } from '../core/spawn.js';
import * as tmux from '../core/tmux.js';

vi.mock('node:fs/promises', async () => {
Expand Down Expand Up @@ -65,6 +66,15 @@ vi.mock('../lib/id.js', () => ({
sessionId: vi.fn(),
}));

vi.mock('../core/spawn.js', async () => {
const actual = await vi.importActual<typeof import('../core/spawn.js')>('../core/spawn.js');
return {
...actual,
spawnNewWorktree: vi.fn(),
spawnAgentBatch: vi.fn(),
};
});

const mockedAccess = vi.mocked(access);
const mockedLoadConfig = vi.mocked(loadConfig);
const mockedResolveAgentConfig = vi.mocked(resolveAgentConfig);
Expand All @@ -77,7 +87,7 @@ const mockedAgentId = vi.mocked(agentId);
const mockedSessionId = vi.mocked(sessionId);
const mockedEnsureSession = vi.mocked(tmux.ensureSession);
const mockedCreateWindow = vi.mocked(tmux.createWindow);
const mockedSplitPane = vi.mocked(tmux.splitPane);
const mockedSpawnAgentBatch = vi.mocked(spawnAgentBatch);

function createManifest(tmuxWindow = '') {
return {
Expand Down Expand Up @@ -136,8 +146,9 @@ describe('spawnCommand', () => {
mockedReadManifest.mockImplementation(async () => structuredClone(manifestState));
mockedResolveWorktree.mockImplementation((manifest, ref) => (manifest as any).worktrees[ref as string]);
mockedUpdateManifest.mockImplementation(async (_projectRoot, updater) => {
manifestState = await updater(structuredClone(manifestState));
return manifestState as any;
const result = await updater(structuredClone(manifestState) as any);
manifestState = result as typeof manifestState;
return result;
});
mockedAgentId.mockImplementation(() => `ag-${nextAgent++}`);
mockedSessionId.mockImplementation(() => `session-${nextSession++}`);
Expand All @@ -151,14 +162,37 @@ describe('spawnCommand', () => {
startedAt: '2026-02-27T00:00:00.000Z',
sessionId: opts.sessionId,
}));
mockedSplitPane.mockResolvedValue({ target: 'ppg-test:1.1' } as any);
mockedSpawnAgentBatch.mockImplementation(async (opts) => {
const agents = [];
for (let i = 0; i < opts.count; i++) {
const aId = mockedAgentId();
const target = i === 0 && opts.reuseWindowForFirstAgent
? opts.windowTarget
: (mockedCreateWindow as any).mock.results?.[i]?.value ?? `ppg-test:${i + 2}`;
const entry = {
id: aId,
name: opts.agentConfig.name,
agentType: opts.agentConfig.name,
status: 'running' as const,
tmuxTarget: target,
prompt: opts.promptText,
startedAt: '2026-02-27T00:00:00.000Z',
sessionId: `session-${nextSession++}`,
};
agents.push(entry);
if (opts.onAgentSpawned) {
await opts.onAgentSpawned(entry);
}
}
return agents;
});
});

test('given lazy tmux window and spawn failure, should persist tmux window before agent writes', async () => {
mockedCreateWindow
.mockResolvedValueOnce('ppg-test:7')
.mockResolvedValueOnce('ppg-test:8');
mockedSpawnAgent.mockRejectedValueOnce(new Error('spawn failed'));
mockedSpawnAgentBatch.mockRejectedValueOnce(new Error('spawn failed'));

await expect(
spawnCommand({
Expand Down Expand Up @@ -188,8 +222,5 @@ describe('spawnCommand', () => {

expect(mockedUpdateManifest).toHaveBeenCalledTimes(2);
expect(Object.keys(manifestState.worktrees.wt1.agents)).toEqual(['ag-1', 'ag-2']);
expect(manifestState.worktrees.wt1.agents['ag-1'].tmuxTarget).toBe('ppg-test:2');
expect(manifestState.worktrees.wt1.agents['ag-2'].tmuxTarget).toBe('ppg-test:3');
expect(mockedEnsureSession).not.toHaveBeenCalled();
});
});
234 changes: 37 additions & 197 deletions src/commands/spawn.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
import fs from 'node:fs/promises';
import { loadConfig, resolveAgentConfig } from '../core/config.js';
import { readManifest, updateManifest, resolveWorktree } from '../core/manifest.js';
import { getRepoRoot, getCurrentBranch, createWorktree, adoptWorktree } from '../core/worktree.js';
import { getRepoRoot, getCurrentBranch, adoptWorktree } from '../core/worktree.js';
import { setupWorktreeEnv } from '../core/env.js';
import { loadTemplate, renderTemplate, type TemplateContext } from '../core/template.js';
import { spawnAgent } from '../core/agent.js';
import { loadTemplate } from '../core/template.js';
import { spawnNewWorktree, spawnAgentBatch } from '../core/spawn.js';
import * as tmux from '../core/tmux.js';
import { openTerminalWindow } from '../core/terminal.js';
import { worktreeId as genWorktreeId, agentId as genAgentId, sessionId as genSessionId } from '../lib/id.js';
import { worktreeId as genWorktreeId } from '../lib/id.js';
import { manifestPath } from '../lib/paths.js';
import { PpgError, NotInitializedError, WorktreeNotFoundError } from '../lib/errors.js';
import { output, success, info } from '../lib/output.js';
import { normalizeName } from '../lib/name.js';
import { parseVars } from '../lib/vars.js';
import type { WorktreeEntry, AgentEntry } from '../types/manifest.js';
import type { Config, AgentConfig } from '../types/config.js';
import type { AgentEntry } from '../types/manifest.js';
import type { AgentConfig } from '../types/config.js';

export interface SpawnOptions {
name?: string;
Expand Down Expand Up @@ -84,16 +84,36 @@ export async function spawnCommand(options: SpawnOptions): Promise<void> {
userVars,
);
} else {
// Create new worktree + agent(s)
await spawnNewWorktree(
// Create new worktree + agent(s) via shared core function
const result = await spawnNewWorktree({
projectRoot,
config,
agentConfig,
name: options.name ?? '',
promptText,
count,
options,
userVars,
);
agentName: options.agent,
baseBranch: options.base,
count,
split: options.split,
});

// CLI-specific: open Terminal window
if (options.open === true) {
openTerminalWindow(result.tmuxWindow.split(':')[0], result.tmuxWindow, result.name).catch(() => {});
}

emitSpawnResult({
json: options.json,
successMessage: `Spawned worktree ${result.worktreeId} with ${result.agents.length} agent(s)`,
worktree: {
id: result.worktreeId,
name: result.name,
branch: result.branch,
path: result.path,
tmuxWindow: result.tmuxWindow,
},
agents: result.agents,
attachRef: result.worktreeId,
});
}
}

Expand All @@ -111,89 +131,6 @@ async function resolvePrompt(options: SpawnOptions, projectRoot: string): Promis
throw new PpgError('One of --prompt, --prompt-file, or --template is required', 'INVALID_ARGS');
}

interface SpawnBatchOptions {
projectRoot: string;
agentConfig: AgentConfig;
promptText: string;
userVars: Record<string, string>;
count: number;
split: boolean;
worktreePath: string;
branch: string;
taskName: string;
sessionName: string;
windowTarget: string;
windowNamePrefix: string;
reuseWindowForFirstAgent: boolean;
onAgentSpawned?: (agent: AgentEntry) => Promise<void>;
}

interface SpawnTargetOptions {
index: number;
split: boolean;
reuseWindowForFirstAgent: boolean;
windowTarget: string;
sessionName: string;
windowNamePrefix: string;
worktreePath: string;
}

async function resolveAgentTarget(opts: SpawnTargetOptions): Promise<string> {
if (opts.index === 0 && opts.reuseWindowForFirstAgent) {
return opts.windowTarget;
}
if (opts.split) {
const direction = opts.index % 2 === 1 ? 'horizontal' : 'vertical';
const pane = await tmux.splitPane(opts.windowTarget, direction, opts.worktreePath);
return pane.target;
}
return tmux.createWindow(opts.sessionName, `${opts.windowNamePrefix}-${opts.index}`, opts.worktreePath);
}

async function spawnAgentBatch(opts: SpawnBatchOptions): Promise<AgentEntry[]> {
const agents: AgentEntry[] = [];
for (let i = 0; i < opts.count; i++) {
const aId = genAgentId();
const target = await resolveAgentTarget({
index: i,
split: opts.split,
reuseWindowForFirstAgent: opts.reuseWindowForFirstAgent,
windowTarget: opts.windowTarget,
sessionName: opts.sessionName,
windowNamePrefix: opts.windowNamePrefix,
worktreePath: opts.worktreePath,
});

const ctx: TemplateContext = {
WORKTREE_PATH: opts.worktreePath,
BRANCH: opts.branch,
AGENT_ID: aId,
PROJECT_ROOT: opts.projectRoot,
TASK_NAME: opts.taskName,
PROMPT: opts.promptText,
...opts.userVars,
};

const agentEntry = await spawnAgent({
agentId: aId,
agentConfig: opts.agentConfig,
prompt: renderTemplate(opts.promptText, ctx),
worktreePath: opts.worktreePath,
tmuxTarget: target,
projectRoot: opts.projectRoot,
branch: opts.branch,
sessionId: genSessionId(),
});

agents.push(agentEntry);
if (opts.onAgentSpawned) {
await opts.onAgentSpawned(agentEntry);
}
}

return agents;
}

interface EmitSpawnResultOptions {
json: boolean | undefined;
successMessage: string;
Expand Down Expand Up @@ -231,106 +168,9 @@ function emitSpawnResult(opts: EmitSpawnResultOptions): void {
}
}

async function spawnNewWorktree(
projectRoot: string,
config: Config,
agentConfig: AgentConfig,
promptText: string,
count: number,
options: SpawnOptions,
userVars: Record<string, string>,
): Promise<void> {
const baseBranch = options.base ?? await getCurrentBranch(projectRoot);
const wtId = genWorktreeId();
const name = options.name ? normalizeName(options.name, wtId) : wtId;
const branchName = `ppg/${name}`;

// Create git worktree
info(`Creating worktree ${wtId} on branch ${branchName}`);
const wtPath = await createWorktree(projectRoot, wtId, {
branch: branchName,
base: baseBranch,
});

// Setup env
await setupWorktreeEnv(projectRoot, wtPath, config);

// Ensure tmux session (manifest is the source of truth for session name)
const manifest = await readManifest(projectRoot);
const sessionName = manifest.sessionName;
await tmux.ensureSession(sessionName);

// Create tmux window
const windowTarget = await tmux.createWindow(sessionName, name, wtPath);

// Register skeleton worktree in manifest before spawning agents
// so partial failures leave a record for cleanup
const worktreeEntry: WorktreeEntry = {
id: wtId,
name,
path: wtPath,
branch: branchName,
baseBranch,
status: 'active',
tmuxWindow: windowTarget,
agents: {},
createdAt: new Date().toISOString(),
};

await updateManifest(projectRoot, (m) => {
m.worktrees[wtId] = worktreeEntry;
return m;
});

// Spawn agents — one tmux window per agent (default), or split panes (--split)
const agents = await spawnAgentBatch({
projectRoot,
agentConfig,
promptText,
userVars,
count,
split: options.split === true,
worktreePath: wtPath,
branch: branchName,
taskName: name,
sessionName,
windowTarget,
windowNamePrefix: name,
reuseWindowForFirstAgent: true,
onAgentSpawned: async (agentEntry) => {
// Update manifest incrementally after each agent spawn.
await updateManifest(projectRoot, (m) => {
if (m.worktrees[wtId]) {
m.worktrees[wtId].agents[agentEntry.id] = agentEntry;
}
return m;
});
},
});

// Only open Terminal window when explicitly requested via --open (fire-and-forget)
if (options.open === true) {
openTerminalWindow(sessionName, windowTarget, name).catch(() => {});
}

emitSpawnResult({
json: options.json,
successMessage: `Spawned worktree ${wtId} with ${agents.length} agent(s)`,
worktree: {
id: wtId,
name,
branch: branchName,
path: wtPath,
tmuxWindow: windowTarget,
},
agents,
attachRef: wtId,
});
}

async function spawnOnExistingBranch(
projectRoot: string,
config: Config,
config: import('../types/config.js').Config,
agentConfig: AgentConfig,
branch: string,
promptText: string,
Expand Down Expand Up @@ -361,15 +201,15 @@ async function spawnOnExistingBranch(
const windowTarget = await tmux.createWindow(sessionName, name, wtPath);

// Register worktree in manifest
const worktreeEntry: WorktreeEntry = {
const worktreeEntry = {
id: wtId,
name,
path: wtPath,
branch,
baseBranch,
status: 'active',
status: 'active' as const,
tmuxWindow: windowTarget,
agents: {},
agents: {} as Record<string, AgentEntry>,
createdAt: new Date().toISOString(),
};

Expand Down
Loading
Loading