mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-15 21:11:08 +02:00
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:
commit
98fe9106cb
7 changed files with 239 additions and 17 deletions
|
|
@ -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;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue