From 4eae5d71418b26c43ed3e2d5e7bb4a1ad0734615 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Thu, 30 Jul 2026 15:40:57 +0000 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20steward=20spawn=20sheet=20?= =?UTF-8?q?=E2=80=94=20template-driven,=20from=20the=20Navigator=20rail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Desktop could only mint workers: AgentSpawn sends an engine `kind`, and the hub derives role from the template persona — so a desktop-first team had no way to create a steward (mobile has spawn_steward_sheet.dart; the Navigator's Stewards section was a dead-end 'No stewards.'). Parity wedge, mirroring the mobile sheet's contract: - state/stewardSpawn.ts (pure, node-tested): the handle taxonomy from lib/services/steward_handle.dart (steward / *-steward; @-forms never collide), name validation post-normalization, template picks (steward*.yaml minus the steward.general* concierge singleton), suggested name from domain templates, and the block-aware backend.kind parser. - ui/StewardSpawn.tsx: Name + template + host + env profile; submit fetches the MERGED template YAML, parses backend.kind for the request's engine kind, and spawns with spawn_spec_yaml (the template's default_role: team.* escalates the role hub-side), permission_mode 'skip' (the bootstrap default everywhere) and auto_open_session so the steward never exists agent-without-session. Env profiles with secret_refs run the same resolve → host-key trust → seal path as the worker sheet (ADR-056 E3b-3). - Navigator: '+' on the Stewards section + the empty state becomes a CTA. - client.spawnAgent gains spawn_spec_yaml / permission_mode / auto_open_session (hub fields already existed). The @steward general concierge (ensure-endpoint singleton) stays out of scope, as on mobile's sheet. Co-Authored-By: Claude Fable 5 --- desktop/src/hub/client.ts | 11 + desktop/src/i18n/index.ts | 22 ++ desktop/src/state/stewardSpawn.test.ts | 76 ++++++ desktop/src/state/stewardSpawn.ts | 88 +++++++ desktop/src/surfaces/Navigator.tsx | 23 +- desktop/src/ui/StewardSpawn.tsx | 305 +++++++++++++++++++++++++ 6 files changed, 524 insertions(+), 1 deletion(-) create mode 100644 desktop/src/state/stewardSpawn.test.ts create mode 100644 desktop/src/state/stewardSpawn.ts create mode 100644 desktop/src/ui/StewardSpawn.tsx diff --git a/desktop/src/hub/client.ts b/desktop/src/hub/client.ts index 14772887f..10a6a1dd5 100644 --- a/desktop/src/hub/client.ts +++ b/desktop/src/hub/client.ts @@ -332,6 +332,17 @@ export class HubClient { // todo→in_progress via the existing derivation, not a client PATCH. task_id?: string; task?: { title: string; body_md?: string }; + // Template-driven spawn (the steward sheet): the full rendered template + // YAML. `kind` stays the ENGINE (the template's backend.kind) — the + // steward persona and its `default_role: team.*` escalation ride the + // spec, exactly like mobile's spawn_steward_sheet. + spawn_spec_yaml?: string; + // "skip" = auto-allow tool calls (the bootstrap default everywhere); + // "prompt" routes every tool call through the attention gate. + permission_mode?: string; + // Atomic spawn-with-session so a steward never exists agent-without- + // session — the session is the spawn's. + auto_open_session?: boolean; }): Promise { return this.transport.post(this.transport.team('/agents/spawn'), body) as Promise; } diff --git a/desktop/src/i18n/index.ts b/desktop/src/i18n/index.ts index 521af5bef..aaecca771 100644 --- a/desktop/src/i18n/index.ts +++ b/desktop/src/i18n/index.ts @@ -130,6 +130,17 @@ const en: Dict = { 'project.bindStewardHint': 'e.g. agents/steward.v1.yaml', 'project.bind': 'Bind', 'spawn.title': 'Spawn agent', + // Steward spawn (desktop parity with mobile's steward sheet). + 'nav.spawnSteward': 'Spawn steward…', + 'steward.title': 'Spawn a steward', + 'steward.lead': 'A steward is a coordinating agent, spawned from a steward template — the template carries the role escalation, journal and persona wiring, not an engine pick. Name it "steward", or give a domain (research, infra-east) for a domain steward.', + 'steward.name': 'Name', + 'steward.namePlaceholder': 'steward — or a domain: research, infra-east', + 'steward.template': 'Template', + 'steward.submit': 'Spawn steward', + 'steward.err.required': 'Name is required.', + 'steward.err.shape': 'Use lowercase letters, digits, and dashes (e.g. research, infra-east).', + 'steward.err.taken': 'A live steward already owns “{handle}”.', 'spawn.handle': 'Handle', 'spawn.handlePlaceholder': 'worker-1', 'spawn.engine': 'Engine', @@ -2185,6 +2196,17 @@ const zh: Dict = { 'project.bindStewardHint': '例如 agents/steward.v1.yaml', 'project.bind': '绑定', 'spawn.title': '生成代理', + // Steward spawn (desktop parity with mobile's steward sheet). + 'nav.spawnSteward': '生成管家…', + 'steward.title': '生成管家', + 'steward.lead': '管家是协调型智能体,由管家模板生成——角色提升、日志与人设接线均来自模板,而非引擎选择。名称填 steward,或填领域名(如 research、infra-east)生成领域管家。', + 'steward.name': '名称', + 'steward.namePlaceholder': 'steward——或领域名:research、infra-east', + 'steward.template': '模板', + 'steward.submit': '生成管家', + 'steward.err.required': '名称不能为空。', + 'steward.err.shape': '仅限小写字母、数字与连字符(如 research、infra-east)。', + 'steward.err.taken': '已有在线管家占用「{handle}」。', 'spawn.handle': '句柄', 'spawn.handlePlaceholder': 'worker-1', 'spawn.engine': '引擎', diff --git a/desktop/src/state/stewardSpawn.test.ts b/desktop/src/state/stewardSpawn.test.ts new file mode 100644 index 000000000..6000d0776 --- /dev/null +++ b/desktop/src/state/stewardSpawn.test.ts @@ -0,0 +1,76 @@ +/// Tests for the desktop steward-spawn contract (state/stewardSpawn.ts) — +/// the handle taxonomy mirrors lib/services/steward_handle.dart, so these +/// pins keep the two ends of the convention from drifting apart. +/// Run locally: `node --test src/state/stewardSpawn.test.ts` from `desktop/`. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + defaultStewardName, + isStewardHandle, + normalizeStewardHandle, + parseBackendKind, + stewardTemplatePicks, + suggestedNameFor, + validateStewardName, +} from './stewardSpawn.ts'; + +test('isStewardHandle: steward / *-steward only — @steward and project stewards excluded', () => { + assert.equal(isStewardHandle('steward'), true); + assert.equal(isStewardHandle('research-steward'), true); + assert.equal(isStewardHandle('@steward'), false); + assert.equal(isStewardHandle('@steward.abcd1234'), false); + assert.equal(isStewardHandle('worker-bee'), false); + assert.equal(isStewardHandle(''), false); +}); + +test('normalizeStewardHandle: bare name gains -steward; steward and suffixed forms pass through', () => { + assert.equal(normalizeStewardHandle('research'), 'research-steward'); + assert.equal(normalizeStewardHandle('steward'), 'steward'); + assert.equal(normalizeStewardHandle('infra-steward'), 'infra-steward'); + assert.equal(normalizeStewardHandle(' research '), 'research-steward'); + assert.equal(normalizeStewardHandle(''), ''); +}); + +test('validateStewardName: shape checked post-normalization', () => { + assert.equal(validateStewardName('steward'), null); + assert.equal(validateStewardName('research'), null); + assert.equal(validateStewardName('infra-east'), null); + assert.equal(validateStewardName(''), 'required'); + assert.equal(validateStewardName(' '), 'required'); + assert.equal(validateStewardName('Steward'), 'shape'); + assert.equal(validateStewardName('my steward'), 'shape'); + assert.equal(validateStewardName('9lives'), 'shape'); +}); + +test('defaultStewardName: steward when free, empty when a live steward owns it', () => { + assert.equal(defaultStewardName(new Set()), 'steward'); + assert.equal(defaultStewardName(new Set(['research-steward'])), 'steward'); + assert.equal(defaultStewardName(new Set(['steward'])), ''); +}); + +test('stewardTemplatePicks: steward*.yaml minus the general-concierge singleton, sorted, with a fallback', () => { + assert.deepEqual( + stewardTemplatePicks(['worker.v1.yaml', 'steward.v1.yaml', 'steward.research.v1.yaml', 'steward.general.v1.yaml']), + ['steward.research.v1.yaml', 'steward.v1.yaml'], + ); + // Nothing listed (listing not loaded / stripped team): the shipped default. + assert.deepEqual(stewardTemplatePicks([]), ['steward.v1.yaml']); + assert.deepEqual(stewardTemplatePicks(['steward.general.v1.yaml']), ['steward.v1.yaml']); +}); + +test('suggestedNameFor: domain templates seed the name; off-convention gives nothing', () => { + assert.equal(suggestedNameFor('steward.research.v1.yaml'), 'research'); + assert.equal(suggestedNameFor('steward.infra-east.v2.yaml'), 'infra-east'); + assert.equal(suggestedNameFor('steward.v1.yaml'), ''); + assert.equal(suggestedNameFor('steward.general.v1.yaml'), 'general'); +}); + +test('parseBackendKind: reads backend.kind, never bleeds across blocks', () => { + const yaml = ['persona:', ' kind: steward.v1', 'backend:', ' cmd: claude', ' kind: claude-code', 'limits:', ' kind: nope', ''].join( + '\n', + ); + assert.equal(parseBackendKind(yaml), 'claude-code'); + // The persona block's kind must not win, and a missing backend block is null. + assert.equal(parseBackendKind('persona:\n kind: steward.v1\n'), null); + assert.equal(parseBackendKind(''), null); +}); diff --git a/desktop/src/state/stewardSpawn.ts b/desktop/src/state/stewardSpawn.ts new file mode 100644 index 000000000..cfb920bfa --- /dev/null +++ b/desktop/src/state/stewardSpawn.ts @@ -0,0 +1,88 @@ +/// Steward-spawn contract for the desktop sheet (ui/StewardSpawn.tsx) — +/// desktop parity with mobile's spawn_steward_sheet.dart. Pure and +/// import-free so `node --test` covers it directly. +/// +/// The conventions mirror `lib/services/steward_handle.dart`, the one place +/// the handle taxonomy lives on mobile — a steward is spawned from a +/// `agents/steward*.yaml` template (its `default_role: team.*` is what +/// escalates the role hub-side; the spawn's `kind` stays the ENGINE parsed +/// from the template's `backend.kind`). Handles: plain `steward` (the legacy +/// default) or `-steward` for domain stewards. The team-scoped general +/// concierge (`@steward`, the `steward.general*` template) is a singleton +/// with its own hub ensure-endpoint and is deliberately NOT offered here. + +/// Is this handle a domain/legacy steward (`steward` / `*-steward`)? The +/// `@steward` concierge and project stewards (`@steward.`) do not +/// match — by design, this predicate drives the collision check against +/// user-typed names, which can never take an `@` form. +export function isStewardHandle(handle: string): boolean { + return handle === 'steward' || handle.endsWith('-steward'); +} + +/// Validate the sheet's Name field. Returns an error CODE (an i18n key +/// suffix under `steward.err*`) or null when acceptable. The user types the +/// bare domain (`research`, `infra-east`) or plain `steward`; the shape rule +/// is checked post-normalization, i.e. against what the hub would store. +export function validateStewardName(raw: string): 'required' | 'shape' | null { + const h = normalizeStewardHandle(raw); + if (h === '') return 'required'; + if (h === 'steward') return null; + return /^[a-z][a-z0-9-]*-steward$/.test(h) ? null : 'shape'; +} + +/// Bare name → the handle the hub stores. Idempotent: `steward` and an +/// already-suffixed `-steward` pass through unchanged. +export function normalizeStewardHandle(raw: string): string { + const h = raw.trim(); + if (h === '' || h === 'steward' || h.endsWith('-steward')) return h; + return `${h}-steward`; +} + +/// Default for the Name field: `steward` when no live steward owns it, +/// otherwise empty so the user picks a domain. +export function defaultStewardName(liveHandles: ReadonlySet): string { + return liveHandles.has('steward') ? '' : 'steward'; +} + +/// The steward templates the sheet offers, from the team's `agents` template +/// listing: every `steward*.yaml` EXCEPT the `steward.general*` singleton +/// (the concierge has its own ensure-spawn path and must not appear next to +/// user-named domain stewards). Sorted; falls back to the shipped default so +/// the sheet works before the listing loads. +export function stewardTemplatePicks(names: readonly string[]): string[] { + const picks = names.filter((n) => n.startsWith('steward') && !n.startsWith('steward.general')); + picks.sort(); + return picks.length > 0 ? picks : ['steward.v1.yaml']; +} + +/// Parse `steward.research.v1.yaml` → `research` to seed the Name field when +/// the user picks a domain template; '' for anything off-convention so a +/// name the user already typed is never clobbered. +export function suggestedNameFor(template: string): string { + const m = /^steward\.([a-z][a-z0-9-]*)\.v\d+\.yaml$/.exec(template); + return m === null ? '' : m[1]; +} + +/// Extract `backend.kind: ` from a steward template body — the value +/// the spawn request sends as `kind` (the ENGINE; the steward persona rides +/// the spec YAML). Mirrors mobile's block-aware mini-parser: find the +/// top-level `backend:` block, then an indented `kind:` whose value is one +/// token; reset at the next top-level key so we never bleed across blocks. +/// null when the field can't be located (malformed / off-convention YAML). +export function parseBackendKind(yaml: string): string | null { + const childRe = /^\s+kind:\s*([A-Za-z0-9_.-]+)\s*$/; + let inBlock = false; + for (const line of yaml.split('\n')) { + const trimmed = line.trimEnd(); + if (trimmed === 'backend:') { + inBlock = true; + continue; + } + if (inBlock && trimmed !== '' && !trimmed.startsWith(' ')) inBlock = false; + if (inBlock) { + const m = childRe.exec(line); + if (m !== null) return m[1]; + } + } + return null; +} diff --git a/desktop/src/surfaces/Navigator.tsx b/desktop/src/surfaces/Navigator.tsx index af170562e..fabb95a99 100644 --- a/desktop/src/surfaces/Navigator.tsx +++ b/desktop/src/surfaces/Navigator.tsx @@ -3,6 +3,7 @@ import { useAgents, useHosts } from '../hub/queries'; import { str, type Entity } from '../hub/types'; import { useT } from '../i18n'; import { Icon } from '../ui/Icon'; +import { StewardSpawn } from '../ui/StewardSpawn'; import { activateOnKey } from '../ui/a11y'; import { useFocus } from '../state/focus'; @@ -74,6 +75,10 @@ export function Navigator(): JSX.Element { const selectAgent = useFocus((s) => s.selectAgent); const selectHost = useFocus((s) => s.selectHost); const [open, setOpen] = useState({ stewards: true, agents: true, hosts: true }); + // The steward-spawn sheet (desktop parity with mobile's steward sheet) — + // the worker sheet can only mint role=worker, so without this the rail's + // Stewards section was a dead end on a fresh desktop-first team. + const [spawnSteward, setSpawnSteward] = useState(false); const toggle = (k: keyof typeof open): void => setOpen((o) => ({ ...o, [k]: !o[k] })); // Agents and Hosts are separate subtabs so the roster isn't one long mixed // tree (director request). The Agents subtab groups stewards + workers; Hosts @@ -161,15 +166,31 @@ export function Navigator(): JSX.Element { count={stewards.length} open={open.stewards} onToggle={() => toggle('stewards')} + onAdd={() => setSpawnSteward(true)} + addTitle={t('nav.spawnSteward')} > {agentsQ.isError && (
{(agentsQ.error as Error).message}
)} {!agentsQ.isError && (stewards.length === 0 - ? loadingOrEmpty(agentsQ.isLoading, t('nav.noStewards')) + ? // The empty state is a CTA, not a dead end: a team without a + // steward has no coordinator, and (unlike mobile's bootstrap + // auto-trigger) the desktop rail is the only surface that + // would ever say so. + agentsQ.isLoading ? ( + loadingOrEmpty(true, '') + ) : ( +
+ {t('nav.noStewards')}{' '} + +
+ ) : stewards.map((a) => agentRow(a, { showHost: true })))} + {spawnSteward && setSpawnSteward(false)} />} {/* Agents — the worker executors (non-steward). */} void }): JSX.Element { + const t = useT(); + const client = useSession((s) => s.client); + const { run, busy, error } = useHubAction(); + const hostsQ = useHosts(); + const agentsQ = useAgents(); + const envProfilesQ = useEnvProfiles(); + const hosts = hostsQ.data ?? []; + const envProfiles = envProfilesQ.data ?? []; + + // Live steward handles (running/pending/paused) — the collision check for + // the Name field. Handle-convention only: an `@`-form (concierge, project + // stewards) can never collide with valid input. + const live = new Set( + (agentsQ.data ?? []) + .filter((a) => { + const s = str(a, 'status') ?? ''; + return (s === 'running' || s === 'pending' || s === 'paused') && isStewardHandle(str(a, 'handle') ?? ''); + }) + .map((a) => str(a, 'handle') ?? ''), + ); + + const [name, setName] = useState(null); + const [template, setTemplate] = useState('steward.v1.yaml'); + const [hostId, setHostId] = useState(''); + const [envProfileId, setEnvProfileId] = useState(''); + const [pending, setPending] = useState(false); + const [nameError, setNameError] = useState(null); + const [sealing, setSealing] = useState(false); + const [secretError, setSecretError] = useState(null); + const [trust, setTrust] = useState<{ info: HostKeyInfo; secrets: Record; profileId: string } | null>( + null, + ); + + // Default the Name once the agents list answers: `steward` when free. + const effectiveName = name ?? defaultStewardName(live); + + const templatesQ = useQuery({ + queryKey: ['templates', 'agents'], + enabled: client !== null, + queryFn: () => client!.listTemplates('agents'), + }); + const templates = stewardTemplatePicks( + (templatesQ.data ?? []).map((row: Entity) => str(row, 'name') ?? '').filter((n) => n !== ''), + ); + // Snap to a listed template if the default isn't among the team's picks. + useEffect(() => { + if (!templates.includes(template)) setTemplate(templates[0]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [templates.join('|')]); + + const effectiveHost = hostId !== '' ? hostId : (hosts[0] !== undefined ? (str(hosts[0], 'id') ?? '') : ''); + const canSubmit = effectiveName.trim() !== '' && effectiveHost !== ''; + + function pickTemplate(tpl: string): void { + setTemplate(tpl); + // A domain template seeds the Name — but never clobbers a typed one. + const suggested = suggestedNameFor(tpl); + if (suggested !== '' && (name === null || name === '')) setName(suggested); + } + + async function doSpawn(specYaml: string, kind: string, handle: string, envelope?: string): Promise { + if (client === null) return; + const res = await run( + () => + client.spawnAgent({ + child_handle: handle, + kind, + host_id: effectiveHost, + env_profile_id: envProfileId !== '' ? envProfileId : undefined, + env_secret_envelope: envelope, + spawn_spec_yaml: specYaml, + permission_mode: 'skip', + auto_open_session: true, + }), + { invalidate: [['agents'], ['attention'], ['sessions']] }, + ); + if (res === undefined) return; + if (str(res, 'status') === 'pending_approval') { + setPending(true); + } else { + onClose(); + } + } + + async function submit(): Promise { + if (client === null || !canSubmit) return; + setSecretError(null); + setNameError(null); + + const code = validateStewardName(effectiveName); + if (code !== null) { + setNameError(t(`steward.err.${code}`)); + return; + } + const handle = normalizeStewardHandle(effectiveName); + if (live.has(handle)) { + setNameError(t('steward.err.taken').replace('{handle}', handle)); + return; + } + + setSealing(true); + try { + // The MERGED template (team overlay over the bundled default) is the + // spawn spec; its backend.kind is the request's engine kind. + const specYaml = await client.getTemplateText('agents', template, true); + const kind = parseBackendKind(specYaml) ?? 'claude-code'; + + const profile = envProfileId !== '' ? envProfiles.find((p) => str(p, 'id') === envProfileId) : undefined; + const refs = secretRefsOf(profile); + if (refs.length === 0) { + await doSpawn(specYaml, kind, handle); + return; + } + const secrets = await resolveSecretRefs(refs); + const host = hosts.find((h) => str(h, 'id') === effectiveHost) as Entity | undefined; + if (host === undefined) { + setSecretError(t('spawn.secretErr.noHostKey')); + return; + } + const info = await inspectHostKey(host, effectiveHost); + if (!info.trusted) { + setTrust({ info, secrets, profileId: envProfileId }); + return; + } + await sealAndSpawn(specYaml, kind, handle, info, secrets, envProfileId); + } catch (e) { + if (e instanceof EnvSecretError) { + setSecretError(t(`spawn.secretErr.${e.code}`)); + } else { + throw e; + } + } finally { + setSealing(false); + } + } + + async function sealAndSpawn( + specYaml: string, + kind: string, + handle: string, + info: HostKeyInfo, + secrets: Record, + profileId: string, + ): Promise { + if (client === null) return; + const envelope = await sealEnvSecrets(secrets, info, client.transport.teamId, profileId); + await doSpawn(specYaml, kind, handle, envelope); + } + + async function confirmTrust(): Promise { + if (trust === null) return; + const held = trust; + setTrust(null); + setSealing(true); + setSecretError(null); + try { + trustHostKey(held.info.hostId, held.info.pubkey); + const specYaml = await client!.getTemplateText('agents', template, true); + const kind = parseBackendKind(specYaml) ?? 'claude-code'; + await sealAndSpawn(specYaml, kind, normalizeStewardHandle(effectiveName), held.info, held.secrets, held.profileId); + } catch (e) { + if (e instanceof EnvSecretError) { + setSecretError(t(`spawn.secretErr.${e.code}`)); + } else { + throw e; + } + } finally { + setSealing(false); + } + } + + return ( + <> + +
+ {t('steward.title')} + + +
+
+

{t('steward.lead')}

+ + + + {envProfiles.length > 0 && ( + + )} + {hosts.length === 0 &&
{t('spawn.noHost')}
} + {pending &&
{t('spawn.pending')}
} + {nameError !== null &&
{nameError}
} + {secretError !== null &&
{secretError}
} + {error !== null &&
{error}
} +
+ {pending ? ( + + ) : ( + + )} +
+
+
+ {trust !== null && ( + setTrust(null)} + onConfirm={() => void confirmTrust()} + /> + )} + + ); +}