Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ cli/vendor/

# Build output
dist
dist-web

# Playwright traces
test-results
Expand Down
16 changes: 16 additions & 0 deletions apps/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,22 @@ async function main() {
);
studioArgv.command( 'ai', false, studioCodeCommandBuilder );

studioArgv.command( {
command: 'web-server',
describe: __( 'Start the Studio Web backend (HTTP/SSE) for the browser UI' ),
builder: ( webYargs: StudioArgv ) => {
return webYargs.option( 'port', {
type: 'number',
description: __( 'Port to listen on' ),
default: 8088,
} );
},
handler: async ( argv ) => {
process.env.STUDIO_WEB_SERVER_PORT = String( ( argv as { port?: number } ).port ?? 8088 );
await import( 'cli/web-server/index.js' );
},
} );

registerExportCommand( studioArgv );
registerImportCommand( studioArgv );
registerMcpCommand( studioArgv );
Expand Down
3 changes: 3 additions & 0 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@
"atomically": "^2.1.1",
"chokidar": "^5.0.0",
"cli-table3": "^0.6.5",
"express": "^4.22.0",
"express-rate-limit": "^8.5.2",
"fs-extra": "^11.3.4",
"http-proxy": "^1.18.1",
"ignore": "^7.0.5",
Expand Down Expand Up @@ -75,6 +77,7 @@
"devDependencies": {
"@studio/common": "file:../../tools/common",
"@types/archiver": "^7.0.0",
"@types/express": "^4.17.23",
"@types/http-proxy": "^1.17.17",
"@types/node-forge": "^1.3.14",
"@types/yargs": "^17.0.35",
Expand Down
1 change: 1 addition & 0 deletions apps/cli/vite.config.base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export const baseConfig = defineConfig( {
lib: {
entry: {
main: resolve( __dirname, 'index.ts' ),
'web-server': resolve( __dirname, 'web-server/index.ts' ),
'process-manager-daemon': resolve( __dirname, 'process-manager-daemon.ts' ),
'proxy-daemon': resolve( __dirname, 'proxy-daemon.ts' ),
'playground-server-child': resolve( __dirname, 'playground-server-child.ts' ),
Expand Down
70 changes: 70 additions & 0 deletions apps/cli/web-server/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Studio Web server

The CLI `web-server` command is the HTTP + SSE backend for running Studio's
agent from a browser (the "Studio Web" exploration). It exposes the same
capabilities the desktop app reaches over IPC, but over HTTP, so the portable
`apps/ui` renderer can talk to it through the **web connector**
(`apps/ui/src/data/core/connectors/web`).

```
npm run build:web --workspace=apps/ui # once, or after UI changes
node apps/cli/dist/cli/main.mjs web-server # listens on 127.0.0.1:8088 (--port / STUDIO_WEB_SERVER_PORT)
```

The server serves the built UI itself — open http://localhost:8088 and that's
the whole setup. The API is namespaced under `/api` so the SPA's real-path
routes (`/sessions/:id`, `/sites/:id`) can share the origin.

For UI development with hot reload, run the Vite dev server instead (it targets
the backend's default port cross-origin):

```
cd apps/ui && npm run dev:web # serves the browser entry on :5300
```

## Process topology

Everything here runs on one machine, but the pieces map cleanly onto a hosted
deployment. Three distinctions matter before counting:

- A **server** is a long-lived listener.
- The **agent** is a short-lived child process forked _per message_ — not a server.

### Local

| Piece | Lifetime | Role |
|-------|----------|------|
| Web UI dev server (Vite) | long-lived | serves the SPA to the browser |
| `web-server` (Express) | long-lived | HTTP + SSE API: sessions, sites, agent runs |
| agent (`code sessions resume … --json`) | per message | forked child, same subcommand the desktop app forks |

The server binds to loopback only: it exposes the local user's sessions and
WordPress.com data without authentication, so it must not be reachable from the
network.

### Hosted (direction, not in this increment)

| Piece | Lifetime | Notes |
|-------|----------|-------|
| Web UI | static | served from a CDN / static host |
| `web-server` API | long-lived **fleet** | one multi-tenant backend |
| session sandbox | **ephemeral, one per session** | the agent runs here; spun up and down per session |

Going from local to hosted, only `web-server` becomes a fleet; the per-session
sandbox replaces "your laptop" as the place the agent runs rather than adding a
new always-on server.

## Endpoints

| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/health` | liveness check |
| `GET` | `/api/events` | SSE stream carrying every run's `AgentRunEvent`s |
| `GET` | `/api/sites` | the user's workable WordPress.com sites (requires `studio auth login`) |
| `GET/POST` | `/api/sessions` | list / create AI sessions (shared session store) |
| `GET/PATCH/DELETE` | `/api/sessions/:id` | load / star-archive / delete a session |
| `POST` | `/api/sessions/:id/messages` | send a prompt — forks the agent, returns `{ runId }` |
| `POST` | `/api/sessions/:id/model` | persist a model override for the session |
| `GET` | `/api/runs/active` | active agent runs |
| `POST` | `/api/runs/:runId/interrupt` | graceful interrupt, SIGKILL on second attempt |
| `POST` | `/api/runs/:runId/answer` | answer an agent question |
167 changes: 167 additions & 0 deletions apps/cli/web-server/agent-runs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { fork, type ChildProcess } from 'node:child_process';
import crypto from 'node:crypto';
import type { ActiveAgentRun, AgentEvent, AgentRunEvent } from '@studio/common/ai/agent-events';
import type { JsonEvent } from '@studio/common/ai/json-events';

/**
* Headless analog of the desktop `run-manager` (apps/studio/src/modules/
* ai-agent/run-manager.ts). It forks the exact same CLI subcommand the desktop
* forks — `code sessions resume <id> <prompt> --json` — relays the child's
* `JsonEvent`s as `AgentRunEvent`s, and synthesizes the same lifecycle events
* (`run.started`, `run.exited`, ...). The only difference is the sink: instead
* of `webContents.send`, events go to a broadcaster (SSE) injected via
* `setBroadcast`.
*/

interface AgentRun {
runId: string;
sessionId: string;
child: ChildProcess;
interrupted: boolean;
interruptAttempts: number;
startedAt: number;
}

const runsBySessionId = new Map< string, AgentRun >();
const runsById = new Map< string, AgentRun >();

type Broadcast = ( event: AgentRunEvent ) => void;
let broadcast: Broadcast = () => {};

export function setBroadcast( fn: Broadcast ): void {
broadcast = fn;
}

function nowIso(): string {
return new Date().toISOString();
}

function send( run: AgentRun, event: AgentEvent ): void {
broadcast( { runId: run.runId, sessionId: run.sessionId, event } );
}

export interface StartAgentRunOptions {
sessionId: string;
prompt: string;
displayMessage?: string;
}

export function startAgentRun( options: StartAgentRunOptions ): { runId: string } {
const { sessionId, prompt, displayMessage } = options;

if ( runsBySessionId.has( sessionId ) ) {
throw new Error( `A run is already in progress for session ${ sessionId }` );
}

const runId = crypto.randomUUID();
const startedAt = Date.now();
const args = [ 'code', 'sessions', 'resume', sessionId, prompt, '--json', '--avoid-telemetry' ];
if ( displayMessage ) {
args.push( '--display-message', displayMessage );
}

// Re-invoke this same CLI bundle. The child emits JSON transport events over
// the Node IPC channel (process.send), which we read via `message`.
const child = fork( process.argv[ 1 ], args, {
stdio: [ 'ignore', 'inherit', 'inherit', 'ipc' ],
execArgv: [ '--experimental-wasm-jspi' ],
env: { ...process.env },
} );

const run: AgentRun = {
runId,
sessionId,
child,
interrupted: false,
interruptAttempts: 0,
startedAt,
};

runsBySessionId.set( sessionId, run );
runsById.set( runId, run );

child.on( 'spawn', () => {
send( run, { type: 'run.started', timestamp: nowIso() } );
} );

child.on( 'message', ( message ) => {
// The CLI's Logger also writes to this channel with a different shape;
// forward only messages that look like the JSON transport envelope.
if ( message && typeof message === 'object' && 'type' in message ) {
send( run, message as JsonEvent );
}
} );

child.on( 'error', ( error ) => {
send( run, {
type: 'error',
timestamp: nowIso(),
message: error.message || 'CLI subprocess failed to start',
} );
} );

child.on( 'exit', ( code ) => {
runsBySessionId.delete( sessionId );
runsById.delete( runId );
if ( run.interrupted ) {
send( run, { type: 'run.interrupted', timestamp: nowIso() } );
}
send( run, {
type: 'run.exited',
timestamp: nowIso(),
status: code === 0 ? 'success' : 'error',
code,
} );
} );

return { runId };
}

export function listActiveAgentRuns(): ActiveAgentRun[] {
return Array.from( runsBySessionId.values() ).map( ( run ) => ( {
runId: run.runId,
sessionId: run.sessionId,
startedAt: run.startedAt,
phase: run.interrupted ? 'interrupting' : 'running',
} ) );
}

const INTERRUPT_FORCE_KILL_TIMEOUT_MS = 2000;

export function interruptAgentRun( runId: string ): void {
const run = runsById.get( runId );
if ( ! run ) {
return;
}
run.interrupted = true;
run.interruptAttempts += 1;
if ( runsBySessionId.get( run.sessionId ) === run ) {
runsBySessionId.delete( run.sessionId );
}

if ( run.interruptAttempts > 1 ) {
run.child.kill( 'SIGKILL' );
return;
}

if ( run.child.connected ) {
run.child.send( { type: 'interrupt' } );
send( run, { type: 'run.interrupting', timestamp: nowIso() } );
setTimeout( () => {
if ( runsById.get( runId ) === run && ! run.child.killed ) {
run.child.kill( 'SIGKILL' );
}
}, INTERRUPT_FORCE_KILL_TIMEOUT_MS ).unref();
return;
}

run.child.kill( 'SIGKILL' );
}

export function answerAgentRun( runId: string, answers: Record< string, string > ): void {
const run = runsById.get( runId );
if ( ! run || ! run.child.connected ) {
return;
}
run.child.send( { type: 'answer', answers } );
}
Loading