Skip to content
Merged
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
9 changes: 9 additions & 0 deletions .changeset/hosted-run-relay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@gemstack/framework': minor
---

feat(framework): hosted run relay — watch one run from multiple browsers (#230)

The first slice toward shared team sessions: a run can now be watched live from more than one machine. `framework relay` hosts a relay; a run started with `framework "..." --share <relay-url>` publishes its event stream to it and prints a shareable URL. Anyone who opens that URL gets the same dashboard over SSE, replaying the run's full history and then following live — so two teammates watch one build together.

Reuses the existing dashboard: the SSE serving is factored into a shared helper and the page's stream/stop paths are now relative so they resolve both on the localhost dashboard and under the relay's `/r/<id>/`. New exports: `startRelay`, `relayPublisher`. Deliberately unauthenticated — accounts, teams, RBAC, and authorized steering layer on later; the relay only projects the stream, it never runs an agent.
12 changes: 12 additions & 0 deletions packages/framework/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,18 @@ test('parseArgs reads --sandbox and rejects an unknown value (#229)', () => {
assert.match(parseArgs(['--sandbox', 'vm', 'x']).error!, /invalid --sandbox/)
})

test('parseArgs reads the relay subcommand and --share (#230)', () => {
const relay = parseArgs(['relay', '--port', '5000'])
assert.equal(relay.relayServe, true)
assert.equal(relay.intent, '') // 'relay' is a subcommand, not an intent word
assert.equal(relay.port, 5000)
const share = parseArgs(['--share', 'http://host:4488', 'a', 'blog'])
assert.equal(share.share, 'http://host:4488')
assert.equal(share.intent, 'a blog')
assert.equal(parseArgs(['x']).relayServe, false)
assert.equal(parseArgs(['x']).share, undefined)
})

test('workspaceSummary describes an empty vs an existing workspace', async () => {
const empty = await mkdtemp(join(tmpdir(), 'framework-ws-'))
try {
Expand Down
52 changes: 50 additions & 2 deletions packages/framework/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
import { ClaudeCodeDriver, type ClaudeCodeDriverOptions, type Driver, type DriverSession, type PermissionMode } from './driver/index.js'
import { hostExecutor } from './host-exec.js'
import { startDashboard, type Dashboard } from './dashboard/index.js'
import { startRelay, relayPublisher, type RelayPublisher } from './relay.js'
import { randomUUID } from 'node:crypto'
import { formatFrameworkEvent, CLAUDE_CODE_SESSION_LINK, type FrameworkEvent } from './events.js'
import {
runFramework,
Expand Down Expand Up @@ -74,6 +76,7 @@ Usage:
framework [intent...] Build what you describe, from scratch.
framework --fake Run the offline demo (no CLI, no model, deterministic).
framework doctor Check prerequisites (Claude Code installed, etc.).
framework relay Host a run relay so teammates can watch a run (#230).

Options:
--fake Use the fake driver + scripted run (offline / CI).
Expand Down Expand Up @@ -115,8 +118,10 @@ Options:
--cf-project <name> Cloudflare Pages project name (for a Pages deploy).
--dokploy-url <url> Dokploy instance URL (required for --deploy dokploy).
--dokploy-app <id> Dokploy application id (required for --deploy dokploy).
--port <n> Dashboard port (default: 4477).
--port <n> Dashboard port (default: 4477); with the relay, the relay port (4488).
--no-dashboard Do not start the localhost dashboard.
--share <relay-url> Publish this run to a relay (from "framework relay") so
teammates can watch it live; prints the shareable URL.
--resume Reopen the last run's dashboard from .framework/ in --cwd
(read-only replay; no new agent run). Survives a restart.
--no-persist Do not write the orchestration state to .framework/.
Expand Down Expand Up @@ -164,6 +169,8 @@ export interface CliOptions {
sandbox?: 'local' | 'docker' | undefined
port?: number
dashboard: boolean
relayServe: boolean
share?: string | undefined
composeExtensions: boolean
sessionLink?: string | undefined
permissionMode?: PermissionMode | undefined
Expand All @@ -187,6 +194,7 @@ export function parseArgs(argv: string[]): CliOptions {
autopilot: false,
technical: false,
dashboard: true,
relayServe: false,
composeExtensions: false,
skipPermissions: false,
resume: false,
Expand Down Expand Up @@ -292,6 +300,9 @@ export function parseArgs(argv: string[]): CliOptions {
else opts.sandbox = where
break
}
case '--share':
opts.share = argv[++i]
break
case '--session-link':
opts.sessionLink = argv[++i]
break
Expand All @@ -318,10 +329,13 @@ export function parseArgs(argv: string[]): CliOptions {
else words.push(arg)
}
}
// `framework doctor` is a subcommand, not an intent.
// `framework doctor` / `framework relay` are subcommands, not an intent.
if (words[0] === 'doctor') {
opts.doctor = true
words.shift()
} else if (words[0] === 'relay') {
opts.relayServe = true
words.shift()
}
opts.intent = words.join(' ').trim()
return opts
Expand Down Expand Up @@ -477,6 +491,10 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
return result.ok ? 0 : 1
}

// `framework relay` hosts the run relay: teammates open a run's URL and watch it
// live (#230). It runs until interrupted; a run publishes to it with `--share`.
if (opts.relayServe) return runRelayServer(opts, io)

// Resume a previous run's dashboard from its persisted log — the reload half of
// #211. No agent runs; we just replay the saved events into a fresh stream so
// the dashboard rehydrates exactly as it looked, then leave it up read-only.
Expand Down Expand Up @@ -635,10 +653,21 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
}
}

// Publish the run to a relay (#230) so teammates can watch it live. Best-effort:
// a relay that is down never fails the run (relayPublisher swallows POST errors).
let publisher: RelayPublisher | undefined
if (opts.share) {
publisher = relayPublisher(opts.share, randomUUID(), err =>
io.err(`relay publish failed (${err instanceof Error ? err.message : String(err)})`),
)
io.out(`◆ shared run: ${publisher.url}`)
}

const onEvent = (event: FrameworkEvent) => {
io.out(formatFrameworkEvent(event))
dashboard?.push(event)
void store?.append(event)
publisher?.publish(event)
}

// Discover installed `framework-*` capability packages (#190) from the signals
Expand Down Expand Up @@ -720,9 +749,28 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise<num
io.err(`\n✗ run failed: ${err instanceof Error ? err.message : String(err)}`)
await dashboard?.close()
return 1
} finally {
// Make sure every event (including the final `end`) reached the relay before exit.
if (publisher) await publisher.flush()
}
}

/**
* `framework relay`: host the run relay (#230). Teammates open a run's URL
* (printed when a run uses `--share <this-url>`) and watch it live with full
* history replay. Runs until interrupted. Unauthenticated by design — anyone with
* a run URL can watch; accounts/teams/steering come later.
*/
async function runRelayServer(opts: CliOptions, io: CliIO): Promise<number> {
const relay = await startRelay(opts.port !== undefined ? { port: opts.port } : {})
io.out(`◆ relay listening at ${relay.url}`)
io.out(` Runs published with \`framework "..." --share ${relay.url}\` are watchable at ${relay.url}/r/<id>/`)
io.out(` Press Ctrl+C to stop.`)
await waitForInterrupt()
await relay.close()
return 0
}

/**
* Reopen the last run from its persisted `.framework/` log and replay it into a
* fresh dashboard (#211). No agent runs; the dashboard rehydrates from the saved
Expand Down
6 changes: 3 additions & 3 deletions packages/framework/src/dashboard/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { CLAUDE_CODE_SESSION_LINK } from '../events.js'

/**
* The single self-contained dashboard page: HTML + inline CSS + inline JS, no
* assets, no build step. The client opens an `EventSource` to `/events` and
* assets, no build step. The client opens an `EventSource` to `events` and
* projects the {@link import('../events.js').FrameworkEvent} stream into panels
* that foreground the orchestration (stack rationale, loop status, decisions)
* beside a tail of the wrapped agent's own activity.
Expand Down Expand Up @@ -261,11 +261,11 @@ function stopRun() {
const btn = $('stop');
btn.disabled = true;
btn.textContent = 'stopping\\u2026';
fetch('/stop', { method: 'POST' }).catch(() => {});
fetch('stop', { method: 'POST' }).catch(() => {});
}
function esc(s) { const d = document.createElement('div'); d.textContent = String(s); return d.innerHTML; }
$('stop').addEventListener('click', stopRun);
const src = new EventSource('/events');
const src = new EventSource('events');
src.onmessage = ev => { try { onEvent(JSON.parse(ev.data)); } catch {} };
src.onerror = () => { $('status').textContent = '\\u25cb offline'; };
`
Expand Down
4 changes: 3 additions & 1 deletion packages/framework/src/dashboard/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ test('dashboard serves the HTML page with the title', async () => {
const { status, body } = await fetchText(dash.url + '/')
assert.equal(status, 200)
assert.match(body, /My Framework/)
assert.match(body, /new EventSource\('\/events'\)/)
// Relative path so it resolves against the page's base URL — /events on the
// localhost dashboard, /r/<id>/events when re-served by the relay (#230).
assert.match(body, /new EventSource\('events'\)/)
// The Modes panel + its renderer ship in the page (#272), hidden until a modes event.
assert.match(body, /id="modes-panel" hidden/)
assert.match(body, /function renderModes/)
Expand Down
36 changes: 2 additions & 34 deletions packages/framework/src/dashboard/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { AddressInfo } from 'node:net'
import { EventStream } from '@gemstack/ai-autopilot'
import type { FrameworkEvent } from '../events.js'
import { dashboardHtml } from './page.js'
import { serveSSE } from './sse.js'

/** Options for {@link startDashboard}. */
export interface DashboardOptions {
Expand Down Expand Up @@ -83,7 +84,7 @@ function handle(
return
}
if (url === '/events') {
streamEvents(req, res, stream, clients)
serveSSE(req, res, stream, clients)
return
}
if (url === '/stop') {
Expand All @@ -109,39 +110,6 @@ function handle(
res.end('not found')
}

function streamEvents(
req: IncomingMessage,
res: ServerResponse,
stream: EventStream<FrameworkEvent>,
clients: Set<ServerResponse>,
): void {
res.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
connection: 'keep-alive',
})
clients.add(res)

const send = (event: FrameworkEvent) => res.write(`data: ${JSON.stringify(event)}\n\n`)
// Replay history, then follow live. A fresh iterator gives this client its own
// cursor, so a late browser still sees the whole run from the start.
void (async () => {
try {
for await (const event of stream[Symbol.asyncIterator]()) send(event)
} catch {
// client went away
} finally {
clients.delete(res)
res.end()
}
})()

req.on('close', () => {
clients.delete(res)
res.end()
})
}

function closeServer(
server: Server,
clients: Set<ServerResponse>,
Expand Down
41 changes: 41 additions & 0 deletions packages/framework/src/dashboard/sse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { EventStream } from '@gemstack/ai-autopilot'
import type { FrameworkEvent } from '../events.js'

/**
* Serve one client the {@link FrameworkEvent} stream over Server-Sent Events:
* replay the whole run's history, then follow live. A fresh async iterator gives
* this client its own cursor from the start, so a late browser still sees the run
* from the beginning. Shared by the localhost dashboard and the hosted relay (#230)
* so both project the identical stream.
*/
export function serveSSE(
req: IncomingMessage,
res: ServerResponse,
stream: EventStream<FrameworkEvent>,
clients: Set<ServerResponse>,
): void {
res.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
connection: 'keep-alive',
})
clients.add(res)

const send = (event: FrameworkEvent) => res.write(`data: ${JSON.stringify(event)}\n\n`)
void (async () => {
try {
for await (const event of stream[Symbol.asyncIterator]()) send(event)
} catch {
// client went away
} finally {
clients.delete(res)
res.end()
}
})()

req.on('close', () => {
clients.delete(res)
res.end()
})
}
7 changes: 7 additions & 0 deletions packages/framework/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ export {
type AppPreview,
} from './run.js'
export { snapshotWorkspace, SANDBOX_IGNORE, type SnapshotOptions } from './sandbox.js'
export {
startRelay,
relayPublisher,
type Relay,
type RelayOptions,
type RelayPublisher,
} from './relay.js'
export {
discoverExtensions,
readProjectSignals,
Expand Down
Loading
Loading