diff --git a/.changeset/spawn-session-tool.md b/.changeset/spawn-session-tool.md new file mode 100644 index 0000000..5b8a4af --- /dev/null +++ b/.changeset/spawn-session-tool.md @@ -0,0 +1,9 @@ +--- +"@jmfederico/pi-web": patch +--- + +Let agents start new sessions with a `spawn_session` tool. An agent can dispatch a fresh, independent session with an initial prompt — useful for ralph-style loops (an agent kicks off the next iteration when done) and for chaining long plans across sessions. Spawned sessions are normal sessions a human can open and interact with, and they now appear in the session list the moment they are created (in the matching workspace) without a manual reload. + +To keep every spawned session visible and controllable, an agent may only spawn into a workspace — any worktree, including one it just created — of the same registered project as the spawning session. The capability is on by default and can be toggled under Settings → Session daemon (or via the `spawnSessions` config key / `PI_WEB_SPAWN_SESSIONS` environment variable); changes take effect after the session daemon restarts. + +Note: this adds a session daemon code path, so `pi-web-sessiond.service` must be restarted manually for the server side of this change to take effect. diff --git a/package-lock.json b/package-lock.json index b44516b..0af3b3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "lit": "^3.3.1", "marked": "^18.0.3", "node-pty": "^1.1.0", + "typebox": "1.1.38", "ws": "^8.20.1" }, "bin": { @@ -9050,7 +9051,6 @@ "version": "1.1.38", "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", - "dev": true, "license": "MIT" }, "node_modules/typescript": { diff --git a/package.json b/package.json index 070877e..11fca51 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "lit": "^3.3.1", "marked": "^18.0.3", "node-pty": "^1.1.0", + "typebox": "1.1.38", "ws": "^8.20.1" }, "devDependencies": { diff --git a/src/client/src/api/parsers.test.ts b/src/client/src/api/parsers.test.ts index 3bcd3ba..10f75f7 100644 --- a/src/client/src/api/parsers.test.ts +++ b/src/client/src/api/parsers.test.ts @@ -9,13 +9,13 @@ describe("API parsers", () => { exists: true, config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } } }, effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true }, - envOverrides: { host: true, port: false, allowedHosts: false }, + envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false }, })).toEqual({ path: "/tmp/config.json", exists: true, config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } } }, effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true }, - envOverrides: { host: true, port: false, allowedHosts: false }, + envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false }, }); }); diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index 23250cd..a32946a 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -445,6 +445,7 @@ function parsePiWebConfigValues(value: unknown): PiWebConfigValues { ...optionalField("allowedHosts", optionalAllowedHosts(record["allowedHosts"])), ...optionalField("shortcuts", optionalShortcuts(record["shortcuts"])), ...optionalField("plugins", optionalPlugins(record["plugins"])), + ...optionalField("spawnSessions", optionalBoolean(record, "spawnSessions")), }; } @@ -479,7 +480,7 @@ function optionalPlugins(value: unknown): PiWebPluginConfigMap | undefined { function parsePiWebConfigEnvOverrides(value: unknown): PiWebConfigEnvOverrides { const record = requireRecord(value); - return { host: requireBoolean(record, "host"), port: requireBoolean(record, "port"), allowedHosts: requireBoolean(record, "allowedHosts") }; + return { host: requireBoolean(record, "host"), port: requireBoolean(record, "port"), allowedHosts: requireBoolean(record, "allowedHosts"), spawnSessions: requireBoolean(record, "spawnSessions") }; } export function parsePiWebPluginsResponse(value: unknown): PiWebPluginsResponse { @@ -723,6 +724,13 @@ export function parseReloaded(value: unknown): { reloaded: true } { return { reloaded: true }; } +function optionalBoolean(record: Record, key: string): boolean | undefined { + const value = record[key]; + if (value === undefined) return undefined; + if (typeof value !== "boolean") throw new Error(`Invalid PI WEB ${key} field`); + return value; +} + function optionalNumber(record: Record, key: string): number | undefined { const value = record[key]; if (value === undefined) return undefined; diff --git a/src/client/src/components/SettingsDialog.ts b/src/client/src/components/SettingsDialog.ts index 7db4667..65076ad 100644 --- a/src/client/src/components/SettingsDialog.ts +++ b/src/client/src/components/SettingsDialog.ts @@ -4,6 +4,7 @@ import type { AppAction } from "../actions"; import { configApi, pluginsApi, type PiWebConfigResponse, type PiWebConfigValues, type PiWebPluginsResponse } from "../api"; import type { SettingsSection } from "../settingsRoute"; import "./settings/SettingsGeneralPanel"; +import "./settings/SettingsSessiondPanel"; import "./settings/SettingsPluginsPanel"; import "./settings/SettingsShortcutsPanel"; @@ -47,6 +48,7 @@ export class SettingsDialog extends LitElement {
@@ -60,6 +62,19 @@ export class SettingsDialog extends LitElement { } private renderActiveSection(): TemplateResult { + if (this.section === "sessiond") { + return html` + this.loadConfig()} + .onSave=${(config: PiWebConfigValues) => this.saveConfig(config)} + > + `; + } if (this.section === "shortcuts") { return html` void | Promise; + @property({ attribute: false }) onSave?: (config: PiWebConfigValues) => void | Promise; + + override render(): TemplateResult { + const config = this.configResponse; + const spawnOverridden = config?.envOverrides.spawnSessions === true; + // On by default: the effective config is the source of truth for the toggle + // state, so an unset config file still shows the feature as enabled. + const effectiveSpawn = config?.effectiveConfig.spawnSessions !== false; + return html` +
+
+

Session daemon

+

These settings affect the long-lived session runtime. Changes are saved to the config file immediately but only take effect after the session daemon restarts.

+
+ +
+ ${this.renderMessages()} +
Restart required: run pi-web restart (or restart the session daemon service) after changing these settings.
+ ${config === undefined && this.loading ? html`
Loading configuration…
` : html` +
+ Config file + ${config?.path ?? "Unknown"} +
+
+ + Allow agents to start sessions + ${spawnOverridden ? html`environment override` : null} + + + When enabled, LLMs can start new sessions, constrained to a workspace (any worktree) of the same registered project so every spawned session stays visible here. On by default. +
+
+

Effective after environment overrides

+
+
Spawn sessions
${effectiveSpawn ? "Enabled" : html`Disabled`}
+
+
+ `} + `; + } + + private renderMessages(): TemplateResult | null { + if (this.error !== "") return html`
${this.error}
`; + if (this.savedMessage !== "") return html`
${this.savedMessage}
`; + return null; + } + + private async toggleSpawnSessions(event: Event): Promise { + const enabled = event.target instanceof HTMLInputElement && event.target.checked; + const baseConfig = this.configResponse?.config ?? {}; + await this.onSave?.({ ...baseConfig, spawnSessions: enabled }); + } + + static override styles = css` + :host { display: block; } + .section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; } + .section-heading > div { display: grid; gap: 6px; min-width: 0; } + h2, h3, p { margin: 0; } + h2 { font-size: 17px; line-height: 1.25; } + h3 { font-size: 13px; line-height: 1.3; } + p { color: var(--pi-muted); line-height: 1.45; } + button, input { font: inherit; } + button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; } + button:disabled { opacity: .55; cursor: not-allowed; } + .secondary { flex: 0 0 auto; } + .message, .loading-card, .config-path-card, .effective-card, .restart-note { border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); padding: 12px; } + .message { margin-bottom: 12px; } + .error-message { border-color: var(--pi-danger); color: var(--pi-danger); background: color-mix(in srgb, var(--pi-danger) 10%, var(--pi-surface)); } + .success-message { border-color: var(--pi-success-border); color: var(--pi-success); background: var(--pi-success-surface); } + .loading-card { color: var(--pi-muted); } + .restart-note { margin-bottom: 14px; border-color: var(--pi-warning-border); color: var(--pi-warning); background: var(--pi-warning-surface); line-height: 1.45; } + .config-path-card { display: grid; gap: 5px; margin-bottom: 14px; } + .config-path-card span, .field-heading, dt { color: var(--pi-muted); font-size: 12px; font-weight: 700; text-transform: uppercase; } + code { border: 1px solid var(--pi-border-muted); border-radius: 5px; background: var(--pi-bg); padding: 1px 4px; color: var(--pi-text); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; } + .field { display: grid; gap: 7px; margin-bottom: 14px; } + .field small { color: var(--pi-muted); line-height: 1.45; } + .field-heading { display: flex; align-items: center; gap: 8px; } + .toggle { display: flex; align-items: center; gap: 9px; cursor: pointer; } + .toggle input { width: 16px; height: 16px; } + .toggle input:disabled { cursor: not-allowed; } + .override-badge { border: 1px solid var(--pi-warning-border); border-radius: 999px; color: var(--pi-warning); background: var(--pi-warning-surface); padding: 2px 7px; font-size: 11px; font-weight: 600; text-transform: none; } + .effective-card { display: grid; gap: 10px; } + .effective-card dl { display: grid; gap: 8px; margin: 0; } + .effective-card dl > div { display: grid; grid-template-columns: 130px minmax(0, 1fr); gap: 12px; align-items: baseline; } + dd { margin: 0; min-width: 0; overflow-wrap: anywhere; } + .muted { color: var(--pi-muted); } + + @media (max-width: 760px) { + .section-heading { display: grid; gap: 12px; } + .section-heading .secondary { justify-self: start; } + .effective-card dl > div { grid-template-columns: minmax(0, 1fr); gap: 3px; } + } + `; +} diff --git a/src/client/src/components/settings/settingsConfigDraft.test.ts b/src/client/src/components/settings/settingsConfigDraft.test.ts index 13ee052..b8f72cd 100644 --- a/src/client/src/components/settings/settingsConfigDraft.test.ts +++ b/src/client/src/components/settings/settingsConfigDraft.test.ts @@ -26,4 +26,14 @@ describe("settings config drafts", () => { plugins: { info: { enabled: false } }, }); }); + + it("preserves the spawnSessions flag when saving general settings", () => { + const result = configFromDraft({ + host: "", + port: "", + allowedHostsMode: "list", + allowedHostsText: "", + }, { spawnSessions: true }); + expect(result.spawnSessions).toBe(true); + }); }); diff --git a/src/client/src/components/settings/settingsConfigDraft.ts b/src/client/src/components/settings/settingsConfigDraft.ts index d4aabc7..ed778b3 100644 --- a/src/client/src/components/settings/settingsConfigDraft.ts +++ b/src/client/src/components/settings/settingsConfigDraft.ts @@ -24,6 +24,7 @@ export function configFromDraft(draft: ConfigDraft, baseConfig: PiWebConfigValue const config: PiWebConfigValues = { ...(baseConfig.shortcuts === undefined ? {} : { shortcuts: baseConfig.shortcuts }), ...(baseConfig.plugins === undefined ? {} : { plugins: baseConfig.plugins }), + ...(baseConfig.spawnSessions === undefined ? {} : { spawnSessions: baseConfig.spawnSessions }), }; const host = draft.host.trim(); const port = draft.port.trim(); diff --git a/src/client/src/controllers/sessionController.test.ts b/src/client/src/controllers/sessionController.test.ts index 93a6bf0..8e6dd49 100644 --- a/src/client/src/controllers/sessionController.test.ts +++ b/src/client/src/controllers/sessionController.test.ts @@ -142,6 +142,38 @@ describe("SessionController", () => { expect(state.selectedSession?.messageCount).toBe(3); }); + it("adds a newly created session to the list when it belongs to the selected workspace", () => { + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [oldSession] }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + undefined, + { socket: new FakeSocket() }, + ); + const spawned: SessionInfo = { ...oldSession, id: "spawned-session", path: "/tmp/spawned-session.jsonl" }; + + controller.applyGlobalEvent({ type: "session.created", session: spawned }); + + expect(state.sessions.map((session) => session.id)).toEqual(["spawned-session", "old-session"]); + }); + + it("ignores a created session for a different workspace or a duplicate id", () => { + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, sessions: [oldSession] }; + const controller = new SessionController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + () => undefined, + undefined, + { socket: new FakeSocket() }, + ); + + controller.applyGlobalEvent({ type: "session.created", session: { ...oldSession, id: "other", cwd: "/other-repo" } }); + controller.applyGlobalEvent({ type: "session.created", session: { ...oldSession } }); + + expect(state.sessions.map((session) => session.id)).toEqual(["old-session"]); + }); + it("toggles the per-session sending state around an inline attachment send and forwards attachments", async () => { let resolvePrompt: (() => void) | undefined; let promptArgs: { attachments?: PromptAttachment[] } | undefined; diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index 74ddb30..144fc23 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -51,6 +51,7 @@ export class SessionController { applyGlobalEvent(event: GlobalSessionEvent): void { if (event.type === "status.update") this.applyStatus(event.status); else if (event.type === "activity.update") this.applyActivity(event.activity); + else if (event.type === "session.created") this.applyCreatedSession(event.session); else this.applySessionName(event.sessionId, event.name); } @@ -576,6 +577,16 @@ export class SessionController { } } + private applyCreatedSession(session: SessionInfo) { + const state = this.getState(); + // Only surface sessions for the workspace currently in view; others are + // picked up when their workspace is opened. Skip if already present (e.g. + // the optimistic insert from startSession in this same tab). + if (state.selectedWorkspace?.path !== session.cwd) return; + if (state.sessions.some((candidate) => candidate.id === session.id)) return; + this.setState({ sessions: [session, ...state.sessions] }); + } + private applyActivity(activity: SessionActivity) { this.setState({ sessionActivities: { ...this.getState().sessionActivities, [activity.sessionId]: activity }, diff --git a/src/client/src/sessionSocket.ts b/src/client/src/sessionSocket.ts index 69bd479..b49e5e2 100644 --- a/src/client/src/sessionSocket.ts +++ b/src/client/src/sessionSocket.ts @@ -131,12 +131,12 @@ export class RealtimeSocket { function isSessionUiEvent(event: unknown): event is SessionUiEvent { const type = eventType(event); - return ["message.append", "assistant.delta", "assistant.thinking.delta", "tool.start", "tool.update", "tool.end", "shell.start", "shell.chunk", "shell.end", "agent.start", "agent.end", "message.end", "status.update", "activity.update", "command.output", "session.error", "session.name", "pi.event"].includes(type); + return ["message.append", "assistant.delta", "assistant.thinking.delta", "tool.start", "tool.update", "tool.end", "shell.start", "shell.chunk", "shell.end", "agent.start", "agent.end", "message.end", "status.update", "activity.update", "command.output", "session.error", "session.name", "session.created", "pi.event"].includes(type); } function isGlobalSessionEvent(event: unknown): event is GlobalSessionEvent { const type = eventType(event); - return type === "status.update" || type === "activity.update" || type === "session.name"; + return type === "status.update" || type === "activity.update" || type === "session.name" || type === "session.created"; } function isRealtimeEvent(event: unknown): event is RealtimeEvent { diff --git a/src/client/src/settingsRoute.test.ts b/src/client/src/settingsRoute.test.ts index 0666490..def70d5 100644 --- a/src/client/src/settingsRoute.test.ts +++ b/src/client/src/settingsRoute.test.ts @@ -35,6 +35,8 @@ function installWindow(href: string): { pushed: string[]; replaced: string[] } { describe("settings route helpers", () => { it("parses supported settings deep links and aliases", () => { expect(parseSettingsSection("general")).toBe("general"); + expect(parseSettingsSection("sessiond")).toBe("sessiond"); + expect(parseSettingsSection("sessions")).toBe("sessiond"); expect(parseSettingsSection("plugins")).toBe("plugins"); expect(parseSettingsSection("shortcuts")).toBe("shortcuts"); expect(parseSettingsSection("keyboard")).toBe("shortcuts"); diff --git a/src/client/src/settingsRoute.ts b/src/client/src/settingsRoute.ts index ae05114..e648f67 100644 --- a/src/client/src/settingsRoute.ts +++ b/src/client/src/settingsRoute.ts @@ -1,4 +1,4 @@ -export type SettingsSection = "general" | "plugins" | "shortcuts"; +export type SettingsSection = "general" | "sessiond" | "plugins" | "shortcuts"; export function readSettingsSection(): SettingsSection | undefined { return parseSettingsSection(new URLSearchParams(window.location.search).get("settings")); @@ -17,6 +17,7 @@ export function writeSettingsSection(section: SettingsSection | undefined, optio export function parseSettingsSection(value: string | null): SettingsSection | undefined { if (value === "general") return "general"; + if (value === "sessiond" || value === "sessions") return "sessiond"; if (value === "plugins") return "plugins"; if (value === "shortcuts" || value === "keyboard" || value === "keyboard-shortcuts") return "shortcuts"; return undefined; diff --git a/src/config.test.ts b/src/config.test.ts index 8640549..32f20e5 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { DEFAULT_MAX_UPLOAD_BYTES, loadPiWebConfig, maxUploadBytes, savePiWebConfig } from "./config.js"; +import { DEFAULT_MAX_UPLOAD_BYTES, loadPiWebConfig, maxUploadBytes, savePiWebConfig, spawnSessionsEnabled } from "./config.js"; let tempDir: string; let configPath: string; @@ -58,6 +58,21 @@ describe("maxUploadBytes", () => { }); }); +describe("spawnSessionsEnabled", () => { + it("is on by default when nothing is configured", () => { + expect(spawnSessionsEnabled({}, {})).toBe(true); + }); + + it("honors an explicit config opt-out", () => { + expect(spawnSessionsEnabled({}, { spawnSessions: false })).toBe(false); + }); + + it("lets the env var override the config in both directions", () => { + expect(spawnSessionsEnabled({ PI_WEB_SPAWN_SESSIONS: "0" }, { spawnSessions: true })).toBe(false); + expect(spawnSessionsEnabled({ PI_WEB_SPAWN_SESSIONS: "1" }, { spawnSessions: false })).toBe(true); + }); +}); + function testOptions(): { env: NodeJS.ProcessEnv } { return { env: { PI_WEB_CONFIG: configPath } }; } diff --git a/src/config.ts b/src/config.ts index ff662a2..c069150 100644 --- a/src/config.ts +++ b/src/config.ts @@ -82,6 +82,9 @@ export function effectivePiWebConfig(options: LoadOptions = {}): LoadedPiWebConf ...(port !== undefined && port !== "" ? { port: parsePort(port, "PI_WEB_PORT") } : {}), ...(allowedHosts !== undefined && allowedHosts !== "" ? { allowedHosts: parseAllowedHostsEnv(allowedHosts) } : {}), ...(maxUpload !== undefined && maxUpload !== "" ? { maxUploadBytes: parseMaxUploadBytes(maxUpload, "PI_WEB_MAX_UPLOAD_BYTES") } : {}), + // Always resolved (on by default) so the effective config is the single + // source of truth for the runtime state and the settings UI toggle. + spawnSessions: spawnSessionsEnabled(env, loaded.config), }, }; } @@ -97,6 +100,7 @@ export function savePiWebConfig(config: PiWebConfig, options: LoadOptions = {}): delete existing["shortcuts"]; delete existing["plugins"]; delete existing["maxUploadBytes"]; + delete existing["spawnSessions"]; const merged = { ...existing, ...piWebConfigRecord(normalized) }; mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`, "utf8"); @@ -118,6 +122,7 @@ function piWebConfigRecord(config: PiWebConfig): Record { ...(config.shortcuts !== undefined ? { shortcuts: config.shortcuts } : {}), ...(config.plugins !== undefined ? { plugins: config.plugins } : {}), ...(config.maxUploadBytes !== undefined ? { maxUploadBytes: config.maxUploadBytes } : {}), + ...(config.spawnSessions !== undefined ? { spawnSessions: config.spawnSessions } : {}), }; } @@ -129,6 +134,7 @@ function parsePiWebConfig(value: Record, path: string): PiWebCo ...(value["shortcuts"] !== undefined ? { shortcuts: parseShortcuts(value["shortcuts"], path) } : {}), ...(value["plugins"] !== undefined ? { plugins: parsePlugins(value["plugins"], path) } : {}), ...(value["maxUploadBytes"] !== undefined ? { maxUploadBytes: parseMaxUploadBytes(value["maxUploadBytes"], "maxUploadBytes", path) } : {}), + ...(value["spawnSessions"] !== undefined ? { spawnSessions: parseSpawnSessions(value["spawnSessions"], path) } : {}), }; } @@ -138,6 +144,23 @@ function parseMaxUploadBytes(value: unknown, key: string, path = "environment"): return bytes; } +function parseSpawnSessions(value: unknown, path: string): boolean { + if (typeof value !== "boolean") throw new Error(`PI WEB config spawnSessions must be a boolean: ${path}`); + return value; +} + +/** + * Whether LLMs may start new sessions via the spawn_session tool. On by default + * (spawned sessions appear in the session list, so humans notice them); set the + * env var `PI_WEB_SPAWN_SESSIONS` or the `spawnSessions` config key to `false` + * to disable. The env var takes precedence over the config file. + */ +export function spawnSessionsEnabled(env: NodeJS.ProcessEnv = process.env, config: PiWebConfig = {}): boolean { + const fromEnv = env["PI_WEB_SPAWN_SESSIONS"]; + if (fromEnv !== undefined && fromEnv !== "") return fromEnv === "1" || fromEnv.toLowerCase() === "true"; + return config.spawnSessions ?? true; +} + function parseString(value: unknown, key: string, path: string): string { if (typeof value !== "string" || value === "") throw new Error(`PI WEB config ${key} must be a non-empty string: ${path}`); return value; diff --git a/src/server/configRoutes.test.ts b/src/server/configRoutes.test.ts index 599d2c5..6a4a3f7 100644 --- a/src/server/configRoutes.test.ts +++ b/src/server/configRoutes.test.ts @@ -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().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 }, }; } diff --git a/src/server/configRoutes.ts b/src/server/configRoutes.ts index 46d83a7..1d341f3 100644 --- a/src/server/configRoutes.ts +++ b/src/server/configRoutes.ts @@ -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"]), }; } diff --git a/src/server/sessiond.ts b/src/server/sessiond.ts index 5654ce5..4e7e9fa 100644 --- a/src/server/sessiond.ts +++ b/src/server/sessiond.ts @@ -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); diff --git a/src/server/sessions/piSessionService.test.ts b/src/server/sessions/piSessionService.test.ts index ed444a0..0e1e97e 100644 --- a/src/server/sessions/piSessionService.test.ts +++ b/src/server/sessions/piSessionService.test.ts @@ -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; 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(); + }); + }); }); diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 2049988..8457c8d 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -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, message: string): void; +} + +const noopLogger: PiSessionLogger = { info() { /* no-op */ } }; function noop(): void { // Intentionally empty default unsubscribe callback. } +function spawnTargetError(decision: Extract): 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; + +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; + /** + * 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 | 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 { 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 { + 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 { diff --git a/src/server/sessions/spawnSessionTool.test.ts b/src/server/sessions/spawnSessionTool.test.ts new file mode 100644 index 0000000..078e634 --- /dev/null +++ b/src/server/sessions/spawnSessionTool.test.ts @@ -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"); + }); +}); diff --git a/src/server/sessions/spawnSessionTool.ts b/src/server/sessions/spawnSessionTool.ts new file mode 100644 index 0000000..dba84e1 --- /dev/null +++ b/src/server/sessions/spawnSessionTool.ts @@ -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; +} + +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({ + 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, + }; + }, + }); +} diff --git a/src/server/sessions/spawnTargetResolver.test.ts b/src/server/sessions/spawnTargetResolver.test.ts new file mode 100644 index 0000000..1b7e16e --- /dev/null +++ b/src/server/sessions/spawnTargetResolver.test.ts @@ -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): 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" }); + }); +}); diff --git a/src/server/sessions/spawnTargetResolver.ts b/src/server/sessions/spawnTargetResolver.ts new file mode 100644 index 0000000..9ae1c8b --- /dev/null +++ b/src/server/sessions/spawnTargetResolver.ts @@ -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; +} + +interface ProjectLister { + list(): Promise; +} + +interface WorkspaceLister { + list(project: Project): Promise; +} + +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 { + 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 { + 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; + } +} diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 6ce5db7..8c8549f 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -59,6 +59,8 @@ export interface PiWebConfigValues { plugins?: PiWebPluginConfigMap; /** Maximum accepted HTTP request body size in bytes (uploads/attachments). */ maxUploadBytes?: number; + /** When true, LLMs can start new sessions via the spawn_session tool. */ + spawnSessions?: boolean; } export type PiWebPluginScope = "bundled" | "local" | "user" | "project"; @@ -80,6 +82,7 @@ export interface PiWebConfigEnvOverrides { host: boolean; port: boolean; allowedHosts: boolean; + spawnSessions: boolean; } export interface PiWebConfigResponse { @@ -498,7 +501,8 @@ export type SessionUiEvent = | { type: "command.output"; level: "info" | "success" | "error"; message: string } | { type: "session.error"; message: string } | { type: "session.name"; sessionId: string; name?: string } + | { type: "session.created"; session: SessionInfo } | { type: "pi.event"; eventType: string }; -export type GlobalSessionEvent = Extract; +export type GlobalSessionEvent = Extract; export type RealtimeEvent = GlobalSessionEvent | TerminalUiEvent | WorkspaceActivityUiEvent;