diff --git a/.changeset/dashboard-mode-checkboxes.md b/.changeset/dashboard-mode-checkboxes.md
new file mode 100644
index 0000000..5e464b1
--- /dev/null
+++ b/.changeset/dashboard-mode-checkboxes.md
@@ -0,0 +1,7 @@
+---
+'@gemstack/framework': minor
+---
+
+feat(framework): show the active Open Loop modes as read-only checkboxes on the dashboard (#272)
+
+When a run builds under a domain preset, the dashboard now renders a Modes panel with the run's modes as checkboxes ([x] technical / [ ] autopilot), so the policy driving the build is visible beside the stack and loop panels. Backed by a new `modes` framework event (`OPEN_LOOP_MODES` is the canonical mode ordering, shared with the meta-select router); a run with no preset emits nothing and shows no panel. The event persists with the run, so `--resume` rehydrates the panel too.
diff --git a/packages/framework/src/dashboard/page.ts b/packages/framework/src/dashboard/page.ts
index bfadb8a..d7cbf10 100644
--- a/packages/framework/src/dashboard/page.ts
+++ b/packages/framework/src/dashboard/page.ts
@@ -64,6 +64,10 @@ export function dashboardHtml(title: string, stoppable = false): string {
font-weight: 600; }
.badge.grade { background: #14371f; color: #67d98f; }
.badge.proto { background: #2a2320; color: #f0a35e; }
+ #modes li { display: flex; align-items: center; gap: 8px; color: #b7c0d0; }
+ #modes .box { color: #6ea8fe; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
+ #modes li.off { color: #5c657a; }
+ #modes li.off .box { color: #3a4256; }
#activity { grid-column: 1 / -1; }
#log { font: 12px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; color: #96a0b3;
max-height: 320px; overflow-y: auto; white-space: pre-wrap; }
@@ -95,6 +99,10 @@ export function dashboardHtml(title: string, stoppable = false): string {
building…
+
Deploy
not decided yet
@@ -198,6 +206,18 @@ function renderRationale(e) {
row.className = 'alt'; row.innerHTML = '' + esc(a.option) + ' \\u2014 ' + esc(a.whyNot);
});
}
+function renderModes(all, active) {
+ const on = new Set(active || []);
+ const ul = $('modes'); ul.innerHTML = '';
+ for (const m of (all || [])) {
+ const checked = on.has(m);
+ const li = document.createElement('li');
+ if (!checked) li.className = 'off';
+ li.innerHTML = '' + (checked ? '[x]' : '[ ]') + ' ' + esc(m);
+ ul.appendChild(li);
+ }
+ $('modes-panel').hidden = false;
+}
function setSessionLink(sessionId, sessionLink) {
const el = $('session-link');
if (sessionLink) {
@@ -229,6 +249,7 @@ function onEvent(fe) {
}
else if (fe.kind === 'bootstrap') bootstrap(fe.event);
else if (fe.kind === 'driver') driver(fe.event);
+ else if (fe.kind === 'modes') renderModes(fe.all, fe.active);
else if (fe.kind === 'log') log(fe.message);
else if (fe.kind === 'end') {
ended = true;
diff --git a/packages/framework/src/dashboard/server.test.ts b/packages/framework/src/dashboard/server.test.ts
index e74a065..97c4d44 100644
--- a/packages/framework/src/dashboard/server.test.ts
+++ b/packages/framework/src/dashboard/server.test.ts
@@ -60,6 +60,9 @@ test('dashboard serves the HTML page with the title', async () => {
assert.equal(status, 200)
assert.match(body, /My Framework/)
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/)
} finally {
await dash.close()
}
diff --git a/packages/framework/src/events.test.ts b/packages/framework/src/events.test.ts
index 7491052..5eca05e 100644
--- a/packages/framework/src/events.test.ts
+++ b/packages/framework/src/events.test.ts
@@ -1,6 +1,7 @@
import { strict as assert } from 'node:assert'
import { test } from 'node:test'
import {
+ OPEN_LOOP_MODES,
SESSION_ID_PLACEHOLDER,
formatFrameworkEvent,
hasSessionIdPlaceholder,
@@ -35,6 +36,17 @@ test('formatFrameworkEvent renders a preview line', () => {
)
})
+test('formatFrameworkEvent renders modes as checkboxes (#272)', () => {
+ assert.equal(
+ formatFrameworkEvent({ kind: 'modes', all: ['autopilot', 'technical'], active: ['technical'] }),
+ ' modes: [ ] autopilot [x] technical',
+ )
+})
+
+test('OPEN_LOOP_MODES is the canonical mode ordering', () => {
+ assert.deepEqual([...OPEN_LOOP_MODES], ['autopilot', 'technical'])
+})
+
test('formatFrameworkEvent distinguishes finished / stopped / failed (#218)', () => {
assert.equal(formatFrameworkEvent({ kind: 'end', ok: true }), '✓ finished')
assert.equal(formatFrameworkEvent({ kind: 'end', ok: false, stopped: true }), '■ stopped')
diff --git a/packages/framework/src/events.ts b/packages/framework/src/events.ts
index da558bc..d206def 100644
--- a/packages/framework/src/events.ts
+++ b/packages/framework/src/events.ts
@@ -30,6 +30,13 @@ export type FrameworkEvent =
| { kind: 'preview'; url: string; command: string }
/** A framework-level log line. */
| { kind: 'log'; message: string }
+ /**
+ * The run's active Open Loop modes (#272), emitted once when a domain preset is
+ * in effect. `all` is every mode the run knows about (stable order); `active` is
+ * the subset switched on for this run. The dashboard renders them as read-only
+ * checkboxes so the policy driving the build is visible.
+ */
+ | { kind: 'modes'; all: readonly string[]; active: readonly string[] }
/**
* The run finished. `ok` is false when it threw. `stopped` marks the common,
* non-error case where the user interrupted it (the dashboard Stop button /
@@ -50,6 +57,10 @@ export function formatFrameworkEvent(event: FrameworkEvent): string {
return `▶ your app is running at ${event.url}`
case 'log':
return ` ${event.message}`
+ case 'modes': {
+ const shown = event.all.map(m => `${event.active.includes(m) ? '[x]' : '[ ]'} ${m}`).join(' ')
+ return ` modes: ${shown}`
+ }
case 'driver':
return formatDriverEvent(event.event)
case 'bootstrap':
@@ -102,6 +113,13 @@ function truncate(text: string, max = 100): string {
return flat.length > max ? flat.slice(0, max - 1) + '…' : flat
}
+/**
+ * The Open Loop modes a run can activate, in the order the dashboard shows them.
+ * The single source of truth for both the mode checkboxes (#272) and the
+ * meta-select router's validation ({@link import('./meta-select.js').META_SELECT_MODES}).
+ */
+export const OPEN_LOOP_MODES = ['autopilot', 'technical'] as const
+
/**
* 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
diff --git a/packages/framework/src/index.ts b/packages/framework/src/index.ts
index 78de5db..796d0ba 100644
--- a/packages/framework/src/index.ts
+++ b/packages/framework/src/index.ts
@@ -69,6 +69,7 @@ export {
resolveSessionLink,
hasSessionIdPlaceholder,
SESSION_ID_PLACEHOLDER,
+ OPEN_LOOP_MODES,
} from './events.js'
export { startDashboard, dashboardHtml, type Dashboard, type DashboardOptions } from './dashboard/index.js'
export {
diff --git a/packages/framework/src/meta-select.ts b/packages/framework/src/meta-select.ts
index 1effeee..b85ee98 100644
--- a/packages/framework/src/meta-select.ts
+++ b/packages/framework/src/meta-select.ts
@@ -1,5 +1,6 @@
import type { DomainPreset } from '@gemstack/ai-autopilot'
import type { DriverSession } from './driver/index.js'
+import { OPEN_LOOP_MODES } from './events.js'
/**
* AI meta-select (#204, Rom's "meta-meta prompt"): before a run, infer which Open
@@ -16,7 +17,7 @@ import type { DriverSession } from './driver/index.js'
*/
/** The Open Loop modes a run can activate. Mirrors the CLI's `--autopilot` / `--technical`. */
-export const META_SELECT_MODES = ['autopilot', 'technical'] as const
+export const META_SELECT_MODES = OPEN_LOOP_MODES
/** The system framing for the meta-select turn: a tiny, fast classifier, not a builder. */
export const META_SELECT_SYSTEM =
diff --git a/packages/framework/src/preset-run.test.ts b/packages/framework/src/preset-run.test.ts
index 98b6dba..1ff07ca 100644
--- a/packages/framework/src/preset-run.test.ts
+++ b/packages/framework/src/preset-run.test.ts
@@ -51,10 +51,30 @@ test('a domain preset frames the run and is narrated', async () => {
assert.ok(log, 'domain preset is logged')
assert.match((log as { message: string }).message, /modes: technical/)
+ // The active modes are emitted for the dashboard checkboxes (#272): both known
+ // modes, with only the active one on.
+ const modes = events.find(e => e.kind === 'modes')
+ assert.deepEqual(modes, { kind: 'modes', all: ['autopilot', 'technical'], active: ['technical'] })
+
// The run still completes normally.
assert.equal(result.productionGrade, true)
})
+test('no modes event is emitted without a domain preset (#272)', async () => {
+ const events: FrameworkEvent[] = []
+ await runFramework({
+ intent: FAKE_INTENT,
+ driver: fakeDriver(),
+ cwd: '/tmp/ws',
+ signals: FAKE_SIGNALS,
+ onEvent: e => events.push(e),
+ })
+ assert.equal(
+ events.some(e => e.kind === 'modes'),
+ false,
+ )
+})
+
test('the preset loop is materialized against the driver and runs its chain', async () => {
const { driver } = recordingDriver()
const { loop } = await runFramework({
diff --git a/packages/framework/src/run.ts b/packages/framework/src/run.ts
index 8857a46..c7529b6 100644
--- a/packages/framework/src/run.ts
+++ b/packages/framework/src/run.ts
@@ -28,7 +28,7 @@ import {
import type { Driver, DriverEvent, DriverSession } from './driver/index.js'
import { memoryFraming, type LoadedMemory } from './memory.js'
import { decideDeploy, deployWith, domainLoopChecklist, driverArchitect, driverBuild, driverChecklist, driverImprove, driverLoopPrompts } from './steps.js'
-import { hasSessionIdPlaceholder, resolveSessionLink, type FrameworkEvent } from './events.js'
+import { hasSessionIdPlaceholder, OPEN_LOOP_MODES, resolveSessionLink, type FrameworkEvent } from './events.js'
/**
* The framework's default full-fledged pass budget. Higher than ai-autopilot's
@@ -289,6 +289,8 @@ export async function runFramework(opts: RunFrameworkOptions): Promise