diff --git a/docs/architecture.md b/docs/architecture.md index ce2189853..0b8afa495 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -121,6 +121,7 @@ Commander.js CLI with Docker-style commands. Common agent operations are also ex - `paseo daemon start/stop/restart/status/pair/set-password` - `paseo chat ls/create/inspect/post/read/wait/delete` - `paseo terminal ls/create/capture/send-keys/kill` +- `paseo script ls/start/stop` - `paseo loop run/ls/inspect/logs/stop` - `paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete` - `paseo heartbeat create/update/delete` diff --git a/docs/service-proxy.md b/docs/service-proxy.md index 6f4436aff..ee8f1ceca 100644 --- a/docs/service-proxy.md +++ b/docs/service-proxy.md @@ -26,6 +26,18 @@ dev--feature-auth--miniweb.localhost Local and public routes use one combined leftmost label (`script--branch--project`). This keeps the hostname compatible with normal single-level wildcard DNS and TLS. If the combined label would exceed DNS's 63-character label limit, Paseo truncates it with a deterministic hash suffix to avoid collisions. +## Managing workspace scripts + +Configured `paseo.json` scripts can be managed without addressing their backing terminal directly: + +```bash +paseo script ls [--cwd | --workspace ] +paseo script start [--cwd | --workspace ] +paseo script stop [--cwd | --workspace ] +``` + +The commands return the same script metadata shown by the workspace: lifecycle, service port, proxy URLs, health, exit code, and supervised terminal ID. `stop` terminates the managed terminal rather than only removing the proxy route, so normal script lifecycle cleanup remains authoritative. MCP exposes matching `list_workspace_scripts`, `start_workspace_script`, and `stop_workspace_script` tools; those require an explicit workspace ID. + ## Configuration Add a `serviceProxy` block under `daemon` in `~/.paseo/config.json`: diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 9124fc6c3..72deee4a6 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -7,6 +7,7 @@ import { createPermitCommand } from "./commands/permit/index.js"; import { createProviderCommand } from "./commands/provider/index.js"; import { createScheduleCommand } from "./commands/schedule/index.js"; import { createSpeechCommand } from "./commands/speech/index.js"; +import { createScriptCommand } from "./commands/script/index.js"; import { createTerminalCommand } from "./commands/terminal/index.js"; import { createWorktreeCommand } from "./commands/worktree/index.js"; import { createWorkspaceCommand } from "./commands/workspace/index.js"; @@ -171,6 +172,9 @@ export function createCli(): Command { // Terminal commands program.addCommand(createTerminalCommand()); + // Workspace script commands + program.addCommand(createScriptCommand()); + // Loop commands program.addCommand(createLoopCommand()); diff --git a/packages/cli/src/commands/script/index.ts b/packages/cli/src/commands/script/index.ts new file mode 100644 index 000000000..92232c33c --- /dev/null +++ b/packages/cli/src/commands/script/index.ts @@ -0,0 +1,39 @@ +import { Command } from "commander"; +import { withOutput } from "../../output/index.js"; +import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; +import { runLsCommand } from "./ls.js"; +import { runStartCommand } from "./start.js"; +import { runStopCommand } from "./stop.js"; + +function addWorkspaceSelectionOptions(command: Command): Command { + return command + .option("--cwd ", "Workspace directory (default: current directory)") + .option( + "--workspace ", + "Workspace ID (required when a directory has multiple workspaces)", + ); +} + +export function createScriptCommand(): Command { + const script = new Command("script").description("Manage configured workspace scripts"); + + addJsonAndDaemonHostOptions( + addWorkspaceSelectionOptions( + script.command("ls").description("List configured workspace scripts"), + ), + ).action(withOutput(runLsCommand)); + + addJsonAndDaemonHostOptions( + addWorkspaceSelectionOptions( + script.command("start").description("Start a configured workspace script").argument(""), + ), + ).action(withOutput(runStartCommand)); + + addJsonAndDaemonHostOptions( + addWorkspaceSelectionOptions( + script.command("stop").description("Stop a running workspace script").argument(""), + ), + ).action(withOutput(runStopCommand)); + + return script; +} diff --git a/packages/cli/src/commands/script/ls.ts b/packages/cli/src/commands/script/ls.ts new file mode 100644 index 000000000..cd7d98e9f --- /dev/null +++ b/packages/cli/src/commands/script/ls.ts @@ -0,0 +1,32 @@ +import type { Command } from "commander"; +import type { ListResult } from "../../output/index.js"; +import { + connectWorkspaceScriptClient, + resolveWorkspaceScriptWorkspaceId, + toWorkspaceScriptCommandError, + type WorkspaceScriptCommandOptions, +} from "./shared.js"; +import { workspaceScriptSchema, type WorkspaceScriptRow } from "./schema.js"; + +export async function runLsCommand( + options: WorkspaceScriptCommandOptions, + _command: Command, +): Promise> { + const client = await connectWorkspaceScriptClient(options.host); + try { + const workspaceId = await resolveWorkspaceScriptWorkspaceId(client, options); + const payload = await client.listWorkspaceScripts(workspaceId); + if (payload.error) { + throw new Error(payload.error); + } + return { type: "list", data: payload.scripts ?? [], schema: workspaceScriptSchema }; + } catch (error) { + throw toWorkspaceScriptCommandError( + "WORKSPACE_SCRIPT_LIST_FAILED", + "list workspace scripts", + error, + ); + } finally { + await client.close().catch(() => {}); + } +} diff --git a/packages/cli/src/commands/script/schema.ts b/packages/cli/src/commands/script/schema.ts new file mode 100644 index 000000000..2ec9e282c --- /dev/null +++ b/packages/cli/src/commands/script/schema.ts @@ -0,0 +1,17 @@ +import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages"; +import type { OutputSchema } from "../../output/index.js"; + +export type WorkspaceScriptRow = WorkspaceScriptPayload; + +export const workspaceScriptSchema: OutputSchema = { + idField: "scriptName", + columns: [ + { header: "NAME", field: "scriptName", width: 20 }, + { header: "TYPE", field: "type", width: 9 }, + { header: "LIFECYCLE", field: "lifecycle", width: 10 }, + { header: "HEALTH", field: (script) => script.health ?? "-", width: 10 }, + { header: "PORT", field: (script) => script.port ?? "-", width: 7 }, + { header: "PROXY URL", field: (script) => script.proxyUrl ?? "-", width: 42 }, + { header: "TERMINAL", field: (script) => script.terminalId ?? "-", width: 12 }, + ], +}; diff --git a/packages/cli/src/commands/script/shared.ts b/packages/cli/src/commands/script/shared.ts new file mode 100644 index 000000000..4e3308bdf --- /dev/null +++ b/packages/cli/src/commands/script/shared.ts @@ -0,0 +1,78 @@ +import { resolve } from "node:path"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import type { CommandError, CommandOptions } from "../../output/index.js"; + +export interface WorkspaceScriptCommandOptions extends CommandOptions { + host?: string; + cwd?: string; + workspace?: string; +} + +export async function connectWorkspaceScriptClient(host?: string): Promise { + const daemonHost = getDaemonHost({ host }); + try { + const client = await connectToDaemon({ host }); + // COMPAT(workspaceScriptManagement): added in v0.1.105, remove gate after 2027-01-10. + if (!client.getLastServerInfoMessage()?.features?.workspaceScriptManagement) { + await client.close().catch(() => {}); + throw { + code: "DAEMON_UPDATE_REQUIRED", + message: "Update the host to use workspace script management.", + } satisfies CommandError; + } + return client; + } catch (error) { + if (error && typeof error === "object" && "code" in error && "message" in error) { + throw error; + } + const message = error instanceof Error ? error.message : String(error); + throw { + code: "DAEMON_NOT_RUNNING", + message: `Cannot connect to daemon at ${daemonHost}: ${message}`, + details: "Start the daemon with: paseo daemon start", + } satisfies CommandError; + } +} + +export async function resolveWorkspaceScriptWorkspaceId( + client: DaemonClient, + options: WorkspaceScriptCommandOptions, +): Promise { + if (options.workspace) { + return options.workspace; + } + + const cwd = resolve(options.cwd ?? process.cwd()); + const payload = await client.fetchWorkspaces({ page: { limit: 200 } }); + const matches = payload.entries.filter( + (workspace) => resolve(workspace.workspaceDirectory) === cwd, + ); + if (matches.length === 1) { + return matches[0]!.id; + } + if (matches.length > 1) { + throw { + code: "WORKSPACE_AMBIGUOUS", + message: `Multiple workspaces use ${cwd}`, + details: "Pass --workspace to select one.", + } satisfies CommandError; + } + throw { + code: "WORKSPACE_NOT_FOUND", + message: `No Paseo workspace found for ${cwd}`, + details: "Open the directory in Paseo first, or pass --workspace .", + } satisfies CommandError; +} + +export function toWorkspaceScriptCommandError( + code: string, + action: string, + error: unknown, +): CommandError { + if (error && typeof error === "object" && "code" in error && "message" in error) { + return error as CommandError; + } + const message = error instanceof Error ? error.message : String(error); + return { code, message: `Failed to ${action}: ${message}` }; +} diff --git a/packages/cli/src/commands/script/start.ts b/packages/cli/src/commands/script/start.ts new file mode 100644 index 000000000..1b377267f --- /dev/null +++ b/packages/cli/src/commands/script/start.ts @@ -0,0 +1,36 @@ +import type { Command } from "commander"; +import type { CommandError, SingleResult } from "../../output/index.js"; +import { + connectWorkspaceScriptClient, + resolveWorkspaceScriptWorkspaceId, + toWorkspaceScriptCommandError, + type WorkspaceScriptCommandOptions, +} from "./shared.js"; +import { workspaceScriptSchema, type WorkspaceScriptRow } from "./schema.js"; + +export async function runStartCommand( + scriptName: string, + options: WorkspaceScriptCommandOptions, + _command: Command, +): Promise> { + const client = await connectWorkspaceScriptClient(options.host); + try { + const workspaceId = await resolveWorkspaceScriptWorkspaceId(client, options); + const payload = await client.startWorkspaceScriptWithStatus(workspaceId, scriptName); + if (payload.error || !payload.script) { + throw { + code: "WORKSPACE_SCRIPT_START_FAILED", + message: payload.error ?? `Script '${scriptName}' did not return status metadata`, + } satisfies CommandError; + } + return { type: "single", data: payload.script, schema: workspaceScriptSchema }; + } catch (error) { + throw toWorkspaceScriptCommandError( + "WORKSPACE_SCRIPT_START_FAILED", + "start workspace script", + error, + ); + } finally { + await client.close().catch(() => {}); + } +} diff --git a/packages/cli/src/commands/script/stop.ts b/packages/cli/src/commands/script/stop.ts new file mode 100644 index 000000000..685565605 --- /dev/null +++ b/packages/cli/src/commands/script/stop.ts @@ -0,0 +1,36 @@ +import type { Command } from "commander"; +import type { CommandError, SingleResult } from "../../output/index.js"; +import { + connectWorkspaceScriptClient, + resolveWorkspaceScriptWorkspaceId, + toWorkspaceScriptCommandError, + type WorkspaceScriptCommandOptions, +} from "./shared.js"; +import { workspaceScriptSchema, type WorkspaceScriptRow } from "./schema.js"; + +export async function runStopCommand( + scriptName: string, + options: WorkspaceScriptCommandOptions, + _command: Command, +): Promise> { + const client = await connectWorkspaceScriptClient(options.host); + try { + const workspaceId = await resolveWorkspaceScriptWorkspaceId(client, options); + const payload = await client.stopWorkspaceScript(workspaceId, scriptName); + if (payload.error || !payload.script) { + throw { + code: "WORKSPACE_SCRIPT_STOP_FAILED", + message: payload.error ?? `Script '${scriptName}' did not return status metadata`, + } satisfies CommandError; + } + return { type: "single", data: payload.script, schema: workspaceScriptSchema }; + } catch (error) { + throw toWorkspaceScriptCommandError( + "WORKSPACE_SCRIPT_STOP_FAILED", + "stop workspace script", + error, + ); + } finally { + await client.close().catch(() => {}); + } +} diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 50e686fad..8e67ea4b5 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -2179,6 +2179,47 @@ export class DaemonClient { }); } + async listWorkspaceScripts( + workspaceId: string, + requestId?: string, + ): Promise< + Extract["payload"] + > { + return this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "workspace.script.list.request", workspaceId }, + responseType: "workspace.script.list.response", + }); + } + + async startWorkspaceScriptWithStatus( + workspaceId: string, + scriptName: string, + requestId?: string, + ): Promise< + Extract["payload"] + > { + return this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "workspace.script.start.request", workspaceId, scriptName }, + responseType: "workspace.script.start.response", + }); + } + + async stopWorkspaceScript( + workspaceId: string, + scriptName: string, + requestId?: string, + ): Promise< + Extract["payload"] + > { + return this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "workspace.script.stop.request", workspaceId, scriptName }, + responseType: "workspace.script.stop.response", + }); + } + async archiveWorkspace( workspaceId: string, requestId?: string, diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index f2e490766..d65825fae 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -2328,6 +2328,26 @@ export const StartWorkspaceScriptRequestSchema = z.object({ requestId: z.string(), }); +export const WorkspaceScriptListRequestSchema = z.object({ + type: z.literal("workspace.script.list.request"), + workspaceId: z.string(), + requestId: z.string(), +}); + +export const WorkspaceScriptStartRequestSchema = z.object({ + type: z.literal("workspace.script.start.request"), + workspaceId: z.string(), + scriptName: z.string(), + requestId: z.string(), +}); + +export const WorkspaceScriptStopRequestSchema = z.object({ + type: z.literal("workspace.script.stop.request"), + workspaceId: z.string(), + scriptName: z.string(), + requestId: z.string(), +}); + export const SubscribeTerminalRequestSchema = z.object({ type: z.literal("subscribe_terminal_request"), terminalId: z.string(), @@ -2530,6 +2550,9 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ CreateTerminalRequestSchema, RenameTerminalRequestSchema, StartWorkspaceScriptRequestSchema, + WorkspaceScriptListRequestSchema, + WorkspaceScriptStartRequestSchema, + WorkspaceScriptStopRequestSchema, SubscribeTerminalRequestSchema, UnsubscribeTerminalRequestSchema, TerminalInputSchema, @@ -2793,6 +2816,8 @@ export const ServerInfoStatusPayloadSchema = z selectiveAgentTimeline: z.boolean().optional(), // COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15. stableProjectIdentity: z.boolean().optional(), + // COMPAT(workspaceScriptManagement): added in v0.1.105, remove gate after 2027-01-10. + workspaceScriptManagement: z.boolean().optional(), }) .optional(), }) @@ -3382,6 +3407,30 @@ export const StartWorkspaceScriptResponseMessageSchema = z.object({ }), }); +const WorkspaceScriptOperationPayloadSchema = z.object({ + requestId: z.string(), + workspaceId: z.string(), + scriptName: z.string().optional(), + script: WorkspaceScriptPayloadSchema.nullable().optional(), + scripts: z.array(WorkspaceScriptPayloadSchema).optional(), + error: z.string().nullable(), +}); + +export const WorkspaceScriptListResponseMessageSchema = z.object({ + type: z.literal("workspace.script.list.response"), + payload: WorkspaceScriptOperationPayloadSchema, +}); + +export const WorkspaceScriptStartResponseMessageSchema = z.object({ + type: z.literal("workspace.script.start.response"), + payload: WorkspaceScriptOperationPayloadSchema, +}); + +export const WorkspaceScriptStopResponseMessageSchema = z.object({ + type: z.literal("workspace.script.stop.response"), + payload: WorkspaceScriptOperationPayloadSchema, +}); + // COMPAT(desktopEditorBridge): added in v0.1.88, remove after 2026-12-03 once old clients no longer parse daemon editor RPC responses. export const LegacyListAvailableEditorsResponseMessageSchema = z.object({ type: z.literal("list_available_editors_response"), @@ -5121,6 +5170,9 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ WorkspaceGithubSearchRepositoriesResponseSchema, ProjectGithubCloneResponseSchema, StartWorkspaceScriptResponseMessageSchema, + WorkspaceScriptListResponseMessageSchema, + WorkspaceScriptStartResponseMessageSchema, + WorkspaceScriptStopResponseMessageSchema, LegacyListAvailableEditorsResponseMessageSchema, LegacyOpenInEditorResponseMessageSchema, ArchiveWorkspaceResponseMessageSchema, @@ -5304,6 +5356,18 @@ export type ProjectGithubCloneResponse = z.infer; +export type WorkspaceScriptListRequest = z.infer; +export type WorkspaceScriptStartRequest = z.infer; +export type WorkspaceScriptStopRequest = z.infer; +export type WorkspaceScriptListResponseMessage = z.infer< + typeof WorkspaceScriptListResponseMessageSchema +>; +export type WorkspaceScriptStartResponseMessage = z.infer< + typeof WorkspaceScriptStartResponseMessageSchema +>; +export type WorkspaceScriptStopResponseMessage = z.infer< + typeof WorkspaceScriptStopResponseMessageSchema +>; export type LegacyListAvailableEditorsResponseMessage = z.infer< typeof LegacyListAvailableEditorsResponseMessageSchema >; diff --git a/packages/protocol/src/messages.workspaces.test.ts b/packages/protocol/src/messages.workspaces.test.ts index a1c07a5ca..72146149f 100644 --- a/packages/protocol/src/messages.workspaces.test.ts +++ b/packages/protocol/src/messages.workspaces.test.ts @@ -622,6 +622,42 @@ describe("workspace message schemas", () => { expect(parsed.payload.workspace.workspaceKind).toBe("directory"); }); + test("parses workspace script management request and response payloads", () => { + expect( + SessionInboundMessageSchema.parse({ + type: "workspace.script.stop.request", + requestId: "req-script-stop", + workspaceId: "ws-repo", + scriptName: "web", + }), + ).toMatchObject({ type: "workspace.script.stop.request", scriptName: "web" }); + + expect( + SessionOutboundMessageSchema.parse({ + type: "workspace.script.start.response", + payload: { + requestId: "req-script-start", + workspaceId: "ws-repo", + scriptName: "web", + script: { + scriptName: "web", + type: "service", + hostname: "web--repo.localhost", + port: 3000, + proxyUrl: "http://web--repo.localhost:6767", + lifecycle: "running", + health: "healthy", + terminalId: "terminal-1", + }, + error: null, + }, + }), + ).toMatchObject({ + type: "workspace.script.start.response", + payload: { script: { terminalId: "terminal-1", lifecycle: "running" } }, + }); + }); + test("parses script_status_update payload", () => { const parsed = SessionOutboundMessageSchema.parse({ type: "script_status_update", diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 12aa43079..906fd16a2 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -10,6 +10,7 @@ import { AgentListItemPayloadSchema, AgentPermissionResponseSchema, AgentSnapshotPayloadSchema, + WorkspaceScriptPayloadSchema, } from "../../messages.js"; import type { AgentListItemPayload } from "../../messages.js"; import { @@ -74,6 +75,7 @@ import type { WorkspaceRegistry, } from "../../workspace-registry.js"; import { resolveWorktreeSourceCwd } from "../../workspace-source.js"; +import type { WorkspaceScriptsService } from "../../session/workspace-scripts/workspace-scripts-service.js"; import { type ArchiveCommandDependencies, type CreatePaseoWorktreeCommandInput, @@ -112,6 +114,7 @@ export interface PaseoToolHostDependencies { title?: string | null, projectId?: string, ) => Promise; + workspaceScripts?: Pick; markWorkspaceArchiving?: ArchiveDependencies["markWorkspaceArchiving"]; clearWorkspaceArchiving?: ArchiveDependencies["clearWorkspaceArchiving"]; createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn; @@ -537,6 +540,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase agentManager, agentStorage, terminalManager, + workspaceScripts, scheduleService, providerSnapshotManager, callerAgentId, @@ -2220,6 +2224,83 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }, ); + registerTool( + "list_workspace_scripts", + { + title: "List workspace scripts", + description: + "List configured workspace scripts and their lifecycle, service port, proxy URL, health, and terminal ID.", + inputSchema: { + workspaceId: z.string().describe("Workspace ID whose configured scripts to list."), + }, + outputSchema: { + scripts: z.array(WorkspaceScriptPayloadSchema), + }, + }, + async ({ workspaceId }) => { + if (!workspaceScripts) { + throw new Error("Workspace script management is not configured"); + } + return { + content: [], + structuredContent: ensureValidJson({ scripts: await workspaceScripts.list(workspaceId) }), + }; + }, + ); + + registerTool( + "start_workspace_script", + { + title: "Start workspace script", + description: + "Start one configured workspace script through Paseo's managed workspace-script launcher.", + inputSchema: { + workspaceId: z.string().describe("Workspace ID containing the configured script."), + scriptName: z.string().min(1).describe("Configured paseo.json script name to start."), + }, + outputSchema: { + script: WorkspaceScriptPayloadSchema, + }, + }, + async ({ workspaceId, scriptName }) => { + if (!workspaceScripts) { + throw new Error("Workspace script management is not configured"); + } + return { + content: [], + structuredContent: ensureValidJson({ + script: await workspaceScripts.launch({ workspaceId, scriptName }), + }), + }; + }, + ); + + registerTool( + "stop_workspace_script", + { + title: "Stop workspace script", + description: "Stop a running workspace script through its supervised terminal lifecycle.", + inputSchema: { + workspaceId: z.string().describe("Workspace ID containing the running script."), + scriptName: z.string().min(1).describe("Configured paseo.json script name to stop."), + }, + outputSchema: { + script: WorkspaceScriptPayloadSchema, + }, + }, + async ({ workspaceId, scriptName }) => { + if (!workspaceScripts) { + throw new Error("Workspace script management is not configured"); + } + return { + content: [], + structuredContent: ensureValidJson({ + script: await workspaceScripts.stop({ workspaceId, scriptName }), + }), + }; + }, + ); + registerTool( "list_terminals", { diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 725a67733..f9e19049b 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -171,12 +171,14 @@ import type { AgentProviderRuntimeSettingsMap, ProviderOverride, } from "./agent/provider-launch-config.js"; -import type { PersistedConfig } from "./persisted-config.js"; +import { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js"; import { createServiceProxySubsystem, type ServiceProxySubsystem } from "./service-proxy.js"; import { releaseWorkspaceServicePortPlan } from "./workspace-service-port-registry.js"; import { ScriptHealthMonitor } from "./script-health-monitor.js"; import { createScriptStatusEmitter } from "./script-status-projection.js"; import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; +import { createWorkspaceScriptsService } from "./session/workspace-scripts/workspace-scripts-service.js"; +import { spawnWorkspaceScript } from "./worktree-bootstrap.js"; import { createManagedProcessRegistry, createSystemManagedProcessTable, @@ -1250,6 +1252,24 @@ export async function createPaseoDaemon( await emitWorkspaceUpdatesExternal([workspace.workspaceId]); return workspace; }, + workspaceScripts: createWorkspaceScriptsService({ + serviceProxy, + scriptRuntimeStore, + terminalManager, + workspaceRegistry, + projectRegistry, + workspaceGitService, + getDaemonTcpPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), + getDaemonTcpHost: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.host : null), + serviceProxyPublicBaseUrl, + resolveScriptHealth: (hostname) => scriptHealthMonitor.getHealthForHostname(hostname), + logger, + // MCP operations do not belong to one WebSocket session, so lifecycle + // status updates fan out to every connected client. + emit: (message) => wsServer?.broadcast(wrapSessionMessage(message)), + spawnWorkspaceScript, + globalServicePorts: loadPersistedConfig(config.paseoHome).worktrees?.servicePorts, + }), markWorkspaceArchiving: markWorkspaceArchivingExternal, clearWorkspaceArchiving: clearWorkspaceArchivingExternal, ensureWorkspaceForCreate: createAgentCommandDependencies.ensureWorkspaceForCreate, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index c060450c9..2119218dc 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -13,6 +13,9 @@ import { type SessionOutboundMessage, type GitSetupOptions, type StartWorkspaceScriptRequest, + type WorkspaceScriptListRequest, + type WorkspaceScriptStartRequest, + type WorkspaceScriptStopRequest, type CloseItemsRequest, type DirectorySuggestionsRequest, type ProjectPlacementPayload, @@ -2157,10 +2160,18 @@ export class Session { } private dispatchTerminalMessage(msg: SessionInboundMessage): Promise | undefined { - if (msg.type === "start_workspace_script_request") { - return this.handleStartWorkspaceScriptRequest(msg); + switch (msg.type) { + case "start_workspace_script_request": + return this.handleStartWorkspaceScriptRequest(msg); + case "workspace.script.list.request": + return this.handleWorkspaceScriptListRequest(msg); + case "workspace.script.start.request": + return this.handleWorkspaceScriptStartRequest(msg); + case "workspace.script.stop.request": + return this.handleWorkspaceScriptStopRequest(msg); + default: + return this.terminalController.dispatch(msg); } - return this.terminalController.dispatch(msg); } // eslint-disable-next-line complexity @@ -5496,6 +5507,91 @@ export class Session { return this.workspaceScripts.start(request); } + private async handleWorkspaceScriptListRequest( + request: WorkspaceScriptListRequest, + ): Promise { + try { + const scripts = await this.workspaceScripts.list(request.workspaceId); + this.emit({ + type: "workspace.script.list.response", + payload: { + requestId: request.requestId, + workspaceId: request.workspaceId, + scripts, + error: null, + }, + }); + } catch (error) { + this.emit({ + type: "workspace.script.list.response", + payload: { + requestId: request.requestId, + workspaceId: request.workspaceId, + scripts: [], + error: error instanceof Error ? error.message : "Failed to list workspace scripts", + }, + }); + } + } + + private async handleWorkspaceScriptStartRequest( + request: WorkspaceScriptStartRequest, + ): Promise { + try { + const script = await this.workspaceScripts.launch(request); + this.emit({ + type: "workspace.script.start.response", + payload: { + requestId: request.requestId, + workspaceId: request.workspaceId, + scriptName: request.scriptName, + script, + error: null, + }, + }); + } catch (error) { + this.emit({ + type: "workspace.script.start.response", + payload: { + requestId: request.requestId, + workspaceId: request.workspaceId, + scriptName: request.scriptName, + script: null, + error: error instanceof Error ? error.message : "Failed to start workspace script", + }, + }); + } + } + + private async handleWorkspaceScriptStopRequest( + request: WorkspaceScriptStopRequest, + ): Promise { + try { + const script = await this.workspaceScripts.stop(request); + this.emit({ + type: "workspace.script.stop.response", + payload: { + requestId: request.requestId, + workspaceId: request.workspaceId, + scriptName: request.scriptName, + script, + error: null, + }, + }); + } catch (error) { + this.emit({ + type: "workspace.script.stop.response", + payload: { + requestId: request.requestId, + workspaceId: request.workspaceId, + scriptName: request.scriptName, + script: null, + error: error instanceof Error ? error.message : "Failed to stop workspace script", + }, + }); + } + } + // COMPAT(desktopEditorBridge): added in v0.1.88, remove after 2026-12-03 once old clients no longer call daemon editor RPCs. private async handleLegacyListAvailableEditorsRequest( request: Extract, diff --git a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts index 137b83ac1..c37763974 100644 --- a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts +++ b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts @@ -220,6 +220,54 @@ describe("emitStatusUpdate", () => { }); }); +describe("stop", () => { + test("kills the supervised terminal and returns the stopped service metadata", async () => { + const dir = mkdtempSync(join(tmpdir(), "workspace-scripts-")); + tempDirs.push(dir); + writeFileSync( + join(dir, "paseo.json"), + JSON.stringify({ scripts: { web: { type: "service", command: "npm run web", port: 3000 } } }), + ); + const runtimeStore = new WorkspaceScriptRuntimeStore(); + runtimeStore.set({ + workspaceId: "ws-1", + scriptName: "web", + type: "service", + lifecycle: "running", + terminalId: "terminal-1", + exitCode: null, + }); + const terminalManager = { + getTerminal: (terminalId: string) => (terminalId === "terminal-1" ? {} : undefined), + async killTerminalAndWait(terminalId: string) { + expect(terminalId).toBe("terminal-1"); + runtimeStore.set({ + workspaceId: "ws-1", + scriptName: "web", + type: "service", + lifecycle: "stopped", + terminalId, + exitCode: 143, + }); + }, + } as unknown as TerminalManager; + const { service } = buildService({ + workspace: { workspaceId: "ws-1", cwd: dir } as PersistedWorkspaceRecord, + scriptRuntimeStore: runtimeStore, + terminalManager, + }); + + await expect(service.stop({ workspaceId: "ws-1", scriptName: "web" })).resolves.toMatchObject({ + scriptName: "web", + type: "service", + port: 3000, + lifecycle: "stopped", + exitCode: 143, + terminalId: "terminal-1", + }); + }); +}); + describe("start", () => { test("reports an error when workspace scripts are unavailable", async () => { const { service, emitted, spawnCalls } = buildService({ terminalManager: null }); diff --git a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts index 800179a1f..b43e13548 100644 --- a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts +++ b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts @@ -3,6 +3,7 @@ import type { SessionOutboundMessage, StartWorkspaceScriptRequest, WorkspaceDescriptorPayload, + WorkspaceScriptPayload, } from "../../messages.js"; import type { TerminalManager } from "../../../terminal/terminal-manager.js"; import type { ServiceProxySubsystem } from "../../service-proxy.js"; @@ -43,6 +44,9 @@ export interface WorkspaceScriptsService { project?: PersistedProjectRecord | null, ): WorkspaceScriptsPayload; emitStatusUpdate(workspaceId: string, workspaceDirectory: string): Promise; + list(workspaceId: string): Promise; + launch(input: { workspaceId: string; scriptName: string }): Promise; + stop(input: { workspaceId: string; scriptName: string }): Promise; start(request: StartWorkspaceScriptRequest): Promise; } @@ -93,11 +97,10 @@ export function createWorkspaceScriptsService(deps: { currentBranch, }; } - if (!snapshot) return undefined; return { projectSlug: deriveProjectSlug( workspace.cwd, - snapshot.git.isGit ? snapshot.git.remoteUrl : null, + snapshot?.git.isGit ? snapshot.git.remoteUrl : null, ), currentBranch, }; @@ -137,47 +140,104 @@ export function createWorkspaceScriptsService(deps: { } } + async function getWorkspace(workspaceId: string) { + const workspace = await workspaceRegistry.get(workspaceId); + if (!workspace) { + throw new Error(`Workspace not found: ${workspaceId}`); + } + return workspace; + } + + function requireAvailable(): { + serviceProxy: ServiceProxySubsystem; + runtimeStore: WorkspaceScriptRuntimeStore; + terminalManager: TerminalManager; + } { + if (!terminalManager || !serviceProxy || !scriptRuntimeStore) { + throw new Error("Workspace scripts are not available on this daemon"); + } + return { serviceProxy, runtimeStore: scriptRuntimeStore, terminalManager }; + } + + async function list(workspaceId: string): Promise { + requireAvailable(); + const workspace = await getWorkspace(workspaceId); + const project = await projectRegistry.get(workspace.projectId); + return buildSnapshot(workspace, project); + } + + async function launchProcess(input: { workspaceId: string; scriptName: string }) { + const available = requireAvailable(); + const workspace = await getWorkspace(input.workspaceId); + const project = await projectRegistry.get(workspace.projectId); + const gitMetadata = resolveGitMetadata(workspace, project); + const result = await spawnWorkspaceScript({ + repoRoot: workspace.cwd, + workspaceId: workspace.workspaceId, + projectSlug: gitMetadata.projectSlug, + branchName: gitMetadata.currentBranch, + scriptName: input.scriptName, + daemonPort: getDaemonTcpPort?.() ?? null, + daemonListenHost: getDaemonTcpHost?.() ?? null, + serviceProxyPublicBaseUrl, + serviceProxy: available.serviceProxy, + runtimeStore: available.runtimeStore, + terminalManager: available.terminalManager, + globalServicePorts, + logger, + onLifecycleChanged: () => { + void emitStatusUpdate(workspace.workspaceId, workspace.cwd); + }, + }); + return { workspace, project, terminalId: result.terminalId }; + } + + async function launch(input: { + workspaceId: string; + scriptName: string; + }): Promise { + const { workspace, project } = await launchProcess(input); + const script = buildSnapshot(workspace, project).find( + (entry) => entry.scriptName === input.scriptName, + ); + if (!script) { + throw new Error(`Script '${input.scriptName}' did not produce a status record`); + } + void emitStatusUpdate(workspace.workspaceId, workspace.cwd); + return script; + } + + async function stop(input: { + workspaceId: string; + scriptName: string; + }): Promise { + const available = requireAvailable(); + const workspace = await getWorkspace(input.workspaceId); + const project = await projectRegistry.get(workspace.projectId); + const runtime = available.runtimeStore.get(input); + if (!runtime || runtime.lifecycle !== "running") { + throw new Error(`Script '${input.scriptName}' is not running`); + } + if (!available.terminalManager.getTerminal(runtime.terminalId)) { + throw new Error(`Terminal for script '${input.scriptName}' is no longer available`); + } + + // The launcher's terminal exit listener owns route removal and runtime state updates. + await available.terminalManager.killTerminalAndWait(runtime.terminalId); + + const script = buildSnapshot(workspace, project).find( + (entry) => entry.scriptName === input.scriptName, + ); + if (!script) { + throw new Error(`Script '${input.scriptName}' did not produce a status record`); + } + void emitStatusUpdate(workspace.workspaceId, workspace.cwd); + return script; + } + async function start(request: StartWorkspaceScriptRequest): Promise { try { - if (!terminalManager || !serviceProxy || !scriptRuntimeStore) { - throw new Error("Workspace scripts are not available on this daemon"); - } - - const workspace = await workspaceRegistry.get(request.workspaceId); - if (!workspace) { - throw new Error(`Workspace not found: ${request.workspaceId}`); - } - const project = await projectRegistry.get(workspace.projectId); - const projectSlug = project - ? deriveProjectServiceSlug(project) - : deriveProjectSlug( - workspace.cwd, - workspaceGitService.peekSnapshot(workspace.cwd)?.git.remoteUrl ?? null, - ); - const branchName = - workspaceGitService.peekSnapshot(workspace.cwd)?.git.currentBranch ?? - workspace.branch ?? - null; - - const serviceResult = await spawnWorkspaceScript({ - repoRoot: workspace.cwd, - workspaceId: workspace.workspaceId, - projectSlug, - branchName, - scriptName: request.scriptName, - daemonPort: getDaemonTcpPort?.() ?? null, - daemonListenHost: getDaemonTcpHost?.() ?? null, - serviceProxyPublicBaseUrl, - serviceProxy, - runtimeStore: scriptRuntimeStore, - terminalManager, - globalServicePorts, - logger, - onLifecycleChanged: () => { - void emitStatusUpdate(workspace.workspaceId, workspace.cwd); - }, - }); - + const { workspace, terminalId } = await launchProcess(request); void emitStatusUpdate(workspace.workspaceId, workspace.cwd); emit({ type: "start_workspace_script_response", @@ -185,18 +245,14 @@ export function createWorkspaceScriptsService(deps: { requestId: request.requestId, workspaceId: request.workspaceId, scriptName: request.scriptName, - terminalId: serviceResult.terminalId, + terminalId, error: null, }, }); } catch (error) { const message = error instanceof Error ? error.message : "Failed to start workspace script"; logger.error( - { - err: error, - workspaceId: request.workspaceId, - scriptName: request.scriptName, - }, + { err: error, workspaceId: request.workspaceId, scriptName: request.scriptName }, "Failed to start workspace script", ); emit({ @@ -212,5 +268,5 @@ export function createWorkspaceScriptsService(deps: { } } - return { buildSnapshot, emitStatusUpdate, start }; + return { buildSnapshot, emitStatusUpdate, list, launch, stop, start }; } diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index b7bc4b881..0959c0349 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1445,6 +1445,8 @@ export class VoiceAssistantWebSocketServer { selectiveAgentTimeline: true, // COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15. stableProjectIdentity: true, + // COMPAT(workspaceScriptManagement): added in v0.1.105, remove gate after 2027-01-10. + workspaceScriptManagement: true, }, }; } diff --git a/public-docs/cli.md b/public-docs/cli.md index 14b564cc1..6d1e6394a 100644 --- a/public-docs/cli.md +++ b/public-docs/cli.md @@ -1,6 +1,6 @@ --- title: CLI -description: "Paseo CLI reference: manage agents, workspaces, schedules, daemons, and permissions from your terminal." +description: "Paseo CLI reference: manage agents, workspaces, scripts, schedules, daemons, and permissions from your terminal." nav: CLI order: 3 category: Getting started @@ -86,6 +86,20 @@ paseo workspace archive Add `--forge ` to PR checkout when Paseo cannot infer the forge from the source checkout. See [Git worktrees](/docs/worktrees) for setup hooks and services. +## Workspace scripts + +List, start, and stop the scripts configured in a workspace's `paseo.json`: + +```bash +paseo script ls +paseo script start web +paseo script stop web +``` + +By default, Paseo selects the workspace whose directory is the current directory. Pass `--cwd ` to select a different directory, or `--workspace ` when a directory has multiple workspaces. These commands also accept `--host` and the standard output options such as `--json`. + +The output includes each script's lifecycle and supervised terminal ID. Services also include their assigned port, proxy URL, and health. See [Git worktrees](/docs/worktrees#scripts-and-services) for `paseo.json` configuration. + ## Listing agents ```bash diff --git a/public-docs/mcp.md b/public-docs/mcp.md index 71ba24f08..309f18e26 100644 --- a/public-docs/mcp.md +++ b/public-docs/mcp.md @@ -1,6 +1,6 @@ --- title: MCP reference -description: Reference for the Paseo tools agents use to manage agents, workspaces, terminals, and schedules. +description: Reference for the Paseo tools agents use to manage agents, workspaces, scripts, terminals, and schedules. nav: MCP reference order: 33 category: Orchestration @@ -55,6 +55,18 @@ MCP does not expose an agent-detach tool. Detaching is a manual user action in t For worktree isolation, `create_workspace` accepts the same useful choices as the app: branch off from a base, check out an existing branch, or check out a pull request. The worktree remains an implementation detail of the workspace lifecycle. +### Workspace scripts + +These tools manage scripts configured in a workspace's `paseo.json`. Each requires an explicit `workspaceId`; start and stop also require the configured `scriptName`. + +| Tool | Function | +| ------------------------ | --------------------------------------------------------------------------------------- | +| `list_workspace_scripts` | List configured scripts with lifecycle, terminal, port, proxy URL, and health metadata. | +| `start_workspace_script` | Start a configured script through Paseo's managed launcher. | +| `stop_workspace_script` | Stop a running script through its supervised terminal. | + +See [Git worktrees](/docs/worktrees#scripts-and-services) for `paseo.json` configuration. + ### Terminals | Tool | Function | diff --git a/public-docs/worktrees.md b/public-docs/worktrees.md index fbe3e72d0..bdc8a24f2 100644 --- a/public-docs/worktrees.md +++ b/public-docs/worktrees.md @@ -114,6 +114,8 @@ Commands run with the worktree as `cwd`. Use `$PASEO_SOURCE_CHECKOUT_PATH` to re `scripts` are named commands you can run inside a worktree on demand. Mark one as a _service_ and Paseo supervises it as a long-running process, assigns it a port, and routes HTTP traffic to it through the daemon's reverse proxy. +Run them from the app, or manage them from automation with [`paseo script`](/docs/cli#workspace-scripts) and the [workspace-script MCP tools](/docs/mcp#workspace-scripts). + ### Plain scripts ```json diff --git a/skills/paseo/SKILL.md b/skills/paseo/SKILL.md index 865972c2e..096d8cb09 100644 --- a/skills/paseo/SKILL.md +++ b/skills/paseo/SKILL.md @@ -1,6 +1,6 @@ --- name: paseo -description: Paseo reference for managing workspaces, agents, schedules, and heartbeats. +description: Paseo reference for managing workspaces, workspace scripts, agents, schedules, and heartbeats. --- Paseo is a daemon that supervises AI coding agents on your machine. Control it through tools or a CLI. @@ -15,6 +15,24 @@ Paseo is a daemon that supervises AI coding agents on your machine. Control it t Worktree creation and reference accounting are implementation details of `isolation: "worktree"`. +## Workspace scripts + +Configured `paseo.json` scripts use the same supervised lifecycle from tools and the CLI. + +**`list_workspace_scripts`** — `{ workspaceId }`. Lists configured scripts with lifecycle, service port, proxy URLs, health, exit code, and terminal ID. + +**`start_workspace_script`** — `{ workspaceId, scriptName }`. Starts one configured script through Paseo's managed workspace-script launcher and returns its status metadata. + +**`stop_workspace_script`** — `{ workspaceId, scriptName }`. Stops a running script through its supervised terminal and returns the stopped status metadata. + +The matching CLI surface accepts either an explicit workspace ID or resolves the current directory: + +```bash +paseo script ls [--cwd | --workspace ] +paseo script start [--cwd | --workspace ] +paseo script stop [--cwd | --workspace ] +``` + ## Agents **`create_agent`** — required: `title`, `provider` (`claude/opus`, `codex/gpt-5.4`, …), `initialPrompt`. Optional: `workspaceId`, `notifyOnFinish`, `settings`, `labels`. Returns `{ agentId, workspaceId, … }`.