Merge pull request #681 from rowboatlabs/feature/apps-bundled-agent-skill

Apps: ship bundled agents that actually work for installers
This commit is contained in:
gagan 2026-07-07 15:26:17 +05:30 committed by GitHub
commit 98fe9106cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 239 additions and 17 deletions

View file

@ -1646,6 +1646,9 @@ export function setupIpcHandlers() {
const preview = appInstallPreviews.get(args.name) ?? await appsInstaller.previewInstall(record); const preview = appInstallPreviews.get(args.name) ?? await appsInstaller.previewInstall(record);
const result = await appsInstaller.installFromRegistry(record, preview); const result = await appsInstaller.installFromRegistry(record, preview);
appInstallPreviews.delete(args.name); 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 }); capture('app_installed', { name: args.name });
return result; return result;
}, },
@ -1654,6 +1657,7 @@ export function setupIpcHandlers() {
return appsInstaller.previewUrlInstall(args.url); return appsInstaller.previewUrlInstall(args.url);
} }
const result = await appsInstaller.confirmUrlInstall(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 }); capture('app_installed', { name: result.app.manifest?.name ?? result.app.folder });
return result; return result;
}, },

View file

@ -272,6 +272,7 @@ export function AppDetail({ folder, onClose }: { folder: string; onClose: () =>
<PublishDialog <PublishDialog
folder={folder} folder={folder}
appName={manifest?.name ?? folder} appName={manifest?.name ?? folder}
published={!!app.publish}
onClose={() => setShowPublish(false)} onClose={() => setShowPublish(false)}
onPublished={() => setReloadNonce((n) => n + 1)} onPublished={() => setReloadNonce((n) => n + 1)}
/> />

View file

@ -186,6 +186,11 @@ export function AppsView({ initialAppFolder, initialVersion, onNewApp }: {
const r = await window.ipc.invoke('apps:list', {}) const r = await window.ipc.invoke('apps:list', {})
if (cancelled) return if (cancelled) return
setApps(r.apps) 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.')) setServerError(r.serverRunning ? null : (r.serverError ?? 'Apps server is not running.'))
} catch (e) { } catch (e) {
if (!cancelled) setServerError(e instanceof Error ? e.message : String(e)) if (!cancelled) setServerError(e instanceof Error ? e.message : String(e))

View file

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react' 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' import type { rowboatApp } from '@x/shared'
// Catalog tab (spec §14): search the registry, install with the D18 capability // 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<Array<ModelChoice & { label: string }>>([])
const [selected, setSelected] = useState<string>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-md rounded-xl border border-border bg-background p-5 shadow-xl">
<div className="mb-1 flex items-center gap-2 text-base font-semibold">
<Bot className="size-5" /> Turn on {names.length === 1 ? 'its background agent' : 'its background agents'}?
</div>
<p className="mb-3 text-sm text-muted-foreground">
{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.
</p>
<ul className="mb-3 list-inside list-disc text-sm text-muted-foreground">
{names.map((n) => <li key={n}>{n}</li>)}
</ul>
{options.length > 0 && (
<label className="mb-3 flex items-center gap-2 text-sm text-muted-foreground">
Run with
<select value={selected} onChange={(e) => setSelected(e.target.value)}
className="min-w-0 flex-1 truncate rounded-md border border-border bg-background px-2 py-1 text-sm">
{defaultModel && !options.some((o) => o.provider === defaultModel.provider && o.model === defaultModel.model) && (
<option value={`${defaultModel.provider}::${defaultModel.model}`}>{defaultModel.model} (default)</option>
)}
{options.map((o) => (
<option key={`${o.provider}::${o.model}`} value={`${o.provider}::${o.model}`}>{o.label}</option>
))}
</select>
</label>
)}
<div className="flex justify-end gap-2">
<button type="button" onClick={onSkip} disabled={busy}
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent">Not now</button>
<button type="button" onClick={() => onEnable(choice())} disabled={busy}
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50">
{busy ? 'Turning on…' : 'Turn on & run now'}
</button>
</div>
</div>
</div>
)
}
export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => void }) { export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => void }) {
const [records, setRecords] = useState<rowboatApp.RegistryRecord[]>([]) const [records, setRecords] = useState<rowboatApp.RegistryRecord[]>([])
const [stale, setStale] = useState(false) const [stale, setStale] = useState(false)
@ -83,6 +158,20 @@ export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => v
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [urlDialog, setUrlDialog] = useState(false) const [urlDialog, setUrlDialog] = useState(false)
const [url, setUrl] = useState('') 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<Map<string, string>>(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) => { const load = async (force = false) => {
setError(null) 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 () => { const confirmInstall = async () => {
if (!preview) return if (!preview) return
setBusy(true) 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:installFromUrl', { url: preview.url, confirmed: true })
: await window.ipc.invoke('apps:install', { name: preview.name ?? '', confirmed: true }) : await window.ipc.invoke('apps:install', { name: preview.name ?? '', confirmed: true })
setPreview(null) 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) { } catch (e) {
setError(e instanceof Error ? e.message : String(e)) setError(e instanceof Error ? e.message : String(e))
} finally { } finally {
@ -186,10 +313,18 @@ export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => v
</div> </div>
<p className="truncate text-xs text-muted-foreground">{r.description || 'No description.'}</p> <p className="truncate text-xs text-muted-foreground">{r.description || 'No description.'}</p>
</div> </div>
<button type="button" onClick={() => void startInstall(r.name)} {installedByName.has(r.name) ? (
className="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent"> <button type="button" title="Installed — open it"
<Download className="size-4" /> Install onClick={() => onInstalled(installedByName.get(r.name)!)}
</button> className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium text-green-600 hover:bg-green-500/10 dark:text-green-500">
<BadgeCheck className="size-4" /> Installed
</button>
) : (
<button type="button" onClick={() => void startInstall(r.name)}
className="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent">
<Download className="size-4" /> Install
</button>
)}
</div> </div>
))} ))}
</div> </div>
@ -226,6 +361,21 @@ export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => v
onCancel={() => setPreview(null)} onCancel={() => setPreview(null)}
/> />
)} )}
{agentPrompt && (
<EnableAgentsDialog
appName={agentPrompt.appName}
names={agentPrompt.names}
defaultModel={agentPrompt.defaultModel}
busy={enabling}
onEnable={(model) => void enableAgents(model)}
onSkip={() => {
const folder = agentPrompt.folder
setAgentPrompt(null)
onInstalled(folder)
}}
/>
)}
</div> </div>
) )
} }

