import { atom, map } from 'nanostores' import { getActionStatus, installSkillFromHub, type ProfileScope, uninstallSkillFromHub, updateSkillsFromHub } from '@/hermes' import { queryClient } from '@/lib/query-client' import { invalidateSlashCompletions } from '@/lib/slash-completion-cache' import { upsertDesktopActionTask } from '@/store/activity' import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile' const POLL_MS = 1200 // Shared with hub.tsx's sources useQuery so a finished action refreshes the // installed map. export const HUB_SOURCES_KEY = ['skill-hub-sources'] as const // The Capabilities Skills-list query key (see app/skills/index.tsx) — kept in // sync here so a hub (un)install updates the Skills tab, not just the hub. const SKILLS_LIST_KEY = ['skills-list'] as const // The built-in optional-skills catalog rows in the Skills tab: an install // flips one of them to an installed (toggle) row, so the catalog's // installed-flags must refetch alongside the skills list. export const OFFICIAL_SKILLS_KEY = ['official-skills'] as const // Non-identifier key for the fleet-wide "Update installed" action. export const UPDATE_ALL_KEY = '__update_all__' export type HubActionKind = 'install' | 'uninstall' | 'update' export interface HubAction { kind: HubActionKind running: boolean lines: string[] } // Per-item action status, keyed by skill identifier (or UPDATE_ALL_KEY). Each // row drives its own button off ITS entry — one install never touches another. export const $hubActions = map>({}) // Optimistic installed overrides so a row flips to its resolved state the instant // its own action finishes, instead of waiting on (and racing) the sources // refetch. install/update → true, uninstall → false; sources reconciles after. export const $hubInstalledOverride = map>({}) // The key whose log the bottom pane currently tails (the latest-started action). export const $hubActiveLog = atom(null) // Hub action state is per-profile: a profile switch must drop every in-flight // entry, optimistic override, and active log so profile A's install/uninstall // state can never render (or be polled) in profile B. Cleared at the source so // it holds regardless of whether the Hub view is mounted. The epoch bumps on // every switch; a runHubAction() started before the switch captures it and bails // before any store write once it no longer matches (so an A action finishing // after the clear can't repopulate B). let _hubProfile: null | string = null let _hubEpoch = 0 $activeGatewayProfile.subscribe(value => { const key = normalizeProfileKey(value) if (_hubProfile !== null && _hubProfile !== key) { _hubEpoch += 1 $hubActions.set({}) $hubInstalledOverride.set({}) $hubActiveLog.set(null) } _hubProfile = key }) // One self-contained task: spawn → tail its own action log into the store → // mark resolved. Concurrency-safe: state is per-key, so parallel installs never // stomp each other, and the sources query is invalidated once at the end. // `profile` is the Capabilities profile-scope override — the action (and its // status polling) runs against THAT profile's backend; undefined keeps the // app-wide active profile (unchanged behavior). async function runHubAction( key: string, kind: HubActionKind, spawn: () => Promise<{ name: string }>, profile?: ProfileScope ): Promise { const epoch = _hubEpoch const switched = () => _hubEpoch !== epoch $hubActions.setKey(key, { kind, running: true, lines: [] }) $hubActiveLog.set(key) try { const started = await spawn() let exitCode: number | null = null for (;;) { const status = await getActionStatus(started.name, 200, profile) // Profile switched mid-flight: the store was cleared for the new profile, // so drop this A-profile result instead of writing it back into B. if (switched()) { return } upsertDesktopActionTask(status) $hubActions.setKey(key, { kind, running: status.running, lines: status.lines }) if (!status.running) { exitCode = status.exit_code break } await new Promise(resolve => setTimeout(resolve, POLL_MS)) } // Only flip the row on a clean exit — a failed install/uninstall must not // render as installed/removed. if (key !== UPDATE_ALL_KEY && exitCode === 0) { $hubInstalledOverride.setKey(key, kind !== 'uninstall') } // Refresh the hub's installed map AND the Capabilities Skills list — a hub // (un)install adds/removes a skill, so its count/rows must update too. void queryClient.invalidateQueries({ queryKey: HUB_SOURCES_KEY }) void queryClient.invalidateQueries({ queryKey: SKILLS_LIST_KEY }) void queryClient.invalidateQueries({ queryKey: OFFICIAL_SKILLS_KEY }) // …and the composer's `/` list, which caches the command catalog for an // hour and would otherwise keep offering the skill we just removed. invalidateSlashCompletions() // A non-zero exit is a real failure — throw so the caller's catch toasts // it. Before this, a failed subprocess (scan gate, network, bad // identifier) just stopped silently: no flip, no toast, and the user read // the unchanged skills list as "install did nothing" (Aug 2026 report). // The last log lines carry the subprocess's actual error. if (exitCode !== null && exitCode !== 0) { const detail = ($hubActions.get()[key]?.lines ?? []).slice(-3).join('\n').trim() throw new Error(detail || `Action exited with code ${exitCode}`) } } catch (err) { // A profile switch points the next poll at the new backend, which 404s the // old action name — that's an abandonment, not a failure, so swallow it // instead of letting the caller toast a phantom error. Real (same-profile) // failures still propagate. if (switched()) { return } throw err } finally { // Skip the running=false write after a switch — it would re-add the key the // profile-switch clear just dropped. const current = $hubActions.get()[key] if (current && !switched()) { $hubActions.setKey(key, { ...current, running: false }) } } } export function installHubSkill(identifier: string, profile?: ProfileScope): Promise { return runHubAction(identifier, 'install', () => installSkillFromHub(identifier, profile), profile) } export function uninstallHubSkill(identifier: string, name: string, profile?: ProfileScope): Promise { return runHubAction(identifier, 'uninstall', () => uninstallSkillFromHub(name, profile), profile) } export function updateHubSkills(profile?: ProfileScope): Promise { return runHubAction(UPDATE_ALL_KEY, 'update', () => updateSkillsFromHub(profile), profile) } export function closeHubLog(): void { $hubActiveLog.set(null) }