diff --git a/apps/desktop/src/app/chat/chat-swap-overlay.tsx b/apps/desktop/src/app/chat/chat-swap-overlay.tsx index 58f733591c..3bba0606ab 100644 --- a/apps/desktop/src/app/chat/chat-swap-overlay.tsx +++ b/apps/desktop/src/app/chat/chat-swap-overlay.tsx @@ -21,7 +21,7 @@ export function ChatSwapOverlay({ profile }: { profile: string | null }) { // The first run swaps profiles twice — into the setup profile, then into the // task profile — and neither is a thing the user asked for or has a name for. - // "Waking up hermes-setup…" over a greeting that is already on screen reads + // "Waking up …" over a greeting that is already on screen reads // as a stall in the one moment that has to feel instant. The flow narrates // its own handoff (the handoff card) and the greeting is banked, so there // is nothing here to cover. diff --git a/apps/desktop/src/app/contrib/onboarding-handoff.ts b/apps/desktop/src/app/contrib/onboarding-handoff.ts index 9245448247..453c7c2a73 100644 --- a/apps/desktop/src/app/contrib/onboarding-handoff.ts +++ b/apps/desktop/src/app/contrib/onboarding-handoff.ts @@ -15,8 +15,7 @@ import { firstTaskTitle, guideSourceConnectionId, readGuideHandoffReceipt, - retrySetupHandoff, - SETUP_PROFILE + retrySetupHandoff } from '@/components/onboarding-chat/setup-profile' import { showHandoffTour } from '@/components/onboarding-chat/signpost' import { findGroupOfPane } from '@/components/pane-shell/tree/model' @@ -28,7 +27,7 @@ import { requestGatewayForAgent } from '@/store/gateway' import { dismissNotification, notify } from '@/store/notifications' import { $onboardingAnswers } from '@/store/onboarding-answers' import { beginOnboardingHandoff, completeOnboardingFlow } from '@/store/onboarding-gate' -import { $activeGatewayProfile, $newChatProfile, $newChatRoute, ensureGatewayAgent } from '@/store/profile' +import { $activeGatewayProfile, $newChatProfile, $newChatRoute, $profiles, ensureGatewayAgent } from '@/store/profile' import { $activeSessionId, $selectedStoredSessionId, @@ -75,7 +74,7 @@ export function useOnboardingHandoff({ !isOnboardingEnabled() || $setupHandoff.get() || !selectedStoredId || - $activeGatewayProfile.get() !== SETUP_PROFILE + $profiles.get().find(p => p.name === $activeGatewayProfile.get())?.role !== 'setup' ) { return } @@ -104,7 +103,7 @@ export function useOnboardingHandoff({ $setupSession.set({ connectionId, - profile: SETUP_PROFILE, + profile: $activeGatewayProfile.get(), runtimeId: $activeSessionId.get() ?? '', storedId: selectedStoredId }) diff --git a/apps/desktop/src/app/contrib/onboarding-kickoff.ts b/apps/desktop/src/app/contrib/onboarding-kickoff.ts index 40ef09699d..249acb7e91 100644 --- a/apps/desktop/src/app/contrib/onboarding-kickoff.ts +++ b/apps/desktop/src/app/contrib/onboarding-kickoff.ts @@ -1,3 +1,4 @@ +import type { OnboardingEnsureSetupProfileResult } from '@hermes/shared' import { useCallback } from 'react' import type { useSessionActions } from '@/app/session/hooks/use-session-actions' @@ -8,13 +9,7 @@ import { pickOnboardingGreeting, takeGuideShape } from '@/components/onboarding-chat/assembly' -import { - $setupSession, - ensureSetupProfile, - guideSourceConnectionId, - SETUP_CHAT_TITLE, - SETUP_PROFILE -} from '@/components/onboarding-chat/setup-profile' +import { $setupSession, guideSourceConnectionId, SETUP_CHAT_TITLE } from '@/components/onboarding-chat/setup-profile' import { isOnboardingEnabled } from '@/lib/onboarding-enabled' import { activeGatewayConnectionId, requestGatewayForProfile } from '@/store/gateway' import { loadMachineProfile } from '@/store/machine' @@ -54,6 +49,7 @@ interface GuideSession { } async function adoptGuideSession( + setupProfile: string, canonical: GuideSession, freeTier: SetupStatus['free_tier'], resumeSession: OnboardingKickoffOptions['resumeSession'], @@ -64,7 +60,7 @@ async function adoptGuideSession( $chatOnboardingThreadIds.set(adoptedRuntimeId ? [canonical.id, adoptedRuntimeId] : [canonical.id]) $setupSession.set({ connectionId: guideSourceConnectionId(canonical.id), - profile: SETUP_PROFILE, + profile: setupProfile, runtimeId: adoptedRuntimeId ?? canonical.id, storedId: canonical.id }) @@ -78,7 +74,7 @@ async function adoptGuideSession( } } -/** Seeds the runbook and a pre-written greeting on hermes-setup before the phase advances. +/** Seeds the runbook and a pre-written greeting on the setup profile before the phase advances. * The seeded assistant row shows the chat's first message without a model turn. */ export function useOnboardingKickoff({ createBackendSessionForSend, @@ -100,11 +96,14 @@ export function useOnboardingKickoff({ let swapped = false try { - await ensureSetupProfile(requestGateway) + const { name: setupProfile } = await requestGateway( + 'onboarding.ensure_setup_profile', + {} + ) // Probe the guide's own socket before switching profiles so a refusal // leaves classic onboarding on the user's current backend. - const record = await requestGatewayForProfile(SETUP_PROFILE, 'setup.status', {}) + const record = await requestGatewayForProfile(setupProfile, 'setup.status', {}) if (record.ready !== true || record.provider_configured !== true) { return false @@ -112,8 +111,8 @@ export function useOnboardingKickoff({ swapped = true $newChatRoute.set(null) - $newChatProfile.set(SETUP_PROFILE) - await ensureGatewayProfile(SETUP_PROFILE) + $newChatProfile.set(setupProfile) + await ensureGatewayProfile(setupProfile) // Idempotent: the gate already took the shape on the tick the guide was // owed, so no full-size shell painted during the profile round trips. @@ -121,7 +120,7 @@ export function useOnboardingKickoff({ await loadMachineProfile() const guideRequest: AmbientGatewayRequest = (method, params, timeout) => - requestGatewayForProfile(SETUP_PROFILE, method, params, timeout) + requestGatewayForProfile(setupProfile, method, params, timeout) // Look the guide up by its exact title: a relaunch adopts the existing guide session before creating // one, so the backend's UNIQUE(title) constraint cannot leave an untitled duplicate behind. @@ -133,7 +132,7 @@ export function useOnboardingKickoff({ const canonical = registryHit?.sessions?.[0] if (canonical?.id) { - await adoptGuideSession(canonical, record.free_tier, resumeSession, guideRequest) + await adoptGuideSession(setupProfile, canonical, record.free_tier, resumeSession, guideRequest) // runGuideKickoff records the guided phase only after adoption. return true @@ -141,7 +140,7 @@ export function useOnboardingKickoff({ const capabilities = await readOnboardingCapabilities({ connectionId: previousConnectionId, - profile: SETUP_PROFILE + profile: setupProfile }) const seedMessages = buildChatOnboardingSeedMessages( @@ -156,7 +155,7 @@ export function useOnboardingKickoff({ createOverrides.reasoningEffort = 'minimal' } - const runtimeId = await runCreatePinnedTo(SETUP_PROFILE, () => + const runtimeId = await runCreatePinnedTo(setupProfile, () => createBackendSessionForSend(null, seedMessages, createOverrides) ) @@ -168,7 +167,7 @@ export function useOnboardingKickoff({ $chatOnboardingThreadIds.set(storedId ? [storedId, runtimeId] : [runtimeId]) $setupSession.set({ connectionId: guideSourceConnectionId(storedId), - profile: SETUP_PROFILE, + profile: setupProfile, runtimeId, storedId }) diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index edd8941583..fc80385a6d 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -374,7 +374,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { const { connectionRef, gateway, gatewayRef, requestGateway: ambientRequestGateway } = useGatewayRequest() // The guide remains selected while handoff creates on another profile. - // Without this pin, the owner ladder sends session.create to hermes-setup + // Without this pin, the owner ladder sends session.create to the setup profile // despite the gateway switch (#89206). Scope it to the create leg so // concurrent session traffic keeps its recorded owner. const handoffCreateProfileRef = useRef(null) diff --git a/apps/desktop/src/components/onboarding-chat/cards/build.tsx b/apps/desktop/src/components/onboarding-chat/cards/build.tsx index 90b8b67ede..57623ba48f 100644 --- a/apps/desktop/src/components/onboarding-chat/cards/build.tsx +++ b/apps/desktop/src/components/onboarding-chat/cards/build.tsx @@ -17,19 +17,20 @@ import { Chip } from '@/components/onboarding-chat/chip' import { $handoffError, $setupHandoff, + $setupSession, firstTaskTitle, guideHandoffReceiptKey, parseHandoffPlan, readGuideHandoffReceipt, requestSetupHandoff, - retrySetupHandoff, - SETUP_PROFILE + retrySetupHandoff } from '@/components/onboarding-chat/setup-profile' import { Button } from '@/components/ui/button' import { answeredAfter } from '@/lib/chat-messages/parts' import { segmentTranscriptDirectives } from '@/lib/transcript-directives' import { cn } from '@/lib/utils' import { $onboardingAnswers, markStepCommitted } from '@/store/onboarding-answers' +import { $activeGatewayProfile } from '@/store/profile' import { assertSessionOwnerResolved } from '@/store/session-owner-resolution' import { isSessionOwnerRoute } from '@/store/session-request-router' @@ -152,7 +153,7 @@ export function HandoffCard({ attrs, locked }: CardProps) { storedId, runtimeId, connectionId: isSessionOwnerRoute(owner) ? owner.connectionId : null, - profile: isSessionOwnerRoute(owner) ? owner.profile : owner || SETUP_PROFILE + profile: isSessionOwnerRoute(owner) ? owner.profile : owner || $setupSession.get()?.profile || $activeGatewayProfile.get() }) } }) @@ -195,7 +196,7 @@ export function HandoffCard({ attrs, locked }: CardProps) { storedId, runtimeId, connectionId: isSessionOwnerRoute(owner) ? owner.connectionId : null, - profile: isSessionOwnerRoute(owner) ? owner.profile : owner || SETUP_PROFILE + profile: isSessionOwnerRoute(owner) ? owner.profile : owner || $setupSession.get()?.profile || $activeGatewayProfile.get() } }) } diff --git a/apps/desktop/src/components/onboarding-chat/first-build.ts b/apps/desktop/src/components/onboarding-chat/first-build.ts index ee40d925e8..bab4fb239e 100644 --- a/apps/desktop/src/components/onboarding-chat/first-build.ts +++ b/apps/desktop/src/components/onboarding-chat/first-build.ts @@ -27,7 +27,7 @@ const CHECK_IN_NOTE = interface FirstBuild { /** Profile of the build session. The note must be routed to this profile explicitly: the user can return to - * Setup's chat while the build runs, which makes hermes-setup the active gateway. */ + * Setup's chat while the build runs, which makes the setup profile the active gateway. */ profile: string sessionId: string tools: number diff --git a/apps/desktop/src/components/onboarding-chat/setup-profile.ts b/apps/desktop/src/components/onboarding-chat/setup-profile.ts index ef1587a959..7c1ed12adc 100644 --- a/apps/desktop/src/components/onboarding-chat/setup-profile.ts +++ b/apps/desktop/src/components/onboarding-chat/setup-profile.ts @@ -1,12 +1,12 @@ /** * The welcome chat that guided onboarding runs in, and the seed prompts for the first build session. * - * The chat belongs to a persistent `hermes-setup` profile, so it survives onboarding and can be found again. `setup` - * is the internal name throughout this module (the profile key, the atoms, the hidden `[setup]` notes); the user sees - * only Hermes and the title `Welcome to Hermes`. + * The chat belongs to the setup profile, which the backend creates and marks (`onboarding.ensure_setup_profile`), so it + * survives onboarding and can be found again. `setup` is the internal name throughout this module (the atoms, the hidden `[setup]` notes); the user + * sees only Hermes and the title `Welcome to Hermes`. * * This module holds the pure pieces: names, souls, seed prompts, and the handoff request atom. The side effects - * (profiles.create, session.create, the chat switch) run in the wiring's kickoff and handoff effects, which hold the + * (session.create, the chat switch) run in the wiring's kickoff and handoff effects, which hold the * gateway and session hooks. */ @@ -15,7 +15,6 @@ import { atom } from 'nanostores' import type { ProfileScope } from '@/api/client' import type { HandoffReceipt } from '@/app/contrib/handoff-leg' import { handoffReceiptKey, readHandoffReceipt } from '@/app/contrib/handoff-receipt' -import type { GatewayRequest } from '@/app/session/hooks/use-prompt-actions/utils' import { CONNECTOR_LEAD_ORDER } from '@/components/onboarding-chat/options' import { connectorTitle } from '@/lib/connector-tools' import { activeGatewayConnectionId } from '@/store/gateway' @@ -25,9 +24,6 @@ import { readOnboardingCapabilities } from '@/store/onboarding-capabilities' import { FIRST_USE_GUIDANCE, PLAIN_SPEECH } from '@/store/onboarding-script' import { getSessionOwnerHint } from '@/store/session' -/** Profile name of the onboarding guide. Prefixed so it cannot collide with a profile the user named "setup". */ -export const SETUP_PROFILE = 'hermes-setup' - /** Title of the welcome chat, and the row the user sees in the sessions list. Kickoff re-finds the chat by exact * title after a relaunch, so this string is also a lookup key. */ export const SETUP_CHAT_TITLE = 'Welcome to Hermes' @@ -125,23 +121,6 @@ export function firstTaskTitle(task: string): string { return trimmed.length > 28 ? `${trimmed.slice(0, 27).trimEnd()}…` : trimmed || 'First build' } -/** SOUL.md for the welcome profile. It applies to the welcome chat and to every later check-in. */ -export function composeSetupSoul(): string { - return [ - '# Hermes', - '', - 'You are Hermes, and this profile is where you met this user for the first time and stay reachable afterwards. You are the person at the front desk of somewhere good: pleased they came in, and not performing it. Quick, unhurried, never flustered, never in the way. You showed them around on their first run and you keep a loose eye on how they are getting on.', - '', - '- Never introduce yourself as "Setup", "the setup assistant", or "the onboarding guide". You are Hermes.', - '- Warmth is in paying attention, not in adjectives. Remember what they told you and use it. Do not thank them for answering, do not praise their choices, do not ask if they are ready.', - '- Offer an opinion lightly when you have one. "Most people wire that one up first" is worth more than a neutral menu.', - '- You are training wheels: useful early, ignorable later. Never guilt-trip, never nag. If the user asks you to stop checking in, stop.', - '- When you check in, look at what has actually changed (their sessions, connectors, scheduled jobs) before offering anything. One concrete suggestion beats a menu.', - '- Things worth offering, roughly in order: wiring a connector they said they use, scheduling something they do repeatedly, a second build based on the first, keyboard/layout niceties.', - '- Write like a person talking to another person. Short sentences, plain words, no headers, no bullet walls, no emoji.' - ].join('\n') -} - export function buildFirstTaskRunbook( task: string, answers: OnboardingAnswers, @@ -283,22 +262,3 @@ export async function buildFirstTaskSeedMessages( export function buildHandoffCompleteNote(task: string): string { return `[setup] handoff complete — "${task.trim()}" is now building in its own session on the default profile, and the user is watching it there. The app is showing them a short tour of the profile rail and the sessions list right now, so do not describe either. Say ONE short line and then stop: you're around if they want a hand, and this chat stays where it is. Do not ask a question, do not offer a list, do not schedule anything.` } - -/** Creates the guide profile. The catch treats an already-existing profile as success, so kickoff can call this on - * every run. */ -export async function ensureSetupProfile(request: GatewayRequest): Promise { - try { - await request('profiles.create', { - description: 'Where Hermes met you — walks your first run, then checks in as you find your feet.', - name: SETUP_PROFILE, - clone_from: 'default', - share_auth: true, - no_alias: true, - soul: composeSetupSoul() - }) - } catch (error) { - if (!(error instanceof Error && /exist/i.test(error.message))) { - throw error - } - } -} diff --git a/apps/desktop/src/store/onboarding-gate.ts b/apps/desktop/src/store/onboarding-gate.ts index f90d655bec..a3eae27122 100644 --- a/apps/desktop/src/store/onboarding-gate.ts +++ b/apps/desktop/src/store/onboarding-gate.ts @@ -3,6 +3,7 @@ import { atom } from 'nanostores' import { isOnboardingEnabled } from '@/lib/onboarding-enabled' import { readKey, writeKey } from '@/lib/storage' +import { $gateway } from './gateway' import { hasSeenIntroReveal, markIntroRevealSeen } from './intro-reveal' import { DEFAULT_ANSWERS, setOnboardingAnswers } from './onboarding-answers' @@ -159,11 +160,13 @@ export function skipGuide(): void { } } -export function devResetOnboardingFlow(): void { +/** Resets the backend's setup profile in place, then the local flow state. */ +export async function devResetOnboardingFlow(): Promise { if (!import.meta.env.DEV) { return } + await $gateway.get()?.request('onboarding.reset_setup_profile', {}) guideKickoff = { status: 'idle' } setPhase('idle') setOnboardingAnswers({ ...DEFAULT_ANSWERS, connectors: [...DEFAULT_ANSWERS.connectors] }) diff --git a/apps/desktop/src/store/profile.ts b/apps/desktop/src/store/profile.ts index a32e5aad08..ea9aef0833 100644 --- a/apps/desktop/src/store/profile.ts +++ b/apps/desktop/src/store/profile.ts @@ -607,7 +607,7 @@ export async function ensureGatewayProfile( // renderer-side $activeGatewayProfile mirror is not proof of the socket: // applyActive can decline an epoch-losing publication while call sites // publish the atom anyway, leaving "atom says X, socket serves Y" (the - // #89206 split-brain — observed live as atom 'default' over a hermes-setup + // #89206 split-brain — observed live as atom 'default' over a setup-profile // socket during the guided-onboarding handoff). Verify the leg we're about // to rely on; on disagreement fall through to the full ensure path, which // re-activates the socket and leaves the atom and route agreeing. The one diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index dc289e939a..6bb632bcea 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -1035,6 +1035,8 @@ export interface ProfileInfo { name: string path: string provider: null | string + /** Backend-assigned role from profile.yaml; `setup` marks the onboarding guide's profile. */ + role?: 'setup' | null skill_count: number } diff --git a/apps/shared/src/gateway-contract.generated.ts b/apps/shared/src/gateway-contract.generated.ts index 8aa888a640..dd59b883a0 100644 --- a/apps/shared/src/gateway-contract.generated.ts +++ b/apps/shared/src/gateway-contract.generated.ts @@ -1624,6 +1624,7 @@ export interface ProfileRow { display_name?: string skill_count?: number previous_names?: string[] + role?: 'setup' | null last_session?: ProfileSessionPreview | null worker_session?: ProfileWorkerSession | null canonical_session?: ProfileCanonicalSession | null @@ -1808,6 +1809,20 @@ export interface ProfilesRememberOnboardingResult { profile?: string target?: string } +/** Client→server method params / server→client request params. Unknown keys are rejected. */ +export type Params = Record +/** ``created`` is false when an existing setup profile was found (and returned untouched). */ +export interface OnboardingEnsureSetupProfileResult { + name: string + path: string + created: boolean + role?: 'setup' +} +export interface OnboardingResetSetupProfileResult { + name: string + path: string + reset?: boolean +} export interface VaultListResult { items?: VaultItem[] } @@ -4622,6 +4637,10 @@ export interface RpcMethods { 'model.options': { params: ModelOptionsParams; result: ModelOptionsResult } /** Save an API key for a provider and return its refreshed inventory row. */ 'model.save_key': { params: ModelSaveKeyParams; result: ModelSaveKeyResult } + /** Create-or-read the backend-owned setup profile; the backend picks the name and finds it by role. */ + 'onboarding.ensure_setup_profile': { params: Params; result: OnboardingEnsureSetupProfileResult } + /** Restore the setup profile to its created state in place (soul, memories, skills, sessions). */ + 'onboarding.reset_setup_profile': { params: Params; result: OnboardingResetSetupProfileResult } /** Spill a large paste to a file and hand back the inline placeholder. */ 'paste.collapse': { params: PasteCollapseParams; result: PasteCollapseResult } /** Render a PDF's pages to PNG and queue them as images for the next turn. */ @@ -4978,6 +4997,8 @@ export const RPC_METHODS = [ 'model.disconnect', 'model.options', 'model.save_key', + 'onboarding.ensure_setup_profile', + 'onboarding.reset_setup_profile', 'paste.collapse', 'pdf.attach', 'pet.cancel', diff --git a/apps/shared/src/gateway-contract.openrpc.json b/apps/shared/src/gateway-contract.openrpc.json index 98e32fa789..e1430078c7 100644 --- a/apps/shared/src/gateway-contract.openrpc.json +++ b/apps/shared/src/gateway-contract.openrpc.json @@ -1752,6 +1752,42 @@ } } }, + { + "name": "onboarding.ensure_setup_profile", + "summary": "Create-or-read the backend-owned setup profile; the backend picks the name and finds it by role.", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/Params" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/OnboardingEnsureSetupProfileResult" + } + } + }, + { + "name": "onboarding.reset_setup_profile", + "summary": "Restore the setup profile to its created state in place (soul, memories, skills, sessions).", + "params": [ + { + "name": "params", + "schema": { + "$ref": "#/components/schemas/Params" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/OnboardingResetSetupProfileResult" + } + } + }, { "name": "paste.collapse", "summary": "Spill a large paste to a file and hand back the inline placeholder.", @@ -17347,6 +17383,61 @@ "title": "OnboardingAnswers", "type": "object" }, + "OnboardingEnsureSetupProfileResult": { + "additionalProperties": false, + "description": "``created`` is false when an existing setup profile was found (and returned untouched).", + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "path": { + "title": "Path", + "type": "string" + }, + "created": { + "title": "Created", + "type": "boolean" + }, + "role": { + "const": "setup", + "default": "setup", + "title": "Role", + "type": "string" + } + }, + "required": [ + "name", + "path", + "created" + ], + "title": "OnboardingEnsureSetupProfileResult", + "type": "object" + }, + "OnboardingResetSetupProfileResult": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "path": { + "title": "Path", + "type": "string" + }, + "reset": { + "default": true, + "title": "Reset", + "type": "boolean" + } + }, + "required": [ + "name", + "path" + ], + "title": "OnboardingResetSetupProfileResult", + "type": "object" + }, "OpenRequestEntry": { "additionalProperties": false, "description": "One unanswered server\u2192client request (``server_requests.Request.snapshot``); the reconnecting\nclient re-delivers it to its request handlers.", @@ -17406,6 +17497,13 @@ "title": "PaneRevealPayload", "type": "object" }, + "Params": { + "additionalProperties": false, + "description": "Client\u2192server method params / server\u2192client request params. Unknown keys are rejected.", + "properties": {}, + "title": "Params", + "type": "object" + }, "PasteCollapseParams": { "additionalProperties": false, "properties": { @@ -20947,6 +21045,19 @@ "title": "Previous Names", "type": "array" }, + "role": { + "anyOf": [ + { + "const": "setup", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Role" + }, "last_session": { "anyOf": [ { diff --git a/hermes_cli/profile_distribution.py b/hermes_cli/profile_distribution.py index 9e458af6c0..3cc35f05b4 100644 --- a/hermes_cli/profile_distribution.py +++ b/hermes_cli/profile_distribution.py @@ -478,6 +478,10 @@ def _copy_dist_payload(staged: Path, target: Path, manifest: DistributionManifes # Make sure the manifest on disk reflects resolved name + source write_manifest(target, manifest) + # A shipped profile.yaml must not carry a backend-assigned role. + if any(rel_parts == ("profile.yaml",) for _, rel_parts in entries): + from hermes_cli.profiles import drop_profile_role + drop_profile_role(target) def _bootstrap_user_dirs(target: Path) -> None: diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index af3f55e4b4..39458d3c0b 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -71,6 +71,11 @@ _CLONE_ALL_HISTORY_EXCLUDE_ROOT: frozenset[str] = frozenset({ # dashboard) skip bundled-skill seeding. Delete the file to opt back in. NO_BUNDLED_SKILLS_MARKER = ".no-bundled-skills" +# ``profile.yaml`` ``role`` values. A role grants backend capabilities (the setup toolset), so +# only the backend writes one, and a copy of a profile (clone-all, import) never inherits it. +SETUP_ROLE = "setup" +PROFILE_ROLES = frozenset({SETUP_ROLE}) + # Header seeded into a profile's empty .env so it owns a credentials file from day one. _PLACEHOLDER_ENV = ( "# Per-profile secrets for this Hermes profile.\n" @@ -552,6 +557,8 @@ class ProfileInfo: # appends here). Lets Bot Mode group chats re-link persisted member # descriptors to the renamed live profile (#110200). previous_names: List[str] = field(default_factory=list) + # Backend-assigned role (``SETUP_ROLE`` or None). Only ``hermes_cli.setup_profile`` writes it. + role: Optional[str] = None def _load_yaml_dict(path: Path) -> Optional[dict]: @@ -797,6 +804,7 @@ def read_profile_meta(profile_dir: Path) -> dict: "display_name": str(data.get("display_name") or "").strip(), "bot_title": bot_title, "previous_names": _clean_previous_names(data.get("previous_names")), + "role": data.get("role") if data.get("role") in PROFILE_ROLES else None, } # A copy per caller (list included): the cached value is shared, and a caller that mutates @@ -824,13 +832,19 @@ def _clean_previous_names(raw) -> List[str]: def write_profile_meta( profile_dir: Path, *, description: Optional[str] = None, description_auto: Optional[bool] = None, display_name: Optional[str] = None, previous_names: Optional[List[str]] = None, + role: Optional[str] = None, ) -> None: """Update ``profile.yaml`` in place: only passed fields are overwritten; the file is - created if missing. The profile directory itself must exist.""" + created if missing. The profile directory itself must exist. ``role`` grants backend + capabilities, so no client-facing writer passes it through.""" if not profile_dir.is_dir(): raise FileNotFoundError(f"profile directory does not exist: {profile_dir}") + if role is not None and role not in PROFILE_ROLES: + raise ValueError(f"unknown profile role: {role!r}") path = profile_dir / "profile.yaml" existing: dict = _load_yaml_dict(path) or {} + if role is not None: + existing["role"] = role if description is not None: existing["description"] = description.strip() if description_auto is not None: @@ -856,6 +870,17 @@ def write_profile_meta( atomic_yaml_write(path, existing, sort_keys=False) +def drop_profile_role(profile_dir: Path) -> None: + """Remove ``role`` from a copied ``profile.yaml``: a copy is an ordinary profile.""" + path = profile_dir / "profile.yaml" + existing = _load_yaml_dict(path) + if not existing or "role" not in existing: + return + existing.pop("role") + from utils import atomic_yaml_write + atomic_yaml_write(path, existing, sort_keys=False) + + def format_profile_label(name: str, display_name: Optional[str]) -> str: """``display_name (canonical_id)``, or the bare id when no display name is set (or it equals the id) — byte-for-byte the pre-feature rendering.""" @@ -1038,9 +1063,10 @@ def _copytree_keep_junctions(src: Path, dst: Path, ignore, dirs_exist_ok: bool = def _clone_all_into(source_dir: Path, profile_dir: Path, canon: str) -> None: - """--clone-all: full copytree minus infrastructure/history, then strip runtime files - and cloned single-use OAuth grants.""" + """--clone-all: full copytree minus infrastructure/history, then strip runtime files, + the backend-assigned role, and cloned single-use OAuth grants.""" _copytree_keep_junctions(source_dir, profile_dir, _clone_all_copytree_ignore(source_dir)) + drop_profile_role(profile_dir) materialized = _materialize_symlinked_files(profile_dir) if materialized: logger.info("profile %s: materialized symlinked %s so the clone never writes through to %s", @@ -1982,6 +2008,7 @@ def import_profile(archive_path: str, name: Optional[str] = None) -> Path: if archive_root != canon: final_source = staging_root / canon extracted.rename(final_source) + drop_profile_role(final_source) shutil.move(str(final_source), str(profile_dir)) return profile_dir diff --git a/hermes_cli/setup_profile.py b/hermes_cli/setup_profile.py new file mode 100644 index 0000000000..6785bbb269 --- /dev/null +++ b/hermes_cli/setup_profile.py @@ -0,0 +1,114 @@ +"""The setup profile: where guided onboarding runs and the guide keeps checking in afterwards. + +The backend owns it. One per home, found by ``role: setup`` in ``profile.yaml`` (the name is an +implementation detail). ``ensure_setup_profile`` creates it once and afterwards returns it as-is; +``reset_setup_profile`` restores the created state in place, keeping the directory, name and role. +""" + +from __future__ import annotations + +import logging +import shutil +from pathlib import Path +from typing import NamedTuple, Optional + +from hermes_cli import profiles as profiles_mod + +logger = logging.getLogger(__name__) + +SETUP_PROFILE_NAME = "hermes-setup" +SETUP_PROFILE_DESCRIPTION = "Where Hermes met you — walks your first run, then checks in as you find your feet." + +SETUP_SOUL = "\n".join([ + "# Hermes", + "", + "You are Hermes, and this profile is where you met this user for the first time and stay reachable afterwards. " + "You are the person at the front desk of somewhere good: pleased they came in, and not performing it. Quick, " + "unhurried, never flustered, never in the way. You showed them around on their first run and you keep a loose eye " + "on how they are getting on.", + "", + '- Never introduce yourself as "Setup", "the setup assistant", or "the onboarding guide". You are Hermes.', + "- Warmth is in paying attention, not in adjectives. Remember what they told you and use it. Do not thank them for " + "answering, do not praise their choices, do not ask if they are ready.", + '- Offer an opinion lightly when you have one. "Most people wire that one up first" is worth more than a neutral ' + "menu.", + "- You are training wheels: useful early, ignorable later. Never guilt-trip, never nag. If the user asks you to " + "stop checking in, stop.", + "- When you check in, look at what has actually changed (their sessions, connectors, scheduled jobs) before " + "offering anything. One concrete suggestion beats a menu.", + "- Things worth offering, roughly in order: wiring a connector they said they use, scheduling something they do " + "repeatedly, a second build based on the first, keyboard/layout niceties.", + "- Write like a person talking to another person. Short sentences, plain words, no headers, no bullet walls, no " + "emoji.", +]) + + +class SetupProfile(NamedTuple): + name: str + path: Path + created: bool + + +def find_setup_profile() -> Optional[tuple[str, Path]]: + """``(name, path)`` of the profile carrying ``role: setup``, first by name; None when absent.""" + found = [(p.name, Path(p.path)) for p in profiles_mod.list_profiles(lazy_skill_count=True) + if p.role == profiles_mod.SETUP_ROLE] + if len(found) > 1: + logger.warning("several profiles carry role: setup (%s); using %s", + ", ".join(name for name, _ in found), found[0][0]) + return found[0] if found else None + + +def ensure_setup_profile() -> SetupProfile: + """Create-or-read. A found profile is returned untouched (soul, memories, skills, config). + + A ``hermes-setup`` profile from before the role existed is adopted: it gets the role and + nothing else, so existing installs keep their guide chat.""" + found = find_setup_profile() + if found is not None: + return SetupProfile(found[0], found[1], created=False) + if profiles_mod.profile_exists(SETUP_PROFILE_NAME): + path = profiles_mod.get_profile_dir(SETUP_PROFILE_NAME) + profiles_mod.write_profile_meta(path, role=profiles_mod.SETUP_ROLE) + return SetupProfile(SETUP_PROFILE_NAME, path, created=False) + path = profiles_mod.create_profile(SETUP_PROFILE_NAME, clone_from="default", clone_config=True, no_alias=True, + description=SETUP_PROFILE_DESCRIPTION) + _write_soul(path) + profiles_mod.write_profile_meta(path, role=profiles_mod.SETUP_ROLE) + return SetupProfile(SETUP_PROFILE_NAME, path, created=True) + + +def reset_setup_profile() -> SetupProfile: + """Restore the created state in place: soul from the template, memories and skills re-copied + from ``default`` as create copies them. Session history is cleared by the caller, which owns + the live sessions and the session store. Raises LookupError when no setup profile exists.""" + found = find_setup_profile() + if found is None: + raise LookupError("no setup profile to reset") + name, path = found + source = profiles_mod.get_profile_dir("default") + _write_soul(path) + _replace_dir(path / "memories") + for relpath in profiles_mod._CLONE_SUBDIR_FILES: + profiles_mod._clone_file(source, path, relpath) + _replace_dir(path / "skills") + if (source / "skills").is_dir(): + profiles_mod._copytree_keep_junctions(source / "skills", path / "skills", + profiles_mod._non_exportable_entries, dirs_exist_ok=True) + return SetupProfile(name, path, created=False) + + +def _write_soul(path: Path) -> None: + # Bytes, so Windows text mode cannot turn the template's \n into \r\n. + from utils import atomic_write_bytes + atomic_write_bytes(path / "SOUL.md", SETUP_SOUL.encode("utf-8")) + + +def _replace_dir(directory: Path) -> None: + """Empty *directory*. A link (symlink or NTFS junction) is removed, never followed: its + target belongs to another profile or an external skills root.""" + if directory.is_symlink() or profiles_mod._junction_target(str(directory)) is not None: + directory.unlink() if directory.is_symlink() else directory.rmdir() + elif directory.exists(): + shutil.rmtree(directory) + directory.mkdir(parents=True) diff --git a/hermes_cli/web_routers/profiles.py b/hermes_cli/web_routers/profiles.py index 6cb69dce16..70cdc3c182 100644 --- a/hermes_cli/web_routers/profiles.py +++ b/hermes_cli/web_routers/profiles.py @@ -92,7 +92,7 @@ def _profile_to_dict(info) -> Dict[str, Any]: "distribution_name": attr("distribution_name", None), "distribution_version": attr("distribution_version", None), "distribution_source": attr("distribution_source", None), - "has_alias": attr("alias_path", None) is not None} + "has_alias": attr("alias_path", None) is not None, "role": attr("role", None)} def _profile_setup_command(name: str) -> str: diff --git a/tui_gateway/contracts/profiles_vault_complete_foreign_subagents.py b/tui_gateway/contracts/profiles_vault_complete_foreign_subagents.py index b00e3fb9e7..2f91188b5b 100644 --- a/tui_gateway/contracts/profiles_vault_complete_foreign_subagents.py +++ b/tui_gateway/contracts/profiles_vault_complete_foreign_subagents.py @@ -156,6 +156,7 @@ class ProfileRow(Result): display_name: str = "" skill_count: int = 0 previous_names: list[str] = Field(default_factory=list) + role: Literal["setup"] | None = None last_session: ProfileSessionPreview | None = None worker_session: ProfileWorkerSession | None = None canonical_session: ProfileCanonicalSession | None = None @@ -375,6 +376,32 @@ method("profiles.remember_onboarding", params=ProfilesRememberOnboardingParams, doc="Write the onboarding facts into the default profile's user memory and confirm they landed.") +# ── onboarding (methods_onboarding) ─────────────────────────────────────────────────────────── + + +class OnboardingEnsureSetupProfileResult(Result): + """``created`` is false when an existing setup profile was found (and returned untouched).""" + + name: str + path: str + created: bool + role: Literal["setup"] = "setup" + + +method("onboarding.ensure_setup_profile", params=Params, result=OnboardingEnsureSetupProfileResult, + doc="Create-or-read the backend-owned setup profile; the backend picks the name and finds it by role.") + + +class OnboardingResetSetupProfileResult(Result): + name: str + path: str + reset: bool = True + + +method("onboarding.reset_setup_profile", params=Params, result=OnboardingResetSetupProfileResult, + doc="Restore the setup profile to its created state in place (soul, memories, skills, sessions).") + + # ── vault (methods_vault) ───────────────────────────────────────────────────────────────────── diff --git a/tui_gateway/methods_config.py b/tui_gateway/methods_config.py index 54f7292c7c..63489d8cad 100644 --- a/tui_gateway/methods_config.py +++ b/tui_gateway/methods_config.py @@ -288,7 +288,13 @@ def _(rid, params: dict) -> dict: def probe(profile, scoped): record = None if profile else wait_for_record() if record is None: + # ``ready`` = this process's boot bootstrap has settled (a named profile has no + # record of its own; the launch record says whether the free tier is minted). + # Since one host backend serves every profile (#118246), the desktop's + # setup-profile probe lands here, and its kickoff requires ``ready``. + launch = wait_for_record() if profile else None return {"provider_configured": bool(_has_any_provider_configured(strict_profile_scope=bool(profile))), + **({"ready": True, "free_tier": launch.free_tier} if launch is not None else {}), **scoped} # ``failure_fields`` rides along only when the free-tier mint did not happen: the code, # the sentence, and whether / when a retry can succeed (``free_tier.provision``). diff --git a/tui_gateway/methods_onboarding.py b/tui_gateway/methods_onboarding.py new file mode 100644 index 0000000000..8ecfb4ee91 --- /dev/null +++ b/tui_gateway/methods_onboarding.py @@ -0,0 +1,59 @@ +"""Onboarding JSON-RPC handlers: the backend owns the setup profile (``hermes_cli.setup_profile``). +Bodies are rebound onto server.py's globals (method_ctx.bind_module) and reference them bare. +""" + +from .method_ctx import HandlerRegistry, bind_module + +_registry = HandlerRegistry() +method = _registry.method + + +@method("onboarding.ensure_setup_profile") +def _(rid, params: dict) -> dict: + """Create-or-read the setup profile. Takes no name: the backend picks it and finds it by role.""" + from hermes_cli.setup_profile import ensure_setup_profile + try: + setup = ensure_setup_profile() + if setup.created: + # Same credential mirroring profiles.create gives the desktop's clones; auth.json stays + # shared with the root so a token refresh never forks. + _mirror_launch_credentials(setup.path, {"share_auth": True}) + except Exception as e: + return _err(rid, 5073, str(e)) + return _ok(rid, {"name": setup.name, "path": str(setup.path), "created": setup.created, "role": "setup"}) + + +@method("onboarding.reset_setup_profile") +def _(rid, params: dict) -> dict: + """Restore the setup profile to its created state in place; clears its session history.""" + from hermes_cli.setup_profile import find_setup_profile, reset_setup_profile + found = find_setup_profile() + if found is None: + return _err(rid, 4072, "no setup profile to reset") + _clear_setup_sessions(found[1]) + try: + setup = reset_setup_profile() + except Exception as e: + return _err(rid, 5074, str(e)) + return _ok(rid, {"name": setup.name, "path": str(setup.path), "reset": True}) + + +def _clear_setup_sessions(profile_dir) -> None: + """Close this process's live sessions in the setup profile, then delete its stored sessions.""" + target = Path(profile_dir).resolve() + with _sessions_lock: + live = [sid for sid, sess in _sessions.items() + if Path(sess.get("profile_home") or _hermes_home).resolve() == target] + for sid in live: + _close_session_by_id(sid, end_reason="setup_reset") + from hermes_state_registry import acquire, release_or_close + db = acquire(target / "state.db") + try: + ids = [row[0] for row in db._read_all("SELECT id FROM sessions")] + db.delete_sessions(ids, sessions_dir=target / "sessions") + finally: + release_or_close(db) + + +def register(server) -> None: + bind_module(globals(), server, skip=("_",)) diff --git a/tui_gateway/methods_profiles.py b/tui_gateway/methods_profiles.py index 3c0a140a35..a4acf5e2c7 100644 --- a/tui_gateway/methods_profiles.py +++ b/tui_gateway/methods_profiles.py @@ -275,7 +275,7 @@ def _(rid, params: dict) -> dict: row = {"name": p.name, "path": str(p.path), "is_default": bool(p.is_default), "model": p.model, "provider": p.provider, "description": p.description or "", "display_name": p.display_name or "", "skill_count": p.skill_count or 0, - "previous_names": list(p.previous_names or [])} + "previous_names": list(p.previous_names or []), "role": p.role} if include_sessions: _profile_session_fields(row, p.path) _profile_ui_meta_fields(row, Path(str(p.path))) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index aa4e993ca0..ac907b6f1a 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -3329,7 +3329,8 @@ from . import ( # noqa: E402 methods_projects as _methods_projects, methods_session_foreign as _methods_session_foreign, methods_session_control as _methods_session_control, methods_subagents as _methods_subagents, methods_vault as _methods_vault, methods_free_tier as _methods_free_tier, - methods_connectors as _methods_connectors, methods_connectors_account as _methods_connectors_account) + methods_connectors as _methods_connectors, methods_connectors_account as _methods_connectors_account, + methods_onboarding as _methods_onboarding) for _m in ( _session_transports, _session_reaper, _session_lifecycle, _session_workdir, _compute_host_bridge, _model_switch, @@ -3340,6 +3341,6 @@ for _m in ( _methods_config_set, _methods_complete, _methods_tools, _methods_profiles, _methods_images, _methods_bot_relay, _prompt_turn, _billing_view, _methods_projects, _methods_session_foreign, _methods_session_control, _methods_subagents, _methods_vault, _methods_free_tier, _methods_connectors, - _methods_connectors_account): + _methods_connectors_account, _methods_onboarding): _m.register(sys.modules[__name__]) del _m