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