mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
feat(cli): manage workspace scripts (#1992)
* feat(cli): manage workspace scripts * docs: document workspace script management --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
@@ -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 daemon start/stop/restart/status/pair/set-password`
|
||||||
- `paseo chat ls/create/inspect/post/read/wait/delete`
|
- `paseo chat ls/create/inspect/post/read/wait/delete`
|
||||||
- `paseo terminal ls/create/capture/send-keys/kill`
|
- `paseo terminal ls/create/capture/send-keys/kill`
|
||||||
|
- `paseo script ls/start/stop`
|
||||||
- `paseo loop run/ls/inspect/logs/stop`
|
- `paseo loop run/ls/inspect/logs/stop`
|
||||||
- `paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete`
|
- `paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete`
|
||||||
- `paseo heartbeat create/update/delete`
|
- `paseo heartbeat create/update/delete`
|
||||||
|
|||||||
@@ -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.
|
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 <path> | --workspace <workspace-id>]
|
||||||
|
paseo script start <name> [--cwd <path> | --workspace <workspace-id>]
|
||||||
|
paseo script stop <name> [--cwd <path> | --workspace <workspace-id>]
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
## Configuration
|
||||||
|
|
||||||
Add a `serviceProxy` block under `daemon` in `~/.paseo/config.json`:
|
Add a `serviceProxy` block under `daemon` in `~/.paseo/config.json`:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { createPermitCommand } from "./commands/permit/index.js";
|
|||||||
import { createProviderCommand } from "./commands/provider/index.js";
|
import { createProviderCommand } from "./commands/provider/index.js";
|
||||||
import { createScheduleCommand } from "./commands/schedule/index.js";
|
import { createScheduleCommand } from "./commands/schedule/index.js";
|
||||||
import { createSpeechCommand } from "./commands/speech/index.js";
|
import { createSpeechCommand } from "./commands/speech/index.js";
|
||||||
|
import { createScriptCommand } from "./commands/script/index.js";
|
||||||
import { createTerminalCommand } from "./commands/terminal/index.js";
|
import { createTerminalCommand } from "./commands/terminal/index.js";
|
||||||
import { createWorktreeCommand } from "./commands/worktree/index.js";
|
import { createWorktreeCommand } from "./commands/worktree/index.js";
|
||||||
import { createWorkspaceCommand } from "./commands/workspace/index.js";
|
import { createWorkspaceCommand } from "./commands/workspace/index.js";
|
||||||
@@ -171,6 +172,9 @@ export function createCli(): Command {
|
|||||||
// Terminal commands
|
// Terminal commands
|
||||||
program.addCommand(createTerminalCommand());
|
program.addCommand(createTerminalCommand());
|
||||||
|
|
||||||
|
// Workspace script commands
|
||||||
|
program.addCommand(createScriptCommand());
|
||||||
|
|
||||||
// Loop commands
|
// Loop commands
|
||||||
program.addCommand(createLoopCommand());
|
program.addCommand(createLoopCommand());
|
||||||
|
|
||||||
|
|||||||
39
packages/cli/src/commands/script/index.ts
Normal file
39
packages/cli/src/commands/script/index.ts
Normal file
@@ -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 <path>", "Workspace directory (default: current directory)")
|
||||||
|
.option(
|
||||||
|
"--workspace <workspace-id>",
|
||||||
|
"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("<name>"),
|
||||||
|
),
|
||||||
|
).action(withOutput(runStartCommand));
|
||||||
|
|
||||||
|
addJsonAndDaemonHostOptions(
|
||||||
|
addWorkspaceSelectionOptions(
|
||||||
|
script.command("stop").description("Stop a running workspace script").argument("<name>"),
|
||||||
|
),
|
||||||
|
).action(withOutput(runStopCommand));
|
||||||
|
|
||||||
|
return script;
|
||||||
|
}
|
||||||
32
packages/cli/src/commands/script/ls.ts
Normal file
32
packages/cli/src/commands/script/ls.ts
Normal file
@@ -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<ListResult<WorkspaceScriptRow>> {
|
||||||
|
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(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
17
packages/cli/src/commands/script/schema.ts
Normal file
17
packages/cli/src/commands/script/schema.ts
Normal file
@@ -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<WorkspaceScriptRow> = {
|
||||||
|
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 },
|
||||||
|
],
|
||||||
|
};
|
||||||
78
packages/cli/src/commands/script/shared.ts
Normal file
78
packages/cli/src/commands/script/shared.ts
Normal file
@@ -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<DaemonClient> {
|
||||||
|
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<string> {
|
||||||
|
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 <workspace-id> 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 <workspace-id>.",
|
||||||
|
} 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}` };
|
||||||
|
}
|
||||||
36
packages/cli/src/commands/script/start.ts
Normal file
36
packages/cli/src/commands/script/start.ts
Normal file
@@ -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<SingleResult<WorkspaceScriptRow>> {
|
||||||
|
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(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
36
packages/cli/src/commands/script/stop.ts
Normal file
36
packages/cli/src/commands/script/stop.ts
Normal file
@@ -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<SingleResult<WorkspaceScriptRow>> {
|
||||||
|
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(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2179,6 +2179,47 @@ export class DaemonClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listWorkspaceScripts(
|
||||||
|
workspaceId: string,
|
||||||
|
requestId?: string,
|
||||||
|
): Promise<
|
||||||
|
Extract<SessionOutboundMessage, { type: "workspace.script.list.response" }>["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<SessionOutboundMessage, { type: "workspace.script.start.response" }>["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<SessionOutboundMessage, { type: "workspace.script.stop.response" }>["payload"]
|
||||||
|
> {
|
||||||
|
return this.sendCorrelatedSessionRequest({
|
||||||
|
requestId,
|
||||||
|
message: { type: "workspace.script.stop.request", workspaceId, scriptName },
|
||||||
|
responseType: "workspace.script.stop.response",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async archiveWorkspace(
|
async archiveWorkspace(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
requestId?: string,
|
requestId?: string,
|
||||||
|
|||||||
@@ -2328,6 +2328,26 @@ export const StartWorkspaceScriptRequestSchema = z.object({
|
|||||||
requestId: z.string(),
|
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({
|
export const SubscribeTerminalRequestSchema = z.object({
|
||||||
type: z.literal("subscribe_terminal_request"),
|
type: z.literal("subscribe_terminal_request"),
|
||||||
terminalId: z.string(),
|
terminalId: z.string(),
|
||||||
@@ -2530,6 +2550,9 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
CreateTerminalRequestSchema,
|
CreateTerminalRequestSchema,
|
||||||
RenameTerminalRequestSchema,
|
RenameTerminalRequestSchema,
|
||||||
StartWorkspaceScriptRequestSchema,
|
StartWorkspaceScriptRequestSchema,
|
||||||
|
WorkspaceScriptListRequestSchema,
|
||||||
|
WorkspaceScriptStartRequestSchema,
|
||||||
|
WorkspaceScriptStopRequestSchema,
|
||||||
SubscribeTerminalRequestSchema,
|
SubscribeTerminalRequestSchema,
|
||||||
UnsubscribeTerminalRequestSchema,
|
UnsubscribeTerminalRequestSchema,
|
||||||
TerminalInputSchema,
|
TerminalInputSchema,
|
||||||
@@ -2793,6 +2816,8 @@ export const ServerInfoStatusPayloadSchema = z
|
|||||||
selectiveAgentTimeline: z.boolean().optional(),
|
selectiveAgentTimeline: z.boolean().optional(),
|
||||||
// COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15.
|
// COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15.
|
||||||
stableProjectIdentity: z.boolean().optional(),
|
stableProjectIdentity: z.boolean().optional(),
|
||||||
|
// COMPAT(workspaceScriptManagement): added in v0.1.105, remove gate after 2027-01-10.
|
||||||
|
workspaceScriptManagement: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.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.
|
// 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({
|
export const LegacyListAvailableEditorsResponseMessageSchema = z.object({
|
||||||
type: z.literal("list_available_editors_response"),
|
type: z.literal("list_available_editors_response"),
|
||||||
@@ -5121,6 +5170,9 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
WorkspaceGithubSearchRepositoriesResponseSchema,
|
WorkspaceGithubSearchRepositoriesResponseSchema,
|
||||||
ProjectGithubCloneResponseSchema,
|
ProjectGithubCloneResponseSchema,
|
||||||
StartWorkspaceScriptResponseMessageSchema,
|
StartWorkspaceScriptResponseMessageSchema,
|
||||||
|
WorkspaceScriptListResponseMessageSchema,
|
||||||
|
WorkspaceScriptStartResponseMessageSchema,
|
||||||
|
WorkspaceScriptStopResponseMessageSchema,
|
||||||
LegacyListAvailableEditorsResponseMessageSchema,
|
LegacyListAvailableEditorsResponseMessageSchema,
|
||||||
LegacyOpenInEditorResponseMessageSchema,
|
LegacyOpenInEditorResponseMessageSchema,
|
||||||
ArchiveWorkspaceResponseMessageSchema,
|
ArchiveWorkspaceResponseMessageSchema,
|
||||||
@@ -5304,6 +5356,18 @@ export type ProjectGithubCloneResponse = z.infer<typeof ProjectGithubCloneRespon
|
|||||||
export type StartWorkspaceScriptResponseMessage = z.infer<
|
export type StartWorkspaceScriptResponseMessage = z.infer<
|
||||||
typeof StartWorkspaceScriptResponseMessageSchema
|
typeof StartWorkspaceScriptResponseMessageSchema
|
||||||
>;
|
>;
|
||||||
|
export type WorkspaceScriptListRequest = z.infer<typeof WorkspaceScriptListRequestSchema>;
|
||||||
|
export type WorkspaceScriptStartRequest = z.infer<typeof WorkspaceScriptStartRequestSchema>;
|
||||||
|
export type WorkspaceScriptStopRequest = z.infer<typeof WorkspaceScriptStopRequestSchema>;
|
||||||
|
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<
|
export type LegacyListAvailableEditorsResponseMessage = z.infer<
|
||||||
typeof LegacyListAvailableEditorsResponseMessageSchema
|
typeof LegacyListAvailableEditorsResponseMessageSchema
|
||||||
>;
|
>;
|
||||||
|
|||||||
@@ -622,6 +622,42 @@ describe("workspace message schemas", () => {
|
|||||||
expect(parsed.payload.workspace.workspaceKind).toBe("directory");
|
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", () => {
|
test("parses script_status_update payload", () => {
|
||||||
const parsed = SessionOutboundMessageSchema.parse({
|
const parsed = SessionOutboundMessageSchema.parse({
|
||||||
type: "script_status_update",
|
type: "script_status_update",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
AgentListItemPayloadSchema,
|
AgentListItemPayloadSchema,
|
||||||
AgentPermissionResponseSchema,
|
AgentPermissionResponseSchema,
|
||||||
AgentSnapshotPayloadSchema,
|
AgentSnapshotPayloadSchema,
|
||||||
|
WorkspaceScriptPayloadSchema,
|
||||||
} from "../../messages.js";
|
} from "../../messages.js";
|
||||||
import type { AgentListItemPayload } from "../../messages.js";
|
import type { AgentListItemPayload } from "../../messages.js";
|
||||||
import {
|
import {
|
||||||
@@ -74,6 +75,7 @@ import type {
|
|||||||
WorkspaceRegistry,
|
WorkspaceRegistry,
|
||||||
} from "../../workspace-registry.js";
|
} from "../../workspace-registry.js";
|
||||||
import { resolveWorktreeSourceCwd } from "../../workspace-source.js";
|
import { resolveWorktreeSourceCwd } from "../../workspace-source.js";
|
||||||
|
import type { WorkspaceScriptsService } from "../../session/workspace-scripts/workspace-scripts-service.js";
|
||||||
import {
|
import {
|
||||||
type ArchiveCommandDependencies,
|
type ArchiveCommandDependencies,
|
||||||
type CreatePaseoWorktreeCommandInput,
|
type CreatePaseoWorktreeCommandInput,
|
||||||
@@ -112,6 +114,7 @@ export interface PaseoToolHostDependencies {
|
|||||||
title?: string | null,
|
title?: string | null,
|
||||||
projectId?: string,
|
projectId?: string,
|
||||||
) => Promise<PersistedWorkspaceRecord>;
|
) => Promise<PersistedWorkspaceRecord>;
|
||||||
|
workspaceScripts?: Pick<WorkspaceScriptsService, "list" | "launch" | "stop">;
|
||||||
markWorkspaceArchiving?: ArchiveDependencies["markWorkspaceArchiving"];
|
markWorkspaceArchiving?: ArchiveDependencies["markWorkspaceArchiving"];
|
||||||
clearWorkspaceArchiving?: ArchiveDependencies["clearWorkspaceArchiving"];
|
clearWorkspaceArchiving?: ArchiveDependencies["clearWorkspaceArchiving"];
|
||||||
createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn;
|
createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn;
|
||||||
@@ -537,6 +540,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
|
|||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
terminalManager,
|
terminalManager,
|
||||||
|
workspaceScripts,
|
||||||
scheduleService,
|
scheduleService,
|
||||||
providerSnapshotManager,
|
providerSnapshotManager,
|
||||||
callerAgentId,
|
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(
|
registerTool(
|
||||||
"list_terminals",
|
"list_terminals",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -171,12 +171,14 @@ import type {
|
|||||||
AgentProviderRuntimeSettingsMap,
|
AgentProviderRuntimeSettingsMap,
|
||||||
ProviderOverride,
|
ProviderOverride,
|
||||||
} from "./agent/provider-launch-config.js";
|
} 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 { createServiceProxySubsystem, type ServiceProxySubsystem } from "./service-proxy.js";
|
||||||
import { releaseWorkspaceServicePortPlan } from "./workspace-service-port-registry.js";
|
import { releaseWorkspaceServicePortPlan } from "./workspace-service-port-registry.js";
|
||||||
import { ScriptHealthMonitor } from "./script-health-monitor.js";
|
import { ScriptHealthMonitor } from "./script-health-monitor.js";
|
||||||
import { createScriptStatusEmitter } from "./script-status-projection.js";
|
import { createScriptStatusEmitter } from "./script-status-projection.js";
|
||||||
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.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 {
|
import {
|
||||||
createManagedProcessRegistry,
|
createManagedProcessRegistry,
|
||||||
createSystemManagedProcessTable,
|
createSystemManagedProcessTable,
|
||||||
@@ -1250,6 +1252,24 @@ export async function createPaseoDaemon(
|
|||||||
await emitWorkspaceUpdatesExternal([workspace.workspaceId]);
|
await emitWorkspaceUpdatesExternal([workspace.workspaceId]);
|
||||||
return workspace;
|
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,
|
markWorkspaceArchiving: markWorkspaceArchivingExternal,
|
||||||
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
|
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
|
||||||
ensureWorkspaceForCreate: createAgentCommandDependencies.ensureWorkspaceForCreate,
|
ensureWorkspaceForCreate: createAgentCommandDependencies.ensureWorkspaceForCreate,
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import {
|
|||||||
type SessionOutboundMessage,
|
type SessionOutboundMessage,
|
||||||
type GitSetupOptions,
|
type GitSetupOptions,
|
||||||
type StartWorkspaceScriptRequest,
|
type StartWorkspaceScriptRequest,
|
||||||
|
type WorkspaceScriptListRequest,
|
||||||
|
type WorkspaceScriptStartRequest,
|
||||||
|
type WorkspaceScriptStopRequest,
|
||||||
type CloseItemsRequest,
|
type CloseItemsRequest,
|
||||||
type DirectorySuggestionsRequest,
|
type DirectorySuggestionsRequest,
|
||||||
type ProjectPlacementPayload,
|
type ProjectPlacementPayload,
|
||||||
@@ -2157,10 +2160,18 @@ export class Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private dispatchTerminalMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
private dispatchTerminalMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
||||||
if (msg.type === "start_workspace_script_request") {
|
switch (msg.type) {
|
||||||
return this.handleStartWorkspaceScriptRequest(msg);
|
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
|
// eslint-disable-next-line complexity
|
||||||
@@ -5496,6 +5507,91 @@ export class Session {
|
|||||||
return this.workspaceScripts.start(request);
|
return this.workspaceScripts.start(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleWorkspaceScriptListRequest(
|
||||||
|
request: WorkspaceScriptListRequest,
|
||||||
|
): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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.
|
// COMPAT(desktopEditorBridge): added in v0.1.88, remove after 2026-12-03 once old clients no longer call daemon editor RPCs.
|
||||||
private async handleLegacyListAvailableEditorsRequest(
|
private async handleLegacyListAvailableEditorsRequest(
|
||||||
request: Extract<SessionInboundMessage, { type: "list_available_editors_request" }>,
|
request: Extract<SessionInboundMessage, { type: "list_available_editors_request" }>,
|
||||||
|
|||||||
@@ -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", () => {
|
describe("start", () => {
|
||||||
test("reports an error when workspace scripts are unavailable", async () => {
|
test("reports an error when workspace scripts are unavailable", async () => {
|
||||||
const { service, emitted, spawnCalls } = buildService({ terminalManager: null });
|
const { service, emitted, spawnCalls } = buildService({ terminalManager: null });
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
StartWorkspaceScriptRequest,
|
StartWorkspaceScriptRequest,
|
||||||
WorkspaceDescriptorPayload,
|
WorkspaceDescriptorPayload,
|
||||||
|
WorkspaceScriptPayload,
|
||||||
} from "../../messages.js";
|
} from "../../messages.js";
|
||||||
import type { TerminalManager } from "../../../terminal/terminal-manager.js";
|
import type { TerminalManager } from "../../../terminal/terminal-manager.js";
|
||||||
import type { ServiceProxySubsystem } from "../../service-proxy.js";
|
import type { ServiceProxySubsystem } from "../../service-proxy.js";
|
||||||
@@ -43,6 +44,9 @@ export interface WorkspaceScriptsService {
|
|||||||
project?: PersistedProjectRecord | null,
|
project?: PersistedProjectRecord | null,
|
||||||
): WorkspaceScriptsPayload;
|
): WorkspaceScriptsPayload;
|
||||||
emitStatusUpdate(workspaceId: string, workspaceDirectory: string): Promise<void>;
|
emitStatusUpdate(workspaceId: string, workspaceDirectory: string): Promise<void>;
|
||||||
|
list(workspaceId: string): Promise<WorkspaceScriptPayload[]>;
|
||||||
|
launch(input: { workspaceId: string; scriptName: string }): Promise<WorkspaceScriptPayload>;
|
||||||
|
stop(input: { workspaceId: string; scriptName: string }): Promise<WorkspaceScriptPayload>;
|
||||||
start(request: StartWorkspaceScriptRequest): Promise<void>;
|
start(request: StartWorkspaceScriptRequest): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,11 +97,10 @@ export function createWorkspaceScriptsService(deps: {
|
|||||||
currentBranch,
|
currentBranch,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (!snapshot) return undefined;
|
|
||||||
return {
|
return {
|
||||||
projectSlug: deriveProjectSlug(
|
projectSlug: deriveProjectSlug(
|
||||||
workspace.cwd,
|
workspace.cwd,
|
||||||
snapshot.git.isGit ? snapshot.git.remoteUrl : null,
|
snapshot?.git.isGit ? snapshot.git.remoteUrl : null,
|
||||||
),
|
),
|
||||||
currentBranch,
|
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<WorkspaceScriptPayload[]> {
|
||||||
|
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<WorkspaceScriptPayload> {
|
||||||
|
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<WorkspaceScriptPayload> {
|
||||||
|
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<void> {
|
async function start(request: StartWorkspaceScriptRequest): Promise<void> {
|
||||||
try {
|
try {
|
||||||
if (!terminalManager || !serviceProxy || !scriptRuntimeStore) {
|
const { workspace, terminalId } = await launchProcess(request);
|
||||||
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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
void emitStatusUpdate(workspace.workspaceId, workspace.cwd);
|
void emitStatusUpdate(workspace.workspaceId, workspace.cwd);
|
||||||
emit({
|
emit({
|
||||||
type: "start_workspace_script_response",
|
type: "start_workspace_script_response",
|
||||||
@@ -185,18 +245,14 @@ export function createWorkspaceScriptsService(deps: {
|
|||||||
requestId: request.requestId,
|
requestId: request.requestId,
|
||||||
workspaceId: request.workspaceId,
|
workspaceId: request.workspaceId,
|
||||||
scriptName: request.scriptName,
|
scriptName: request.scriptName,
|
||||||
terminalId: serviceResult.terminalId,
|
terminalId,
|
||||||
error: null,
|
error: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Failed to start workspace script";
|
const message = error instanceof Error ? error.message : "Failed to start workspace script";
|
||||||
logger.error(
|
logger.error(
|
||||||
{
|
{ err: error, workspaceId: request.workspaceId, scriptName: request.scriptName },
|
||||||
err: error,
|
|
||||||
workspaceId: request.workspaceId,
|
|
||||||
scriptName: request.scriptName,
|
|
||||||
},
|
|
||||||
"Failed to start workspace script",
|
"Failed to start workspace script",
|
||||||
);
|
);
|
||||||
emit({
|
emit({
|
||||||
@@ -212,5 +268,5 @@ export function createWorkspaceScriptsService(deps: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { buildSnapshot, emitStatusUpdate, start };
|
return { buildSnapshot, emitStatusUpdate, list, launch, stop, start };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1445,6 +1445,8 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
selectiveAgentTimeline: true,
|
selectiveAgentTimeline: true,
|
||||||
// COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15.
|
// COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15.
|
||||||
stableProjectIdentity: true,
|
stableProjectIdentity: true,
|
||||||
|
// COMPAT(workspaceScriptManagement): added in v0.1.105, remove gate after 2027-01-10.
|
||||||
|
workspaceScriptManagement: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: CLI
|
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
|
nav: CLI
|
||||||
order: 3
|
order: 3
|
||||||
category: Getting started
|
category: Getting started
|
||||||
@@ -86,6 +86,20 @@ paseo workspace archive <workspace-id>
|
|||||||
|
|
||||||
Add `--forge <name>` to PR checkout when Paseo cannot infer the forge from the source checkout. See [Git worktrees](/docs/worktrees) for setup hooks and services.
|
Add `--forge <name>` 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 <path>` to select a different directory, or `--workspace <workspace-id>` 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
|
## Listing agents
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: MCP reference
|
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
|
nav: MCP reference
|
||||||
order: 33
|
order: 33
|
||||||
category: Orchestration
|
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.
|
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
|
### Terminals
|
||||||
|
|
||||||
| Tool | Function |
|
| Tool | Function |
|
||||||
|
|||||||
@@ -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.
|
`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
|
### Plain scripts
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: paseo
|
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.
|
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"`.
|
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 <path> | --workspace <workspace-id>]
|
||||||
|
paseo script start <name> [--cwd <path> | --workspace <workspace-id>]
|
||||||
|
paseo script stop <name> [--cwd <path> | --workspace <workspace-id>]
|
||||||
|
```
|
||||||
|
|
||||||
## Agents
|
## Agents
|
||||||
|
|
||||||
**`create_agent`** — required: `title`, `provider` (`claude/opus`, `codex/gpt-5.4`, …), `initialPrompt`. Optional: `workspaceId`, `notifyOnFinish`, `settings`, `labels`. Returns `{ agentId, workspaceId, … }`.
|
**`create_agent`** — required: `title`, `provider` (`claude/opus`, `codex/gpt-5.4`, …), `initialPrompt`. Optional: `workspaceId`, `notifyOnFinish`, `settings`, `labels`. Returns `{ agentId, workspaceId, … }`.
|
||||||
|
|||||||
Reference in New Issue
Block a user