Skip to content

Commit c4545c2

Browse files
authored
feat(framework): live session link on the dashboard from the agent's real session id (#175)
Capture the wrapped agent's real session id once the first driver turn returns and stream it as a new session-update FrameworkEvent, rendered live on the dashboard header and printed to the terminal. --session-link now accepts a {sessionId} template that resolves once the id is known; a literal URL still shows immediately. Remote Control exposes no URL derivable from a session id (verified against code.claude.com/docs/en/remote-control), so this ships the honest version: the real id is always surfaced, and a real URL drops in via the template later with no rework. Closes #174
1 parent d31a260 commit c4545c2

8 files changed

Lines changed: 178 additions & 9 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@gemstack/framework': minor
3+
---
4+
5+
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.

packages/framework/src/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ Options:
5151
--no-dashboard Do not start the localhost dashboard.
5252
--skip-preflight Skip the prerequisite checks before a live run.
5353
--session-link <url> Link to the live agent session (shown on the dashboard).
54+
Use {sessionId} as a placeholder to template in the real
55+
id, e.g. "https://example.com/s/{sessionId}".
5456
-h, --help Show this help.
5557
-v, --version Print the version.
5658

packages/framework/src/dashboard/page.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@ export function dashboardHtml(title: string): string {
2222
header h1 { margin: 0; font-size: 16px; font-weight: 600; letter-spacing: .2px; }
2323
header .sub { color: #7b8496; font-size: 12px; }
2424
header a { color: #6ea8fe; text-decoration: none; }
25-
#status { margin-left: auto; font-size: 12px; color: #7b8496; }
25+
header a:hover { text-decoration: underline; }
26+
#session-link { margin-left: auto; font-size: 12px; color: #7b8496; }
27+
#session-link code { color: #b7c0d0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
28+
#status { font-size: 12px; color: #7b8496; }
2629
main { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; padding: 18px 20px; max-width: 1100px; }
2730
section { background: #10141d; border: 1px solid #1c2230; border-radius: 10px; padding: 14px 16px; }
2831
section h2 { margin: 0 0 10px; font-size: 12px; text-transform: uppercase;
@@ -50,6 +53,7 @@ export function dashboardHtml(title: string): string {
5053
<header>
5154
<h1>${escapeHtml(title)}</h1>
5255
<span class="sub" id="session">connecting…</span>
56+
<span id="session-link"></span>
5357
<span id="status">●</span>
5458
</header>
5559
<main>
@@ -146,12 +150,23 @@ function driver(e) {
146150
else if (e.type === 'error') log(' ! ' + e.message);
147151
else if (e.type === 'start') log('\\u203a prompt sent');
148152
}
153+
function setSessionLink(sessionId, sessionLink) {
154+
const el = $('session-link');
155+
if (sessionLink) {
156+
el.innerHTML = '\\u25b6 <a href="' + esc(sessionLink) + '" target="_blank" rel="noopener">live session</a>';
157+
if (sessionId) el.title = sessionId;
158+
} else if (sessionId) {
159+
el.innerHTML = 'session <code>' + esc(sessionId) + '</code>';
160+
}
161+
}
149162
function onEvent(fe) {
150163
if (fe.kind === 'session') {
151164
let s = fe.fake ? 'fake driver' : fe.driver;
152165
s += ' in ' + fe.workspace;
153-
$('session').innerHTML = esc(s) + (fe.sessionLink ? ' · <a href="' + esc(fe.sessionLink) + '" target="_blank">live session</a>' : '');
154-
} else if (fe.kind === 'bootstrap') bootstrap(fe.event);
166+
$('session').textContent = s;
167+
if (fe.sessionLink) setSessionLink(undefined, fe.sessionLink);
168+
} else if (fe.kind === 'session-update') setSessionLink(fe.sessionId, fe.sessionLink);
169+
else if (fe.kind === 'bootstrap') bootstrap(fe.event);
155170
else if (fe.kind === 'driver') driver(fe.event);
156171
else if (fe.kind === 'log') log(fe.message);
157172
else if (fe.kind === 'end') { $('status').textContent = fe.ok ? '\\u25cf done' : '\\u25cf failed'; }
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { strict as assert } from 'node:assert'
2+
import { test } from 'node:test'
3+
import {
4+
SESSION_ID_PLACEHOLDER,
5+
formatFrameworkEvent,
6+
hasSessionIdPlaceholder,
7+
resolveSessionLink,
8+
} from './events.js'
9+
10+
test('hasSessionIdPlaceholder distinguishes templates from literal URLs', () => {
11+
assert.equal(hasSessionIdPlaceholder(`https://x.dev/s/${SESSION_ID_PLACEHOLDER}`), true)
12+
assert.equal(hasSessionIdPlaceholder('https://x.dev/live'), false)
13+
})
14+
15+
test('resolveSessionLink fills the placeholder and is a no-op for a literal', () => {
16+
assert.equal(resolveSessionLink('https://x.dev/s/{sessionId}', 'abc123'), 'https://x.dev/s/abc123')
17+
// Every occurrence is replaced.
18+
assert.equal(resolveSessionLink('{sessionId}-{sessionId}', 'z'), 'z-z')
19+
// A literal (no placeholder) comes back unchanged.
20+
assert.equal(resolveSessionLink('https://x.dev/live', 'abc123'), 'https://x.dev/live')
21+
})
22+
23+
test('formatFrameworkEvent renders a session-update line', () => {
24+
assert.equal(formatFrameworkEvent({ kind: 'session-update', sessionId: 'abc123' }), ' session abc123')
25+
assert.equal(
26+
formatFrameworkEvent({ kind: 'session-update', sessionId: 'abc123', sessionLink: 'https://x.dev/s/abc123' }),
27+
' session abc123 — https://x.dev/s/abc123',
28+
)
29+
})

packages/framework/src/events.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ import type { DriverEvent } from './driver/index.js'
1111
export type FrameworkEvent =
1212
/** Emitted once at start: which agent is wrapped, the workspace, and a link. */
1313
| { kind: 'session'; driver: string; workspace: string; fake: boolean; sessionLink?: string }
14+
/**
15+
* Emitted once the wrapped agent reports its real session id (not known at
16+
* start). Carries the live id and, when a link template was supplied, the
17+
* resolved URL to jump into that session (#165). Re-emitted if the id changes
18+
* (each Claude Code prompt is a fresh session), keeping the link current.
19+
*/
20+
| { kind: 'session-update'; sessionId: string; sessionLink?: string }
1421
/** A bootstrap-phase narration event (scope / architect / checklist / deploy / ...). */
1522
| { kind: 'bootstrap'; event: BootstrapEvent }
1623
/** The wrapped agent's own progress, forwarded verbatim (never gated on). */
@@ -27,6 +34,8 @@ export function formatFrameworkEvent(event: FrameworkEvent): string {
2734
return `◆ ${event.fake ? 'fake' : event.driver} in ${event.workspace}${
2835
event.sessionLink ? ` — ${event.sessionLink}` : ''
2936
}`
37+
case 'session-update':
38+
return ` session ${event.sessionId}${event.sessionLink ? ` — ${event.sessionLink}` : ''}`
3039
case 'log':
3140
return ` ${event.message}`
3241
case 'driver':
@@ -80,3 +89,21 @@ function truncate(text: string, max = 100): string {
8089
const flat = text.replace(/\s+/g, ' ').trim()
8190
return flat.length > max ? flat.slice(0, max - 1) + '…' : flat
8291
}
92+
93+
/**
94+
* The placeholder a `--session-link` template uses for the real session id.
95+
* Remote Control does not expose a URL you can build from a session id, so we
96+
* surface the honest id and let a template drop a real URL in when there is one:
97+
* `--session-link "https://example.com/s/{sessionId}"`.
98+
*/
99+
export const SESSION_ID_PLACEHOLDER = '{sessionId}'
100+
101+
/** Whether a `--session-link` value is a template (needs the id) vs a literal URL. */
102+
export function hasSessionIdPlaceholder(template: string): boolean {
103+
return template.includes(SESSION_ID_PLACEHOLDER)
104+
}
105+
106+
/** Fill a `--session-link` template with the real session id (no-op for a literal). */
107+
export function resolveSessionLink(template: string, sessionId: string): string {
108+
return template.split(SESSION_ID_PLACEHOLDER).join(sessionId)
109+
}

packages/framework/src/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,13 @@ export {
5555
type ServeConfig,
5656
} from './run.js'
5757
export { hostExecutor, type HostExecutorOptions } from './host-exec.js'
58-
export { type FrameworkEvent, formatFrameworkEvent } from './events.js'
58+
export {
59+
type FrameworkEvent,
60+
formatFrameworkEvent,
61+
resolveSessionLink,
62+
hasSessionIdPlaceholder,
63+
SESSION_ID_PLACEHOLDER,
64+
} from './events.js'
5965
export { startDashboard, dashboardHtml, type Dashboard, type DashboardOptions } from './dashboard/index.js'
6066
export { runCli, parseArgs, buildDeployTarget, type CliIO, type CliOptions } from './cli.js'
6167
export {

packages/framework/src/run.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,67 @@ test('runFramework drives the whole flow through the driver, offline, to product
3636
assert.equal(events.at(-1)!.kind, 'end')
3737
})
3838

39+
test('runFramework surfaces the wrapped agent real session id via session-update', async () => {
40+
const events: FrameworkEvent[] = []
41+
await runFramework({
42+
intent: FAKE_INTENT,
43+
driver: fakeDriver(), // reports sessionId "fake-orders-app"
44+
cwd: '/tmp/ws',
45+
signals: FAKE_SIGNALS,
46+
onEvent: e => events.push(e),
47+
})
48+
49+
const updates = events.filter(e => e.kind === 'session-update')
50+
// The fake reports one stable id across all prompts, so it fires exactly once.
51+
assert.equal(updates.length, 1)
52+
assert.equal(updates[0]!.kind === 'session-update' && updates[0]!.sessionId, 'fake-orders-app')
53+
// No link template was given, so the update carries no link.
54+
assert.equal(updates[0]!.kind === 'session-update' && updates[0]!.sessionLink, undefined)
55+
// It arrives after the initial session event (id is not known at start).
56+
const sessionIdx = events.findIndex(e => e.kind === 'session')
57+
const updateIdx = events.findIndex(e => e.kind === 'session-update')
58+
assert.ok(sessionIdx >= 0 && updateIdx > sessionIdx)
59+
})
60+
61+
test('runFramework resolves a {sessionId} link template once the id is known', async () => {
62+
const events: FrameworkEvent[] = []
63+
await runFramework({
64+
intent: FAKE_INTENT,
65+
driver: fakeDriver(),
66+
cwd: '/tmp/ws',
67+
signals: FAKE_SIGNALS,
68+
sessionLink: 'https://code.example.com/s/{sessionId}',
69+
onEvent: e => events.push(e),
70+
})
71+
72+
// The template cannot resolve at start, so the initial session event omits it.
73+
const session = events.find(e => e.kind === 'session')
74+
assert.ok(session && session.kind === 'session')
75+
assert.equal(session.sessionLink, undefined)
76+
77+
// Once the id is known, the resolved URL is surfaced.
78+
const update = events.find(e => e.kind === 'session-update')
79+
assert.ok(update && update.kind === 'session-update')
80+
assert.equal(update.sessionLink, 'https://code.example.com/s/fake-orders-app')
81+
})
82+
83+
test('runFramework shows a literal session link immediately (no template)', async () => {
84+
const events: FrameworkEvent[] = []
85+
await runFramework({
86+
intent: FAKE_INTENT,
87+
driver: fakeDriver(),
88+
cwd: '/tmp/ws',
89+
signals: FAKE_SIGNALS,
90+
sessionLink: 'https://code.example.com/live',
91+
onEvent: e => events.push(e),
92+
})
93+
94+
// A literal URL (no placeholder) is shown right away on the session event.
95+
const session = events.find(e => e.kind === 'session')
96+
assert.ok(session && session.kind === 'session')
97+
assert.equal(session.sessionLink, 'https://code.example.com/live')
98+
})
99+
39100
test('runFramework prototype scope skips the full-fledged loop', async () => {
40101
const { result } = await runFramework({
41102
intent: 'a quick landing page',

packages/framework/src/run.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ import {
1515
type FrameworkSignals,
1616
type LocalRunnerSession,
1717
} from '@gemstack/ai-autopilot'
18-
import type { Driver, DriverSession } from './driver/index.js'
18+
import type { Driver, DriverEvent, DriverSession } from './driver/index.js'
1919
import { decideDeploy, deployWith, driverArchitect, driverBuild, driverChecklist, driverImprove } from './steps.js'
20-
import type { FrameworkEvent } from './events.js'
20+
import { hasSessionIdPlaceholder, resolveSessionLink, type FrameworkEvent } from './events.js'
2121

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

125+
// The session id is not known until the first driver turn returns, so a
126+
// templated link (`.../{sessionId}`) can only resolve later. A literal link is
127+
// shown right away; a template waits for `session-update`.
128+
const linkTemplate = opts.sessionLink
129+
const literalLink = linkTemplate && !hasSessionIdPlaceholder(linkTemplate) ? linkTemplate : undefined
130+
121131
emit({
122132
kind: 'session',
123133
driver: opts.driver.name,
124134
workspace: opts.cwd,
125135
fake: opts.driver.name === 'fake',
126-
...(opts.sessionLink ? { sessionLink: opts.sessionLink } : {}),
136+
...(literalLink ? { sessionLink: literalLink } : {}),
127137
})
128138
emit({
129139
kind: 'log',
130140
message: `Detected ${detection.framework ?? preset.framework} (confidence ${detection.confidence}); framing with ${personas.length} persona(s)`,
131141
})
132142

143+
// Watch the black box for its real session id (the {type:'result'} event) and
144+
// surface it as `session-update` once known — that is the honest handle a UI
145+
// links to. Re-emit when it changes, since each Claude Code prompt is a fresh
146+
// session; the dashboard just updates the link in place.
147+
let lastSessionId: string | undefined
148+
const onDriverEvent = (event: DriverEvent) => {
149+
emit({ kind: 'driver', event })
150+
if (event.type === 'result' && event.sessionId && event.sessionId !== lastSessionId) {
151+
lastSessionId = event.sessionId
152+
const link = linkTemplate ? resolveSessionLink(linkTemplate, event.sessionId) : undefined
153+
emit({ kind: 'session-update', sessionId: event.sessionId, ...(link ? { sessionLink: link } : {}) })
154+
}
155+
}
156+
133157
// 2. One driver session for the whole run; each prompt is a fresh invocation.
134158
const session: DriverSession = await opts.driver.start({
135159
cwd: opts.cwd,
136160
system,
137161
...(opts.model ? { model: opts.model } : {}),
138162
...(opts.signal ? { signal: opts.signal } : {}),
139-
onEvent: event => emit({ kind: 'driver', event }),
163+
onEvent: onDriverEvent,
140164
})
141165

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

0 commit comments

Comments
 (0)