View file

@ -3,13 +3,18 @@ import { CheckCircle2, Github, Loader2, XCircle } from 'lucide-react'
// Publish dialog (spec §14): device-flow sign-in → confirmation → step // Publish dialog (spec §14): device-flow sign-in → confirmation → step
// progress (§11.2 step names) → success links / typed error (name_taken gets // 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 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 folder: string
appName: string appName: string
/** App already has a publish record — run publish-update instead. */
published?: boolean
onClose: () => void onClose: () => void
onPublished: () => void onPublished: () => void
}) { }) {
@ -18,7 +23,8 @@ export function PublishDialog({ folder, appName, onClose, onPublished }: {
const [userCode, setUserCode] = useState('') const [userCode, setUserCode] = useState('')
const [steps, setSteps] = useState<string[]>([]) const [steps, setSteps] = useState<string[]>([])
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [result, setResult] = useState<{ repoUrl: string; releaseUrl: string; prUrl?: string; status: string } | null>(null) const [increment, setIncrement] = useState<Increment>('patch')
const [result, setResult] = useState<{ repoUrl?: string; releaseUrl: string; prUrl?: string; status: string; version?: string } | null>(null)
const pollTimer = useRef<number | null>(null) const pollTimer = useRef<number | null>(null)
useEffect(() => { useEffect(() => {
@ -75,8 +81,13 @@ export function PublishDialog({ folder, appName, onClose, onPublished }: {
setError(null) setError(null)
setSteps([]) setSteps([])
try { try {
const r = await window.ipc.invoke('apps:publish', { folder }) if (published) {
setResult(r) 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') setPhase('done')
onPublished() onPublished()
} catch (e) { } catch (e) {
@ -128,15 +139,47 @@ export function PublishDialog({ folder, appName, onClose, onPublished }: {
{phase === 'confirm' && ( {phase === 'confirm' && (
<div className="space-y-3 text-sm"> <div className="space-y-3 text-sm">
<p className="text-muted-foreground">Signed in as <span className="font-medium text-foreground">@{login}</span>. This will publish <span className="font-medium text-foreground">{appName}</span> publicly.</p> <p className="text-muted-foreground">
Signed in as <span className="font-medium text-foreground">@{login}</span>.{' '}
{published
? <>This will publish a new version of <span className="font-medium text-foreground">{appName}</span> (a new release on the existing repo).</>
: <>This will publish <span className="font-medium text-foreground">{appName}</span> publicly.</>}
</p>
{published && (
<label className="flex items-center gap-2 text-muted-foreground">
Version bump
<select value={increment} onChange={(e) => setIncrement(e.target.value as Increment)}
className="rounded-md border border-border bg-background px-2 py-1 text-sm">
<option value="patch">patch</option>
<option value="minor">minor</option>
<option value="major">major</option>
</select>
</label>
)}
<button type="button" onClick={() => void publish()} <button type="button" onClick={() => void publish()}
className="w-full rounded-md bg-primary py-2 text-sm font-medium text-primary-foreground hover:opacity-90"> className="w-full rounded-md bg-primary py-2 text-sm font-medium text-primary-foreground hover:opacity-90">
Publish {published ? 'Publish update' : 'Publish'}
</button> </button>
</div> </div>
)} )}
{(phase === 'publishing' || phase === 'done' || phase === 'error') && ( {published && (phase === 'publishing' || phase === 'done' || phase === 'error') && (
<div className="space-y-3 text-sm">
{phase === 'publishing' && (
<p className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="size-4 animate-spin" /> Publishing update
</p>
)}
{phase === 'done' && result && (
<div className="space-y-1">
<p className="flex items-center gap-2"><CheckCircle2 className="size-4 text-green-600" /> Published v{result.version}</p>
<div className="text-xs">Release: <a className="text-primary underline" href={result.releaseUrl} target="_blank" rel="noreferrer">{result.releaseUrl}</a></div>
</div>
)}
</div>
)}
{!published && (phase === 'publishing' || phase === 'done' || phase === 'error') && (
<div className="space-y-1.5 text-sm"> <div className="space-y-1.5 text-sm">
{Object.entries(STEP_LABELS).map(([key, label]) => { {Object.entries(STEP_LABELS).map(([key, label]) => {
const doneStep = steps.includes(key) || phase === 'done' const doneStep = steps.includes(key) || phase === 'done'

View file

@ -143,9 +143,16 @@ shell**; never generate a refresh script) and store it via the
**\`app-set-data\`** builtin: \`{ appFolder, file: "data.json", data: <object> }\` **\`app-set-data\`** builtin: \`{ appFolder, file: "data.json", data: <object> }\`
pass the object directly, never \`JSON.stringify\` it. The write is atomic and 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 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 good series with empties).
definition into \`agents/<slug>.yaml\` with ONLY \`name\`, \`instructions\`,
\`triggers\`, and list the filename in \`manifest.agents\`. **ALWAYS bundle the agent into the app as well** (required, not optional):
mirror the definition into \`agents/<slug>.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 ## 6. Prohibitions

View file

@ -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 { TriggersSchema, type BackgroundTask } from '@x/shared/dist/background-task.js';
import type { AppSummary } from '@x/shared/dist/rowboat-app.js'; import type { AppSummary } from '@x/shared/dist/rowboat-app.js';
import { WorkDir } from '../config/config.js'; import { WorkDir } from '../config/config.js';
import { getDefaultModelAndProvider } from '../models/defaults.js';
import { appDir, agentTaskSlug } from './indexer.js'; import { appDir, agentTaskSlug } from './indexer.js';
// Bundled background agents (spec §8). Definitions ship in the app package at // Bundled background agents (spec §8). Definitions ship in the app package at
@ -94,6 +95,17 @@ async function materializeAgent(folder: string, agentFile: string): Promise<stri
sourceApp: folder, sourceApp: folder,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}; };
// Packages can't pin a model (§8.1 — the author's providers aren't the
// installer's), but the bg-task category default is a lite model that
// reliably mangles large tool payloads (invalid JSON → contract
// rejections → the app never gets data). Pin the HOST's default model at
// materialization instead — the installer's machine decides, exactly like
// the copilot does for tasks it authors.
try {
const sel = await getDefaultModelAndProvider();
task.model = sel.model;
task.provider = sel.provider;
} catch { /* no model config — leave the category default */ }
await fs.mkdir(taskDir, { recursive: true }); await fs.mkdir(taskDir, { recursive: true });
await fs.writeFile(taskYaml, stringifyYaml(task), 'utf-8'); await fs.writeFile(taskYaml, stringifyYaml(task), 'utf-8');
await fs.writeFile(path.join(taskDir, 'index.md'), '', 'utf-8').catch(() => undefined); await fs.writeFile(path.join(taskDir, 'index.md'), '', 'utf-8').catch(() => undefined);