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
+9
View File
@@ -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.
+1 -1
View File
@@ -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": {
+1
View File
@@ -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": {
+2 -2
View File
@@ -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 },
});
});
+9 -1
View File
@@ -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<string, unknown>, 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<string, unknown>, key: string): number | undefined {
const value = record[key];
if (value === undefined) return undefined;
@@ -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 {
<div class="settings-body">
<nav class="settings-nav" aria-label="Settings sections">
${this.renderNavButton("general", "General", "Server config")}
${this.renderNavButton("sessiond", "Session daemon", "Runtime settings")}
${this.renderNavButton("plugins", "Plugins", "Enable and disable")}
${this.renderNavButton("shortcuts", "Keyboard", "Shortcuts")}
</nav>
@@ -60,6 +62,19 @@ export class SettingsDialog extends LitElement {
}
private renderActiveSection(): TemplateResult {
if (this.section === "sessiond") {
return html`
<settings-sessiond-panel
.configResponse=${this.configResponse}
.loading=${this.loading}
.saving=${this.saving}
.error=${this.error}
.savedMessage=${this.savedMessage}
.onReload=${() => this.loadConfig()}
.onSave=${(config: PiWebConfigValues) => this.saveConfig(config)}
></settings-sessiond-panel>
`;
}
if (this.section === "shortcuts") {
return html`
<settings-shortcuts-panel
@@ -0,0 +1,114 @@
import { css, html, LitElement, type TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import type { PiWebConfigResponse, PiWebConfigValues } from "../../api";
@customElement("settings-sessiond-panel")
export class SettingsSessiondPanel extends LitElement {
@property({ attribute: false }) configResponse: PiWebConfigResponse | undefined;
@property({ type: Boolean }) loading = false;
@property({ type: Boolean }) saving = false;
@property() error = "";
@property() savedMessage = "";
@property({ attribute: false }) onReload?: () => void | Promise<void>;
@property({ attribute: false }) onSave?: (config: PiWebConfigValues) => void | Promise<void>;
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`
<div class="section-heading">
<div>
<h2>Session daemon</h2>
<p>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.</p>
</div>
<button class="secondary" ?disabled=${this.loading} @click=${() => { void this.onReload?.(); }}>Reload</button>
</div>
${this.renderMessages()}
<div class="restart-note" role="note">Restart required: run <code>pi-web restart</code> (or restart the session daemon service) after changing these settings.</div>
${config === undefined && this.loading ? html`<div class="loading-card">Loading configuration…</div>` : html`
<div class="config-path-card">
<span>Config file</span>
<code>${config?.path ?? "Unknown"}</code>
</div>
<div class="field">
<span class="field-heading">
<span>Allow agents to start sessions</span>
${spawnOverridden ? html`<span class="override-badge">environment override</span>` : null}
</span>
<label class="toggle">
<input
type="checkbox"
.checked=${effectiveSpawn}
?disabled=${this.loading || this.saving || spawnOverridden}
@change=${(event: Event) => { void this.toggleSpawnSessions(event); }}
>
<span>Enable the <code>spawn_session</code> tool</span>
</label>
<small>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.</small>
</div>
<section class="effective-card" aria-label="Effective configuration summary">
<h3>Effective after environment overrides</h3>
<dl>
<div><dt>Spawn sessions</dt><dd>${effectiveSpawn ? "Enabled" : html`<span class="muted">Disabled</span>`}</dd></div>
</dl>
</section>
`}
`;
}
private renderMessages(): TemplateResult | null {
if (this.error !== "") return html`<div class="message error-message">${this.error}</div>`;
if (this.savedMessage !== "") return html`<div class="message success-message">${this.savedMessage}</div>`;
return null;
}
private async toggleSpawnSessions(event: Event): Promise<void> {
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; }
}
`;
}
@@ -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);
});
});
@@ -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();
@@ -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;
@@ -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 },
+2 -2
View File
@@ -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 {
+2
View File
@@ -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");
+2 -1
View File
@@ -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;
+16 -1
View File
@@ -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 } };
}
+23
View File
@@ -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<string, unknown> {
...(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<string, unknown>, 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;
+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;
}
}
+5 -1
View File
@@ -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<SessionUiEvent, { type: "status.update" | "activity.update" | "session.name" }>;
export type GlobalSessionEvent = Extract<SessionUiEvent, { type: "status.update" | "activity.update" | "session.name" | "session.created" }>;
export type RealtimeEvent = GlobalSessionEvent | TerminalUiEvent | WorkspaceActivityUiEvent;