diff --git a/desktop/src/hub/client.ts b/desktop/src/hub/client.ts index 14772887..10a6a1dd 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 521af5be..aaecca77 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 00000000..6000d077 --- /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 00000000..cfb920bf --- /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 af170562..fabb95a9 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()} + /> + )} + + ); +}