diff --git a/.changeset/framework-session-link.md b/.changeset/framework-session-link.md new file mode 100644 index 0000000..ba4c2e8 --- /dev/null +++ b/.changeset/framework-session-link.md @@ -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. diff --git a/packages/framework/src/cli.ts b/packages/framework/src/cli.ts index 8a91c27..7ffbd49 100644 --- a/packages/framework/src/cli.ts +++ b/packages/framework/src/cli.ts @@ -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 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. diff --git a/packages/framework/src/dashboard/page.ts b/packages/framework/src/dashboard/page.ts index ecc710c..23d58b7 100644 --- a/packages/framework/src/dashboard/page.ts +++ b/packages/framework/src/dashboard/page.ts @@ -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; @@ -50,6 +53,7 @@ export function dashboardHtml(title: string): string {

${escapeHtml(title)}

connecting… +
@@ -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 live session'; + if (sessionId) el.title = sessionId; + } else if (sessionId) { + el.innerHTML = 'session ' + esc(sessionId) + ''; + } +} 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 ? ' · live session' : ''); - } 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'; } diff --git a/packages/framework/src/events.test.ts b/packages/framework/src/events.test.ts new file mode 100644 index 0000000..de83763 --- /dev/null +++ b/packages/framework/src/events.test.ts @@ -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', + ) +}) diff --git a/packages/framework/src/events.ts b/packages/framework/src/events.ts index 469aa6e..8e6f167 100644 --- a/packages/framework/src/events.ts +++ b/packages/framework/src/events.ts @@ -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). */ @@ -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': @@ -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) +} diff --git a/packages/framework/src/index.ts b/packages/framework/src/index.ts index 8631dfc..891d4b9 100644 --- a/packages/framework/src/index.ts +++ b/packages/framework/src/index.ts @@ -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 { diff --git a/packages/framework/src/run.test.ts b/packages/framework/src/run.test.ts index f1ce527..fd1fe64 100644 --- a/packages/framework/src/run.test.ts +++ b/packages/framework/src/run.test.ts @@ -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', diff --git a/packages/framework/src/run.ts b/packages/framework/src/run.ts index 190fd82..6b050a5 100644 --- a/packages/framework/src/run.ts +++ b/packages/framework/src/run.ts @@ -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 { @@ -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 @@ -118,25 +122,45 @@ export async function runFramework(opts: RunFrameworkOptions): Promise { + 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