diff --git a/packages/pi-coding-agent/src/core/package-manager.ts b/packages/pi-coding-agent/src/core/package-manager.ts index da19d3d94..44209e04f 100644 --- a/packages/pi-coding-agent/src/core/package-manager.ts +++ b/packages/pi-coding-agent/src/core/package-manager.ts @@ -414,7 +414,13 @@ function resolveExtensionEntries(dir: string): string[] | null { const packageJsonPath = join(dir, "package.json"); if (existsSync(packageJsonPath)) { const manifest = readPiManifestFile(packageJsonPath); - if (manifest?.extensions?.length) { + if (manifest) { + // When a pi manifest exists, it is authoritative — don't fall through + // to index.ts/index.js auto-detection. This allows library directories + // (like cmux) to opt out by declaring "pi": {} with no extensions. + if (!manifest.extensions?.length) { + return null; + } const entries: string[] = []; for (const extPath of manifest.extensions) { const resolvedExtPath = resolve(dir, extPath); @@ -422,9 +428,7 @@ function resolveExtensionEntries(dir: string): string[] | null { entries.push(resolvedExtPath); } } - if (entries.length > 0) { - return entries; - } + return entries.length > 0 ? entries : null; } } diff --git a/scripts/copy-resources.cjs b/scripts/copy-resources.cjs index 56d728a40..4ff932917 100644 --- a/scripts/copy-resources.cjs +++ b/scripts/copy-resources.cjs @@ -18,6 +18,23 @@ function copyNonTsFiles(srcDir, destDir) { } mkdirSync(dirname(destPath), { recursive: true }); + + // Rewrite pi.extensions paths from .ts to .js in package.json files + // so they match the compiled output (tsc compiles index.ts → index.js + // but package.json is copied as-is). + if (entry.name === 'package.json') { + try { + const pkg = JSON.parse(require('fs').readFileSync(srcPath, 'utf-8')); + if (Array.isArray(pkg?.pi?.extensions)) { + pkg.pi.extensions = pkg.pi.extensions.map(ext => + ext.replace(/\.ts$/, '.js').replace(/\.tsx$/, '.js') + ); + require('fs').writeFileSync(destPath, JSON.stringify(pkg, null, 2) + '\n'); + continue; + } + } catch { /* fall through to plain copy */ } + } + copyFileSync(srcPath, destPath); } } diff --git a/src/extension-discovery.ts b/src/extension-discovery.ts index 8e586a052..23a52ec0d 100644 --- a/src/extension-discovery.ts +++ b/src/extension-discovery.ts @@ -8,24 +8,29 @@ function isExtensionFile(name: string): boolean { /** * Resolves the entry-point file(s) for a single extension directory. * - * 1. If the directory contains a package.json with a `pi.extensions` array, - * each entry is resolved relative to the directory and returned (if it exists). - * 2. Otherwise falls back to `index.ts` → `index.js`. + * 1. If the directory contains a package.json with a `pi` manifest object, + * the manifest is authoritative: + * - `pi.extensions` array → resolve each entry relative to the directory. + * - `pi: {}` (no extensions) → return empty (library opt-out, e.g. cmux). + * 2. Only when no `pi` manifest exists does it fall back to `index.ts` → `index.js`. */ export function resolveExtensionEntries(dir: string): string[] { const packageJsonPath = join(dir, 'package.json') if (existsSync(packageJsonPath)) { try { const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) - const declared = pkg?.pi?.extensions - if (Array.isArray(declared)) { - const resolved = declared + if (pkg?.pi && typeof pkg.pi === 'object') { + // When a pi manifest exists, it is authoritative — don't fall through + // to index.ts/index.js auto-detection. This allows library directories + // (like cmux) to opt out by declaring "pi": {} with no extensions. + const declared = pkg.pi.extensions + if (!Array.isArray(declared) || declared.length === 0) { + return [] + } + return declared .filter((entry: unknown): entry is string => typeof entry === 'string') .map((entry: string) => resolve(dir, entry)) .filter((entry: string) => existsSync(entry)) - if (resolved.length > 0) { - return resolved - } } } catch { // Ignore malformed manifests and fall back to index.ts/index.js discovery. diff --git a/src/resources/extensions/cmux/index.ts b/src/resources/extensions/cmux/index.ts index bac7d2f81..ca13d329b 100644 --- a/src/resources/extensions/cmux/index.ts +++ b/src/resources/extensions/cmux/index.ts @@ -289,10 +289,17 @@ export class CmuxClient { } async createSplit(direction: "right" | "down" | "left" | "up"): Promise { + return this.createSplitFrom(this.config.surfaceId, direction); + } + + async createSplitFrom( + sourceSurfaceId: string | undefined, + direction: "right" | "down" | "left" | "up", + ): Promise { if (!this.config.splits) return null; const before = new Set(await this.listSurfaceIds()); const args = ["new-split", direction]; - const scopedArgs = this.appendSurface(this.appendWorkspace(args), this.config.surfaceId); + const scopedArgs = this.appendSurface(this.appendWorkspace(args), sourceSurfaceId); await this.runAsync(scopedArgs); const after = await this.listSurfaceIds(); for (const id of after) { @@ -301,6 +308,55 @@ export class CmuxClient { return null; } + /** + * Create a grid of surfaces for parallel agent execution. + * + * Layout strategy (gsd stays in the original surface): + * 1 agent: [gsd | A] + * 2 agents: [gsd | A] + * [ | B] + * 3 agents: [gsd | A] + * [ C | B] + * 4 agents: [gsd | A] + * [ C | B] (D splits from B downward) + * [ | D] + * + * Returns surface IDs in order, or empty array on failure. + */ + async createGridLayout(count: number): Promise { + if (!this.config.splits || count <= 0) return []; + const surfaces: string[] = []; + + // First split: create right column from the gsd surface + const rightCol = await this.createSplitFrom(this.config.surfaceId, "right"); + if (!rightCol) return []; + surfaces.push(rightCol); + if (count === 1) return surfaces; + + // Second split: split right column down → bottom-right + const bottomRight = await this.createSplitFrom(rightCol, "down"); + if (!bottomRight) return surfaces; + surfaces.push(bottomRight); + if (count === 2) return surfaces; + + // Third split: split gsd surface down → bottom-left + const bottomLeft = await this.createSplitFrom(this.config.surfaceId, "down"); + if (!bottomLeft) return surfaces; + surfaces.push(bottomLeft); + if (count === 3) return surfaces; + + // Fourth+: split subsequent surfaces down from the last created + let lastSurface = bottomRight; + for (let i = 3; i < count; i++) { + const next = await this.createSplitFrom(lastSurface, "down"); + if (!next) break; + surfaces.push(next); + lastSurface = next; + } + + return surfaces; + } + async sendSurface(surfaceId: string, text: string): Promise { const payload = text.endsWith("\n") ? text : `${text}\n`; const stdout = await this.runAsync(["send-surface", "--surface", surfaceId, payload]); diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index a8973c080..e48eb68ff 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -159,7 +159,7 @@ async function guardRemoteSession( export function registerGSDCommand(pi: ExtensionAPI): void { pi.registerCommand("gsd", { - description: "GSD — Get Shit Done: /gsd help|start|templates|next|auto|stop|pause|status|visualize|queue|quick|capture|triage|dispatch|history|undo|skip|export|cleanup|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|forensics|changelog|migrate|remote|steer|knowledge|new-milestone|parallel|cmux|update", + description: "GSD — Get Shit Done: /gsd help|start|templates|next|auto|stop|pause|status|widget|visualize|queue|quick|capture|triage|dispatch|history|undo|rate|skip|export|cleanup|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|logs|forensics|changelog|migrate|remote|steer|knowledge|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update", getArgumentCompletions: (prefix: string) => { const subcommands = [ { cmd: "help", desc: "Categorized command reference with descriptions" }, @@ -210,7 +210,11 @@ export function registerGSDCommand(pi: ExtensionAPI): void { { cmd: "templates", desc: "List available workflow templates" }, { cmd: "extensions", desc: "Manage extensions (list, enable, disable, info)" }, ]; + const hasTrailingSpace = prefix.endsWith(" "); const parts = prefix.trim().split(/\s+/); + if (hasTrailingSpace && parts.length >= 1) { + parts.push(""); + } if (parts.length <= 1) { return subcommands @@ -536,6 +540,18 @@ export function registerGSDCommand(pi: ExtensionAPI): void { .map((p) => ({ value: `dispatch ${p.cmd}`, label: p.cmd, description: p.desc })); } + if (parts[0] === "rate" && parts.length <= 2) { + const tierPrefix = parts[1] ?? ""; + const tiers = [ + { cmd: "over", desc: "Model was overqualified for this task" }, + { cmd: "ok", desc: "Model was appropriate for this task" }, + { cmd: "under", desc: "Model was underqualified for this task" }, + ]; + return tiers + .filter((t) => t.cmd.startsWith(tierPrefix)) + .map((t) => ({ value: `rate ${t.cmd}`, label: t.cmd, description: t.desc })); + } + return []; }, diff --git a/src/resources/extensions/gsd/tests/cmux.test.ts b/src/resources/extensions/gsd/tests/cmux.test.ts index d174285b1..f25953542 100644 --- a/src/resources/extensions/gsd/tests/cmux.test.ts +++ b/src/resources/extensions/gsd/tests/cmux.test.ts @@ -100,6 +100,99 @@ test("buildCmuxStatusLabel and progress prefer deepest active unit", () => { assert.deepEqual(buildCmuxProgress(state), { value: 0.4, label: "2/5 tasks" }); }); +describe("createGridLayout", () => { + // Create a mock CmuxClient that tracks createSplitFrom calls + function makeMockClient() { + let nextId = 1; + const calls: Array<{ source: string | undefined; direction: string }> = []; + + const client = { + calls, + async createGridLayout(count: number) { + // Simulate the grid layout logic with a fake client + if (count <= 0) return []; + const surfaces: string[] = []; + + const createSplitFrom = async (source: string | undefined, direction: string) => { + calls.push({ source, direction }); + return `surface-${nextId++}`; + }; + + const rightCol = await createSplitFrom("gsd-surface", "right"); + surfaces.push(rightCol); + if (count === 1) return surfaces; + + const bottomRight = await createSplitFrom(rightCol, "down"); + surfaces.push(bottomRight); + if (count === 2) return surfaces; + + const bottomLeft = await createSplitFrom("gsd-surface", "down"); + surfaces.push(bottomLeft); + if (count === 3) return surfaces; + + let lastSurface = bottomRight; + for (let i = 3; i < count; i++) { + const next = await createSplitFrom(lastSurface, "down"); + surfaces.push(next); + lastSurface = next; + } + + return surfaces; + }, + }; + return client; + } + + test("1 agent creates single right split", async () => { + const mock = makeMockClient(); + const surfaces = await mock.createGridLayout(1); + assert.equal(surfaces.length, 1); + assert.deepEqual(mock.calls, [ + { source: "gsd-surface", direction: "right" }, + ]); + }); + + test("2 agents creates right column then splits it down", async () => { + const mock = makeMockClient(); + const surfaces = await mock.createGridLayout(2); + assert.equal(surfaces.length, 2); + assert.deepEqual(mock.calls, [ + { source: "gsd-surface", direction: "right" }, + { source: "surface-1", direction: "down" }, + ]); + }); + + test("3 agents creates 2x2 grid (gsd + 3 agent surfaces)", async () => { + const mock = makeMockClient(); + const surfaces = await mock.createGridLayout(3); + assert.equal(surfaces.length, 3); + assert.deepEqual(mock.calls, [ + { source: "gsd-surface", direction: "right" }, + { source: "surface-1", direction: "down" }, + { source: "gsd-surface", direction: "down" }, + ]); + }); + + test("4 agents creates 2x2 grid with extra split", async () => { + const mock = makeMockClient(); + const surfaces = await mock.createGridLayout(4); + assert.equal(surfaces.length, 4); + assert.deepEqual(mock.calls, [ + { source: "gsd-surface", direction: "right" }, + { source: "surface-1", direction: "down" }, + { source: "gsd-surface", direction: "down" }, + { source: "surface-2", direction: "down" }, + ]); + }); + + test("0 agents returns empty", async () => { + const mock = makeMockClient(); + const surfaces = await mock.createGridLayout(0); + assert.equal(surfaces.length, 0); + assert.equal(mock.calls.length, 0); + }); +}); + describe("cmux extension discovery opt-out", () => { test("cmux directory has package.json with pi manifest to prevent auto-discovery as extension", () => { const cmuxDir = path.resolve( diff --git a/src/resources/extensions/subagent/index.ts b/src/resources/extensions/subagent/index.ts index b60e8b2d6..c9609572f 100644 --- a/src/resources/extensions/subagent/index.ts +++ b/src/resources/extensions/subagent/index.ts @@ -452,7 +452,7 @@ async function runSingleAgent( async function runSingleAgentInCmuxSplit( cmuxClient: CmuxClient, - direction: "right" | "down", + directionOrSurfaceId: "right" | "down" | string, defaultCwd: string, agents: AgentConfig[], agentName: string, @@ -503,7 +503,12 @@ async function runSingleAgentInCmuxSplit( const stdoutPath = path.join(tmpOutputDir, "stdout.jsonl"); const stderrPath = path.join(tmpOutputDir, "stderr.log"); const exitPath = path.join(tmpOutputDir, "exit.code"); - const cmuxSurfaceId = await cmuxClient.createSplit(direction); + // Accept either a pre-created surface ID or a direction to create a new split + const isDirection = directionOrSurfaceId === "right" || directionOrSurfaceId === "down" + || directionOrSurfaceId === "left" || directionOrSurfaceId === "up"; + const cmuxSurfaceId = isDirection + ? await cmuxClient.createSplit(directionOrSurfaceId as "right" | "down" | "left" | "up") + : directionOrSurfaceId; if (!cmuxSurfaceId) { return runSingleAgent(defaultCwd, agents, agentName, task, cwd, step, signal, onUpdate, makeDetails); } @@ -806,12 +811,16 @@ export default function (pi: ExtensionAPI) { const MAX_RETRIES = 1; // Retry failed tasks once const batchId = crypto.randomUUID(); const batchSize = params.tasks.length; + // Pre-create a grid layout for cmux splits so agents get a clean tiled arrangement + const gridSurfaces = cmuxSplitsEnabled + ? await cmuxClient.createGridLayout(Math.min(batchSize, MAX_CONCURRENCY)) + : []; const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, async (t, index) => { const workerId = registerWorker(t.agent, t.task, index, batchSize, batchId); const runTask = () => cmuxSplitsEnabled ? runSingleAgentInCmuxSplit( cmuxClient, - index % 2 === 0 ? "right" : "down", + gridSurfaces[index] ?? (index % 2 === 0 ? "right" : "down"), ctx.cwd, agents, t.agent, diff --git a/src/tests/extension-discovery.test.ts b/src/tests/extension-discovery.test.ts new file mode 100644 index 000000000..b3744c5ba --- /dev/null +++ b/src/tests/extension-discovery.test.ts @@ -0,0 +1,121 @@ +import test, { describe } from 'node:test' +import assert from 'node:assert/strict' +import { mkdirSync, writeFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { resolveExtensionEntries, discoverExtensionEntryPaths } from '../extension-discovery.ts' + +function makeTempDir(): string { + const dir = join(tmpdir(), `ext-discovery-test-${Date.now()}-${Math.random().toString(36).slice(2)}`) + mkdirSync(dir, { recursive: true }) + return dir +} + +describe('resolveExtensionEntries', () => { + test('returns index.ts when no package.json exists', () => { + const dir = makeTempDir() + try { + writeFileSync(join(dir, 'index.ts'), 'export default function() {}') + const entries = resolveExtensionEntries(dir) + assert.equal(entries.length, 1) + assert.ok(entries[0].endsWith('index.ts')) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + test('returns index.js when no package.json and no index.ts', () => { + const dir = makeTempDir() + try { + writeFileSync(join(dir, 'index.js'), 'module.exports = function() {}') + const entries = resolveExtensionEntries(dir) + assert.equal(entries.length, 1) + assert.ok(entries[0].endsWith('index.js')) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + test('returns declared extensions from pi.extensions array', () => { + const dir = makeTempDir() + try { + writeFileSync(join(dir, 'package.json'), JSON.stringify({ + pi: { extensions: ['main.js'] } + })) + writeFileSync(join(dir, 'main.js'), 'module.exports = function() {}') + writeFileSync(join(dir, 'index.js'), 'should not be returned') + const entries = resolveExtensionEntries(dir) + assert.equal(entries.length, 1) + assert.ok(entries[0].endsWith('main.js')) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + test('returns empty array when pi manifest has no extensions (library opt-out)', () => { + const dir = makeTempDir() + try { + writeFileSync(join(dir, 'package.json'), JSON.stringify({ + name: '@gsd/cmux', + pi: {} + })) + writeFileSync(join(dir, 'index.js'), 'export function utility() {}') + const entries = resolveExtensionEntries(dir) + assert.equal(entries.length, 0, 'pi: {} should opt out of extension discovery') + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + test('returns empty array when pi.extensions is an empty array', () => { + const dir = makeTempDir() + try { + writeFileSync(join(dir, 'package.json'), JSON.stringify({ + pi: { extensions: [] } + })) + writeFileSync(join(dir, 'index.js'), 'should not be returned') + const entries = resolveExtensionEntries(dir) + assert.equal(entries.length, 0) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + test('falls back to index.ts when package.json has no pi field', () => { + const dir = makeTempDir() + try { + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'some-pkg' })) + writeFileSync(join(dir, 'index.ts'), 'export default function() {}') + const entries = resolveExtensionEntries(dir) + assert.equal(entries.length, 1) + assert.ok(entries[0].endsWith('index.ts')) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}) + +describe('discoverExtensionEntryPaths', () => { + test('skips library directories with pi: {} opt-out', () => { + const root = makeTempDir() + try { + // Real extension + const extDir = join(root, 'my-ext') + mkdirSync(extDir) + writeFileSync(join(extDir, 'index.js'), 'module.exports = function() {}') + + // Library with opt-out (like cmux) + const libDir = join(root, 'cmux') + mkdirSync(libDir) + writeFileSync(join(libDir, 'package.json'), JSON.stringify({ pi: {} })) + writeFileSync(join(libDir, 'index.js'), 'export function utility() {}') + + const paths = discoverExtensionEntryPaths(root) + assert.equal(paths.length, 1, 'should discover my-ext but skip cmux') + assert.ok(paths[0].includes('my-ext')) + assert.ok(!paths.some(p => p.includes('cmux')), 'cmux should not be discovered') + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) +})