diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 607dc213..6c60ea63 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -1646,6 +1646,9 @@ export function setupIpcHandlers() { const preview = appInstallPreviews.get(args.name) ?? await appsInstaller.previewInstall(record); const result = await appsInstaller.installFromRegistry(record, preview); appInstallPreviews.delete(args.name); + // Materialize bundled agents NOW, not on the next apps:list poll — the + // renderer's post-install enable dialog patches these tasks immediately. + if (result.app) await appsAgents.syncAppAgents(result.app); capture('app_installed', { name: args.name }); return result; }, @@ -1654,6 +1657,7 @@ export function setupIpcHandlers() { return appsInstaller.previewUrlInstall(args.url); } const result = await appsInstaller.confirmUrlInstall(args.url); + if (result.app) await appsAgents.syncAppAgents(result.app); capture('app_installed', { name: result.app.manifest?.name ?? result.app.folder }); return result; }, diff --git a/apps/x/apps/renderer/src/components/apps/app-detail.tsx b/apps/x/apps/renderer/src/components/apps/app-detail.tsx index 39d294f0..6e8aa554 100644 --- a/apps/x/apps/renderer/src/components/apps/app-detail.tsx +++ b/apps/x/apps/renderer/src/components/apps/app-detail.tsx @@ -272,6 +272,7 @@ export function AppDetail({ folder, onClose }: { folder: string; onClose: () => setShowPublish(false)} onPublished={() => setReloadNonce((n) => n + 1)} /> diff --git a/apps/x/apps/renderer/src/components/apps/apps-view.tsx b/apps/x/apps/renderer/src/components/apps/apps-view.tsx index 44db99fa..9fdb89de 100644 --- a/apps/x/apps/renderer/src/components/apps/apps-view.tsx +++ b/apps/x/apps/renderer/src/components/apps/apps-view.tsx @@ -186,6 +186,11 @@ export function AppsView({ initialAppFolder, initialVersion, onNewApp }: { const r = await window.ipc.invoke('apps:list', {}) if (cancelled) return setApps(r.apps) + // Drop a selection whose app no longer exists (uninstalled while + // open). Left stale, a later reinstall makes this view yank the user + // into the app frame mid-flow — e.g. while they're in the catalog's + // post-install agent dialog. + setSelectedFolder((cur) => (cur && !r.apps.some((a) => a.folder === cur) ? null : cur)) setServerError(r.serverRunning ? null : (r.serverError ?? 'Apps server is not running.')) } catch (e) { if (!cancelled) setServerError(e instanceof Error ? e.message : String(e)) diff --git a/apps/x/apps/renderer/src/components/apps/catalog.tsx b/apps/x/apps/renderer/src/components/apps/catalog.tsx index 3820c6ab..99bdda2c 100644 --- a/apps/x/apps/renderer/src/components/apps/catalog.tsx +++ b/apps/x/apps/renderer/src/components/apps/catalog.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { Download, Link2, RefreshCw, Search, ShieldAlert } from 'lucide-react' +import { BadgeCheck, Bot, Download, Link2, RefreshCw, Search, ShieldAlert } from 'lucide-react' import type { rowboatApp } from '@x/shared' // Catalog tab (spec §14): search the registry, install with the D18 capability @@ -74,6 +74,81 @@ function InstallConfirmDialog({ preview, busy, onConfirm, onCancel }: { ) } +type ModelChoice = { provider: string; model: string } + +/** Post-install opt-in (§8.3): bundled agents land disabled; without this + * prompt a fresh installer opens an empty app with no hint that the refresher + * exists, buried in bg-tasks. The model picker defaults to the host-pinned + * model but lets the user override before the first run. */ +function EnableAgentsDialog({ appName, names, defaultModel, busy, onEnable, onSkip }: { + appName: string + names: string[] + defaultModel?: ModelChoice + busy: boolean + onEnable: (model: ModelChoice | null) => void + onSkip: () => void +}) { + const [options, setOptions] = useState>([]) + const [selected, setSelected] = useState(defaultModel ? `${defaultModel.provider}::${defaultModel.model}` : '') + + useEffect(() => { + void (async () => { + try { + const r = await window.ipc.invoke('models:list', null) + setOptions(r.providers.flatMap((p) => p.models.map((m) => ({ + provider: p.id, + model: m.id, + label: `${m.name ?? m.id} (${p.name})`, + })))) + } catch { /* no picker — enable keeps the pinned model */ } + })() + }, []) + + const choice = (): ModelChoice | null => { + const [provider, model] = selected.split('::') + return provider && model ? { provider, model } : null + } + + return ( +
+
+
+ Turn on {names.length === 1 ? 'its background agent' : 'its background agents'}? +
+

+ {appName} ships {names.length === 1 ? 'an agent' : 'agents'} that keep{names.length === 1 ? 's' : ''} its + data fresh on a schedule, using your connected accounts and AI models. {names.length === 1 ? 'It is' : 'They are'} currently off. +

+
    + {names.map((n) =>
  • {n}
  • )} +
+ {options.length > 0 && ( + + )} +
+ + +
+
+
+ ) +} + export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => void }) { const [records, setRecords] = useState([]) const [stale, setStale] = useState(false) @@ -83,6 +158,20 @@ export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => v const [busy, setBusy] = useState(false) const [urlDialog, setUrlDialog] = useState(false) const [url, setUrl] = useState('') + const [agentPrompt, setAgentPrompt] = useState<{ folder: string; appName: string; slugs: string[]; names: string[]; defaultModel?: ModelChoice } | null>(null) + const [enabling, setEnabling] = useState(false) + // Registry name → local folder, for apps already installed from the catalog. + const [installedByName, setInstalledByName] = useState>(new Map()) + + const loadInstalled = async () => { + try { + const r = await window.ipc.invoke('apps:list', {}) + setInstalledByName(new Map( + r.apps.filter((a) => a.kind === 'installed' && a.install).map((a) => [a.install!.name, a.folder]), + )) + } catch { /* cards just show Install */ } + } + useEffect(() => { void loadInstalled() }, []) const load = async (force = false) => { setError(null) @@ -127,6 +216,26 @@ export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => v } } + const enableAgents = async (model: ModelChoice | null) => { + if (!agentPrompt) return + setEnabling(true) + try { + for (const slug of agentPrompt.slugs) { + await window.ipc.invoke('bg-task:patch', { + slug, + partial: { active: true, ...(model ? { model: model.model, provider: model.provider } : {}) }, + }) + // First run so the app opens with data; resolves when the run ends, so + // fire-and-forget. + void window.ipc.invoke('bg-task:run', { slug }).catch(() => { /* surfaced in bg-tasks */ }) + } + } catch { /* patch failures land the user in the same place as "Not now" */ } + const folder = agentPrompt.folder + setAgentPrompt(null) + setEnabling(false) + onInstalled(folder) + } + const confirmInstall = async () => { if (!preview) return setBusy(true) @@ -136,7 +245,25 @@ export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => v ? await window.ipc.invoke('apps:installFromUrl', { url: preview.url, confirmed: true }) : await window.ipc.invoke('apps:install', { name: preview.name ?? '', confirmed: true }) setPreview(null) - if (r.status === 'installed' && r.app) onInstalled(r.app.folder) + void loadInstalled() + if (r.status === 'installed' && r.app) { + if (r.app.agentSlugs.length > 0) { + // Offer to switch the bundled agents on (they install disabled). + let defaultModel: ModelChoice | undefined + const names = await Promise.all(r.app.agentSlugs.map(async (slug) => { + try { + const g = await window.ipc.invoke('bg-task:get', { slug }) + if (g.task?.model && g.task.provider) defaultModel = { model: g.task.model, provider: g.task.provider } + return g.task?.name ?? slug + } catch { + return slug + } + })) + setAgentPrompt({ folder: r.app.folder, appName: r.app.manifest?.name ?? r.app.folder, slugs: r.app.agentSlugs, names, defaultModel }) + } else { + onInstalled(r.app.folder) + } + } } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { @@ -186,10 +313,18 @@ export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => v

{r.description || 'No description.'}

- + {installedByName.has(r.name) ? ( + + ) : ( + + )} ))} @@ -226,6 +361,21 @@ export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => v onCancel={() => setPreview(null)} /> )} + + {agentPrompt && ( + void enableAgents(model)} + onSkip={() => { + const folder = agentPrompt.folder + setAgentPrompt(null) + onInstalled(folder) + }} + /> + )} ) } diff --git a/apps/x/apps/renderer/src/components/apps/publish-dialog.tsx b/apps/x/apps/renderer/src/components/apps/publish-dialog.tsx index f14dd7e0..ed798a8e 100644 --- a/apps/x/apps/renderer/src/components/apps/publish-dialog.tsx +++ b/apps/x/apps/renderer/src/components/apps/publish-dialog.tsx @@ -3,13 +3,18 @@ import { CheckCircle2, Github, Loader2, XCircle } from 'lucide-react' // Publish dialog (spec §14): device-flow sign-in → confirmation → step // progress (§11.2 step names) → success links / typed error (name_taken gets -// an inline hint to rename and retry). +// an inline hint to rename and retry). With `published` the dialog runs the +// UPDATE path instead (§11.3: version bump + new release, no registry) — +// re-running first-publish on a published app always fails with name_taken. type Phase = 'auth' | 'device' | 'confirm' | 'publishing' | 'done' | 'error' +type Increment = 'patch' | 'minor' | 'major' -export function PublishDialog({ folder, appName, onClose, onPublished }: { +export function PublishDialog({ folder, appName, published, onClose, onPublished }: { folder: string appName: string + /** App already has a publish record — run publish-update instead. */ + published?: boolean onClose: () => void onPublished: () => void }) { @@ -18,7 +23,8 @@ export function PublishDialog({ folder, appName, onClose, onPublished }: { const [userCode, setUserCode] = useState('') const [steps, setSteps] = useState([]) const [error, setError] = useState(null) - const [result, setResult] = useState<{ repoUrl: string; releaseUrl: string; prUrl?: string; status: string } | null>(null) + const [increment, setIncrement] = useState('patch') + const [result, setResult] = useState<{ repoUrl?: string; releaseUrl: string; prUrl?: string; status: string; version?: string } | null>(null) const pollTimer = useRef(null) useEffect(() => { @@ -75,8 +81,13 @@ export function PublishDialog({ folder, appName, onClose, onPublished }: { setError(null) setSteps([]) try { - const r = await window.ipc.invoke('apps:publish', { folder }) - setResult(r) + if (published) { + const r = await window.ipc.invoke('apps:publishUpdate', { folder, increment }) + setResult({ releaseUrl: r.releaseUrl, status: 'published', version: r.version }) + } else { + const r = await window.ipc.invoke('apps:publish', { folder }) + setResult(r) + } setPhase('done') onPublished() } catch (e) { @@ -128,15 +139,47 @@ export function PublishDialog({ folder, appName, onClose, onPublished }: { {phase === 'confirm' && (
-

