import { existsSync, readFileSync } from "node:fs";import { realpath, stat } from "node:fs/promises";import { homedir } from "node:os";import path from "node:path";import { Effect } from "effect";import { listProjectsFromStore, resolveAllowedWorkspace } from "./projects-store";import { hasEnabledConnectorsSync } from "./connectors-service";import { resolveBundledResource } from "./plugin-resources";import type { AgentThinkingLevel, AgentToolAccess } from "../../../shared/agent/agent-turn"; export type RuntimeSkillRef = { id?: string; name?: string; path?: string;}; export type RuntimePromptTemplateRef = { id?: string; name?: string; path?: string;}; export type RuntimeStartOptions = { thinkingLevel?: AgentThinkingLevel; toolAccess?: AgentToolAccess; browserToolEnabled?: boolean; browserSessionId?: string; browserBackend?: "embedded" | "sitegeist"; skills?: RuntimeSkillRef[]; promptTemplates?: RuntimePromptTemplateRef[];}; export type AgentSessionOptionsInput = { options: RuntimeStartOptions; /** Resolved session cwd, exported to extensions as LOCAL_STUDIO_CWD. */ cwd?: string; processEnv?: NodeJS.ProcessEnv;}; export type AgentSessionOptions = { // Absolute filesystem paths to .ts/.js extension modules. The SDK's // resource-loader uses jiti to load these; we hand paths instead of // pre-imported factories so we never trigger webpack's static analyser on a // dynamic `import(variable)` in the Next runtime bundle. extensionPaths: string[]; skills: string[]; /** Absolute prompt-template file/dir paths; forwarded to the SDK. */ promptTemplatePaths: string[]; envInjections: Record<string, string>;}; function resolveDefaultAgentCwd(): string { if (process.env.LOCAL_STUDIO_AGENT_CWD) return process.env.LOCAL_STUDIO_AGENT_CWD; try { const usable = listProjectsFromStore().find((entry) => entry.exists); if (usable) return usable.path; } catch { // The project registry is optional during first run. } const cwd = process.cwd(); if (path.basename(cwd) === "frontend") return path.resolve(cwd, ".."); if (cwd === "/" || cwd === "") return homedir(); return cwd;} export function expandHome(value: string): string { if (value === "~") return homedir(); if (value.startsWith(`~${path.sep}`)) return path.join(homedir(), value.slice(2)); return value;} // Resolve user-facing cwd input into the concrete directory Pi should run in.// The default keeps packaged Electron launches out of "/" by preferring the// selected project registry, then repo root during dev, then the user home.export function resolveAgentCwdEffect(input?: string): Effect.Effect<string, unknown> { const defaultCwd = resolveDefaultAgentCwd(); const raw = input?.trim() || defaultCwd; const expanded = expandHome(raw); const candidate = path.isAbsolute(expanded) ? expanded : path.resolve(defaultCwd, expanded); return Effect.gen(function* () { const resolved = yield* Effect.tryPromise({ try: () => realpath(candidate), catch: (error) => error, }); const info = yield* Effect.tryPromise({ try: () => stat(resolved), catch: (error) => error, }); if (!info.isDirectory()) { return yield* Effect.fail(new Error(`Agent cwd is not a directory: ${resolved}`)); } return resolveAllowedWorkspace(resolved); });} // One resolver for every bundled resource (see plugin-resources) so the// extension/MCP/skill lookup and the plugin lookup cannot drift apart again.function resolveBundledResourcePath(kind: string, name: string, override?: string): string | null { if (override && existsSync(override)) return override; return resolveBundledResource(kind, name);} export function resolveBundledPiExtensionPath( fileName: string, envOverride?: string,): string | null { return resolveBundledResourcePath("pi-extensions", fileName, envOverride);} export function resolveBrowserExtensionPath(): string | null { return resolveBundledPiExtensionPath( "browser.ts", process.env.LOCAL_STUDIO_BROWSER_EXTENSION_PATH, );} export function resolveSitegeistBrowserExtensionPath(): string | null { return resolveBundledPiExtensionPath( "sitegeist-browser.ts", process.env.LOCAL_STUDIO_SITEGEIST_BROWSER_EXTENSION_PATH, );} /** Bundled stdio MCP servers (desktop/resources/mcp) — same ladder as extensions. */export function resolveBundledMcpServerPath(fileName: string): string | null { return resolveBundledResourcePath("mcp", fileName);} export function resolveConnectorsExtensionPath(): string | null { return resolveBundledPiExtensionPath( "connectors.ts", process.env.LOCAL_STUDIO_CONNECTORS_EXTENSION_PATH, );} export function resolveSubagentsExtensionPath(): string | null { return resolveBundledPiExtensionPath( "subagents.ts", process.env.LOCAL_STUDIO_SUBAGENTS_EXTENSION_PATH, );} export function resolveAutomationsExtensionPath(): string | null { return resolveBundledPiExtensionPath( "automations.ts", process.env.LOCAL_STUDIO_AUTOMATIONS_EXTENSION_PATH, );} export function resolveTimeoutExtensionPath(): string | null { return resolveBundledPiExtensionPath( "local-studio-timeouts.ts", process.env.LOCAL_STUDIO_TIMEOUT_EXTENSION_PATH, );} export function resolveAgentPolicyExtensionPath(): string | null { return resolveBundledPiExtensionPath( "local-studio-agent-policy.ts", process.env.LOCAL_STUDIO_AGENT_POLICY_EXTENSION_PATH, );} // Locate a bundled skill directory (contains SKILL.md). Searched only when the// matching tool surface is ON so it can be appended to the SDK skill list and// teach the model how/when to use those tools.function resolveBundledSkillPath(name: string, override?: string): string | null { return resolveBundledResourcePath("skills", name, override);} export function resolveBrowserSkillPath(): string | null { return resolveBundledSkillPath("browser", process.env.LOCAL_STUDIO_BROWSER_SKILL_PATH);} export function resolveSitegeistBrowserSkillPath(): string | null { return resolveBundledSkillPath( "sitegeist-browser", process.env.LOCAL_STUDIO_SITEGEIST_BROWSER_SKILL_PATH, );} export function runtimeOptionsFingerprint(options: RuntimeStartOptions): string { const skills = (options.skills ?? []) .map((skill) => `${skill.name ?? ""}:${skill.path ?? ""}`) .sort(); const promptTemplates = (options.promptTemplates ?? []) .map((template) => `${template.name ?? ""}:${template.path ?? ""}`) .sort(); return JSON.stringify({ thinkingLevel: options.thinkingLevel ?? "high", toolAccess: options.toolAccess ?? "full", browser: options.browserToolEnabled === true, browserBackend: browserBackend(options), browserSessionId: options.browserSessionId ?? "", skills, promptTemplates, });} export function selectedSkillPaths(skills: RuntimeSkillRef[]): string[] { return uniqueExistingPaths(skills.map((skill) => skill.path));} export function selectedPromptTemplatePaths(templates: RuntimePromptTemplateRef[]): string[] { return uniqueExistingPaths(templates.map((template) => template.path));} export function uniqueExistingPaths(values: Array<string | null | undefined>): string[] { const seen = new Set<string>(); return values.filter((value): value is string => { if (!value || !existsSync(value)) return false; const resolved = path.resolve(value); if (seen.has(resolved)) return false; seen.add(resolved); return true; });} export function deriveFrontendBase(env: NodeJS.ProcessEnv = process.env): string { const port = env.PORT || "3000"; return `http://127.0.0.1:${port}`;} function shouldLoadBrowserTool(options: RuntimeStartOptions): boolean { return options.browserToolEnabled === true;} function browserBackend(options: RuntimeStartOptions): "embedded" | "sitegeist" { const backend = options.browserBackend ?? process.env.LOCAL_STUDIO_BROWSER_BACKEND; if (backend === "sitegeist") return "sitegeist"; return "embedded";} function browserExtensionPathFor(backend: "embedded" | "sitegeist"): string | null { if (backend === "sitegeist") return resolveSitegeistBrowserExtensionPath(); return resolveBrowserExtensionPath();} function browserSkillPathFor(backend: "embedded" | "sitegeist"): string | null { if (backend === "sitegeist") return resolveSitegeistBrowserSkillPath(); return resolveBrowserSkillPath();} function runtimeExtensionPaths(options: RuntimeStartOptions): string[] { const timeoutExtensionPath = resolveTimeoutExtensionPath(); const agentPolicyExtensionPath = resolveAgentPolicyExtensionPath(); const browserExtensionPath = shouldLoadBrowserTool(options) ? browserExtensionPathFor(browserBackend(options)) : null; return uniqueExistingPaths([ timeoutExtensionPath, agentPolicyExtensionPath, browserExtensionPath, hasEnabledConnectorsSync() ? resolveConnectorsExtensionPath() : null, resolveSubagentsExtensionPath(), // Lets the agent create/list/delete scheduled automations. resolveAutomationsExtensionPath(), // NOTE: session-goal injection is no longer a bundled extension — it runs // in-process via createGoalPromptExtension (see pi-runtime.ts), keyed by the // canonical piSessionId. A bundled extension read the wrong id over RPC. ]);} function runtimeSkillPaths(options: RuntimeStartOptions): string[] { const loadBrowser = shouldLoadBrowserTool(options); const backend = browserBackend(options); return uniqueExistingPaths([ ...selectedSkillPaths(options.skills ?? []), loadBrowser ? browserSkillPathFor(backend) : null, ]);} function runtimeEnvInjections( options: RuntimeStartOptions, env: NodeJS.ProcessEnv, cwd: string,): Record<string, string> { const frontendBase = env.LOCAL_STUDIO_FRONTEND_BASE ?? deriveFrontendBase(env); const relay = readSitegeistRelayEnv(env); return { LOCAL_STUDIO_BROWSER_SESSION_ID: options.browserSessionId ?? "", // The project this session runs in. Extensions that spawn later work (the // automations extension) would otherwise store an empty cwd and get the // first registered project when the scheduler resolves the default. LOCAL_STUDIO_CWD: cwd, LOCAL_STUDIO_FRONTEND_BASE: frontendBase, SITEGEIST_RELAY_URL: env.SITEGEIST_RELAY_URL ?? relay.SITEGEIST_RELAY_URL ?? "", SITEGEIST_RELAY_TOKEN: env.SITEGEIST_RELAY_TOKEN ?? relay.SITEGEIST_RELAY_TOKEN ?? "", SITEGEIST_RELAY_SESSION_ID: options.browserSessionId ?? "", };} function readSitegeistRelayEnv(env: NodeJS.ProcessEnv): Record<string, string> { const filePath = expandHome( env.LOCAL_STUDIO_SITEGEIST_RELAY_ENV_PATH ?? "~/.config/sitegeist-relay/env", ); if (!existsSync(filePath)) return {}; try { return Object.fromEntries( readFileSync(filePath, "utf8") .split(/\r?\n/) .flatMap((line): Array<[string, string]> => { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) return []; const clean = trimmed.startsWith("export ") ? trimmed.slice(7).trim() : trimmed; const index = clean.indexOf("="); if (index < 1) return []; const key = clean.slice(0, index).trim(); const value = clean .slice(index + 1) .trim() .replace(/^['"]|['"]$/g, ""); return key.startsWith("SITEGEIST_RELAY_") ? [[key, value]] : []; }), ); } catch { return {}; }} export function applyRuntimeEnvInjections( envInjections: Record<string, string>, env: NodeJS.ProcessEnv = process.env,): void { for (const [key, value] of Object.entries(envInjections)) env[key] = value;} export function buildAgentSessionOptionsSync(input: AgentSessionOptionsInput): AgentSessionOptions { const options = input.options; return { extensionPaths: runtimeExtensionPaths(options), skills: runtimeSkillPaths(options), promptTemplatePaths: selectedPromptTemplatePaths(options.promptTemplates ?? []), envInjections: runtimeEnvInjections(options, input.processEnv ?? process.env, input.cwd ?? ""), };}
import { existsSync, readFileSync } from "node:fs";
import { realpath, stat } from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
import { Effect } from "effect";
import { listProjectsFromStore, resolveAllowedWorkspace } from "./projects-store";
import { hasEnabledConnectorsSync } from "./connectors-service";
import { resolveBundledResource } from "./plugin-resources";
import type { AgentThinkingLevel, AgentToolAccess } from "../../../shared/agent/agent-turn";
export type RuntimeSkillRef = {
id?: string;
name?: string;
path?: string;
};
export type RuntimePromptTemplateRef = {
id?: string;
name?: string;
path?: string;
};
export type RuntimeStartOptions = {
thinkingLevel?: AgentThinkingLevel;
toolAccess?: AgentToolAccess;
browserToolEnabled?: boolean;
browserSessionId?: string;
browserBackend?: "embedded" | "sitegeist";
skills?: RuntimeSkillRef[];
promptTemplates?: RuntimePromptTemplateRef[];
};
export type AgentSessionOptionsInput = {
options: RuntimeStartOptions;
/** Resolved session cwd, exported to extensions as LOCAL_STUDIO_CWD. */
cwd?: string;
processEnv?: NodeJS.ProcessEnv;
};
export type AgentSessionOptions = {
// Absolute filesystem paths to .ts/.js extension modules. The SDK's
// resource-loader uses jiti to load these; we hand paths instead of
// pre-imported factories so we never trigger webpack's static analyser on a
// dynamic `import(variable)` in the Next runtime bundle.
extensionPaths: string[];
skills: string[];
/** Absolute prompt-template file/dir paths; forwarded to the SDK. */
promptTemplatePaths: string[];
envInjections: Record<string, string>;
};
function resolveDefaultAgentCwd(): string {
if (process.env.LOCAL_STUDIO_AGENT_CWD) return process.env.LOCAL_STUDIO_AGENT_CWD;
try {
const usable = listProjectsFromStore().find((entry) => entry.exists);
if (usable) return usable.path;
} catch {
// The project registry is optional during first run.
}
const cwd = process.cwd();
if (path.basename(cwd) === "frontend") return path.resolve(cwd, "..");
if (cwd === "/" || cwd === "") return homedir();
return cwd;
}
export function expandHome(value: string): string {
if (value === "~") return homedir();
if (value.startsWith(`~${path.sep}`)) return path.join(homedir(), value.slice(2));
return value;
}
// Resolve user-facing cwd input into the concrete directory Pi should run in.
// The default keeps packaged Electron launches out of "/" by preferring the
// selected project registry, then repo root during dev, then the user home.
export function resolveAgentCwdEffect(input?: string): Effect.Effect<string, unknown> {
const defaultCwd = resolveDefaultAgentCwd();
const raw = input?.trim() || defaultCwd;
const expanded = expandHome(raw);
const candidate = path.isAbsolute(expanded) ? expanded : path.resolve(defaultCwd, expanded);
return Effect.gen(function* () {
const resolved = yield* Effect.tryPromise({
try: () => realpath(candidate),
catch: (error) => error,
});
const info = yield* Effect.tryPromise({
try: () => stat(resolved),
catch: (error) => error,
});
if (!info.isDirectory()) {
return yield* Effect.fail(new Error(`Agent cwd is not a directory: ${resolved}`));
}
return resolveAllowedWorkspace(resolved);
});
}
// One resolver for every bundled resource (see plugin-resources) so the
// extension/MCP/skill lookup and the plugin lookup cannot drift apart again.
function resolveBundledResourcePath(kind: string, name: string, override?: string): string | null {
if (override && existsSync(override)) return override;
return resolveBundledResource(kind, name);
}
export function resolveBundledPiExtensionPath(
fileName: string,
envOverride?: string,
): string | null {
return resolveBundledResourcePath("pi-extensions", fileName, envOverride);
}
export function resolveBrowserExtensionPath(): string | null {
return resolveBundledPiExtensionPath(
"browser.ts",
process.env.LOCAL_STUDIO_BROWSER_EXTENSION_PATH,
);
}
export function resolveSitegeistBrowserExtensionPath(): string | null {
return resolveBundledPiExtensionPath(
"sitegeist-browser.ts",
process.env.LOCAL_STUDIO_SITEGEIST_BROWSER_EXTENSION_PATH,
);
}
/** Bundled stdio MCP servers (desktop/resources/mcp) — same ladder as extensions. */
export function resolveBundledMcpServerPath(fileName: string): string | null {
return resolveBundledResourcePath("mcp", fileName);
}
export function resolveConnectorsExtensionPath(): string | null {
return resolveBundledPiExtensionPath(
"connectors.ts",
process.env.LOCAL_STUDIO_CONNECTORS_EXTENSION_PATH,
);
}
export function resolveSubagentsExtensionPath(): string | null {
return resolveBundledPiExtensionPath(
"subagents.ts",
process.env.LOCAL_STUDIO_SUBAGENTS_EXTENSION_PATH,
);
}
export function resolveAutomationsExtensionPath(): string | null {
return resolveBundledPiExtensionPath(
"automations.ts",
process.env.LOCAL_STUDIO_AUTOMATIONS_EXTENSION_PATH,
);
}
export function resolveTimeoutExtensionPath(): string | null {
return resolveBundledPiExtensionPath(
"local-studio-timeouts.ts",
process.env.LOCAL_STUDIO_TIMEOUT_EXTENSION_PATH,
);
}
export function resolveAgentPolicyExtensionPath(): string | null {
return resolveBundledPiExtensionPath(
"local-studio-agent-policy.ts",
process.env.LOCAL_STUDIO_AGENT_POLICY_EXTENSION_PATH,
);
}
// Locate a bundled skill directory (contains SKILL.md). Searched only when the
// matching tool surface is ON so it can be appended to the SDK skill list and
// teach the model how/when to use those tools.
function resolveBundledSkillPath(name: string, override?: string): string | null {
return resolveBundledResourcePath("skills", name, override);
}
export function resolveBrowserSkillPath(): string | null {
return resolveBundledSkillPath("browser", process.env.LOCAL_STUDIO_BROWSER_SKILL_PATH);
}
export function resolveSitegeistBrowserSkillPath(): string | null {
return resolveBundledSkillPath(
"sitegeist-browser",
process.env.LOCAL_STUDIO_SITEGEIST_BROWSER_SKILL_PATH,
);
}
export function runtimeOptionsFingerprint(options: RuntimeStartOptions): string {
const skills = (options.skills ?? [])
.map((skill) => `${skill.name ?? ""}:${skill.path ?? ""}`)
.sort();
const promptTemplates = (options.promptTemplates ?? [])
.map((template) => `${template.name ?? ""}:${template.path ?? ""}`)
.sort();
return JSON.stringify({
thinkingLevel: options.thinkingLevel ?? "high",
toolAccess: options.toolAccess ?? "full",
browser: options.browserToolEnabled === true,
browserBackend: browserBackend(options),
browserSessionId: options.browserSessionId ?? "",
skills,
promptTemplates,
});
}
export function selectedSkillPaths(skills: RuntimeSkillRef[]): string[] {
return uniqueExistingPaths(skills.map((skill) => skill.path));
}
export function selectedPromptTemplatePaths(templates: RuntimePromptTemplateRef[]): string[] {
return uniqueExistingPaths(templates.map((template) => template.path));
}
export function uniqueExistingPaths(values: Array<string | null | undefined>): string[] {
const seen = new Set<string>();
return values.filter((value): value is string => {
if (!value || !existsSync(value)) return false;
const resolved = path.resolve(value);
if (seen.has(resolved)) return false;
seen.add(resolved);
return true;
});
}
export function deriveFrontendBase(env: NodeJS.ProcessEnv = process.env): string {
const port = env.PORT || "3000";
return `http://127.0.0.1:${port}`;
}
function shouldLoadBrowserTool(options: RuntimeStartOptions): boolean {
return options.browserToolEnabled === true;
}
function browserBackend(options: RuntimeStartOptions): "embedded" | "sitegeist" {
const backend = options.browserBackend ?? process.env.LOCAL_STUDIO_BROWSER_BACKEND;
if (backend === "sitegeist") return "sitegeist";
return "embedded";
}
function browserExtensionPathFor(backend: "embedded" | "sitegeist"): string | null {
if (backend === "sitegeist") return resolveSitegeistBrowserExtensionPath();
return resolveBrowserExtensionPath();
}
function browserSkillPathFor(backend: "embedded" | "sitegeist"): string | null {
if (backend === "sitegeist") return resolveSitegeistBrowserSkillPath();
return resolveBrowserSkillPath();
}
function runtimeExtensionPaths(options: RuntimeStartOptions): string[] {
const timeoutExtensionPath = resolveTimeoutExtensionPath();
const agentPolicyExtensionPath = resolveAgentPolicyExtensionPath();
const browserExtensionPath = shouldLoadBrowserTool(options)
? browserExtensionPathFor(browserBackend(options))
: null;
return uniqueExistingPaths([
timeoutExtensionPath,
agentPolicyExtensionPath,
browserExtensionPath,
hasEnabledConnectorsSync() ? resolveConnectorsExtensionPath() : null,
resolveSubagentsExtensionPath(),
// Lets the agent create/list/delete scheduled automations.
resolveAutomationsExtensionPath(),
// NOTE: session-goal injection is no longer a bundled extension — it runs
// in-process via createGoalPromptExtension (see pi-runtime.ts), keyed by the
// canonical piSessionId. A bundled extension read the wrong id over RPC.
]);
}
function runtimeSkillPaths(options: RuntimeStartOptions): string[] {
const loadBrowser = shouldLoadBrowserTool(options);
const backend = browserBackend(options);
return uniqueExistingPaths([
...selectedSkillPaths(options.skills ?? []),
loadBrowser ? browserSkillPathFor(backend) : null,
]);
}
function runtimeEnvInjections(
options: RuntimeStartOptions,
env: NodeJS.ProcessEnv,
cwd: string,
): Record<string, string> {
const frontendBase = env.LOCAL_STUDIO_FRONTEND_BASE ?? deriveFrontendBase(env);
const relay = readSitegeistRelayEnv(env);
return {
LOCAL_STUDIO_BROWSER_SESSION_ID: options.browserSessionId ?? "",
// The project this session runs in. Extensions that spawn later work (the
// automations extension) would otherwise store an empty cwd and get the
// first registered project when the scheduler resolves the default.
LOCAL_STUDIO_CWD: cwd,
LOCAL_STUDIO_FRONTEND_BASE: frontendBase,
SITEGEIST_RELAY_URL: env.SITEGEIST_RELAY_URL ?? relay.SITEGEIST_RELAY_URL ?? "",
SITEGEIST_RELAY_TOKEN: env.SITEGEIST_RELAY_TOKEN ?? relay.SITEGEIST_RELAY_TOKEN ?? "",
SITEGEIST_RELAY_SESSION_ID: options.browserSessionId ?? "",
};
}
function readSitegeistRelayEnv(env: NodeJS.ProcessEnv): Record<string, string> {
const filePath = expandHome(
env.LOCAL_STUDIO_SITEGEIST_RELAY_ENV_PATH ?? "~/.config/sitegeist-relay/env",
);
if (!existsSync(filePath)) return {};
try {
return Object.fromEntries(
readFileSync(filePath, "utf8")
.split(/\r?\n/)
.flatMap((line): Array<[string, string]> => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) return [];
const clean = trimmed.startsWith("export ") ? trimmed.slice(7).trim() : trimmed;
const index = clean.indexOf("=");
if (index < 1) return [];
const key = clean.slice(0, index).trim();
const value = clean
.slice(index + 1)
.trim()
.replace(/^['"]|['"]$/g, "");
return key.startsWith("SITEGEIST_RELAY_") ? [[key, value]] : [];
}),
);
} catch {
return {};
}
}
export function applyRuntimeEnvInjections(
envInjections: Record<string, string>,
env: NodeJS.ProcessEnv = process.env,
): void {
for (const [key, value] of Object.entries(envInjections)) env[key] = value;
}
export function buildAgentSessionOptionsSync(input: AgentSessionOptionsInput): AgentSessionOptions {
const options = input.options;
return {
extensionPaths: runtimeExtensionPaths(options),
skills: runtimeSkillPaths(options),
promptTemplatePaths: selectedPromptTemplatePaths(options.promptTemplates ?? []),
envInjections: runtimeEnvInjections(options, input.processEnv ?? process.env, input.cwd ?? ""),
};
}