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
5 changes: 5 additions & 0 deletions .changeset/framework-session-link.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@gemstack/framework': minor
---

Surface the live agent session on the dashboard. The wrapped agent's real session id is captured once the first turn returns and streamed as a new `session-update` event, so the dashboard header shows the live session (and the terminal prints it). `--session-link` now accepts a `{sessionId}` template that resolves to a real URL once the id is known; a literal URL still shows immediately.
2 changes: 2 additions & 0 deletions packages/framework/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ Options:
--no-dashboard Do not start the localhost dashboard.
--skip-preflight Skip the prerequisite checks before a live run.
--session-link <url> Link to the live agent session (shown on the dashboard).
Use {sessionId} as a placeholder to template in the real
id, e.g. "https://example.com/s/{sessionId}".
-h, --help Show this help.
-v, --version Print the version.

Expand Down
21 changes: 18 additions & 3 deletions packages/framework/src/dashboard/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ export function dashboardHtml(title: string): string {
header h1 { margin: 0; font-size: 16px; font-weight: 600; letter-spacing: .2px; }
header .sub { color: #7b8496; font-size: 12px; }
header a { color: #6ea8fe; text-decoration: none; }
#status { margin-left: auto; font-size: 12px; color: #7b8496; }
header a:hover { text-decoration: underline; }
#session-link { margin-left: auto; font-size: 12px; color: #7b8496; }
#session-link code { color: #b7c0d0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
#status { font-size: 12px; color: #7b8496; }
main { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; padding: 18px 20px; max-width: 1100px; }
section { background: #10141d; border: 1px solid #1c2230; border-radius: 10px; padding: 14px 16px; }
section h2 { margin: 0 0 10px; font-size: 12px; text-transform: uppercase;
Expand Down Expand Up @@ -50,6 +53,7 @@ export function dashboardHtml(title: string): string {
<header>
<h1>${escapeHtml(title)}</h1>
<span class="sub" id="session">connecting…</span>
<span id="session-link"></span>
<span id="status">●</span>
</header>
<main>
Expand Down Expand Up @@ -146,12 +150,23 @@ function driver(e) {
else if (e.type === 'error') log(' ! ' + e.message);
else if (e.type === 'start') log('\\u203a prompt sent');
}
function setSessionLink(sessionId, sessionLink) {
const el = $('session-link');
if (sessionLink) {
el.innerHTML = '\\u25b6 <a href="' + esc(sessionLink) + '" target="_blank" rel="noopener">live session</a>';
if (sessionId) el.title = sessionId;
} else if (sessionId) {
el.innerHTML = 'session <code>' + esc(sessionId) + '</code>';
}
}
function onEvent(fe) {
if (fe.kind === 'session') {
let s = fe.fake ? 'fake driver' : fe.driver;
s += ' in ' + fe.workspace;
$('session').innerHTML = esc(s) + (fe.sessionLink ? ' · <a href="' + esc(fe.sessionLink) + '" target="_blank">live session</a>' : '');
} else if (fe.kind === 'bootstrap') bootstrap(fe.event);
$('session').textContent = s;
if (fe.sessionLink) setSessionLink(undefined, fe.sessionLink);
} else if (fe.kind === 'session-update') setSessionLink(fe.sessionId, fe.sessionLink);
else if (fe.kind === 'bootstrap') bootstrap(fe.event);
else if (fe.kind === 'driver') driver(fe.event);
else if (fe.kind === 'log') log(fe.message);
else if (fe.kind === 'end') { $('status').textContent = fe.ok ? '\\u25cf done' : '\\u25cf failed'; }
Expand Down
29 changes: 29 additions & 0 deletions packages/framework/src/events.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { strict as assert } from 'node:assert'
import { test } from 'node:test'
import {
SESSION_ID_PLACEHOLDER,
formatFrameworkEvent,
hasSessionIdPlaceholder,
resolveSessionLink,
} from './events.js'

test('hasSessionIdPlaceholder distinguishes templates from literal URLs', () => {
assert.equal(hasSessionIdPlaceholder(`https://x.dev/s/${SESSION_ID_PLACEHOLDER}`), true)
assert.equal(hasSessionIdPlaceholder('https://x.dev/live'), false)
})

test('resolveSessionLink fills the placeholder and is a no-op for a literal', () => {
assert.equal(resolveSessionLink('https://x.dev/s/{sessionId}', 'abc123'), 'https://x.dev/s/abc123')
// Every occurrence is replaced.
assert.equal(resolveSessionLink('{sessionId}-{sessionId}', 'z'), 'z-z')
// A literal (no placeholder) comes back unchanged.
assert.equal(resolveSessionLink('https://x.dev/live', 'abc123'), 'https://x.dev/live')
})

test('formatFrameworkEvent renders a session-update line', () => {
assert.equal(formatFrameworkEvent({ kind: 'session-update', sessionId: 'abc123' }), ' session abc123')
assert.equal(
formatFrameworkEvent({ kind: 'session-update', sessionId: 'abc123', sessionLink: 'https://x.dev/s/abc123' }),
' session abc123 — https://x.dev/s/abc123',
)
})
27 changes: 27 additions & 0 deletions packages/framework/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ import type { DriverEvent } from './driver/index.js'
export type FrameworkEvent =
/** Emitted once at start: which agent is wrapped, the workspace, and a link. */
| { kind: 'session'; driver: string; workspace: string; fake: boolean; sessionLink?: string }
/**
* Emitted once the wrapped agent reports its real session id (not known at
* start). Carries the live id and, when a link template was supplied, the
* resolved URL to jump into that session (#165). Re-emitted if the id changes
* (each Claude Code prompt is a fresh session), keeping the link current.
*/
| { kind: 'session-update'; sessionId: string; sessionLink?: string }
/** A bootstrap-phase narration event (scope / architect / checklist / deploy / ...). */
| { kind: 'bootstrap'; event: BootstrapEvent }
/** The wrapped agent's own progress, forwarded verbatim (never gated on). */
Expand All @@ -27,6 +34,8 @@ export function formatFrameworkEvent(event: FrameworkEvent): string {
return `◆ ${event.fake ? 'fake' : event.driver} in ${event.workspace}${
event.sessionLink ? ` — ${event.sessionLink}` : ''
}`
case 'session-update':
return ` session ${event.sessionId}${event.sessionLink ? ` — ${event.sessionLink}` : ''}`
case 'log':
return ` ${event.message}`
case 'driver':
Expand Down Expand Up @@ -80,3 +89,21 @@ function truncate(text: string, max = 100): string {
const flat = text.replace(/\s+/g, ' ').trim()
return flat.length > max ? flat.slice(0, max - 1) + '…' : flat
}

/**
* The placeholder a `--session-link` template uses for the real session id.
* Remote Control does not expose a URL you can build from a session id, so we
* surface the honest id and let a template drop a real URL in when there is one:
* `--session-link "https://example.com/s/{sessionId}"`.
*/
export const SESSION_ID_PLACEHOLDER = '{sessionId}'

/** Whether a `--session-link` value is a template (needs the id) vs a literal URL. */
export function hasSessionIdPlaceholder(template: string): boolean {
return template.includes(SESSION_ID_PLACEHOLDER)
}

/** Fill a `--session-link` template with the real session id (no-op for a literal). */
export function resolveSessionLink(template: string, sessionId: string): string {
return template.split(SESSION_ID_PLACEHOLDER).join(sessionId)
}
8 changes: 7 additions & 1 deletion packages/framework/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,13 @@ export {
type ServeConfig,
} from './run.js'
export { hostExecutor, type HostExecutorOptions } from './host-exec.js'
export { type FrameworkEvent, formatFrameworkEvent } from './events.js'
export {
type FrameworkEvent,
formatFrameworkEvent,
resolveSessionLink,
hasSessionIdPlaceholder,
SESSION_ID_PLACEHOLDER,
} from './events.js'
export { startDashboard, dashboardHtml, type Dashboard, type DashboardOptions } from './dashboard/index.js'
export { runCli, parseArgs, buildDeployTarget, type CliIO, type CliOptions } from './cli.js'
export {
Expand Down
61 changes: 61 additions & 0 deletions packages/framework/src/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,67 @@ test('runFramework drives the whole flow through the driver, offline, to product
assert.equal(events.at(-1)!.kind, 'end')
})

test('runFramework surfaces the wrapped agent real session id via session-update', async () => {
const events: FrameworkEvent[] = []
await runFramework({
intent: FAKE_INTENT,
driver: fakeDriver(), // reports sessionId "fake-orders-app"
cwd: '/tmp/ws',
signals: FAKE_SIGNALS,
onEvent: e => events.push(e),
})

const updates = events.filter(e => e.kind === 'session-update')
// The fake reports one stable id across all prompts, so it fires exactly once.
assert.equal(updates.length, 1)
assert.equal(updates[0]!.kind === 'session-update' && updates[0]!.sessionId, 'fake-orders-app')
// No link template was given, so the update carries no link.
assert.equal(updates[0]!.kind === 'session-update' && updates[0]!.sessionLink, undefined)
// It arrives after the initial session event (id is not known at start).
const sessionIdx = events.findIndex(e => e.kind === 'session')
const updateIdx = events.findIndex(e => e.kind === 'session-update')
assert.ok(sessionIdx >= 0 && updateIdx > sessionIdx)
})

test('runFramework resolves a {sessionId} link template once the id is known', async () => {
const events: FrameworkEvent[] = []
await runFramework({
intent: FAKE_INTENT,
driver: fakeDriver(),
cwd: '/tmp/ws',
signals: FAKE_SIGNALS,
sessionLink: 'https://code.example.com/s/{sessionId}',
onEvent: e => events.push(e),
})

// The template cannot resolve at start, so the initial session event omits it.
const session = events.find(e => e.kind === 'session')
assert.ok(session && session.kind === 'session')
assert.equal(session.sessionLink, undefined)

// Once the id is known, the resolved URL is surfaced.
const update = events.find(e => e.kind === 'session-update')
assert.ok(update && update.kind === 'session-update')
assert.equal(update.sessionLink, 'https://code.example.com/s/fake-orders-app')
})

test('runFramework shows a literal session link immediately (no template)', async () => {
const events: FrameworkEvent[] = []
await runFramework({
intent: FAKE_INTENT,
driver: fakeDriver(),
cwd: '/tmp/ws',
signals: FAKE_SIGNALS,
sessionLink: 'https://code.example.com/live',
onEvent: e => events.push(e),
})

// A literal URL (no placeholder) is shown right away on the session event.
const session = events.find(e => e.kind === 'session')
assert.ok(session && session.kind === 'session')
assert.equal(session.sessionLink, 'https://code.example.com/live')
})

test('runFramework prototype scope skips the full-fledged loop', async () => {
const { result } = await runFramework({
intent: 'a quick landing page',
Expand Down
34 changes: 29 additions & 5 deletions packages/framework/src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ import {
type FrameworkSignals,
type LocalRunnerSession,
} from '@gemstack/ai-autopilot'
import type { Driver, DriverSession } from './driver/index.js'
import type { Driver, DriverEvent, DriverSession } from './driver/index.js'
import { decideDeploy, deployWith, driverArchitect, driverBuild, driverChecklist, driverImprove } from './steps.js'
import type { FrameworkEvent } from './events.js'
import { hasSessionIdPlaceholder, resolveSessionLink, type FrameworkEvent } from './events.js'

/** The deploy decision to narrate at the end (plan-only in v1: it does not ship). */
export interface DeployDecision {
Expand Down Expand Up @@ -76,7 +76,11 @@ export interface RunFrameworkOptions {
* checklist gates on the app actually running, not just an agent review.
*/
serve?: ServeConfig
/** A claude.ai/code (or other) link to the live agent session, for the dashboard. */
/**
* A link to the live agent session, shown on the dashboard. Either a literal
* URL, or a template with `{sessionId}` (see {@link SESSION_ID_PLACEHOLDER})
* that resolves once the wrapped agent reports its real id via `session-update`.
*/
sessionLink?: string
/** Interrupt the run between phases. */
signal?: AbortSignal
Expand Down Expand Up @@ -118,25 +122,45 @@ export async function runFramework(opts: RunFrameworkOptions): Promise<RunFramew
const personas = presetPersonas(preset)
const system = personas.map(personaInstructions).join('\n\n')

// The session id is not known until the first driver turn returns, so a
// templated link (`.../{sessionId}`) can only resolve later. A literal link is
// shown right away; a template waits for `session-update`.
const linkTemplate = opts.sessionLink
const literalLink = linkTemplate && !hasSessionIdPlaceholder(linkTemplate) ? linkTemplate : undefined

emit({
kind: 'session',
driver: opts.driver.name,
workspace: opts.cwd,
fake: opts.driver.name === 'fake',
...(opts.sessionLink ? { sessionLink: opts.sessionLink } : {}),
...(literalLink ? { sessionLink: literalLink } : {}),
})
emit({
kind: 'log',
message: `Detected ${detection.framework ?? preset.framework} (confidence ${detection.confidence}); framing with ${personas.length} persona(s)`,
})

// Watch the black box for its real session id (the {type:'result'} event) and
// surface it as `session-update` once known — that is the honest handle a UI
// links to. Re-emit when it changes, since each Claude Code prompt is a fresh
// session; the dashboard just updates the link in place.
let lastSessionId: string | undefined
const onDriverEvent = (event: DriverEvent) => {
emit({ kind: 'driver', event })
if (event.type === 'result' && event.sessionId && event.sessionId !== lastSessionId) {
lastSessionId = event.sessionId
const link = linkTemplate ? resolveSessionLink(linkTemplate, event.sessionId) : undefined
emit({ kind: 'session-update', sessionId: event.sessionId, ...(link ? { sessionLink: link } : {}) })
}
}

// 2. One driver session for the whole run; each prompt is a fresh invocation.
const session: DriverSession = await opts.driver.start({
cwd: opts.cwd,
system,
...(opts.model ? { model: opts.model } : {}),
...(opts.signal ? { signal: opts.signal } : {}),
onEvent: event => emit({ kind: 'driver', event }),
onEvent: onDriverEvent,
})

// Boot-and-serve gate: adopt the agent's workspace so the checklist can gate
Expand Down
Loading