Signed in as @{login}. This will publish {appName} publicly.

+

+ Signed in as @{login}.{' '} + {published + ? <>This will publish a new version of {appName} (a new release on the existing repo). + : <>This will publish {appName} publicly.} +

+ {published && ( + + )}
)} - {(phase === 'publishing' || phase === 'done' || phase === 'error') && ( + {published && (phase === 'publishing' || phase === 'done' || phase === 'error') && ( +
+ {phase === 'publishing' && ( +

+ Publishing update… +

+ )} + {phase === 'done' && result && ( +
+

Published v{result.version}

+ +
+ )} +
+ )} + + {!published && (phase === 'publishing' || phase === 'done' || phase === 'error') && (
{Object.entries(STEP_LABELS).map(([key, label]) => { const doneStep = steps.includes(key) || phase === 'done' diff --git a/apps/x/packages/core/src/application/assistant/skills/apps/skill.ts b/apps/x/packages/core/src/application/assistant/skills/apps/skill.ts index 593bf88f..11b75583 100644 --- a/apps/x/packages/core/src/application/assistant/skills/apps/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/apps/skill.ts @@ -143,9 +143,16 @@ shell**; never generate a refresh script) and store it via the **\`app-set-data\`** builtin: \`{ appFolder, file: "data.json", data: }\` — pass the object directly, never \`JSON.stringify\` it. The write is atomic and contract-checked; on a failed fetch keep the last good data (never overwrite -good series with empties). If the app should ship the agent, mirror the -definition into \`agents/.yaml\` with ONLY \`name\`, \`instructions\`, -\`triggers\`, and list the filename in \`manifest.agents\`. +good series with empties). + +**ALWAYS bundle the agent into the app as well** (required, not optional): +mirror the definition into \`agents/.yaml\` with ONLY \`name\`, +\`instructions\`, \`triggers\` (no \`active\`, no \`model\` — the schema rejects +them) and list the filename in \`manifest.agents\`. The app package ships only +what's inside the app folder: without this mirror, a published copy of the app +is dead on arrival — installers get a UI whose data never refreshes. The +bundled copy materializes as a disabled bg-task for installers (they opt in); +the \`create-background-task\` task you made stays the author's live agent. ## 6. Prohibitions diff --git a/apps/x/packages/core/src/apps/agents.ts b/apps/x/packages/core/src/apps/agents.ts index de27918f..ff20b8ee 100644 --- a/apps/x/packages/core/src/apps/agents.ts +++ b/apps/x/packages/core/src/apps/agents.ts @@ -5,6 +5,7 @@ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import { TriggersSchema, type BackgroundTask } from '@x/shared/dist/background-task.js'; import type { AppSummary } from '@x/shared/dist/rowboat-app.js'; import { WorkDir } from '../config/config.js'; +import { getDefaultModelAndProvider } from '../models/defaults.js'; import { appDir, agentTaskSlug } from './indexer.js'; // Bundled background agents (spec §8). Definitions ship in the app package at @@ -94,6 +95,17 @@ async function materializeAgent(folder: string, agentFile: string): Promise undefined);