feat(beehive): separate manager sections and enable guarded restart

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-16 08:49:42 -04:00
parent 176497874f
commit a83a47b19a
9 changed files with 318 additions and 35 deletions

View File

@@ -0,0 +1,159 @@
# Revised UX checkpoint — incomplete
This is a local implementation checkpoint for delegation 6d8d3485, **not the
requested end-to-end candidate**. Base: `176497874fb450818fddcad6b04ce0e619183f81`.
No push, export, installation or native rebuild was performed.
## Implemented
- Four top-level sections: Host, Agents, Harnesses, Providers. Keyboard arrows
cycle sections; header pointer targets use the same order, including 40-column
layout. Each section retains its exact selection; a removed selection is not
silently replaced by another target.
- Host displays host name, exact relay URL, state and saved/loaded revisions.
Its single Start/Stop control follows the verified service state; unknown
ownership still refuses Start. Existing awaited Stop is unchanged.
- Harnesses displays discovered adapters and saved runtime details. Opening the
section invokes existing bounded discovery. Refresh and Add runtime are here.
- Providers independently displays saved providers, Add provider and Models for
the selected provider. Details omit OS credential references.
- Agents exposes the existing signed management Restart operation, replacing the
legacy blanket refusal. Exact selection/revision/freshness, assigned host,
stopped/running phase and unresolved-operation gates precede submission.
Host Restart still owns preflight, awaited teardown, replay and cancellation.
## Source/operation map
`manager-navigation.ts` owns section order and selection retention;
`manager-view.ts` chooses section controls and captures selected requests;
`opentui-screen.ts` owns keyboard/pointer presentation. The Node
`manager-controller.ts` remains the sole UI management-request boundary.
`intents.ts`, `host.ts`, service custody/ownership and all management-wire limits
are unchanged. Saving provider/runtime configuration still does not alter an
active run. Existing Pi and Codex runtime paths were not changed.
Current checkout Desktop sources inspected:
- `desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs`: relay-self
membership authority, paginated kind 39002 membership and owner kind 30177
coordinates, exact-agent profiles and policy verification. **Not integrated
into this checkpoint.** A broad kind-0 scan is not an equivalent directory.
- `desktop/src-tauri/src/managed_agents/discovery/{catalog,runtime_metadata}.rs`
and `desktop/src/features/agents/lib/agentConfigCore.ts`: capability-driven
harness/provider/model/effort fields and dependent-value policies.
- Existing Beehive `harness-discovery.ts`, `harness-contract.json`,
`runtime-form.ts`, `settings-credentials.ts`, `settings-runtime.ts` and native
helper contract remain in force. The packaged discovery contract is still
pinned to historical `78618804...`; no new current-Desktop parity claim.
No Desktop contributor rules changed; no Desktop files were edited.
## Required work still missing
1. **Global relay agent list**: Agents still uses private host reports, including
host/operation rows. Next: add a bounded, cancellable authenticated directory
adapter using the current Desktop consumer and explicit fixtures, then project
exact-key directory rows independently from management authority.
2. **Registration-first continuation**: Register is still identity-only. Runtime
choice, environment variables and automatic continuation are absent. Next:
introduce a typed operation/result state machine (the current renderer request
returns `void`, including failures), bind the exact nsec public key and saved
runtime atomically, then continue only after verified success and fresh
authority. Cancellation must discard continuation without claiming OS rollback.
Secret possession must never synthesize assignment/genesis authority.
3. **Move in this manager**: absent. Next: expose the existing signed Move
preparation/grant flow, capturing source and destination revisions; registration
of a local destination must finish before source ownership is consumed. Reuse
the real `move.test.ts`/named-Move fixtures for partial, stopped and unknown
results rather than chaining optimistic Stop and Start.
4. **Current Desktop discovery/provider parity**: existing provider setup and
runtime configuration are reused, not refreshed end to end. Next: compare the
packaged contract and provider/auth/model consumers with current Desktop;
preserve capability-specific Pi/Codex behavior and OS custody.
5. **Concise UI copy**: old prose, draft/publication actions and identity-only
registration copy remain. Next: remove stale actions/copy with the completed
agent workflow. Host name and relay are displayed, not newly editable.
No unresolved product-authority question was identified; these are unfinished
implementation tasks, not external blockers. Do not schedule delivery acceptance
against this checkpoint as if they were complete.
## Validation
Artifact directory:
`/Users/loganj/.buzz/artifacts/beehive-ux-6d8d3485/`
- Node 24.15.0 TypeScript check: passed (`typecheck-final.log`).
- Focused manager/navigation + real Restart integration: **9 passed**
(`focused.log`). The final full run also covers added stale/standby Restart
manager guards.
- Bun 1.4.2 renderer: **4 passed** (`renderer-final.log`): four-way keyboard and
pointer navigation, narrow header, selected details, disabled controls,
hidden entry, cancellation and editor ownership.
- One final default-concurrent package suite: **202 passed, 4 failed, 19 skipped**,
225 tests, 32.344 seconds (`full-suite.log`). **Not green.** Failures:
- `acp.test.ts`: ACP operation timed out (additional to the brief's historical
three failures; cause not established).
- `admission-cancel.test.ts`: wrong-authority control elapsed 2787 > 2500ms;
log retains not-authority and accepted Start/Stop receipts.
- `broker.test.ts`: `ok` stimulus conversation response timeout.
- `disconnect.test.ts`: missing resistant readiness, null trace.
No timeout, concurrency, assertion or production containment relaxation, and no
full-suite rerun. Similar historical symptoms are not a waiver or diagnosis.
- Native unchanged: reuse supplied historical 700 pass / 0 fail / 1 ignored
evidence; not a new native run. No repository-wide CI claim.
- Real isolated 100x30 PTY walkthrough passed (`pty-final.log`, `walkthrough.json`):
all sections; cancelled registration and runtime form; provider cancellation
preserves settings byte-for-byte; provider Save creates a second synthetic
provider; selected-provider details and model listing; hidden keys absent;
clean exit. External credential/relay/provider/browser/harness/service actions
were explicitly blocked. This is not registration continuation or Move proof.
Failed evidence is retained: `renderer.log` and `renderer-diagnostic.log` used
`pressKey('right')`, which sends literal text; corrected to the mock's
`pressArrow('right')`. `pty-first/` and `pty.log` retain the first failed save
probe: the driver assumed Home selects the first action; it does not. Corrected
only the driver to use bounded Up arrows. `full-suite.log` retains all failures.
## Reusable reviewer walkthrough
From this checkout, in a real terminal at least 100x30:
```sh
sh beehive/tools/ux-walkthrough.sh
```
The script creates a fresh `beehive-pairing-cli-ux-` HOME, prints its path,
sets `BEEHIVE_TEST_CREDENTIAL_FILE` and `NODE_OPTIONS --import` to the extended
installed loader, seeds only synthetic host/provider data and starts the actual
Node controller/Bun renderer. It does not invoke the installed Beehive launcher.
Use only synthetic values. Every invocation creates a new fixture directory.
1. Inspect Host name/relay/state and Start control. Real service operations are
blocked by the fixture; this is intentionally not a host lifecycle demo.
2. Right to Agents; `a`, Up to Register, Enter; Escape cancels hidden input.
3. Right to Harnesses; inspect Buzz Agent and Refresh. Add runtime traverses
Harness → Provider → Model → Runtime name. Escape cancels.
4. Right to Providers; inspect Review OpenAI. `a` selects controls; Up/Down and
Enter open Add provider or Models. Save a synthetic OpenAI key with `yes`.
5. Tab to list; Up/Down changes selected-provider details. Left/Right restores
each section's prior selection. `q` quits outside forms; Ctrl-Q always quits.
For another machine, set `BEEHIVE_REVIEW_NODE` and `BEEHIVE_REVIEW_BUN` to direct
Node 24.15.0 and Bun 1.4.2 executables. The defaults point at existing local
runtime artifacts, not downloaded or rebuilt binaries.
Evidence replay: `cat <artifact-dir>/06-providers.ansi` in a 100x30 terminal.
`01-host.txt` through `10-provider-selected.txt` are plain frame reconstructions
from the real PTY bytes (not screenshots); corresponding `.ansi` streams are the
raw evidence. `pty-walkthrough.py` is the saved automation, `frames.py` the decoder.
Exact validation commands (Node/Bun variables are the script's defaults):
```sh
NODE=/Users/loganj/Library/Caches/hermit/pkg/node-24.15.0/bin/node
BUN=/Users/loganj/.buzz/artifacts/beehive-opentui-57f9c8fa/runtime/bun-darwin-aarch64/bun
cd beehive
"$NODE" node_modules/typescript/bin/tsc --noEmit
"$NODE" --test test/manager-controller.test.ts test/manager-navigation.test.ts test/restart.test.ts
"$BUN" test ./test/opentui-screen.bun.ts
"$NODE" --test test/*.test.ts
```

View File

@@ -166,7 +166,7 @@ export class ManagerController {
const fresh = this.fresh(m);
const blocked = !fresh ? 'The host report is old or the host cannot be reached. Wait for a recent report before you act.' : this.client?.status().some(o => o.request.host === m.host && o.request.agent === m.agent && !['completed','failed'].includes(o.state)) ? 'An operation has no confirmed result. Inspect operations before you act.' : '';
return { id, label: `Agent ${short(m.agent)} · ${fresh ? m.body.phase : 'Unknown'}`, revision: m.revision, configurations: Object.keys((m.body.configurations ?? {}) as object),
disabled: { 'select-config': blocked, stop: blocked, start: blocked || (m.body.phase !== 'stopped' || m.body.actualRun || m.body.assignedHost !== m.host ? 'Start requires a recent report from the assigned host. It must report the agent stopped with no current run.' : '') },
disabled: { 'select-config': blocked, stop: blocked, restart: blocked || (m.body.assignedHost !== m.host || !['stopped', 'running'].includes(String(m.body.phase)) ? 'Restart requires a recent report from the assigned host.' : ''), start: blocked || (m.body.phase !== 'stopped' || m.body.actualRun || m.body.assignedHost !== m.host ? 'Start requires a recent report from the assigned host. It must report the agent stopped with no current run.' : '') },
evidence: JSON.stringify(m, null, 2), detail: `Agent ${short(m.agent)}
Host ${short(m.host)}
${fresh ? 'Recent host report' : 'Current status unknown. The report is old or the host cannot be reached. Last report:'}
@@ -329,10 +329,10 @@ A Stop result is not a recent host report that confirms the agent is stopped.` }
if (!this.owner || !this.client) throw plain('Owner sign-in required');
const current = this.inventory.get(request.target ?? '');
if (!current || !this.fresh(current) || current.revision !== request.revision) throw plain('The selection changed, or the host report is old or unavailable. Select an agent with a recent report. No request was sent.');
if (request.action === 'restart') throw plain('Restart can change the local setup. Use Stop. Inspect a recent host report that confirms the agent is stopped. Then use Start. No Restart request is sent. No request was sent.');
if (request.action === 'restart' && (current.body.assignedHost !== current.host || !['stopped', 'running'].includes(String(current.body.phase)))) throw plain('Restart requires a recent report from the assigned host.');
if (request.action === 'start' && (current.body.phase !== 'stopped' || current.body.actualRun || current.body.assignedHost !== current.host)) throw plain('Start requires a recent report from the assigned host. It must report the agent stopped with no current run.');
if (this.client.status().some(o => o.request.host === current.host && o.request.agent === current.agent && !['completed','failed'].includes(o.state))) throw plain('An operation has no confirmed result. Inspect operations and check operation results before you act again.');
const operation = message(request.action === 'select-config' ? 'save' : request.action as 'start' | 'stop', current.host, current.agent, current.revision, request.action === 'select-config' ? { configurationAction: 'select', name: v.name } : {});
const operation = message(request.action === 'select-config' ? 'save' : request.action as 'start' | 'stop' | 'restart', current.host, current.agent, current.revision, request.action === 'select-config' ? { configurationAction: 'select', name: v.name } : {});
this.client.submit(operation);
this.status = `Operation ${operation.id}: ${plainOperationType(operation)} saved and pending.\nHost: ${current.host}\nAgent: ${current.agent}\nRelay publication does not confirm host acceptance. Inspect operations. Unknown does not mean stopped.`;
} else if (request.action !== 'refresh') throw plain('Not available in this build');

View File

@@ -0,0 +1,29 @@
import type { ManagerSnapshot, ManagerItem } from './manager-controller.ts';
/** Section order is shared by pointer hit testing and keyboard navigation. */
export const managerSections = ['Host', 'Agents', 'Harnesses', 'Providers'] as const;
/** Public presentation only. A catalog item never becomes management authority. */
export function sectionRows(snapshot: ManagerSnapshot, section: number): ManagerItem[] {
const settings = snapshot.settings;
if (section === 0) return snapshot.local.map(row => row.id !== 'host' ? row : ({ ...row, detail: `Host name: ${row.label}\nRelay URL: ${snapshot.hostRelay ?? snapshot.routing?.relay ?? '—'}\nState: ${snapshot.service?.state ?? 'unknown'}\nSaved revision: ${settings?.revision ?? 0}\nLoaded revision: ${snapshot.service?.revision ?? '—'}` }));
if (section === 1) return snapshot.agents;
if (section === 2) return [
...(snapshot.harnesses ?? []).map(h => ({ id: `harness:${h.id}`, label: `${h.label} · ${h.state}`, detail: `Harness: ${h.label}\nState: ${h.state}\nExecutable: ${h.executable ?? '—'}\nCLI: ${h.cli ?? '—'}` })),
...(settings?.runtimes ?? []).map(r => ({ id: `runtime:${r.id}`, label: r.name, detail: `Runtime: ${r.name}\nHarness: ${r.harness}\nProvider: ${settings?.providers.find(p => p.id === r.providerId)?.name ?? '—'}\nModel: ${r.model}\nEffort: ${r.effort ?? 'Inherit'}` })),
];
if (section === 3) return (settings?.providers ?? []).map(p => ({ id: p.id, label: p.name, detail: `Provider: ${p.name}\nType: ${p.type}\nEndpoint: ${p.endpoint}` }));
return [];
}
/** Exact per-section selection retention; removal never selects a replacement. */
export class ManagerNavigation {
section = 0;
private selected = new Map<number, string>();
select(id: string) { this.selected.set(this.section, id); }
switch(section: number) { if (Number.isInteger(section) && section >= 0 && section < managerSections.length) this.section = section; }
selection(rows: ManagerItem[]) {
if (!this.selected.has(this.section) && rows.length) this.select(rows[0]!.id);
return this.selected.get(this.section) ?? '';
}
}

View File

@@ -1,3 +1,4 @@
import { ManagerNavigation, sectionRows } from './manager-navigation.ts';
import { runtimeForm } from './runtime-form.ts';
import { createCliRenderer } from '@opentui/core';
import { createReadStream, createWriteStream } from 'node:fs';
@@ -9,6 +10,7 @@ const renderer = await createCliRenderer({ exitOnCtrlC: false });
const screen = new OpenTuiScreen(renderer);
const output = createWriteStream('', { fd: 4 });
let snapshot: ManagerSnapshot = { local: [], agents: [], status: 'Connecting to Beehive…' };
const navigation = new ManagerNavigation();
let scope = 0, selected = '', sequence = 0, lastStatus = '';
let pending: { id: number; resolve: () => void } | undefined;
function request(action: string, values?: Record<string,string>, target?: string, revision?: number) {
@@ -51,19 +53,12 @@ function eligibility(action: string) {
}
function render() {
screen.setOwner(snapshot.owner?.slice(-12));
const hostRows = [
{ id: 'agents', label: 'Agents', detail: '' }, ...(snapshot.settings?.agents ?? []).map(a => ({ id: a.publicKey, label: a.profile?.name ?? a.publicKey, detail: '' })),
{ id: 'providers', label: 'Providers', detail: '' }, ...(snapshot.settings?.providers ?? []).map(p => ({ id: p.id, label: p.name, detail: '' })),
{ id: 'runtimes', label: 'Runtimes', detail: '' }, ...(snapshot.settings?.runtimes ?? []).map(r => ({ id: r.id, label: r.name, detail: '' })),
];
const rows = scope === 0 ? hostRows : snapshot.agents;
if (selected === 'empty') selected = '';
if (!selected || (scope === 0 && selected === 'missing' && rows.some(r => r.id === 'host'))) selected = rows[0]?.id ?? '';
const rows = sectionRows(snapshot, scope);
selected = navigation.selection(rows);
const vanished = !!selected && !rows.some(row => row.id === selected);
screen.setRelay(snapshot.hostRelay ?? snapshot.routing?.relay, snapshot.service?.relay ?? 'unknown', snapshot.relayName);
const configured = snapshot.local.some(r => r.id === 'host');
const actions: ManagerAction[] = scope === 0 ? [
{ label: 'Register agent', run: async () => {
const registerAction: ManagerAction = { label: 'Register agent', run: async () => {
if (!configured) { await routing('configure'); return; }
let secret = await screen.input('Register agent\nAgent nsec private key. Hidden. Saved only in Beehive’s OS credential store.', '', true);
if (secret === undefined) return;
@@ -72,7 +67,8 @@ function render() {
if (!preview) { secret = ''; return; }
if (await screen.confirm(`Register agent\n${preview.profile?.name ?? 'Name unavailable'}\n${preview.publicKey}\n${preview.profileState === 'unavailable' ? 'Relay unavailable. Register without a profile?' : preview.profileState === 'none' ? 'No profile found.' : preview.profile?.about ?? ''}\nThis does not authorize or start an agent. Save?`)) await request('register-agent',{ secret });
secret = '';
} },
} };
let actions: ManagerAction[] = scope !== 1 ? [
{ label: 'Add provider', run: async () => {
await request('provider-form');
const type = await screen.choose('Add provider',['OpenAI','Databricks v2']); if (!type) return;
@@ -103,9 +99,9 @@ function render() {
if (!await screen.confirm(`Use ${name} for the next start?\nHost and agent: ${row.id}\nRevision: ${row.revision}\nThis does not change the current run.`)) return;
await request('select-config', { name }, row.id, row.revision);
} },
...(['start','stop','restart'] as const).map(action => ({ label: action === 'restart' ? 'Restart — unavailable' : `${action === 'start' ? 'Start' : 'Stop'} selected agent`, disabled: action === 'restart' ? 'Restart can change the local setup. Use Stop. Inspect a recent host report that confirms the agent is stopped. Then use Start. No Restart request is sent.' : eligibility(action), run: async () => {
...(['start','stop','restart'] as const).map(action => ({ label: action[0]!.toUpperCase() + action.slice(1), disabled: eligibility(action), run: async () => {
const row = snapshot.agents.find(r => r.id === selected); if (!row) return;
if (!await screen.confirm(`${action === 'start' ? 'Start' : 'Stop'} this agent?\nHost and agent: ${row.id}\nRevision: ${row.revision}`)) return;
if (!await screen.confirm(`${action[0]!.toUpperCase() + action.slice(1)} agent?\nHost and agent: ${row.id}\nRevision: ${row.revision}`)) return;
await request(action, undefined, row.id, row.revision);
} })),
{ label: 'Inspect operations', run: () => request('operations') },
@@ -116,14 +112,28 @@ function render() {
{ label: 'Sign in with saved owner key', run: () => routing('signin') },
{ label: 'Import matching owner key and sign in', run: () => routing('signin', true) },
];
if (scope === 0) actions = actions.filter(a => a.label === (snapshot.service?.state === 'running' ? 'Stop' : 'Start'));
if (scope === 1) actions.unshift(registerAction);
if (scope === 2) actions = [
...actions.filter(a => a.label === 'Add runtime'),
{ label: 'Refresh', run: () => request('runtime-form') },
];
if (scope === 3) actions = [
...actions.filter(a => a.label === 'Add provider'),
{ label: 'Models', disabled: rows.some(r => r.id === selected) ? undefined : 'Select provider', run: async () => {
const provider = selected;
await request('models', { provider });
if (snapshot.models) await screen.choose('Models', snapshot.models);
} },
];
if (scope === 1 && !snapshot.owner) actions.push({ label: 'New private instructions draft…', run: async () => { const name = await screen.input('Private instructions · 1 of 2\nDraft name', '', false, false, v => v.trim() ? '' : 'Enter a draft name.'); if (name === undefined) return; const instructions = await screen.input(`Private instructions · 2 of 2\nDraft: ${name}\nSaved only on this computer. Not published or used by an agent. Do not enter passwords or keys.`, '', false, true, v => v.trim() ? '' : 'Enter instructions.'); if (instructions !== undefined) await request('draft', { name, instructions }); } });
if (scope !== 0) actions.push({ label: 'Quit Beehive', run: () => screen.close() });
screen.show(vanished ? [{ id: selected, label: 'Selection no longer available', detail: 'This item is no longer available. Select a current item. No operation will use a replacement item.' }, ...rows] : scope === 0 ? hostRows : snapshot.agents.length ? snapshot.agents : [{ id: 'empty', label: snapshot.owner ? 'No host reports yet' : 'Owner sign-in required', detail: 'Sign in as owner to manage agents. This list uses private host reports, not a separate agent directory. An empty list or an offline host does not mean agents are stopped. Local Host and local drafts do not require sign-in.' }], actions, selected);
screen.show(vanished ? [{ id: selected, label: 'Selection unavailable', detail: 'Unavailable' }, ...rows] : rows.length ? rows : [{ id: 'empty', label: 'Empty', detail: '—' }], actions, selected);
const notice = scope === 0 ? `Host: ${snapshot.service?.state ?? 'unknown'} · Saved: ${snapshot.settings?.revision ?? 0} · Loaded: ${snapshot.service?.revision ?? 'not confirmed'}\n${snapshot.status}` : snapshot.status;
if (lastStatus !== notice) { lastStatus = notice; screen.notice(notice); }
}
screen.onScope = value => { scope = value; selected = ''; render(); };
screen.onSelect = id => { if (selected !== id) { selected = id; render(); } };
screen.onScope = value => { navigation.switch(value); scope = navigation.section; render(); if (scope === 2 && !snapshot.harnesses) void request('runtime-form'); };
screen.onSelect = id => { if (id === 'empty') return; if (selected !== id) { if (id !== 'empty') navigation.select(id); selected = id; render(); } };
const input = createInterface({ input: createReadStream('', { fd: 3 }) });
input.on('line', line => {
try {

View File

@@ -1,4 +1,5 @@
import { RGBA, BoxRenderable, InputRenderable, ScrollBoxRenderable, SelectRenderable, TextRenderable, TextareaRenderable, type CliRenderer, type KeyEvent } from '@opentui/core';
import { managerSections } from './manager-navigation.ts';
import { stripVTControlCharacters } from 'node:util';
export type ManagerRow = { id: string; label: string; detail: string; evidence?: string };
@@ -42,7 +43,15 @@ export class OpenTuiScreen {
constructor(readonly renderer: CliRenderer) {
this.root = new BoxRenderable(renderer, { width: '100%', height: '100%', flexDirection: 'column', backgroundColor: bg });
renderer.root.add(this.root);
this.header = new TextRenderable(renderer, { fg, height: 2, onMouseDown: event => { if (event.y !== 0) return; const end = this.scope === 0 ? 20 : 18; if (event.x >= 8 && event.x < end) this.switchScope(0); else if (event.x >= end + 3 && event.x < end + 3 + (this.scope === 1 ? 8 : 6)) this.switchScope(1); } });
this.header = new TextRenderable(renderer, { fg, height: 2, onMouseDown: event => {
if (event.y !== 0) return;
let start = this.renderer.width < 56 ? 0 : 8;
for (let index = 0; index < managerSections.length; index++) {
const width = managerSections[index]!.length + (this.scope === index ? 2 : 0);
if (event.x >= start && event.x < start + width) { this.switchScope(index); return; }
start += width + (this.renderer.width < 56 ? 1 : 3);
}
} });
this.root.add(this.header);
const body = new BoxRenderable(renderer, { flexGrow: 1, flexDirection: 'row', minHeight: 1 });
this.root.add(body);
@@ -88,7 +97,7 @@ export class OpenTuiScreen {
private focusStyle() {
this.listFrame.title = `${this.list.focused ? '[List]' : 'List'} · ${this.list.getSelectedIndex() + 1}/${this.rows.length}`;
this.scroll.title = this.scope === 0 ? 'Host settings' : 'Agent controls';
this.scroll.title = managerSections[this.scope] ?? 'Details';
for (const widget of [this.list, this.actions]) { widget.selectedBackgroundColor = widget.focused ? fg : bg; widget.selectedTextColor = widget.focused ? bg : fg; }
}
private narrow = false;
@@ -100,18 +109,19 @@ export class OpenTuiScreen {
private formWidth() { return this.scope === 0 ? Math.max(20, this.renderer.width - this.listFrame.width - 2) : Math.min(72, this.renderer.width - 4); }
private formLeft() { return this.scope === 0 ? this.listFrame.width : Math.floor((this.renderer.width - this.formWidth()) / 2); }
private resize() {
this.heading();
if (this.overlay && this.modal) { this.overlay.width = this.formWidth(); this.overlay.left = this.formLeft(); this.overlay.height = this.compactOverlay ? Math.min(14, this.renderer.height - 4) : this.renderer.height - 4; }
this.small.visible = this.renderer.width < 40 || this.renderer.height < 16;
this.narrow = this.scope !== 0 && (this.renderer.width < 88 || this.renderer.height < 24);
this.listFrame.visible = !this.narrow || !this.drilled;
this.scroll.visible = !this.narrow || this.drilled;
this.listFrame.width = this.narrow ? '100%' : Math.min(32, Math.max(14, Math.floor(this.renderer.width * .28)));
this.scroll.title = this.scope === 0 ? 'Host settings' : 'Agent controls';
this.scroll.title = managerSections[this.scope] ?? 'Details';
this.footer.content = this.relayFooter + '\n' + (this.renderer.width >= 70 ? OpenTuiScreen.hintsWide : OpenTuiScreen.hintsNarrow); this.footer.height = 2;
}
private relayFooter = 'Relay: Not configured · disconnected';
setRelay(url?: string, state = 'unknown', name?: string) { this.relayFooter = url ? `Relay: ${name ? `${clean(name)} · ` : ''}${clean(url)} · ${state}` : 'Relay: Not configured · disconnected'; this.resize(); }
private heading() { this.header.content = `BEEHIVE ${this.scope === 0 ? '[Local Host]' : 'Local Host'} | ${this.scope === 1 ? '[Agents]' : 'Agents'}\n${this.scope === 0 ? 'This computer · no owner sign-in needed' : 'Owner · ' + clean(this.owner)}`; }
private heading() { this.header.content = `${this.renderer.width < 56 ? '' : 'BEEHIVE '}${managerSections.map((name, index) => this.scope === index ? `[${name}]` : name).join(this.renderer.width < 56 ? ' ' : ' | ')}\n${clean(this.owner)}`; }
setOwner(publicSuffix?: string) { this.owner = publicSuffix ? `${publicSuffix} · signed in · key saved here` : 'signed out'; this.heading(); }
private switchScope(scope: number) {
if (this.modal || this.busy || this.small.visible || this.closed) return;
@@ -137,7 +147,7 @@ export class OpenTuiScreen {
if (key.name === 'f3' && this.scope !== 0) { key.preventDefault(); this.inspect(); return; }
if (key.name === 'f4') { key.preventDefault(); this.outcome(); return; }
if (this.focusIndex === 0 && ['home','end','pageup','pagedown'].includes(key.name)) { key.preventDefault(); this.navigate(this.list, key.name); }
if (key.name === 'left' || key.name === 'right') { key.preventDefault(); this.switchScope(1 - this.scope); }
if (key.name === 'left' || key.name === 'right') { key.preventDefault(); this.switchScope((this.scope + (key.name === 'left' ? managerSections.length - 1 : 1)) % managerSections.length); }
if (key.name === 'tab') { key.preventDefault(); this.focusIndex = this.focusIndex === 0 ? 2 : 0; [this.list, this.scroll, this.actions][this.focusIndex]!.focus(); this.focusStyle(); }
if (key.name === 'f1') { key.preventDefault(); this.help(); }
}
@@ -154,7 +164,7 @@ export class OpenTuiScreen {
if (this.closed) return;
const prior = selected ?? this.rows[this.list.getSelectedIndex()]?.id;
this.rows = rows; this.commands = actions;
this.detail.visible = this.scope !== 0;
this.detail.visible = true;
const commandOptions = actions.map(a => ({ name: clean(a.label + (a.disabled ? ' — unavailable' : '')), description: '' }));
if (JSON.stringify(this.actions.options) !== JSON.stringify(commandOptions)) this.actions.options = commandOptions;
this.actions.height = Math.max(5,actions.length);
@@ -184,7 +194,7 @@ export class OpenTuiScreen {
}
inspect() { if (this.scope === 0) return; const row = this.rows[this.list.getSelectedIndex()]; this.read('Technical details', row?.evidence ?? 'No technical details for this item.'); }
private outcome() { this.read('Status · latest message', this.lastNotice || 'No status message yet.'); }
private help() { this.read('Help', 'Tab: switch list and controls. Arrows: select. Enter: open.\nLeft/Right: switch Host settings and remote Agents.\nEsc: cancel or back. q or Ctrl-C: quit. The host keeps running.\nHost Start and Stop control this computer’s service, not an agent.\nAgent execution still requires authenticated management authority.\nShortcuts do not run while you edit fields.'); }
private help() { this.read('Help', 'Tab: switch list and controls. Arrows: select. Enter: open.\nLeft/Right: Host, Agents, Harnesses, Providers.\nEsc: cancel or back. q or Ctrl-C: quit. The host keeps running.\nHost Start and Stop control this computer’s service, not an agent.\nAgent execution still requires authenticated management authority.\nShortcuts do not run while you edit fields.'); }
private read(title: string, content: string) {
if (this.modal || this.closed) return;
const box = new ScrollBoxRenderable(this.renderer, { position: 'absolute', top: 2, left: this.formLeft(), width: this.formWidth(), height: this.renderer.height - 4, border: true, title: title + ' · Esc close', backgroundColor: bg, scrollY: true });

View File

@@ -67,17 +67,21 @@ test('manager gates owner, exact selection/revision/freshness, unresolved operat
assert.equal(f.snapshot.agents[0]!.disabled!.start, '');
const target = JSON.stringify(['host-a','agent-a']);
await request('start',{ target, revision: 2 }); assert.match(f.snapshot.status,/selection changed/);
await request('restart',{ target, revision: 2 }); assert.match(f.snapshot.status,/selection changed/); assert.equal(f.submitted.length,0);
await request('start',{ target, revision: 3 }); assert.equal(f.submitted.length,1); assert.equal(f.submitted[0]!.type,'start');
await f.controller.request({ id, action: 'start', target, revision: 3 }); assert.equal(f.submitted.length,1,'duplicate request cannot act twice');
await request('restart',{ target, revision: 3 }); assert.match(f.snapshot.status,/No Restart request is sent/); assert.equal(f.submitted.length,1,'handler refuses even if directly invoked');
await request('restart',{ target, revision: 3 }); assert.equal(f.submitted.at(-1)!.type,'restart'); assert.equal(f.submitted.length,2);
f.states([{ request: f.submitted[0], state: 'unknown' }]);
await request('stop',{ target, revision: 3 }); assert.match(f.snapshot.status,/no confirmed result/); assert.equal(f.submitted.length,1);
await request('stop',{ target, revision: 3 }); assert.match(f.snapshot.status,/no confirmed result/); assert.equal(f.submitted.length,2);
await request('restart',{ target, revision: 3 }); assert.equal(f.submitted.length,2,'unresolved operation blocks Restart');
f.states([{ request: f.submitted[0], state: 'completed' }]);
await request('select-config',{ target, revision: 3, values: { name: 'Alternative' } }); assert.equal(f.submitted[1]!.type,'save');
await request('stop',{ target, revision: 3 }); assert.equal(f.submitted[2]!.type,'stop');
await request('select-config',{ target, revision: 3, values: { name: 'Alternative' } }); assert.equal(f.submitted[2]!.type,'save');
await request('stop',{ target, revision: 3 }); assert.equal(f.submitted[3]!.type,'stop');
f.receive({ ...m, revision: 4, body: { ...m.body, observedAt: Date.now()+1, phase: 'running', actualRun: { run: 'actual' } } });
await request('start',{ target, revision: 4 }); assert.match(f.snapshot.status,/recent report from the assigned host/);
await request('signout'); assert.equal(f.snapshot.owner,undefined); assert.equal(f.submitted.length,3); assert.equal(f.closed,1);
f.receive({ ...m, revision: 5, body: { ...m.body, observedAt: Date.now()+2, assignedHost: 'host-b' } });
await request('restart',{ target, revision: 5 }); assert.match(f.snapshot.status,/assigned host/); assert.equal(f.submitted.length,4);
await request('signout'); assert.equal(f.snapshot.owner,undefined); assert.equal(f.submitted.length,4); assert.equal(f.closed,1);
f.receive(m); assert.equal(f.controller.snapshot().agents.length,0,'late receive is fenced after signout');
} finally { f.cleanup(); }
});

View File

@@ -0,0 +1,25 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { ManagerNavigation, managerSections, sectionRows } from '../src/manager-navigation.ts';
import type { ManagerSnapshot } from '../src/manager-controller.ts';
test('four sections retain exact selections without retargeting removed items', () => {
assert.deepEqual(managerSections, ['Host', 'Agents', 'Harnesses', 'Providers']);
const navigation = new ManagerNavigation();
const rows = ['a','b'].map(id => ({ id, label: id, detail: id }));
assert.equal(navigation.selection(rows), 'a');
navigation.select('b'); navigation.switch(1);
assert.equal(navigation.selection(rows), 'a');
navigation.switch(0); assert.equal(navigation.selection(rows), 'b');
assert.equal(navigation.selection(rows.slice(0,1)), 'b');
navigation.switch(9); assert.equal(navigation.section, 0);
});
test('section rows expose selected public fields, not credential references or other controls', () => {
const snapshot: ManagerSnapshot = { local: [{ id: 'host', label: 'This host', detail: '' }], agents: [], status: '', hostRelay: 'wss://fixture.invalid', service: { state: 'stopped' }, settings: { version: 1, revision: 1, agents: [], providers: [{ id: 'p', name: 'Provider A', type: 'openai', endpoint: 'https://api.openai.com/v1', key: { service: 'beehive', account: 'secret-reference' } }], runtimes: [{ id: 'r', name: 'Runtime A', harness: 'buzz-agent', providerId: 'p', model: 'gpt-5', effort: 'high', executable: '/fixture/harness' }] } };
assert.match(sectionRows(snapshot,0)[0]!.detail, /Host name: This host\nRelay URL: wss:\/\/fixture.invalid\nState: stopped/);
assert.equal(sectionRows(snapshot,1).length, 0);
assert.match(sectionRows(snapshot,2)[0]!.detail, /Provider: Provider A\nModel: gpt-5\nEffort: high/);
assert.equal(sectionRows(snapshot,3)[0]!.id,'p');
assert.ok(!JSON.stringify(sectionRows(snapshot,3)).includes('secret-reference'));
});

View File

@@ -3,16 +3,16 @@ import assert from 'node:assert/strict';
import { createTestRenderer } from '@opentui/core/testing';
import { OpenTuiScreen } from '../src/opentui-screen.ts';
test('host settings has right-pane controls, no inspection menu, and hidden ordinary-key entry', async () => {
test('host shows selected details and controls with hidden ordinary-key entry', async () => {
const ui = await createTestRenderer({ width: 100, height: 30, exitOnCtrlC: false });
const view = new OpenTuiScreen(ui.renderer);
try {
let calls = 0;
view.show(['Agents','Providers','Runtimes'].map(label => ({ id: label,label,detail: 'PRIVATE DETAIL MUST NOT DISPLAY' })),['Register agent','Add provider','Add runtime','Start','Stop'].map(label => ({ label,run: () => { calls++; } })));
view.show(['Agents','Providers','Runtimes'].map(label => ({ id: label,label,detail: 'Host name: Fixture' })),['Register agent','Add provider','Add runtime','Start','Stop'].map(label => ({ label,run: () => { calls++; } })));
view.setRelay('wss://fixture.invalid','disconnected'); await ui.renderOnce();
const frame = ui.captureCharFrame();
assert.match(frame,/Host settings/); assert.match(frame,/Register agent/); assert.match(frame,/wss:\/\/fixture.invalid.*disconnected/);
assert.ok(!frame.includes('Inspect')); assert.ok(!frame.includes('PRIVATE DETAIL')); assert.ok(!frame.includes('Actions (a)'));
assert.match(frame,/\[Host\]/); assert.match(frame,/Register agent/); assert.match(frame,/wss:\/\/fixture.invalid.*disconnected/);
assert.ok(!frame.includes('Inspect')); assert.ok(frame.includes('Host name: Fixture')); assert.ok(!frame.includes('Actions (a)'));
ui.mockInput.pressTab(); ui.mockInput.pressEnter(); await ui.renderOnce(); assert.equal(calls,1);
const secret = 'synthetic-aq?io-private-value';
const entered = view.input('Hidden fixture key','',true);
@@ -55,3 +55,25 @@ test('footer shows the optional sanitized relay name before the exact URL and st
assert.match(ui.captureCharFrame(),/Relay: Not configured · disconnected/);
} finally { view.close(); }
});
test('four sections navigate in both directions and remain readable at narrow width', async () => {
const ui = await createTestRenderer({ width: 100, height: 30, exitOnCtrlC: false });
const view = new OpenTuiScreen(ui.renderer);
const scopes: number[] = [];
view.onScope = scope => { scopes.push(scope); view.show([{ id: String(scope), label: 'Selected', detail: `Section ${scope}` }], []); };
try {
for (const name of ['Agents', 'Harnesses', 'Providers', 'Host']) {
ui.mockInput.pressArrow('right'); await ui.renderOnce();
assert.ok(ui.captureCharFrame().includes(`[${name}]`), JSON.stringify({name,scopes,frame:ui.captureCharFrame()}));
}
ui.mockInput.pressArrow('left'); await ui.renderOnce();
assert.deepEqual(scopes, [1, 2, 3, 0, 3]);
ui.resize(40, 24); await ui.renderOnce();
assert.match(ui.captureCharFrame(), /Host Agents Harnesses \[Providers\]/);
await ui.mockMouse.click(13,0); await ui.renderOnce();
assert.equal(scopes.at(-1),2);
await ui.mockMouse.click(25,0); await ui.renderOnce();
assert.equal(scopes.at(-1),3);
} finally { view.close(); }
});

View File

@@ -0,0 +1,24 @@
#!/bin/sh
# Synthetic UI only: fresh file credentials and explicitly blocked external seams.
set -eu
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
NODE=${BEEHIVE_REVIEW_NODE:-/Users/loganj/Library/Caches/hermit/pkg/node-24.15.0/bin/node}
BUN=${BEEHIVE_REVIEW_BUN:-/Users/loganj/.buzz/artifacts/beehive-opentui-57f9c8fa/runtime/bun-darwin-aarch64/bun}
[ -x "$NODE" ] && [ -x "$BUN" ] || { echo 'Set BEEHIVE_REVIEW_NODE and BEEHIVE_REVIEW_BUN' >&2; exit 1; }
HOME=$(mktemp -d "${TMPDIR:-/tmp}/beehive-pairing-cli-ux-XXXXXX")
export HOME
export BEEHIVE_TEST_CREDENTIAL_FILE="$HOME/credentials.json"
export NODE_OPTIONS="--import=$ROOT/test/manager-installed-loader.ts"
export BEEHIVE_BUN="$BUN"
unset BUZZ_PRIVATE_KEY BUZZ_AUTH_TAG BUZZ_RELAY_URL BEEHIVE_REAL_BUZZ_ACP
cd "$ROOT"
"$NODE" --input-type=module <<'JS'
import { fixtureCredential } from './test/manager-installed-fixture.ts';
import { publicKey } from './src/protocol.ts';
const directory = `${process.env.HOME}/.beehive/host`;
const signal = new AbortController().signal;
await fixtureCredential({action:'configure',directory,label:'Review host',owner:publicKey('1'.repeat(64)),relay:'wss://fixture.invalid'},signal);
await fixtureCredential({action:'add-openai',directory,name:'Review OpenAI',secret:'synthetic-only'},signal);
JS
printf 'Synthetic review HOME: %s\n' "$HOME" >&2
exec "$NODE" src/manager-entry.ts