Archived
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:
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user