Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions desktop/src/hub/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Entity> {
return this.transport.post(this.transport.team('/agents/spawn'), body) as Promise<Entity>;
}
Expand Down
22 changes: 22 additions & 0 deletions desktop/src/i18n/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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': '引擎',
Expand Down
76 changes: 76 additions & 0 deletions desktop/src/state/stewardSpawn.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
88 changes: 88 additions & 0 deletions desktop/src/state/stewardSpawn.ts
Original file line number Diff line number Diff line change
@@ -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 `<name>-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.<pid8>`) 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 `<name>-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>): 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: <engine>` 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;
}
23 changes: 22 additions & 1 deletion desktop/src/surfaces/Navigator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 && (
<div className="region-pad error">{(agentsQ.error as Error).message}</div>
)}
{!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, '')
) : (
<div className="region-pad muted">
{t('nav.noStewards')}{' '}
<button className="link-btn" onClick={() => setSpawnSteward(true)}>
{t('nav.spawnSteward')}
</button>
</div>
)
: stewards.map((a) => agentRow(a, { showHost: true })))}
</KindSection>
{spawnSteward && <StewardSpawn onClose={() => setSpawnSteward(false)} />}

{/* Agents — the worker executors (non-steward). */}
<KindSection
Expand Down
Loading
Loading