feat: let agents start new sessions via spawn_session tool

Add a project-scoped spawn_session tool so agents can dispatch new,
independent sessions (ralph loops, long-plan chaining). Spawned sessions
are constrained to a workspace/worktree of the same registered project,
appear in the session list immediately via a new session.created event,
and the capability is on by default with a Settings -> Session daemon
toggle (spawnSessions / PI_WEB_SPAWN_SESSIONS).

Note: adds a session daemon code path, so pi-web-sessiond.service must be
restarted manually for the server side to take effect.
This commit is contained in:
Federico Jaramillo Martinez
2026-06-16 14:53:37 +02:00
parent bfcd975c83
commit 95c151233a
26 changed files with 630 additions and 18 deletions
+3 -3
View File
@@ -37,11 +37,11 @@ describe("config routes", () => {
const response = await app.inject({
method: "PUT",
url: "/api/config",
payload: { config: { host: "0.0.0.0", port: 9000, allowedHosts: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } } } },
payload: { config: { host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } } } },
});
expect(response.statusCode).toBe(200);
expect(savedConfig).toEqual({ host: "0.0.0.0", port: 9000, allowedHosts: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } } });
expect(savedConfig).toEqual({ host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } } });
expect(response.json<PiWebConfigResponse>().config).toEqual(savedConfig);
});
@@ -64,6 +64,6 @@ function responseFor(config: PiWebConfigValues, exists: boolean): PiWebConfigRes
exists,
config,
effectiveConfig: config,
envOverrides: { host: false, port: false, allowedHosts: false },
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false },
};
}
+6
View File
@@ -58,6 +58,7 @@ function parseConfigRequest(value: unknown): PiWebConfig {
const allowedHosts = value["allowedHosts"];
const shortcuts = value["shortcuts"];
const plugins = value["plugins"];
const spawnSessions = value["spawnSessions"];
if (host !== undefined) {
if (typeof host !== "string") throw new Error("PI WEB config host must be a string");
config.host = host;
@@ -69,6 +70,10 @@ function parseConfigRequest(value: unknown): PiWebConfig {
if (allowedHosts !== undefined) config.allowedHosts = parseAllowedHostsRequest(allowedHosts);
if (shortcuts !== undefined) config.shortcuts = parseShortcutsRequest(shortcuts);
if (plugins !== undefined) config.plugins = parsePluginsRequest(plugins);
if (spawnSessions !== undefined) {
if (typeof spawnSessions !== "boolean") throw new Error("PI WEB config spawnSessions must be a boolean");
config.spawnSessions = spawnSessions;
}
return config;
}
@@ -106,6 +111,7 @@ function piWebConfigEnvOverrides(env: NodeJS.ProcessEnv): PiWebConfigEnvOverride
host: isEnvSet(env["PI_WEB_HOST"]),
port: isEnvSet(env["PI_WEB_PORT"]) || isEnvSet(env["PORT"]),
allowedHosts: isEnvSet(env["PI_WEB_ALLOWED_HOSTS"]),
spawnSessions: isEnvSet(env["PI_WEB_SPAWN_SESSIONS"]),
};
}
+15 -2
View File
@@ -10,12 +10,16 @@ import { AuthService } from "./sessions/authService.js";
import { registerAuthRoutes } from "./sessions/authRoutes.js";
import { PiSessionService } from "./sessions/piSessionService.js";
import { registerSessionRoutes } from "./sessions/sessionRoutes.js";
import { ProjectScopedSpawnTargetResolver } from "./sessions/spawnTargetResolver.js";
import { ProjectService } from "./projects/projectService.js";
import { ProjectStore } from "./storage/projectStore.js";
import { WorkspaceService } from "./workspaces/workspaceService.js";
import { sessiondSocketPath } from "../sessiond/config.js";
import { TerminalService } from "./terminals/terminalService.js";
import { registerTerminalRoutes } from "./terminals/terminalRoutes.js";
import { getPiWebRuntimeComponent } from "./piWebStatus.js";
import { SESSIOND_RUNTIME_CAPABILITIES } from "../shared/capabilities.js";
import { maxUploadBytes } from "../config.js";
import { effectivePiWebConfig, maxUploadBytes, spawnSessionsEnabled } from "../config.js";
const app = Fastify({ logger: true, bodyLimit: maxUploadBytes() });
await app.register(fastifyWebsocket);
@@ -23,7 +27,16 @@ await app.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const workspaceActivity = new WorkspaceActivityService(eventHub);
const auth = new AuthService();
const sessions = new PiSessionService(eventHub, { modelRegistry: auth.modelRegistry, workspaceActivity });
const { config } = effectivePiWebConfig();
const spawnTargets = spawnSessionsEnabled(process.env, config)
? new ProjectScopedSpawnTargetResolver({ projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() })
: undefined;
const sessions = new PiSessionService(eventHub, {
modelRegistry: auth.modelRegistry,
workspaceActivity,
logger: app.log,
...(spawnTargets === undefined ? {} : { spawnTargets }),
});
auth.subscribe((change) => { sessions.applyAuthChange(change); });
const terminals = new TerminalService(eventHub, workspaceActivity);
registerWorkspaceActivityRoutes(app, workspaceActivity);
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
import type { GlobalSessionEvent, SessionUiEvent } from "../../shared/apiTypes.js";
import { SessionEventHub } from "../realtime/sessionEventHub.js";
import { PiSessionService, type PiAgentSession, type PiSessionManager, type PiSessionRuntime, type PiSessionServiceDependencies } from "./piSessionService.js";
import type { SpawnTargetDecision } from "./spawnTargetResolver.js";
class CapturingSessionEventHub extends SessionEventHub {
readonly sessionEvents: { sessionId: string; event: SessionUiEvent }[] = [];
@@ -158,6 +159,7 @@ describe("PiSessionService", () => {
expect(session).toMatchObject({ id: "session-1", cwd: "/workspace", messageCount: 0 });
expect(service.activeCount()).toBe(1);
expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "session-1")).toBe(true);
expect(hub.globalEvents.some((event) => event.type === "session.created" && event.session.id === "session-1" && event.session.cwd === "/workspace")).toBe(true);
await service.dispose();
expect(fake.calls.abort).toBe(1);
@@ -747,4 +749,61 @@ describe("PiSessionService", () => {
expect(fake.calls.clearQueue).toBe(1);
await service.dispose();
});
describe("spawnSession", () => {
function spawnService(decision: SpawnTargetDecision) {
const fake = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl" });
const log: { details: Record<string, unknown>; message: string }[] = [];
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
spawnTargets: { resolveSpawnTarget: () => Promise.resolve(decision) },
logger: { info: (details, message) => { log.push({ details, message }); } },
heartbeatIntervalMs: 60_000,
});
return { fake, service, log };
}
it("starts a session at the resolved target, delivers the prompt, and logs the spawn", async () => {
const { fake, service, log } = spawnService({ allowed: true, cwd: "/workspace-feature" });
const result = await service.spawnSession({ spawningCwd: "/workspace", prompt: "continue the plan", cwd: "/workspace-feature" });
expect(result).toEqual({ sessionId: "spawned-1", cwd: "/workspace-feature" });
expect(fake.calls.prompt).toEqual([{ text: "continue the plan", options: undefined }]);
expect(log).toEqual([{ details: { spawningCwd: "/workspace", sessionId: "spawned-1", cwd: "/workspace-feature", promptLength: 17 }, message: "spawn_session started a new session" }]);
await service.dispose();
});
it("rejects an out-of-project target without starting a session", async () => {
const { fake, service } = spawnService({ allowed: false, reason: "out-of-project", allowedCwds: ["/workspace"] });
await expect(service.spawnSession({ spawningCwd: "/workspace", prompt: "go", cwd: "/elsewhere" }))
.rejects.toThrow("cwd must be a workspace of this project. Allowed: /workspace");
expect(fake.calls.prompt).toEqual([]);
expect(service.activeCount()).toBe(0);
await service.dispose();
});
it("rejects when the spawning session is not in a registered project", async () => {
const { service } = spawnService({ allowed: false, reason: "not-registered" });
await expect(service.spawnSession({ spawningCwd: "/workspace", prompt: "go", cwd: undefined }))
.rejects.toThrow("Spawning session is not in a registered project");
await service.dispose();
});
it("is disabled when no spawn target resolver is configured", async () => {
const fake = fakeRuntime("spawned-x");
const service = new PiSessionService(new CapturingSessionEventHub(), {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
});
await expect(service.spawnSession({ spawningCwd: "/workspace", prompt: "go", cwd: undefined }))
.rejects.toThrow("Spawning sessions is disabled");
await service.dispose();
});
});
});
+65 -4
View File
@@ -31,11 +31,29 @@ import type { SavedPromptAttachment } from "../../shared/apiTypes.js";
import { cwdPathsEqual } from "../workingDirectory.js";
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
import { createSpawnSessionToolDefinition, type SpawnSessionInvocation, type SpawnSessionResult } from "./spawnSessionTool.js";
import type { SpawnTargetDecision, SpawnTargetResolver } from "./spawnTargetResolver.js";
/**
* Minimal structured-logging seam, shaped like Fastify's logger so sessiond can
* pass `app.log` directly. Defaults to a no-op so the service stays usable
* without booting a server (e.g. in tests).
*/
export interface PiSessionLogger {
info(details: Record<string, unknown>, message: string): void;
}
const noopLogger: PiSessionLogger = { info() { /* no-op */ } };
function noop(): void {
// Intentionally empty default unsubscribe callback.
}
function spawnTargetError(decision: Extract<SpawnTargetDecision, { allowed: false }>): Error {
if (decision.reason === "not-registered") return new Error("Spawning session is not in a registered project");
return new Error(`cwd must be a workspace of this project. Allowed: ${decision.allowedCwds.join(", ")}`);
}
function authLossWarningKey(sessionId: string, provider: string, modelId: string): string {
return `${sessionId}:${provider}/${modelId}`;
}
@@ -187,10 +205,15 @@ function defaultCreateAgentRuntime(createRuntime: CreateAgentSessionRuntimeFacto
return createAgentSessionRuntime(createRuntime, { ...options, sessionManager: options.sessionManager });
}
function createDefaultRuntimeFactory(authStorage: AuthStorage, modelRegistry: ModelRegistryInstance): CreateAgentSessionRuntimeFactory {
type SpawnSessionFn = (input: SpawnSessionInvocation) => Promise<SpawnSessionResult>;
function createDefaultRuntimeFactory(authStorage: AuthStorage, modelRegistry: ModelRegistryInstance, spawn?: SpawnSessionFn): CreateAgentSessionRuntimeFactory {
return async ({ cwd, agentDir, sessionManager, sessionStartEvent }) => {
const services = await createAgentSessionServices({ cwd, agentDir, authStorage, modelRegistry });
const customTools = [createPiWebEditToolDefinition(cwd)];
const customTools = [
createPiWebEditToolDefinition(cwd),
...(spawn === undefined ? [] : [createSpawnSessionToolDefinition(cwd, { spawn })]),
];
const options = sessionStartEvent === undefined
? { services, sessionManager, customTools }
: { services, sessionManager, sessionStartEvent, customTools };
@@ -232,6 +255,14 @@ export interface PiSessionServiceDependencies {
modelRegistry?: ModelRegistryInstance;
heartbeatIntervalMs?: number;
workspaceActivity?: Pick<WorkspaceActivityService, "applySessionStatus" | "applySessionActivity" | "removeSession" | "reconcileSessionActivity">;
/**
* When provided, the `spawn_session` tool is registered on every session,
* letting the LLM start new sessions scoped to its project's workspaces.
* Omit to keep the capability disabled (the tool is never registered).
*/
spawnTargets?: SpawnTargetResolver;
/** Structured logger for notable runtime events (e.g. spawns). */
logger?: PiSessionLogger;
}
export class PiSessionService {
@@ -249,13 +280,21 @@ export class PiSessionService {
private readonly createAgentRuntime: CreateAgentRuntime;
private readonly modelRegistry: ModelRegistryInstance;
private readonly workspaceActivity: Pick<WorkspaceActivityService, "applySessionStatus" | "applySessionActivity" | "removeSession" | "reconcileSessionActivity"> | undefined;
private readonly spawnTargets: SpawnTargetResolver | undefined;
private readonly logger: PiSessionLogger;
constructor(private readonly events: SessionEventHub, deps: PiSessionServiceDependencies = {}) {
this.archiveStore = deps.archiveStore ?? new SessionArchiveStore();
this.agentDir = deps.agentDir ?? getAgentDir();
this.sessionManager = deps.sessionManager ?? createPiSessionManagerGateway({ agentDir: this.agentDir });
this.modelRegistry = deps.modelRegistry ?? ModelRegistry.create(AuthStorage.create());
this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(this.modelRegistry.authStorage, this.modelRegistry);
this.spawnTargets = deps.spawnTargets;
this.logger = deps.logger ?? noopLogger;
this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(
this.modelRegistry.authStorage,
this.modelRegistry,
this.spawnTargets === undefined ? undefined : (input) => this.spawnSession(input),
);
this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime;
this.workspaceActivity = deps.workspaceActivity;
this.heartbeat = setInterval(() => { this.publishHeartbeats(); }, deps.heartbeatIntervalMs ?? 2000);
@@ -318,7 +357,7 @@ export class PiSessionService {
async start(cwd: string): Promise<ClientSession> {
const active = await this.create(this.sessionManager.create(cwd), cwd);
const { session } = active.runtime;
return {
const created: ClientSession = {
id: session.sessionId,
path: session.sessionFile ?? "",
cwd,
@@ -327,6 +366,28 @@ export class PiSessionService {
messageCount: session.messages.length,
firstMessage: "",
};
// Broadcast so other clients (and the spawning agent's UI) can add the new
// session to their list without a manual reload.
this.events.publishGlobal({ type: "session.created", session: created });
return created;
}
/**
* Start a new session on behalf of a LLM and deliver an initial prompt to it.
* The target cwd is constrained to a workspace of the same registered project
* as the spawning session so the new session is visible in the web UI.
*/
async spawnSession(input: SpawnSessionInvocation): Promise<SpawnSessionResult> {
if (this.spawnTargets === undefined) throw new Error("Spawning sessions is disabled");
const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
if (!decision.allowed) throw spawnTargetError(decision);
const created = await this.start(decision.cwd);
await this.prompt(created.id, input.prompt);
this.logger.info(
{ spawningCwd: input.spawningCwd, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length },
"spawn_session started a new session",
);
return { sessionId: created.id, cwd: decision.cwd };
}
async messages(ref: PiSessionLookup, page?: { before?: number; limit?: number }): Promise<unknown[] | ClientMessagePage> {
@@ -0,0 +1,37 @@
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import { describe, expect, it, vi } from "vitest";
import { createSpawnSessionToolDefinition } from "./spawnSessionTool.js";
// The spawn tool's execute() never reads ctx, so an empty stub is sufficient.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub; execute() does not use ctx.
const ctx = {} as ExtensionContext;
describe("createSpawnSessionToolDefinition", () => {
it("passes the spawning cwd and params to the spawn callback and reports success", async () => {
const spawn = vi.fn(() => Promise.resolve({ sessionId: "new-1", cwd: "/repos/a-feature" }));
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
const result = await tool.execute("call-1", { prompt: "do the thing", cwd: "/repos/a-feature" }, undefined, undefined, ctx);
expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", prompt: "do the thing", cwd: "/repos/a-feature" });
expect(result.details).toEqual({ sessionId: "new-1", cwd: "/repos/a-feature" });
expect(result.content[0]).toMatchObject({ type: "text", text: "Started session new-1 in /repos/a-feature." });
});
it("defaults cwd to undefined so the service falls back to the spawning cwd", async () => {
const spawn = vi.fn(() => Promise.resolve({ sessionId: "new-2", cwd: "/repos/a" }));
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
await tool.execute("call-2", { prompt: "continue" }, undefined, undefined, ctx);
expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", prompt: "continue", cwd: undefined });
});
it("propagates the spawn callback error so the agent loop reports it", async () => {
const spawn = vi.fn(() => Promise.reject(new Error("cwd must be a workspace of this project. Allowed: /repos/a")));
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
await expect(tool.execute("call-3", { prompt: "x", cwd: "/elsewhere" }, undefined, undefined, ctx))
.rejects.toThrow("cwd must be a workspace of this project. Allowed: /repos/a");
});
});
+54
View File
@@ -0,0 +1,54 @@
import { Type } from "typebox";
import { defineTool } from "@earendil-works/pi-coding-agent";
export interface SpawnSessionResult {
sessionId: string;
cwd: string;
}
export interface SpawnSessionInvocation {
spawningCwd: string;
prompt: string;
cwd: string | undefined;
}
export interface SpawnSessionToolDeps {
spawn(input: SpawnSessionInvocation): Promise<SpawnSessionResult>;
}
type SpawnSessionToolDetails = SpawnSessionResult;
const SpawnSessionParams = Type.Object({
prompt: Type.String({
description: "The first instruction to send to the newly created session. The new session runs independently; you do not receive its output.",
}),
cwd: Type.Optional(Type.String({
description: "Working directory for the new session. Must be a workspace (worktree, or root) of the same project as this session. Defaults to this session's working directory.",
})),
});
/**
* Custom tool that lets the LLM start a new, independent pi-web session and
* deliver an initial prompt to it. The spawned session is a normal pi-web session
* a human can open and interact with. The tool is constructed per-session, so it
* carries the spawning session's cwd for project-scope validation.
*/
export function createSpawnSessionToolDefinition(spawningCwd: string, deps: SpawnSessionToolDeps) {
return defineTool<typeof SpawnSessionParams, SpawnSessionToolDetails>({
name: "spawn_session",
label: "Spawn session",
description: "Start a new, independent pi-web session and send it an initial prompt. Use this to dispatch a fresh agent to continue work or follow a plan. The new session runs on its own and a human can interact with it; you do not receive its output.",
promptSnippet: "spawn_session: start a new independent session with a first prompt",
parameters: SpawnSessionParams,
async execute(_toolCallId, params) {
// Failures throw: the agent loop turns the thrown message into an error
// tool result the model sees, so the spawning agent can adapt (e.g. pick a
// valid workspace) rather than crash.
const result = await deps.spawn({ spawningCwd, prompt: params.prompt, cwd: params.cwd });
return {
content: [{ type: "text", text: `Started session ${result.sessionId} in ${result.cwd}.` }],
details: result,
};
},
});
}
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import type { Project, Workspace } from "../types.js";
import { ProjectScopedSpawnTargetResolver } from "./spawnTargetResolver.js";
function project(id: string, path: string): Project {
return { id, name: id, path, createdAt: "2026-01-01T00:00:00.000Z" };
}
function workspace(projectId: string, path: string): Workspace {
return { id: `${projectId}:${path}`, projectId, path, label: path, isMain: false, isGitRepo: true, isGitWorktree: true };
}
function resolverFor(projects: Project[], workspacesByProject: Record<string, Workspace[]>): ProjectScopedSpawnTargetResolver {
return new ProjectScopedSpawnTargetResolver({
projects: { list: () => Promise.resolve(projects) },
workspaces: { list: (p) => Promise.resolve(workspacesByProject[p.id] ?? []) },
});
}
describe("ProjectScopedSpawnTargetResolver", () => {
it("allows a target that is a workspace of the spawning session's project", async () => {
const resolver = resolverFor([project("a", "/repos/a"), project("b", "/repos/b")], {
a: [workspace("a", "/repos/a"), workspace("a", "/repos/a-feature")],
b: [workspace("b", "/repos/b")],
});
await expect(resolver.resolveSpawnTarget("/repos/a", "/repos/a-feature")).resolves.toEqual({ allowed: true, cwd: "/repos/a-feature" });
});
it("defaults the target to the spawning cwd when none is requested", async () => {
const resolver = resolverFor([project("a", "/repos/a")], { a: [workspace("a", "/repos/a")] });
await expect(resolver.resolveSpawnTarget("/repos/a", undefined)).resolves.toEqual({ allowed: true, cwd: "/repos/a" });
});
it("returns the canonical workspace path even when the request differs only by trailing slash", async () => {
const resolver = resolverFor([project("a", "/repos/a")], { a: [workspace("a", "/repos/a")] });
await expect(resolver.resolveSpawnTarget("/repos/a", "/repos/a/")).resolves.toEqual({ allowed: true, cwd: "/repos/a" });
});
it("rejects a target outside the project's workspaces and lists the allowed ones", async () => {
const resolver = resolverFor([project("a", "/repos/a")], { a: [workspace("a", "/repos/a"), workspace("a", "/repos/a-feature")] });
await expect(resolver.resolveSpawnTarget("/repos/a", "/elsewhere")).resolves.toEqual({
allowed: false,
reason: "out-of-project",
allowedCwds: ["/repos/a", "/repos/a-feature"],
});
});
it("rejects when the spawning cwd is in no registered project", async () => {
const resolver = resolverFor([project("a", "/repos/a")], { a: [workspace("a", "/repos/a")] });
await expect(resolver.resolveSpawnTarget("/elsewhere", undefined)).resolves.toEqual({ allowed: false, reason: "not-registered" });
});
});
@@ -0,0 +1,79 @@
import type { Project, Workspace } from "../types.js";
import { cwdPathsEqual } from "../workingDirectory.js";
/**
* Decision describing whether a LLM-spawned session may target a given cwd.
*
* - `allowed: true` carries the canonical workspace path to start the session in
* (always one of the project's known workspace paths, so it is guaranteed
* visible in the web UI).
* - `not-registered` means the spawning session's cwd belongs to no registered
* project, so spawning must be refused to preserve visibility.
* - `out-of-project` means the requested cwd is not a workspace of the spawning
* session's project; `allowedCwds` lists the valid targets for the caller to
* surface.
*/
export type SpawnTargetDecision =
| { allowed: true; cwd: string }
| { allowed: false; reason: "not-registered" }
| { allowed: false; reason: "out-of-project"; allowedCwds: string[] };
/**
* Owns the rule that keeps LLM-spawned sessions visible: a spawned session may
* only target a workspace (worktree, or root) of the registered project that
* owns the spawning session. The rule is evaluated live so a worktree the agent
* just created with `git worktree add` is included.
*/
export interface SpawnTargetResolver {
/**
* Decide whether a session spawned from `spawningCwd` may target
* `requestedCwd` (defaulting to `spawningCwd` when omitted), returning the
* canonical target cwd when allowed.
*/
resolveSpawnTarget(spawningCwd: string, requestedCwd: string | undefined): Promise<SpawnTargetDecision>;
}
interface ProjectLister {
list(): Promise<Project[]>;
}
interface WorkspaceLister {
list(project: Project): Promise<Workspace[]>;
}
export interface ProjectScopedSpawnTargetResolverDeps {
projects: ProjectLister;
workspaces: WorkspaceLister;
}
/**
* Default resolver composing the project registry and live worktree discovery.
* It finds the registered project whose current workspace set contains the
* spawning session's cwd, then validates the requested target against that set.
*/
export class ProjectScopedSpawnTargetResolver implements SpawnTargetResolver {
constructor(private readonly deps: ProjectScopedSpawnTargetResolverDeps) {}
async resolveSpawnTarget(spawningCwd: string, requestedCwd: string | undefined): Promise<SpawnTargetDecision> {
const allowedCwds = await this.allowedSpawnTargets(spawningCwd);
if (allowedCwds === undefined) return { allowed: false, reason: "not-registered" };
const target = requestedCwd === undefined || requestedCwd === "" ? spawningCwd : requestedCwd;
const match = allowedCwds.find((path) => cwdPathsEqual(path, target));
if (match === undefined) return { allowed: false, reason: "out-of-project", allowedCwds };
return { allowed: true, cwd: match };
}
/**
* Workspace paths of the registered project that owns `spawningCwd`, or
* `undefined` when no registered project contains it.
*/
private async allowedSpawnTargets(spawningCwd: string): Promise<string[] | undefined> {
const projects = await this.deps.projects.list();
for (const project of projects) {
const workspaces = await this.deps.workspaces.list(project);
const paths = workspaces.map((workspace) => workspace.path);
if (paths.some((path) => cwdPathsEqual(path, spawningCwd))) return paths;
}
return undefined;
}
}