From 41db6bf9d104f9b1d61f5b58b90d36f7a7c7843b Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sun, 12 Apr 2026 18:44:51 -0500 Subject: [PATCH 01/35] =?UTF-8?q?fix:=20onboarding=20prompt=20=E2=80=94=20?= =?UTF-8?q?prevent=20URL=20hallucination,=20add=20page=20URL=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added explicit rule: NEVER guess URLs, page URLs are heyiris.io/p/{slug} - Added rule: READ CLI output carefully, don't make up values - Changed Web Page flow: run `iris pages create` instead of just linking dashboard - Includes publish command and correct URL format Fixes: agent hallucinated https://hour-de-mayo.heyiris.io instead of reading the correct URL from CLI output. Co-Authored-By: Claude Opus 4.6 (1M context) --- install | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/install b/install index 92eeeef61c5e..a76985728c37 100755 --- a/install +++ b/install @@ -2189,6 +2189,8 @@ if [ "$AUTH_SUCCESS" = "true" ]; then RULES: - NEVER use curl or call APIs directly. Only CLI commands. +- NEVER guess or hallucinate URLs. Page URLs follow the format: https://heyiris.io/p/{slug} (NOT subdomains). Always read the URL from CLI output. +- When a CLI command produces output, READ it carefully and use the exact values shown. Do not make up IDs, URLs, or status values. - At the END of EVERY response, always include these links in a clean ASCII table like this: ┌──────────────────────────────────────────────────────────────┐ @@ -2241,7 +2243,8 @@ Wait for their answer. Based on what they pick, IMMEDIATELY run the correspondin - Agent: Ask what it should do in one sentence, then run: iris agents create - Multi-Agent Workflow: Ask what to automate in one sentence, then run: iris workflows list -- Web Page: Ask what the page is for, then guide to dashboard: $DASHBOARD (Pages tab) +- Web Page: Ask what the page is for, then run: iris pages create --slug --title "" --template landing + After creation, the public URL is https://heyiris.io/p/<slug> (shown in CLI output). Use iris pages publish <slug> to go live. - Knowledge base / RAG: Run: iris bloqs create - List knowledge bases: Run: iris bloqs list - Add content to KB: Run: iris bloqs ingest <bloqId> <file> From 3cf99fc03c518f18d195221e6b02cb32f4bd085f Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Sun, 12 Apr 2026 18:54:46 -0500 Subject: [PATCH 02/35] fix: use API public_url for pages instead of client-side URL construction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit publicUrl() now accepts a page object and reads public_url from the API response (set by Page model's $appends accessor). Falls back to local construction only when API doesn't provide it. Prevents URL hallucination — the canonical URL comes from the server. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- packages/opencode/src/cli/cmd/platform-pages.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-pages.ts b/packages/opencode/src/cli/cmd/platform-pages.ts index dece4e2890aa..97bc7b178b25 100644 --- a/packages/opencode/src/cli/cmd/platform-pages.ts +++ b/packages/opencode/src/cli/cmd/platform-pages.ts @@ -9,7 +9,15 @@ import { join } from "path" // Helpers // ============================================================================ -function publicUrl(slug: string): string { +/** + * Get the public URL for a page. Prefers the API-provided public_url, + * falls back to constructing from slug. + */ +function publicUrl(slugOrPage: string | { public_url?: string; slug?: string }): string { + if (typeof slugOrPage === "object" && slugOrPage.public_url) { + return slugOrPage.public_url + } + const slug = typeof slugOrPage === "string" ? slugOrPage : (slugOrPage.slug ?? "") const env = process.env.IRIS_ENV ?? "production" return env === "local" ? `http://local.iris.freelabel.net:9300/p/${slug}` @@ -128,7 +136,7 @@ const ListCmd = cmd({ const tpl = p?.json_content?.meta?.template ?? p?.json_content?.type ?? "-" console.log(` ${bold(p.slug)} ${dim(`#${p.id}`)} ${formatStatus(p.status)}`) console.log(` ${dim(p.title ?? "")} ${dim(`[${tpl}]`)}`) - console.log(` ${dim(publicUrl(p.slug))}`) + console.log(` ${dim(publicUrl(p))}`) console.log() } printDivider() @@ -170,7 +178,7 @@ const ViewCmd = cmd({ printKV("Title", page.title) printKV("Status", formatStatus(page.status)) printKV("Published", page.published_at ?? "Not published") - printKV("URL", publicUrl(page.slug)) + printKV("URL", publicUrl(page)) const compCount = page?.json_content?.components?.length ?? 0 printKV("Components", compCount) printDivider() @@ -506,7 +514,7 @@ const CreateCmd = cmd({ printKV("Slug", p.slug) printKV("Title", p.title) printKV("Status", p.status) - printKV("URL", publicUrl(p.slug)) + printKV("URL", publicUrl(p)) printDivider() prompts.outro(dim(`iris pages publish ${p.slug}`)) } catch (err) { From ab19d9bda0b43a43c9b0163b6399de7688e9caa8 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Sun, 12 Apr 2026 19:02:52 -0500 Subject: [PATCH 03/35] fix: pages create sends json_content + auto_publish The API requires json_content (array) but the CLI wasn't sending it, causing "Validation failed" on every create. Now sends a minimal landing template with HeroSection component and auto_publish=true so pages are immediately live at their public URL. Also uses API public_url in all page display outputs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../opencode/src/cli/cmd/platform-pages.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/platform-pages.ts b/packages/opencode/src/cli/cmd/platform-pages.ts index 97bc7b178b25..5ad68011d115 100644 --- a/packages/opencode/src/cli/cmd/platform-pages.ts +++ b/packages/opencode/src/cli/cmd/platform-pages.ts @@ -494,6 +494,22 @@ const CreateCmd = cmd({ const sp = prompts.spinner() sp.start("Creating…") try { + // Build initial json_content — the API requires it + const template = args.template ?? "landing" + const jsonContent = { + meta: { template, version: 1 }, + theme: {}, + components: [ + { + type: "HeroSection", + props: { + title: args.title, + subtitle: args["seo-description"] ?? "", + }, + }, + ], + } + const payload: Record<string, unknown> = { slug: args.slug, title: args.title, @@ -502,8 +518,9 @@ const CreateCmd = cmd({ owner_type: args["owner-type"], owner_id: args["owner-id"], status: "draft", + json_content: jsonContent, + auto_publish: true, } - if (args.template) payload.template = args.template const res = await irisFetch("/api/v1/pages", { method: "POST", body: JSON.stringify(payload) }) if (!(await handleApiError(res, "Create page"))) { sp.stop("Failed", 1); prompts.outro("Done"); return } const data = (await res.json()) as { data?: any } From 88ae83d48e5556280c4b9be19c86f3bed38f54ea Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Sun, 12 Apr 2026 19:13:58 -0500 Subject: [PATCH 04/35] fix: route pages CLI through iris-api proxy instead of fl-api direct SDK keys authenticate against iris-api, not fl-api. Added pagesFetch() helper that routes all pages CRUD to IRIS_API (which proxies to fl-api with its service token). Fixes 401 on pages list/create/update. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../opencode/src/cli/cmd/platform-pages.ts | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-pages.ts b/packages/opencode/src/cli/cmd/platform-pages.ts index 5ad68011d115..9aff515a7749 100644 --- a/packages/opencode/src/cli/cmd/platform-pages.ts +++ b/packages/opencode/src/cli/cmd/platform-pages.ts @@ -1,7 +1,7 @@ import { cmd } from "./cmd" import * as prompts from "@clack/prompts" import { UI } from "../ui" -import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bold, success, highlight } from "./iris-api" +import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bold, success, highlight, IRIS_API } from "./iris-api" import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs" import { join } from "path" @@ -24,6 +24,12 @@ function publicUrl(slugOrPage: string | { public_url?: string; slug?: string }): : `https://heyiris.io/p/${slug}` } +// Pages CRUD routes through iris-api (which proxies to fl-api with service token). +// The SDK key authenticates against iris-api; fl-api doesn't recognize it directly. +function pagesFetch(path: string, options?: RequestInit): Promise<Response> { + return irisFetch(path, options ?? {}, IRIS_API) +} + function formatStatus(status: string): string { if (status === "published") return success("● Published") if (status === "draft") return `${UI.Style.TEXT_WARNING}○ Draft${UI.Style.TEXT_NORMAL}` @@ -36,7 +42,7 @@ async function getBySlug(slug: string, includeJson = false): Promise<any | null> include_json: includeJson ? "1" : "0", include_drafts: "1", }) - const res = await irisFetch(`/api/v1/pages/by-slug/${encodeURIComponent(slug)}?${params}`) + const res = await pagesFetch(`/api/v1/pages/by-slug/${encodeURIComponent(slug)}?${params}`) if (!res.ok) { await handleApiError(res, `Get page ${slug}`) return null @@ -105,7 +111,7 @@ const ListCmd = cmd({ const sp = prompts.spinner() sp.start("Loading pages…") try { - const res = await irisFetch("/api/v1/pages") + const res = await pagesFetch("/api/v1/pages") if (!(await handleApiError(res, "List pages"))) { sp.stop("Failed", 1); prompts.outro("Done"); return } const json = (await res.json()) as any // Handle both direct array and Laravel paginator ({ data: { data: [...] } }) @@ -237,7 +243,7 @@ const SetCmd = cmd({ const json = page.json_content ?? {} const parsed = parseValue(args.value) setNestedValue(json, args.path, parsed) - const res = await irisFetch(`/api/v1/pages/${page.id}`, { + const res = await pagesFetch(`/api/v1/pages/${page.id}`, { method: "PUT", body: JSON.stringify({ json_content: json }), }) @@ -336,7 +342,7 @@ const PushCmd = cmd({ if (local.seo_description) updateData.seo_description = local.seo_description if (local.og_image) updateData.og_image = local.og_image - const res = await irisFetch(`/api/v1/pages/${page.id}`, { + const res = await pagesFetch(`/api/v1/pages/${page.id}`, { method: "PUT", body: JSON.stringify(updateData), }) @@ -437,7 +443,7 @@ const PublishCmd = cmd({ try { const page = await getBySlug(args.slug, false) if (!page) { sp.stop("Failed", 1); prompts.outro("Done"); return } - const res = await irisFetch(`/api/v1/pages/${page.id}/publish`, { method: "POST" }) + const res = await pagesFetch(`/api/v1/pages/${page.id}/publish`, { method: "POST" }) if (!(await handleApiError(res, "Publish"))) { sp.stop("Failed", 1); prompts.outro("Done"); return } sp.stop(success("Published")) console.log(` ${highlight(publicUrl(args.slug))}`) @@ -463,7 +469,7 @@ const UnpublishCmd = cmd({ try { const page = await getBySlug(args.slug, false) if (!page) { sp.stop("Failed", 1); prompts.outro("Done"); return } - const res = await irisFetch(`/api/v1/pages/${page.id}/unpublish`, { method: "POST" }) + const res = await pagesFetch(`/api/v1/pages/${page.id}/unpublish`, { method: "POST" }) if (!(await handleApiError(res, "Unpublish"))) { sp.stop("Failed", 1); prompts.outro("Done"); return } sp.stop(success("Unpublished (draft)")) prompts.outro("Done") @@ -521,7 +527,7 @@ const CreateCmd = cmd({ json_content: jsonContent, auto_publish: true, } - const res = await irisFetch("/api/v1/pages", { method: "POST", body: JSON.stringify(payload) }) + const res = await pagesFetch("/api/v1/pages", { method: "POST", body: JSON.stringify(payload) }) if (!(await handleApiError(res, "Create page"))) { sp.stop("Failed", 1); prompts.outro("Done"); return } const data = (await res.json()) as { data?: any } const p = data?.data ?? data @@ -587,7 +593,7 @@ const VersionsCmd = cmd({ try { const page = await getBySlug(args.slug, false) if (!page) { sp.stop("Failed", 1); prompts.outro("Done"); return } - const res = await irisFetch(`/api/v1/pages/${page.id}/versions`) + const res = await pagesFetch(`/api/v1/pages/${page.id}/versions`) if (!(await handleApiError(res, "Versions"))) { sp.stop("Failed", 1); prompts.outro("Done"); return } const data = (await res.json()) as { data?: any[] } const versions = data?.data ?? [] @@ -624,7 +630,7 @@ const RollbackCmd = cmd({ try { const page = await getBySlug(args.slug, false) if (!page) { sp.stop("Failed", 1); prompts.outro("Done"); return } - const res = await irisFetch(`/api/v1/pages/${page.id}/rollback/${args.version}`, { method: "POST" }) + const res = await pagesFetch(`/api/v1/pages/${page.id}/rollback/${args.version}`, { method: "POST" }) if (!(await handleApiError(res, "Rollback"))) { sp.stop("Failed", 1); prompts.outro("Done"); return } sp.stop(success(`Rolled back to v${args.version}`)) prompts.outro("Done") From 2661d6cdc4e660af73fd0bc376846886bac84476 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Sun, 12 Apr 2026 20:11:55 -0500 Subject: [PATCH 05/35] fix: page URL fallback to main.heyiris.io (heyiris.io doesn't route /p/) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- packages/opencode/src/cli/cmd/platform-pages.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/platform-pages.ts b/packages/opencode/src/cli/cmd/platform-pages.ts index 9aff515a7749..7541c86c7212 100644 --- a/packages/opencode/src/cli/cmd/platform-pages.ts +++ b/packages/opencode/src/cli/cmd/platform-pages.ts @@ -21,7 +21,7 @@ function publicUrl(slugOrPage: string | { public_url?: string; slug?: string }): const env = process.env.IRIS_ENV ?? "production" return env === "local" ? `http://local.iris.freelabel.net:9300/p/${slug}` - : `https://heyiris.io/p/${slug}` + : `https://main.heyiris.io/p/${slug}` } // Pages CRUD routes through iris-api (which proxies to fl-api with service token). From ed1c6b9881c1ec1fc9d8987fb711e3f43a8a0c78 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Sun, 12 Apr 2026 21:06:14 -0500 Subject: [PATCH 06/35] feat: persistent component registry + AGENTS.md training for all sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. scaffold/AGENTS.md: Added Critical Rules, Genesis component types, integration function table — loaded into EVERY agent session 2. iris pages component-registry: New CLI command listing all 24 valid component types with descriptions and required props 3. pages create: Fixed template — uses Hero (valid) not HeroSection, includes proper theme + SiteFooter, auto-publishes 4. install: Onboarding prompt includes component whitelist + rules Fixes: agent hallucinating invalid component types (blank pages), guessing URLs, and having no discoverability for page builder. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- install | 6 +- .../opencode/src/cli/cmd/platform-pages.ts | 87 ++++++++++++++++++- scaffold/AGENTS.md | 41 +++++++++ 3 files changed, 130 insertions(+), 4 deletions(-) diff --git a/install b/install index a76985728c37..ef2700be002a 100755 --- a/install +++ b/install @@ -2244,7 +2244,11 @@ Wait for their answer. Based on what they pick, IMMEDIATELY run the correspondin - Agent: Ask what it should do in one sentence, then run: iris agents create - Multi-Agent Workflow: Ask what to automate in one sentence, then run: iris workflows list - Web Page: Ask what the page is for, then run: iris pages create --slug <slug> --title "<title>" --template landing - After creation, the public URL is https://heyiris.io/p/<slug> (shown in CLI output). Use iris pages publish <slug> to go live. + After creation, the public URL is shown in CLI output (main.heyiris.io/p/<slug>). Auto-publishes on create. + To add more components: iris pages pull <slug>, edit pages/<slug>.json, iris pages push <slug>. + Run: iris pages component-registry — to see ALL available component types. + VALID component types (use ONLY these exact names): Hero, SiteNavigation, SiteFooter, AnnouncementBanner, TestimonialsSection, TeamSection, ContactSection, LogoMarquee, FeatureShowcase, ComparisonMatrix, ClientGrid, CareersListing, PortfolioGallery, ProductGrid, ServiceMenu, EventGrid, FundingTiers, BeforeAfter, MapSection, NewsletterSignup, StepWizard, FileUpload, ShoppingCart, OrderConfirmation. + NEVER invent component types. If unsure, run: iris pages component-registry - Knowledge base / RAG: Run: iris bloqs create - List knowledge bases: Run: iris bloqs list - Add content to KB: Run: iris bloqs ingest <bloqId> <file> diff --git a/packages/opencode/src/cli/cmd/platform-pages.ts b/packages/opencode/src/cli/cmd/platform-pages.ts index 7541c86c7212..5e52987e7f79 100644 --- a/packages/opencode/src/cli/cmd/platform-pages.ts +++ b/packages/opencode/src/cli/cmd/platform-pages.ts @@ -503,14 +503,29 @@ const CreateCmd = cmd({ // Build initial json_content — the API requires it const template = args.template ?? "landing" const jsonContent = { - meta: { template, version: 1 }, - theme: {}, + version: "1.0", + type: template, + theme: { mode: "dark", backgroundColor: "#000000", branding: { name: args.title, primaryColor: "#34d399" } }, components: [ { - type: "HeroSection", + type: "Hero", + id: `${args.slug}-hero`, props: { + themeMode: "dark", title: args.title, subtitle: args["seo-description"] ?? "", + labelText: "NEW", + labelColor: "#34d399", + textAlign: "center", + }, + }, + { + type: "SiteFooter", + id: `${args.slug}-footer`, + props: { + themeMode: "dark", + brandName: args.title, + links: [], }, }, ], @@ -642,6 +657,71 @@ const RollbackCmd = cmd({ }, }) +// ============================================================================ +// Component Registry — available component types for the page builder +// ============================================================================ + +const COMPONENT_REGISTRY: { type: string; description: string; requiredProps: string[] }[] = [ + { type: "Hero", description: "Full-width hero banner with title, subtitle, CTA buttons", requiredProps: ["title"] }, + { type: "SiteNavigation", description: "Top navigation bar with logo, links, CTA button", requiredProps: ["logo"] }, + { type: "SiteFooter", description: "Footer with brand name, links, copyright", requiredProps: ["brandName"] }, + { type: "AnnouncementBanner", description: "Dismissible banner strip at top of page", requiredProps: ["text"] }, + { type: "TestimonialsSection", description: "Customer testimonials with avatars and quotes", requiredProps: ["testimonials"] }, + { type: "TeamSection", description: "Team member grid with photos and roles", requiredProps: ["members"] }, + { type: "ContactSection", description: "Contact form with configurable fields", requiredProps: ["heading"] }, + { type: "LogoMarquee", description: "Auto-scrolling logo carousel (trusted by...)", requiredProps: ["logos"] }, + { type: "FeatureShowcase", description: "Feature highlights with icons and descriptions", requiredProps: ["features"] }, + { type: "ComparisonMatrix", description: "Pricing/feature comparison table", requiredProps: ["columns", "rows"] }, + { type: "ClientGrid", description: "Client/partner logo grid", requiredProps: ["clients"] }, + { type: "CareersListing", description: "Job listings with department filters", requiredProps: ["jobs"] }, + { type: "PortfolioGallery", description: "Image/project gallery grid with lightbox", requiredProps: ["items"] }, + { type: "ProductGrid", description: "E-commerce product cards with prices", requiredProps: ["products"] }, + { type: "ServiceMenu", description: "Service/menu items with prices and descriptions", requiredProps: ["categories"] }, + { type: "EventGrid", description: "Event cards with dates and venues", requiredProps: ["events"] }, + { type: "FundingTiers", description: "Pricing/funding tier cards", requiredProps: ["tiers"] }, + { type: "BeforeAfter", description: "Before/after image slider comparison", requiredProps: ["before", "after"] }, + { type: "MapSection", description: "Interactive map with location markers", requiredProps: ["locations"] }, + { type: "NewsletterSignup", description: "Email signup form", requiredProps: ["heading"] }, + { type: "StepWizard", description: "Multi-step form wizard", requiredProps: ["steps"] }, + { type: "FileUpload", description: "File upload dropzone", requiredProps: [] }, + { type: "ShoppingCart", description: "Shopping cart with line items", requiredProps: [] }, + { type: "OrderConfirmation", description: "Order confirmation/receipt page", requiredProps: [] }, +] + +const ComponentRegistryCmd = cmd({ + command: "component-registry", + aliases: ["registry", "available-components"], + describe: "list available component types for the page builder", + builder: (y) => y.option("json", { type: "boolean" }), + async handler(args) { + UI.empty() + prompts.intro("◈ Page Component Registry") + + if (args.json) { + console.log(JSON.stringify(COMPONENT_REGISTRY, null, 2)) + prompts.outro("Done") + return + } + + console.log() + console.log(` ${bold("Available components for Genesis pages:")}`) + console.log() + for (const c of COMPONENT_REGISTRY) { + console.log(` ${highlight(c.type)}`) + console.log(` ${dim(c.description)}`) + if (c.requiredProps.length) { + console.log(` ${dim("Required props: " + c.requiredProps.join(", "))}`) + } + console.log() + } + console.log(` ${dim(`${COMPONENT_REGISTRY.length} components available`)}`) + console.log() + prompts.log.info(`Example: ${dim('iris pages set my-page "components.1" \'{"type":"Hero","props":{"title":"Hello"}}\'')}`) + prompts.log.info(`Reference: ${dim("iris pages pull component-showcase")} — pull a working example`) + prompts.outro("Done") + }, +}) + // ============================================================================ // Root // ============================================================================ @@ -663,6 +743,7 @@ export const PlatformPagesCommand = cmd({ .command(UnpublishCmd) .command(CreateCmd) .command(ComponentsCmd) + .command(ComponentRegistryCmd) .command(VersionsCmd) .command(RollbackCmd) .demandCommand(), diff --git a/scaffold/AGENTS.md b/scaffold/AGENTS.md index b983dc9eb0fd..3799c3fd997d 100644 --- a/scaffold/AGENTS.md +++ b/scaffold/AGENTS.md @@ -46,6 +46,47 @@ Recipes available out of the box: When the user asks something that might match a recipe, **read the recipe file first** instead of guessing. The recipes have exact commands, expected output, and known gotchas. +## Critical Rules + +- **NEVER use curl or call APIs directly.** Use `iris` CLI commands. +- **NEVER guess or hallucinate URLs.** Always read URLs from CLI output. Page URLs follow: `main.heyiris.io/p/{slug}` +- **NEVER invent component type names.** Run `iris pages component-registry` first. Invalid types render blank. +- **READ CLI output carefully.** Use exact values shown — don't make up IDs, URLs, or status values. + +## Genesis Page Builder — Component Rules + +When building or editing pages with `iris pages`, follow these rules: + +1. **Run `iris pages component-registry`** before adding components to see all valid types +2. **Use `iris pages pull component-showcase`** as a reference for working component JSON +3. **Page URLs** are shown in CLI output — format: `main.heyiris.io/p/{slug}` + +**Valid component types (use ONLY these exact names):** +Hero, SiteNavigation, SiteFooter, AnnouncementBanner, TestimonialsSection, TeamSection, ContactSection, LogoMarquee, FeatureShowcase, ComparisonMatrix, ClientGrid, CareersListing, PortfolioGallery, ProductGrid, ServiceMenu, EventGrid, FundingTiers, BeforeAfter, MapSection, NewsletterSignup, StepWizard, FileUpload, ShoppingCart, OrderConfirmation + +**Every component needs:** `type` (exact name from above), `id` (unique string), `props` (object) + +**Workflow: pull → edit → push** +```bash +iris pages pull <slug> # download to pages/<slug>.json +# edit the JSON file +iris pages push <slug> # upload back +``` + +## Integration Functions + +When running `iris integrations exec <type>` without a function, the CLI shows available functions. + +| Integration | Functions | +|-------------|-----------| +| gmail | `read_emails`, `search_emails`, `send_email` | +| google-drive | `search_files`, `export_file`, `read_doc` | +| google-calendar | `get_events`, `create_event` | +| slack | `send_message`, `list_channels` | +| canva | `list_designs`, `export_design` | + +Run `iris integrations exec <type>` (no function) to discover functions for any integration. + ## What you should NOT assume - You are NOT working on the IRIS source code unless `cwd` is the `iris-code` repo. By default, assume the user is in their OWN project and behave like a general-purpose coding agent there. From eade2bf21f913cdd68dd3e4228afac450045e222 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Sun, 12 Apr 2026 21:23:00 -0500 Subject: [PATCH 07/35] =?UTF-8?q?fix:=20rebrand=20opencode=E2=86=92iris=20?= =?UTF-8?q?in=20help,=20add=20pages=20recipe,=20update=20AGENTS.md=20comma?= =?UTF-8?q?nds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. scriptName("opencode") → scriptName("iris") — all help text now shows iris 2. scaffold/AGENTS.md: updated command table (iris pages, iris leads, etc.) 3. scaffold/how-to/pages.md: full Genesis recipe with valid component types 4. manifest.json: added pages.md entry 5. install: onboarding prompt includes component whitelist Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- packages/opencode/src/index.ts | 7 ++- scaffold/AGENTS.md | 18 +++--- scaffold/how-to/README.md | 1 + scaffold/how-to/pages.md | 105 +++++++++++++++++++++++++++++++++ scaffold/manifest.json | 6 ++ 5 files changed, 128 insertions(+), 9 deletions(-) create mode 100644 scaffold/how-to/pages.md diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index c997ba64a1d5..59993fa08459 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -57,7 +57,7 @@ import { PlatformOutreachStrategyCommand } from "./cli/cmd/platform-outreach-str import { PlatformOutreachCampaignCommand } from "./cli/cmd/platform-outreach-campaign" import { PlatformOutreachSendCommand } from "./cli/cmd/platform-outreach-send" import { PlatformSomCommand } from "./cli/cmd/platform-som" -import { PlatformMonitorCommand } from "./cli/cmd/platform-monitor" +import { PlatformMonitorCommand, BriefingCommand } from "./cli/cmd/platform-monitor" import { PlatformInvoicesCommand } from "./cli/cmd/platform-invoices" import { PlatformPaymentsCommand } from "./cli/cmd/platform-payments" import { PlatformDeliverCommand } from "./cli/cmd/platform-deliver" @@ -67,6 +67,7 @@ import { PlatformBugCommand } from "./cli/cmd/platform-bug" import { PlatformAtlasMeetingsCommand } from "./cli/cmd/platform-atlas-meetings" import { PlatformAtlasBrandKitCommand } from "./cli/cmd/platform-atlas-brand-kit" import { PlatformLeadsMeetingCommand } from "./cli/cmd/platform-leads-meeting" +import { PlatformOnboardCommand } from "./cli/cmd/platform-onboard" import { PlatformPagesCommand } from "./cli/cmd/platform-pages" import { PlatformPagesBatchCommand } from "./cli/cmd/platform-pages-batch" import { PlatformPartialsCommand } from "./cli/cmd/platform-partials" @@ -118,7 +119,7 @@ const rawArgs = hideBin(process.argv) const cli = yargs(rawArgs) .parserConfiguration({ "populate--": true }) - .scriptName("opencode") + .scriptName("iris") .wrap(100) .help("help", "show help") .alias("help", "h") @@ -209,6 +210,7 @@ const cli = yargs(rawArgs) .command(reg(PlatformOutreachSendCommand)) .command(reg(PlatformSomCommand)) .command(reg(PlatformMonitorCommand)) + .command(reg(BriefingCommand)) .command(reg(PlatformInvoicesCommand)) .command(reg(PlatformPaymentsCommand)) .command(reg(PlatformDeliverCommand)) @@ -221,6 +223,7 @@ const cli = yargs(rawArgs) .command(reg(PlatformAtlasMeetingsCommand)) .command(reg(PlatformAtlasBrandKitCommand)) .command(reg(PlatformLeadsMeetingCommand)) + .command(reg(PlatformOnboardCommand)) .command(reg(PlatformPagesCommand)) .command(reg(PlatformPagesBatchCommand)) .command(reg(PlatformPartialsCommand)) diff --git a/scaffold/AGENTS.md b/scaffold/AGENTS.md index 3799c3fd997d..c6664a82fd7c 100644 --- a/scaffold/AGENTS.md +++ b/scaffold/AGENTS.md @@ -16,15 +16,19 @@ You are running inside the **IRIS CLI** — an AI coding assistant from the IRIS | `iris-login` | Interactive auth — writes `~/.iris/sdk/.env`. Run after install. | | `iris-daemon start \| stop \| status` | Local Hive daemon (port 3200) for distributed compute | | `iris hive` | Distributed compute / agent mesh commands | -| `iris platform-leads` | Lead capture, enrichment, outreach | -| `iris platform-bloqs` | Manage bloqs (the core unit of IRIS knowledge/work) | -| `iris platform-pages` | Genesis composable page builder | -| `iris platform-workflows` | Workflow execution and history | -| `iris platform-agents` | Agent CRUD, scheduling, heartbeat config | -| `iris platform-chat` | Chat with agents from the terminal | +| `iris leads` | Lead capture, enrichment, outreach (alias: `crm`) | +| `iris bloqs` | Manage bloqs — knowledge bases (aliases: `kb`, `memory`) | +| `iris pages` | Genesis composable page builder (alias: `genesis`) | +| `iris workflows` | Workflow execution and history | +| `iris agents` | Agent CRUD, scheduling, heartbeat config | +| `iris chat` | Chat with agents from the terminal (alias: `c`) | +| `iris integrations` | Execute integration functions, OAuth connect (alias: `int`) | +| `iris connect <type>` | Connect an integration via OAuth | +| `iris list-connected` | Show connected integrations | | `iris mcp serve` | Expose IRIS as an MCP server for other agents | -| `iris auth` / `iris models` / `iris run` / `iris generate` | Standard CLI ops | +| `iris auth` / `iris models` / `iris run` | Standard CLI ops | | `iris github` | GitHub integration | +| `iris bug report` | Report bugs to the IRIS team | | `iris --help` | Full command tree | When the user asks "how do I X" and X maps to an IRIS command, **suggest the command first** before writing code from scratch. diff --git a/scaffold/how-to/README.md b/scaffold/how-to/README.md index 8926ed8ca278..1934251ddc56 100644 --- a/scaffold/how-to/README.md +++ b/scaffold/how-to/README.md @@ -10,6 +10,7 @@ This directory contains step-by-step recipes for common IRIS workflows. Each fil | "send a campaign", "outreach", "find leads on linkedin/twitter/instagram", "DM people", "discover prospects" | `outreach-campaign.md` | | "connect my machine", "hive", "distributed", "run on multiple machines", "node not registering" | `hive-dispatch.md` | | "send a proposal", "create a deal", "invoice a client", "contract", "payment gate" | `lead-to-proposal.md` | +| "build a page", "create a landing page", "genesis", "add components", "page builder" | `pages.md` | ## How to use these files diff --git a/scaffold/how-to/pages.md b/scaffold/how-to/pages.md new file mode 100644 index 000000000000..1e0f04297286 --- /dev/null +++ b/scaffold/how-to/pages.md @@ -0,0 +1,105 @@ +# Genesis Pages — How-To + +Build and manage composable landing pages from the CLI. + +## Quick Reference + +```bash +iris pages list # list all pages +iris pages view <slug> # view page details + public URL +iris pages create --slug <slug> --title "<title>" # create + auto-publish +iris pages pull <slug> # download JSON to pages/<slug>.json +iris pages push <slug> # upload local JSON back to API +iris pages publish <slug> # publish a draft page +iris pages unpublish <slug> # take a page offline +iris pages components <slug> # list components on a page +iris pages component-registry # list ALL valid component types +iris pages versions <slug> # show version history +iris pages rollback <slug> --version <n> # rollback to previous version +``` + +## Create a Page + +```bash +iris pages create --slug my-page --title "My Page" --seo-description "Page description" +``` + +This creates a page with a Hero + SiteFooter and auto-publishes it. +The public URL is shown in the output: `main.heyiris.io/p/my-page` + +## Add Components + +The recommended workflow is pull → edit → push: + +```bash +iris pages pull my-page # creates pages/my-page.json +# edit pages/my-page.json — add components to the "components" array +iris pages push my-page # uploads changes, creates new version +``` + +## Valid Component Types + +**ONLY use these exact type names.** Invalid types render as blank: + +| Type | Description | +|------|-------------| +| Hero | Full-width hero banner with title, subtitle, CTA buttons | +| SiteNavigation | Top navigation bar with logo, links, CTA button | +| SiteFooter | Footer with brand name, links, copyright | +| AnnouncementBanner | Dismissible banner strip at top of page | +| TestimonialsSection | Customer testimonials with avatars and quotes | +| TeamSection | Team member grid with photos and roles | +| ContactSection | Contact form with configurable fields | +| LogoMarquee | Auto-scrolling logo carousel | +| FeatureShowcase | Feature highlights with icons and descriptions | +| ComparisonMatrix | Pricing/feature comparison table | +| ClientGrid | Client/partner logo grid | +| CareersListing | Job listings with department filters | +| PortfolioGallery | Image/project gallery grid with lightbox | +| ProductGrid | E-commerce product cards with prices | +| ServiceMenu | Service/menu items with prices and descriptions | +| EventGrid | Event cards with dates and venues | +| FundingTiers | Pricing/funding tier cards | +| BeforeAfter | Before/after image slider comparison | +| MapSection | Interactive map with location markers | +| NewsletterSignup | Email signup form | +| StepWizard | Multi-step form wizard | +| FileUpload | File upload dropzone | +| ShoppingCart | Shopping cart with line items | +| OrderConfirmation | Order confirmation/receipt page | + +## Component JSON Structure + +Every component needs `type`, `id`, and `props`: + +```json +{ + "type": "Hero", + "id": "my-hero", + "props": { + "themeMode": "dark", + "title": "Welcome", + "subtitle": "This is my page", + "labelText": "NEW", + "labelColor": "#34d399", + "primaryButtonText": "Get Started", + "primaryButtonUrl": "#contact", + "textAlign": "center" + } +} +``` + +## Reference Page + +Pull the component showcase for working examples of every component: + +```bash +iris pages pull component-showcase +cat pages/component-showcase.json # 28 components with full props +``` + +## Common Gotchas + +- **Blank page?** You used an invalid component type. Run `iris pages component-registry` to check. +- **Auth error on pages list?** The CLI routes pages through iris-api. If auth fails, the service token may need refreshing. +- **Page URL format:** `main.heyiris.io/p/{slug}` — NOT `heyiris.io/p/{slug}` (that domain doesn't route /p/). diff --git a/scaffold/manifest.json b/scaffold/manifest.json index 6f2c5ba94c81..5e8556fdc444 100644 --- a/scaffold/manifest.json +++ b/scaffold/manifest.json @@ -49,6 +49,12 @@ "dest": "how-to/debug-install-failures.md", "managed": true, "purpose": "Diagnose 5 common install failure modes: baseline asset mismatch, macOS version floor, missing deps, scaffold fetch fail, branding. Includes quick diagnostic command to send users." + }, + { + "src": "how-to/pages.md", + "dest": "how-to/pages.md", + "managed": true, + "purpose": "Genesis page builder: create pages, valid component types, pull/edit/push workflow, component-showcase reference." } ] } From ee21834b92b943d6b7c0582280a731b91c0009d4 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Sun, 12 Apr 2026 21:37:07 -0500 Subject: [PATCH 08/35] Add iris proposals + iris contracts CLI commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - iris proposals create <lead-id> — generate proposal from lead data, attach contract, create Stripe checkout - iris proposals status <lead-id> — check proposal/deal status - iris proposals list — list all leads with active proposals - iris contracts send <lead-id> — send contract for signing - iris contracts status <lead-id> — check signing status - iris contracts templates — list available contract templates Both build on existing payment-gate API. Proposals wraps the full flow (proposal + contract + payment), contracts focuses on the signing piece. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../src/cli/cmd/platform-contracts.ts | 204 +++++++++++++++ .../src/cli/cmd/platform-proposals.ts | 247 ++++++++++++++++++ packages/opencode/src/index.ts | 4 + 3 files changed, 455 insertions(+) create mode 100644 packages/opencode/src/cli/cmd/platform-contracts.ts create mode 100644 packages/opencode/src/cli/cmd/platform-proposals.ts diff --git a/packages/opencode/src/cli/cmd/platform-contracts.ts b/packages/opencode/src/cli/cmd/platform-contracts.ts new file mode 100644 index 000000000000..22113a28ad5d --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-contracts.ts @@ -0,0 +1,204 @@ +import { cmd } from "./cmd" +import * as prompts from "@clack/prompts" +import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bold, success, highlight } from "./iris-api" + +// ============================================================================ +// Contracts Send — send a contract to a lead for signing +// ============================================================================ + +const ContractsSendCommand = cmd({ + command: "send <lead-id>", + describe: "send a contract to a lead for signing", + builder: (yargs) => + yargs + .positional("lead-id", { describe: "lead ID", type: "number", demandOption: true }) + .option("template", { alias: "t", describe: "contract template name", type: "string" }) + .option("scope", { alias: "s", describe: "scope of work", type: "string" }) + .option("amount", { alias: "a", describe: "contract amount ($)", type: "number" }) + .option("json", { describe: "JSON output", type: "boolean" }), + async handler(args) { + if (!(await requireAuth())) return + + const leadId = args["lead-id"] + + // Fetch lead info + const leadRes = await irisFetch(`/api/v1/leads/${leadId}`) + if (!(await handleApiError(leadRes, "Fetch lead"))) return + const lead = await leadRes.json().catch(() => ({})) + const leadData = lead?.data ?? lead + + if (!leadData?.id) { + prompts.log.error(`Lead #${leadId} not found`) + return + } + + const name = leadData.name ?? leadData.first_name ?? `Lead #${leadId}` + console.log("") + console.log(bold(`Sending contract to: ${name}`)) + + // Prompt for missing fields + let scope = args.scope + if (!scope) { + const input = await prompts.text({ message: "Scope of work:" }) + if (prompts.isCancel(input)) return + scope = String(input) + } + + let amount = args.amount + if (!amount) { + const input = await prompts.text({ message: "Contract amount ($):", validate: (v) => isNaN(Number(v)) ? "Must be a number" : undefined }) + if (prompts.isCancel(input)) return + amount = Number(input) + } + + const body: Record<string, unknown> = { + scope, + amount, + send_contract: true, + } + if (args.template) body.template = args.template + + const spinner = prompts.spinner() + spinner.start("Creating contract...") + + // Use payment-gate endpoint which handles contract creation + const res = await irisFetch(`/api/v1/leads/${leadId}/payment-gate`, { + method: "POST", + body: JSON.stringify(body), + }) + + spinner.stop("Contract created") + + if (!(await handleApiError(res, "Send contract"))) return + const data = await res.json().catch(() => ({})) + + if (args.json) { console.log(JSON.stringify(data, null, 2)); return } + + if (!data.success) { + if (data.error === "duplicate") { + prompts.log.warn("A contract already exists for this lead") + const step = data.step?.data ?? {} + if (step.contract_signing_url) printKV("Existing Contract", step.contract_signing_url) + return + } + prompts.log.error(data.message || "Failed to create contract") + return + } + + console.log("") + console.log(success("Contract sent!")) + printDivider() + printKV("Lead", `${name} (#${leadId})`) + printKV("Amount", `$${Number(amount).toFixed(2)}`) + printKV("Scope", String(scope)) + printKV("Signing URL", data.contract_signing_url ?? dim("(not generated)")) + printKV("Proposal URL", data.proposal_url ?? dim("(not attached)")) + printDivider() + }, +}) + +// ============================================================================ +// Contracts Status — check contract signing status +// ============================================================================ + +const ContractsStatusCommand = cmd({ + command: "status <lead-id>", + aliases: ["check"], + describe: "check contract signing status for a lead", + builder: (yargs) => + yargs + .positional("lead-id", { describe: "lead ID", type: "number", demandOption: true }) + .option("json", { describe: "JSON output", type: "boolean" }), + async handler(args) { + if (!(await requireAuth())) return + + const leadId = args["lead-id"] + const res = await irisFetch(`/api/v1/leads/${leadId}/deal-status`) + if (!(await handleApiError(res, "Get contract status"))) return + + const result = await res.json().catch(() => ({})) + const status = result?.data ?? result + + if (args.json) { console.log(JSON.stringify(status, null, 2)); return } + + if (!status?.has_payment_gate) { + prompts.log.info(`No contract for lead #${leadId}`) + console.log(dim(`Send one: iris contracts send ${leadId}`)) + return + } + + console.log("") + console.log(bold(`Contract Status — Lead #${leadId}`)) + printDivider() + printKV("Contract", status.contract_signed ? success("SIGNED") : highlight("PENDING")) + printKV("Payment", status.payment_received ? success("RECEIVED") : highlight("PENDING")) + printKV("Amount", `$${Number(status.amount ?? 0).toFixed(2)}`) + printKV("Scope", status.scope ?? dim("—")) + + if (status.contract_signing_url) { + console.log("") + printKV("Signing URL", status.contract_signing_url) + } + printDivider() + }, +}) + +// ============================================================================ +// Contracts Templates — list available contract templates +// ============================================================================ + +const ContractsTemplatesCommand = cmd({ + command: "templates", + aliases: ["tpl"], + describe: "list available contract templates", + builder: (yargs) => + yargs + .option("json", { describe: "JSON output", type: "boolean" }), + async handler(args) { + if (!(await requireAuth())) return + + const res = await irisFetch(`/api/v1/bloq-packages?type=contract_template`) + if (!(await handleApiError(res, "List templates"))) return + + const result = await res.json().catch(() => ({})) + const templates = result?.data ?? result ?? [] + + if (args.json) { console.log(JSON.stringify(templates, null, 2)); return } + + if (!Array.isArray(templates) || templates.length === 0) { + prompts.log.info("No contract templates found") + console.log(dim("Templates are created as service packages with type=contract_template")) + return + } + + console.log("") + console.log(bold(`Contract Templates (${templates.length})`)) + printDivider() + + for (const tpl of templates) { + const name = tpl.name ?? "Untitled" + const price = tpl.price ? `$${Number(tpl.price).toFixed(2)}` : dim("—") + console.log(` ${highlight(`#${tpl.id}`)} ${name.padEnd(30)} ${price}`) + if (tpl.description) console.log(` ${dim(tpl.description.slice(0, 60))}`) + } + + printDivider() + }, +}) + +// ============================================================================ +// Root command +// ============================================================================ + +export const PlatformContractsCommand = cmd({ + command: "contracts", + aliases: ["contract"], + describe: "send contracts for signing, track status, manage templates", + builder: (yargs) => + yargs + .command(ContractsSendCommand) + .command(ContractsStatusCommand) + .command(ContractsTemplatesCommand) + .demandCommand(), + async handler() {}, +}) diff --git a/packages/opencode/src/cli/cmd/platform-proposals.ts b/packages/opencode/src/cli/cmd/platform-proposals.ts new file mode 100644 index 000000000000..152eae160df0 --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-proposals.ts @@ -0,0 +1,247 @@ +import { cmd } from "./cmd" +import * as prompts from "@clack/prompts" +import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bold, success, highlight } from "./iris-api" + +// ============================================================================ +// Proposals Create — generate proposal from lead data + send +// ============================================================================ + +const ProposalsCreateCommand = cmd({ + command: "create <lead-id>", + aliases: ["generate", "send"], + describe: "generate a proposal from lead notes/tasks and send for signing", + builder: (yargs) => + yargs + .positional("lead-id", { describe: "lead ID", type: "number", demandOption: true }) + .option("amount", { alias: "a", describe: "total amount ($)", type: "number" }) + .option("scope", { alias: "s", describe: "scope of work", type: "string" }) + .option("package", { alias: "p", describe: "service package ID (auto-fills amount + scope)", type: "number" }) + .option("template", { alias: "t", describe: "proposal template name", type: "string" }) + .option("no-contract", { describe: "skip contract attachment", type: "boolean" }) + .option("no-send", { describe: "generate but don't send to client", type: "boolean" }) + .option("json", { describe: "JSON output", type: "boolean" }), + async handler(args) { + if (!(await requireAuth())) return + + const leadId = args["lead-id"] + + // Step 1: Fetch lead data (notes, tasks, deliverables) + const leadRes = await irisFetch(`/api/v1/leads/${leadId}?include=notes,tasks,deliverables`) + if (!(await handleApiError(leadRes, "Fetch lead"))) return + const lead = await leadRes.json().catch(() => ({})) + const leadData = lead?.data ?? lead + + if (!leadData?.id) { + prompts.log.error(`Lead #${leadId} not found`) + return + } + + console.log("") + console.log(bold(`Generating proposal for: ${leadData.name ?? leadData.first_name ?? `Lead #${leadId}`}`)) + + // Step 2: If no amount/scope, prompt or pull from package + let amount = args.amount + let scope = args.scope + + if (args.package) { + const pkgRes = await irisFetch(`/api/v1/bloq-packages/${args.package}`) + if (pkgRes.ok) { + const pkg = await pkgRes.json().catch(() => ({})) + const pkgData = pkg?.data ?? pkg + amount = amount ?? pkgData.price + scope = scope ?? pkgData.scope_template ?? pkgData.description + printKV("Package", pkgData.name ?? `#${args.package}`) + } + } + + if (!amount) { + const input = await prompts.text({ message: "Total amount ($):", validate: (v) => isNaN(Number(v)) ? "Must be a number" : undefined }) + if (prompts.isCancel(input)) return + amount = Number(input) + } + + if (!scope) { + const input = await prompts.text({ message: "Scope of work:" }) + if (prompts.isCancel(input)) return + scope = String(input) + } + + // Step 3: Create payment gate (which generates proposal + contract + Stripe) + const body: Record<string, unknown> = { + amount, + scope, + auto_send_reminders: true, + generate_proposal: true, + } + if (args.package) body.package_id = args.package + if (args["no-contract"]) body.skip_contract = true + if (args.template) body.template = args.template + + const spinner = prompts.spinner() + spinner.start("Generating proposal...") + + const res = await irisFetch(`/api/v1/leads/${leadId}/payment-gate`, { + method: "POST", + body: JSON.stringify(body), + }) + + spinner.stop("Proposal generated") + + if (!(await handleApiError(res, "Create proposal"))) return + const data = await res.json().catch(() => ({})) + + if (args.json) { console.log(JSON.stringify(data, null, 2)); return } + + if (!data.success) { + if (data.error === "duplicate") { + prompts.log.warn(data.message || "A proposal already exists for this lead") + const step = data.step?.data ?? {} + if (step.proposal_url) { + console.log("") + printKV("Existing Proposal", step.proposal_url) + } + return + } + prompts.log.error(data.message || "Failed to create proposal") + return + } + + console.log("") + console.log(success("Proposal created!")) + printDivider() + printKV("Lead", `${leadData.name ?? leadData.first_name ?? ""} (#${leadId})`) + printKV("Amount", `$${Number(amount).toFixed(2)}`) + printKV("Scope", String(scope)) + printDivider() + printKV("Proposal URL", data.proposal_url ?? dim("(not generated)")) + printKV("Contract URL", data.contract_signing_url ?? dim("(not attached)")) + printKV("Payment URL", data.stripe_checkout_url ?? dim("(not configured)")) + printDivider() + + if (!args["no-send"] && data.proposal_url) { + console.log("") + console.log(dim("Proposal ready to send. Use outreach or share the URL above.")) + } + }, +}) + +// ============================================================================ +// Proposals Status — check proposal + deal status +// ============================================================================ + +const ProposalsStatusCommand = cmd({ + command: "status <lead-id>", + aliases: ["check"], + describe: "check proposal and deal status for a lead", + builder: (yargs) => + yargs + .positional("lead-id", { describe: "lead ID", type: "number", demandOption: true }) + .option("json", { describe: "JSON output", type: "boolean" }), + async handler(args) { + if (!(await requireAuth())) return + + const leadId = args["lead-id"] + const res = await irisFetch(`/api/v1/leads/${leadId}/deal-status`) + if (!(await handleApiError(res, "Get proposal status"))) return + + const result = await res.json().catch(() => ({})) + const status = result?.data ?? result + + if (args.json) { console.log(JSON.stringify(status, null, 2)); return } + + if (!status?.has_payment_gate) { + prompts.log.info(`No proposal for lead #${leadId}`) + console.log(dim(`Create one: iris proposals create ${leadId}`)) + return + } + + const statusLabels: Record<string, string> = { + deal_closed: success("CLOSED"), + awaiting_payment: highlight("AWAITING PAYMENT"), + awaiting_contract: highlight("AWAITING CONTRACT"), + awaiting_both: dim("PENDING"), + } + + console.log("") + console.log(bold(`Proposal Status — Lead #${leadId}`)) + printDivider() + printKV("Status", statusLabels[status.status] ?? status.status) + printKV("Amount", `$${Number(status.amount ?? 0).toFixed(2)}`) + printKV("Scope", status.scope ?? dim("—")) + printKV("Contract", status.contract_signed ? success("Signed") : highlight("Pending")) + printKV("Payment", status.payment_received ? success("Received") : highlight("Pending")) + printKV("Reminders", `${status.reminders_sent ?? 0}/${status.reminders_total ?? 0} sent`) + printDivider() + + if (status.proposal_url) printKV("Proposal", status.proposal_url) + if (status.contract_signing_url) printKV("Contract", status.contract_signing_url) + if (status.stripe_checkout_url) printKV("Payment", status.stripe_checkout_url) + + if (status.proposal_url || status.contract_signing_url) { + printDivider() + } + }, +}) + +// ============================================================================ +// Proposals List — list all leads with active proposals +// ============================================================================ + +const ProposalsListCommand = cmd({ + command: "list", + aliases: ["ls"], + describe: "list leads with active proposals/payment gates", + builder: (yargs) => + yargs + .option("status", { alias: "s", describe: "filter by status", type: "string", choices: ["pending", "awaiting_payment", "awaiting_contract", "deal_closed"] }) + .option("json", { describe: "JSON output", type: "boolean" }), + async handler(args) { + if (!(await requireAuth())) return + + const params = new URLSearchParams({ has_payment_gate: "1" }) + if (args.status) params.set("deal_status", args.status) + + const res = await irisFetch(`/api/v1/leads?${params}`) + if (!(await handleApiError(res, "List proposals"))) return + + const result = await res.json().catch(() => ({})) + const leads = result?.data ?? result ?? [] + + if (args.json) { console.log(JSON.stringify(leads, null, 2)); return } + + if (!Array.isArray(leads) || leads.length === 0) { + prompts.log.info("No active proposals found") + return + } + + console.log("") + console.log(bold(`Active Proposals (${leads.length})`)) + printDivider() + + for (const lead of leads) { + const name = lead.name ?? lead.first_name ?? `Lead #${lead.id}` + const amount = lead.deal_amount ? `$${Number(lead.deal_amount).toFixed(2)}` : dim("—") + const st = lead.deal_status ?? dim("unknown") + console.log(` ${highlight(`#${lead.id}`)} ${name.padEnd(25)} ${amount.padEnd(12)} ${st}`) + } + + printDivider() + }, +}) + +// ============================================================================ +// Root command +// ============================================================================ + +export const PlatformProposalsCommand = cmd({ + command: "proposals", + aliases: ["proposal"], + describe: "create, send, and track client proposals with contracts + payment", + builder: (yargs) => + yargs + .command(ProposalsCreateCommand) + .command(ProposalsStatusCommand) + .command(ProposalsListCommand) + .demandCommand(), + async handler() {}, +}) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 59993fa08459..145402a590a9 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -68,6 +68,8 @@ import { PlatformAtlasMeetingsCommand } from "./cli/cmd/platform-atlas-meetings" import { PlatformAtlasBrandKitCommand } from "./cli/cmd/platform-atlas-brand-kit" import { PlatformLeadsMeetingCommand } from "./cli/cmd/platform-leads-meeting" import { PlatformOnboardCommand } from "./cli/cmd/platform-onboard" +import { PlatformProposalsCommand } from "./cli/cmd/platform-proposals" +import { PlatformContractsCommand } from "./cli/cmd/platform-contracts" import { PlatformPagesCommand } from "./cli/cmd/platform-pages" import { PlatformPagesBatchCommand } from "./cli/cmd/platform-pages-batch" import { PlatformPartialsCommand } from "./cli/cmd/platform-partials" @@ -224,6 +226,8 @@ const cli = yargs(rawArgs) .command(reg(PlatformAtlasBrandKitCommand)) .command(reg(PlatformLeadsMeetingCommand)) .command(reg(PlatformOnboardCommand)) + .command(reg(PlatformProposalsCommand)) + .command(reg(PlatformContractsCommand)) .command(reg(PlatformPagesCommand)) .command(reg(PlatformPagesBatchCommand)) .command(reg(PlatformPartialsCommand)) From 61472021880aed24193dc92f99a017917a70874c Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Sun, 12 Apr 2026 22:29:31 -0500 Subject: [PATCH 09/35] feat: unified scheduler, code workflows, site onboarding, iris-daemon consolidation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scheduler: - iris schedule create — single entry point for ALL job types (agent_task, heartbeat, competitor_intelligence, seo_rank_check, hive_task_dispatch, custom_script, code_workflow) - iris schedule delete — remove scheduled jobs - Added 'schedule' alias for 'schedules' Code Workflows: - iris workflows create --type code --script ./file.js --runtime javascript - Stores script in workflow settings, runs on Hive node via sandbox_execute - Supports javascript, bash, python runtimes Site Onboarding: - iris onboard <url> — extract brand identity + auto-generate Genesis page - iris onboard <url> --extract-only — brand extraction without page creation - Aliases: connect-site Installer: - Updated repo URLs from iris-bridge → iris-daemon (GitHub redirects old URL so no breakage) - macOS, Windows, and scaffold docs all updated Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- install | 4 +- install.ps1 | 4 +- .../opencode/src/cli/cmd/platform-onboard.ts | 273 ++++++++++++++++++ .../src/cli/cmd/platform-schedules.ts | 172 ++++++++++- .../src/cli/cmd/platform-workflows.ts | 42 ++- scaffold/how-to/hive-dispatch.md | 2 +- 6 files changed, 488 insertions(+), 9 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/platform-onboard.ts diff --git a/install b/install index ef2700be002a..6ad275e13b09 100755 --- a/install +++ b/install @@ -975,7 +975,7 @@ install_bridge() { bridge_updated=true else # Fresh clone from public repo - if git clone --quiet https://github.com/FREELABEL/iris-bridge.git "$bridge_dir" 2>/dev/null; then + if git clone --quiet https://github.com/FREELABEL/iris-daemon.git "$bridge_dir" 2>/dev/null; then (cd "$bridge_dir" && npm install --production --silent 2>/dev/null) || { print_message warning "${ORANGE}npm install had issues — bridge may need manual setup${NC}" } @@ -984,7 +984,7 @@ install_bridge() { fi else print_message info "${MUTED}[5/7] Agent Bridge .......................... could not download${NC}" - print_message info "${MUTED} Try manually: ${NC}git clone https://github.com/FREELABEL/iris-bridge.git ~/.iris/bridge" + print_message info "${MUTED} Try manually: ${NC}git clone https://github.com/FREELABEL/iris-daemon.git ~/.iris/bridge" return 0 fi fi diff --git a/install.ps1 b/install.ps1 index dcb322155b73..e19c064bc1cd 100644 --- a/install.ps1 +++ b/install.ps1 @@ -186,13 +186,13 @@ if (-not $HasNode) { $BridgeUpdated = $true } else { try { - git clone --quiet https://github.com/FREELABEL/iris-bridge.git $BridgeDir 2>$null + git clone --quiet https://github.com/FREELABEL/iris-daemon.git $BridgeDir 2>$null Push-Location $BridgeDir npm install --production --silent 2>$null Pop-Location } catch { Write-StepSkipped "5/5" "Agent Bridge" "could not download" - Write-Muted "Try manually: git clone https://github.com/FREELABEL/iris-bridge.git ~\.iris\bridge" + Write-Muted "Try manually: git clone https://github.com/FREELABEL/iris-daemon.git ~\.iris\bridge" } } diff --git a/packages/opencode/src/cli/cmd/platform-onboard.ts b/packages/opencode/src/cli/cmd/platform-onboard.ts new file mode 100644 index 000000000000..52940a51d6ea --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-onboard.ts @@ -0,0 +1,273 @@ +import { cmd } from "./cmd" +import * as prompts from "@clack/prompts" +import { UI } from "../ui" +import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bold, success, highlight } from "./iris-api" + +// ============================================================================ +// iris onboard <url> — connect existing website → branded Genesis page +// ============================================================================ + +export const PlatformOnboardCommand = cmd({ + command: "onboard <url>", + aliases: ["connect-site"], + describe: "connect an existing website — extract brand identity and auto-generate a branded Genesis page", + builder: (y) => + y + .positional("url", { describe: "website URL to onboard", type: "string", demandOption: true }) + .option("slug", { describe: "page slug (auto-generated from brand name if omitted)", type: "string" }) + .option("owner-type", { describe: "page owner type", type: "string", default: "bloq" }) + .option("owner-id", { describe: "page owner ID", type: "number", default: 38 }) + .option("no-publish", { describe: "create as draft (don't auto-publish)", type: "boolean", default: false }) + .option("save-to-lead", { describe: "also save brand data to this lead ID", type: "number" }) + .option("extract-only", { describe: "only extract brand identity, don't create page", type: "boolean", default: false }), + async handler(args) { + UI.empty() + const url = args.url as string + prompts.intro(`◈ Onboard: ${url}`) + if (!(await requireAuth())) { prompts.outro("Done"); return } + + const sp = prompts.spinner() + + if (args["extract-only"]) { + // Brand extraction only mode + sp.start("Extracting brand identity…") + try { + const payload: Record<string, unknown> = { + tool: "websiteBrandExtractor", + params: { + url, + save_to_lead_id: args["save-to-lead"] ?? null, + }, + } + const res = await irisFetch("/api/v6/workspace/tools/execute", { method: "POST", body: JSON.stringify(payload) }) + if (!(await handleApiError(res, "Brand extraction"))) { sp.stop("Failed", 1); prompts.outro("Done"); return } + const data = await res.json() as any + const result = data?.result ? (typeof data.result === "string" ? JSON.parse(data.result) : data.result) : data + + if (!result?.success) { + sp.stop("Failed", 1) + prompts.log.error(result?.error ?? "Brand extraction failed") + prompts.outro("Done") + return + } + + sp.stop(success("Brand extracted")) + const brand = result.brand ?? {} + printDivider() + printKV("Brand", brand.brand_name ?? "Unknown") + printKV("Tagline", brand.tagline ?? "—") + printKV("Primary", brand.colors?.primary ?? "—") + printKV("Secondary", brand.colors?.secondary ?? "—") + printKV("Accent", brand.colors?.accent ?? "—") + printKV("Background", brand.colors?.background ?? "—") + printKV("Fonts", (brand.font_families ?? []).join(", ") || "—") + printKV("Style", brand.design_style ?? "—") + printKV("Theme", brand.theme_mode ?? "—") + + if (brand.logo_urls?.length) { + printKV("Logo", brand.logo_urls[0].src ?? "—") + } + + const socialLinks = Object.entries(brand.social_links ?? {}).filter(([, v]) => v) + if (socialLinks.length > 0) { + printKV("Social", socialLinks.map(([k]) => k).join(", ")) + } + + if (result.saved_to_lead_id) { + printKV("Saved to Lead", `#${result.saved_to_lead_id}`) + } + + printDivider() + prompts.outro(dim("Use iris onboard <url> (without --extract-only) to create a page")) + return + } catch (err) { + sp.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + return + } + } + + // Full onboarding: extract brand + create page + sp.start("Scraping brand identity…") + + try { + // Step 1: Extract brand + const extractPayload = { + tool: "websiteBrandExtractor", + params: { url }, + } + const extractRes = await irisFetch("/api/v6/workspace/tools/execute", { method: "POST", body: JSON.stringify(extractPayload) }) + if (!(await handleApiError(extractRes, "Brand extraction"))) { sp.stop("Failed", 1); prompts.outro("Done"); return } + const extractData = await extractRes.json() as any + const extractResult = extractData?.result ? (typeof extractData.result === "string" ? JSON.parse(extractData.result) : extractData.result) : extractData + + if (!extractResult?.success) { + sp.stop("Failed", 1) + prompts.log.error(extractResult?.error ?? "Brand extraction failed") + prompts.outro("Done") + return + } + + const brand = extractResult.brand ?? {} + const brandName = brand.brand_name ?? new URL(url).hostname + sp.stop(success(`Brand: ${brandName}`)) + + // Show brand summary + printDivider() + printKV("Brand", brandName) + printKV("Primary", brand.colors?.primary ?? "—") + printKV("Secondary", brand.colors?.secondary ?? "—") + printKV("Style", brand.design_style ?? "—") + printKV("Theme", brand.theme_mode ?? "—") + printDivider() + + // Step 2: Create page + const pageSlug = args.slug ?? brandName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") + sp.start(`Creating page: ${pageSlug}…`) + + // Build page JSON from brand data + const colors = brand.colors ?? {} + const primary = colors.primary ?? "#3b82f6" + const secondary = colors.secondary ?? "#8b5cf6" + const themeMode = brand.theme_mode ?? "dark" + const background = colors.background ?? (themeMode === "dark" ? "#0a0a0a" : "#ffffff") + + const navLinks = (brand.navigation ?? []).slice(0, 5).map((n: any) => ({ + label: n.label ?? "", + url: n.href ?? "#", + })) + + const logoUrl = brand.logo_urls?.[0]?.src + const logo = logoUrl ? { text: null, url: "/", imageUrl: logoUrl } : { text: brandName, url: "/" } + + const ctaButton = brand.ctas?.[0] + ? { text: brand.ctas[0].text, url: brand.ctas[0].href ?? "#" } + : { text: "Get Started", url: "#contact" } + + const socialLinks = Object.entries(brand.social_links ?? {}) + .filter(([, v]) => v) + .map(([k, v]) => ({ label: k.charAt(0).toUpperCase() + k.slice(1), url: v as string })) + + const jsonContent = { + version: "1.0", + type: "landing", + theme: { + mode: themeMode, + backgroundColor: background, + branding: { + name: brandName, + primaryColor: primary, + secondaryColor: secondary, + }, + }, + components: [ + { + type: "SiteNavigation", + id: "nav-1", + props: { + logo, + links: navLinks.length > 0 ? navLinks : [{ label: "Home", url: "#" }, { label: "About", url: "#about" }], + ctaButton, + themeMode, + }, + }, + { + type: "Hero", + id: "hero-1", + props: { + title: brandName, + subtitle: brand.tagline ?? extractResult.page_description ?? "", + backgroundGradient: `linear-gradient(135deg, ${primary} 0%, ${secondary} 100%)`, + themeMode, + textAlign: "center", + minHeight: "500px", + }, + }, + { + type: "TextBlock", + id: "about-1", + props: { + title: `About ${brandName}`, + content: extractResult.page_description ?? brand.tagline ?? `Welcome to ${brandName}.`, + themeMode, + }, + }, + { + type: "ButtonCTA", + id: "cta-1", + props: { + text: ctaButton.text, + url: ctaButton.url, + themeMode, + }, + }, + { + type: "SiteFooter", + id: "footer-1", + props: { + logo, + tagline: brand.tagline ?? brandName, + columns: [ + { title: "Navigation", links: navLinks.length > 0 ? navLinks : [{ label: "Home", url: "#" }] }, + ...(socialLinks.length > 0 ? [{ title: "Social", links: socialLinks.slice(0, 5) }] : []), + ], + themeMode, + }, + }, + ], + } + + const pagePayload: Record<string, unknown> = { + slug: pageSlug, + title: brandName, + seo_title: extractResult.page_title ?? brandName, + seo_description: extractResult.page_description ?? brand.tagline ?? "", + owner_type: args["owner-type"], + owner_id: args["owner-id"], + status: "draft", + json_content: jsonContent, + auto_publish: !args["no-publish"], + ai_source_type: "site_onboarding", + ai_source_prompt: `Auto-generated from ${url}`, + } + + const pageRes = await irisFetch("/api/v1/pages", { method: "POST", body: JSON.stringify(pagePayload) }) + if (!(await handleApiError(pageRes, "Create page"))) { sp.stop("Failed", 1); prompts.outro("Done"); return } + const pageData = (await pageRes.json()) as { data?: any } + const page = pageData?.data ?? pageData + + sp.stop(success(`Created #${page.id}`)) + + // Save brand to lead if requested + if (args["save-to-lead"]) { + const leadPayload = { + tool: "websiteBrandExtractor", + params: { url, save_to_lead_id: args["save-to-lead"] }, + } + await irisFetch("/api/v6/workspace/tools/execute", { method: "POST", body: JSON.stringify(leadPayload) }).catch(() => {}) + } + + printDivider() + printKV("Page ID", page.id) + printKV("Slug", page.slug ?? pageSlug) + printKV("Status", args["no-publish"] ? "draft" : "published") + + const env = process.env.IRIS_ENV ?? "production" + const pageUrl = env === "local" + ? `http://local.iris.freelabel.net:9300/p/${pageSlug}` + : `https://heyiris.io/p/${pageSlug}` + printKV("URL", pageUrl) + printDivider() + + prompts.log.info(dim(`Edit: iris pages set ${pageSlug} "theme.branding.primaryColor" "${primary}"`)) + prompts.log.info(dim(`View: iris pages view ${pageSlug}`)) + prompts.outro(success("Site onboarded")) + + } catch (err) { + sp.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, +}) diff --git a/packages/opencode/src/cli/cmd/platform-schedules.ts b/packages/opencode/src/cli/cmd/platform-schedules.ts index 626b22e5fea1..efe3d2a21383 100644 --- a/packages/opencode/src/cli/cmd/platform-schedules.ts +++ b/packages/opencode/src/cli/cmd/platform-schedules.ts @@ -291,20 +291,190 @@ const SchedulesToggleCommand = cmd({ }, }) +const SchedulesCreateCommand = cmd({ + command: "create", + describe: "create a scheduled job (any type: agent, heartbeat, competitor crawl, SEO check, hive)", + builder: (yargs) => + yargs + .option("type", { + describe: "job type", + type: "string", + choices: ["agent_task", "heartbeat", "competitor_intelligence", "seo_rank_check", "hive_task_dispatch", "custom_script", "code_workflow"], + demandOption: true, + }) + .option("frequency", { + describe: "run frequency", + type: "string", + choices: ["once", "hourly", "every_2_hours", "every_4_hours", "every_6_hours", "every_8_hours", "every_12_hours", "daily", "weekdays", "weekly", "monthly"], + default: "daily", + }) + .option("agent", { describe: "agent ID (required — used for scheduling)", type: "number", demandOption: true }) + .option("name", { describe: "job name/task_name", type: "string" }) + .option("prompt", { describe: "task prompt (for agent_task type)", type: "string" }) + .option("time", { describe: "time of day to run (HH:MM, 24h)", type: "string", default: "09:00" }) + .option("timezone", { describe: "timezone", type: "string", default: "America/New_York" }) + .option("max-runs", { describe: "max number of executions (null = unlimited)", type: "number" }) + .option("params", { describe: "JSON params for tool jobs (e.g., sources, keywords, domain)", type: "string" }) + .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Create Schedule: ${args.type}`) + + const token = await requireAuth() + if (!token) { prompts.outro("Done"); return } + + const userId = await requireUserId(args["user-id"]) + if (!userId) { prompts.outro("Done"); return } + + // Parse params JSON + let params: Record<string, unknown> = {} + if (args.params) { + try { + params = JSON.parse(args.params) + } catch { + prompts.log.error("Invalid JSON in --params") + prompts.outro("Done") + return + } + } + + // Build default names based on type + const typeNames: Record<string, string> = { + agent_task: "Scheduled Agent Task", + heartbeat: "Heartbeat", + competitor_intelligence: "Competitor Intelligence Crawl", + seo_rank_check: "SEO Rank Check", + hive_task_dispatch: "Hive Task Dispatch", + custom_script: "Custom Script", + code_workflow: "Code Workflow", + } + const taskName = args.name ?? typeNames[args.type] ?? args.type + + // Build default prompts based on type + const typePrompts: Record<string, string> = { + agent_task: args.prompt ?? "Execute scheduled task", + heartbeat: "Run heartbeat check", + competitor_intelligence: "Crawl competitor sources and ingest into knowledge base", + seo_rank_check: "Check keyword rankings vs competitors", + hive_task_dispatch: "Dispatch Hive campaign task", + custom_script: "Execute custom script on Hive node", + code_workflow: "Execute code workflow on Hive node", + } + const prompt = args.prompt ?? typePrompts[args.type] ?? taskName + + const payload: Record<string, unknown> = { + agent_id: args.agent, + task_name: taskName, + prompt, + time: args.time, + frequency: args.frequency, + timezone: args.timezone, + data: { + type: args.type, + params, + ...params, + }, + } + + if (args["max-runs"]) { + payload.max_runs = args["max-runs"] + } + + const spinner = prompts.spinner() + spinner.start("Creating schedule…") + + try { + const res = await irisFetch(`/api/v1/users/${userId}/bloqs/scheduled-jobs`, { + method: "POST", + body: JSON.stringify(payload), + }) + const ok = await handleApiError(res, "Create schedule") + if (!ok) { spinner.stop("Failed", 1); prompts.outro("Done"); return } + + const data = (await res.json()) as { data?: any } + const job = data?.data ?? data + spinner.stop(success(`Created #${job.id ?? "?"}`)) + + printDivider() + printKV("ID", job.id) + printKV("Type", args.type) + printKV("Name", taskName) + printKV("Frequency", args.frequency) + printKV("Time", args.time) + printKV("Agent", args.agent) + printKV("Status", job.status ?? "scheduled") + printKV("Next Run", job.next_run_at ?? "pending") + if (Object.keys(params).length > 0) { + printKV("Params", JSON.stringify(params).slice(0, 100)) + } + printDivider() + + prompts.log.info(dim(`iris schedule list — view all schedules`)) + prompts.log.info(dim(`iris schedule run ${job.id ?? "<id>"} — trigger now`)) + prompts.outro(success("Schedule created")) + } catch (err) { + spinner.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, +}) + +const SchedulesDeleteCommand = cmd({ + command: "delete <id>", + aliases: ["rm"], + describe: "delete a scheduled job", + builder: (yargs) => + yargs + .positional("id", { describe: "schedule ID", type: "number", demandOption: true }) + .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Delete Schedule #${args.id}`) + + const token = await requireAuth() + if (!token) { prompts.outro("Done"); return } + + const userId = await requireUserId(args["user-id"]) + if (!userId) { prompts.outro("Done"); return } + + const spinner = prompts.spinner() + spinner.start("Deleting…") + + try { + const res = await irisFetch(`/api/v1/users/${userId}/bloqs/scheduled-jobs/${args.id}`, { + method: "DELETE", + }) + const ok = await handleApiError(res, "Delete schedule") + if (!ok) { spinner.stop("Failed", 1); prompts.outro("Done"); return } + + spinner.stop(success("Deleted")) + prompts.outro("Done") + } catch (err) { + spinner.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, +}) + // ============================================================================ // Root command // ============================================================================ export const PlatformSchedulesCommand = cmd({ command: "schedules", - describe: "manage agent scheduled jobs", + aliases: ["schedule"], + describe: "manage scheduled jobs — create, list, run, toggle, delete (all job types)", builder: (yargs) => yargs + .command(SchedulesCreateCommand) .command(SchedulesListCommand) .command(SchedulesGetCommand) .command(SchedulesRunCommand) .command(SchedulesHistoryCommand) .command(SchedulesToggleCommand) + .command(SchedulesDeleteCommand) .demandCommand(), async handler() {}, }) diff --git a/packages/opencode/src/cli/cmd/platform-workflows.ts b/packages/opencode/src/cli/cmd/platform-workflows.ts index 55ab383c28a1..e1ed41e04983 100644 --- a/packages/opencode/src/cli/cmd/platform-workflows.ts +++ b/packages/opencode/src/cli/cmd/platform-workflows.ts @@ -390,13 +390,15 @@ const WorkflowsGetCommand = cmd({ const WorkflowsCreateCommand = cmd({ command: "create", - describe: "create a new workflow", + describe: "create a new workflow (visual, agentic, or code)", builder: (yargs) => yargs .option("name", { describe: "workflow name", type: "string" }) .option("description", { describe: "workflow description", type: "string" }) .option("bloq-id", { describe: "bloq ID", type: "number" }) - .option("type", { describe: "workflow type", type: "string" }) + .option("type", { describe: "workflow type (standard, code)", type: "string" }) + .option("script", { describe: "path to script file (for code workflows)", type: "string" }) + .option("runtime", { describe: "script runtime: javascript, bash, python (default: javascript)", type: "string", default: "javascript" }) .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), async handler(args) { UI.empty() @@ -408,10 +410,26 @@ const WorkflowsCreateCommand = cmd({ const userId = await requireUserId(args["user-id"]) if (!userId) { prompts.outro("Done"); return } + // If --script is provided, auto-set type to 'code' + const isCode = args.script || args.type === "code" + let scriptContent = "" + + if (args.script) { + const { existsSync, readFileSync } = await import("fs") + if (!existsSync(args.script)) { + prompts.log.error(`Script file not found: ${args.script}`) + prompts.outro("Done") + return + } + scriptContent = readFileSync(args.script, "utf-8") + } + let name = args.name if (!name) { + const defaultName = args.script ? args.script.split("/").pop()?.replace(/\.[^.]+$/, "") ?? "code-workflow" : "" name = (await prompts.text({ message: "Workflow name", + initialValue: defaultName, validate: (x) => (x && x.length > 0 ? undefined : "Required"), })) as string if (prompts.isCancel(name)) { prompts.outro("Cancelled"); return } @@ -424,7 +442,17 @@ const WorkflowsCreateCommand = cmd({ const payload: Record<string, unknown> = { name } if (args.description) payload.description = args.description if (args["bloq-id"]) payload.bloq_id = args["bloq-id"] - if (args.type) payload.type = args.type + + if (isCode) { + payload.type = "code" + payload.execution_mode = "code" + payload.settings = { + script_content: scriptContent, + runtime: args.runtime, + } + } else if (args.type) { + payload.type = args.type + } const res = await irisFetch(`/api/v1/users/${userId}/bloqs/workflows`, { method: "POST", @@ -440,8 +468,16 @@ const WorkflowsCreateCommand = cmd({ printDivider() printKV("ID", w.id) printKV("Name", w.name) + printKV("Type", isCode ? "code" : (w.type ?? "standard")) + if (isCode) { + printKV("Runtime", args.runtime) + printKV("Script", `${scriptContent.length} chars`) + } printDivider() + if (isCode) { + prompts.log.info(dim(`iris workflows run ${w.id} — execute on Hive node`)) + } prompts.outro(dim(`iris workflows get ${w.id}`)) } catch (err) { spinner.stop("Error", 1) diff --git a/scaffold/how-to/hive-dispatch.md b/scaffold/how-to/hive-dispatch.md index 945d64f0aacf..50e686400465 100644 --- a/scaffold/how-to/hive-dispatch.md +++ b/scaffold/how-to/hive-dispatch.md @@ -15,7 +15,7 @@ If the daemon directory doesn't exist: ```bash $ ls ~/.iris/bridge/daemon.js # If missing, re-run the IRIS installer with Node present, OR clone manually: -$ git clone https://github.com/FREELABEL/iris-bridge.git ~/.iris/bridge && cd ~/.iris/bridge && npm install --production +$ git clone https://github.com/FREELABEL/iris-daemon.git ~/.iris/bridge && cd ~/.iris/bridge && npm install --production ``` ## Step 1: Start the daemon From a31e7a019759629faf60885296f4cf9607ff55c4 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Sun, 12 Apr 2026 22:30:16 -0500 Subject: [PATCH 10/35] chore: bump version to 1.1.21 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- packages/opencode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 58f4cd8905a6..074acf41c473 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.1.20", + "version": "1.1.21", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", From ae3afbd205d39904cc621383122269f26acd4ec1 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Sun, 12 Apr 2026 22:52:15 -0500 Subject: [PATCH 11/35] Add billing flags to iris proposals create command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New flags: --interval, --duration, --deposit, --brand-logo Renamed: --no-contract → --skip-contract, --no-send → --skip-send (yargs treats --no-X as boolean negation which caused silent failures) Passes interval, duration_months, deposit_percent, brand_logo_url to the /payment-gate API endpoint. Output shows billing summary (amount/interval, duration, total, deposit). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../src/cli/cmd/platform-proposals.ts | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-proposals.ts b/packages/opencode/src/cli/cmd/platform-proposals.ts index 152eae160df0..dd5b7b614d79 100644 --- a/packages/opencode/src/cli/cmd/platform-proposals.ts +++ b/packages/opencode/src/cli/cmd/platform-proposals.ts @@ -15,10 +15,14 @@ const ProposalsCreateCommand = cmd({ .positional("lead-id", { describe: "lead ID", type: "number", demandOption: true }) .option("amount", { alias: "a", describe: "total amount ($)", type: "number" }) .option("scope", { alias: "s", describe: "scope of work", type: "string" }) + .option("interval", { alias: "i", describe: "billing interval: month|quarter|year|one-time", type: "string" }) + .option("duration", { alias: "d", describe: "duration in months (default: 12)", type: "number" }) + .option("deposit", { describe: "deposit percentage 0-100", type: "number" }) + .option("brand-logo", { describe: "brand logo URL for proposal header", type: "string" }) .option("package", { alias: "p", describe: "service package ID (auto-fills amount + scope)", type: "number" }) .option("template", { alias: "t", describe: "proposal template name", type: "string" }) - .option("no-contract", { describe: "skip contract attachment", type: "boolean" }) - .option("no-send", { describe: "generate but don't send to client", type: "boolean" }) + .option("skip-contract", { describe: "skip contract attachment", type: "boolean" }) + .option("skip-send", { describe: "generate but don't send to client", type: "boolean" }) .option("json", { describe: "JSON output", type: "boolean" }), async handler(args) { if (!(await requireAuth())) return @@ -74,8 +78,12 @@ const ProposalsCreateCommand = cmd({ generate_proposal: true, } if (args.package) body.package_id = args.package - if (args["no-contract"]) body.skip_contract = true + if (args["skip-contract"]) body.skip_contract = true if (args.template) body.template = args.template + if (args.interval) body.interval = args.interval + if (args.duration) body.duration_months = args.duration + if (args.deposit !== undefined) body.deposit_percent = args.deposit + if (args["brand-logo"]) body.brand_logo_url = args["brand-logo"] const spinner = prompts.spinner() spinner.start("Generating proposal...") @@ -110,7 +118,17 @@ const ProposalsCreateCommand = cmd({ console.log(success("Proposal created!")) printDivider() printKV("Lead", `${leadData.name ?? leadData.first_name ?? ""} (#${leadId})`) - printKV("Amount", `$${Number(amount).toFixed(2)}`) + printKV("Amount", `$${Number(amount).toFixed(2)}${args.interval && args.interval !== "one-time" ? "/" + args.interval : ""}`) + if (args.interval && args.interval !== "one-time") { + const dur = args.duration ?? 12 + const total = Number(amount) * dur + printKV("Duration", `${dur} months`) + printKV("Total", `$${total.toFixed(2)}`) + if (args.deposit) { + const dep = total * (args.deposit / 100) + printKV("Deposit", `${args.deposit}% = $${dep.toFixed(2)} upfront`) + } + } printKV("Scope", String(scope)) printDivider() printKV("Proposal URL", data.proposal_url ?? dim("(not generated)")) @@ -118,7 +136,7 @@ const ProposalsCreateCommand = cmd({ printKV("Payment URL", data.stripe_checkout_url ?? dim("(not configured)")) printDivider() - if (!args["no-send"] && data.proposal_url) { + if (!args["skip-send"] && data.proposal_url) { console.log("") console.log(dim("Proposal ready to send. Use outreach or share the URL above.")) } From eca1826e384b893ef10a2b0f5e8bc901b388b629 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Sun, 12 Apr 2026 22:52:43 -0500 Subject: [PATCH 12/35] =?UTF-8?q?feat:=20component=20validation=20on=20pag?= =?UTF-8?q?es=20push=20=E2=80=94=20rejects=20invalid=20types=20before=20up?= =?UTF-8?q?load?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validateComponents() checks every component against VALID_COMPONENT_TYPES set before sending to API. Catches hallucinated types like ImageShowcase, YouTubeSection, HeroSection and shows the full valid type list + command to run. Agent self-corrects from the error output. Also adds pages.md how-to recipe and manifest entry. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../opencode/src/cli/cmd/platform-pages.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/packages/opencode/src/cli/cmd/platform-pages.ts b/packages/opencode/src/cli/cmd/platform-pages.ts index 5e52987e7f79..d50c3423ffb0 100644 --- a/packages/opencode/src/cli/cmd/platform-pages.ts +++ b/packages/opencode/src/cli/cmd/platform-pages.ts @@ -336,6 +336,18 @@ const PushCmd = cmd({ return } + // Validate component types BEFORE pushing + const validation = validateComponents(jsonContent) + if (!validation.valid) { + sp.stop("Validation failed", 1) + for (const err of validation.errors) { + if (err === "") console.log() + else prompts.log.error(err) + } + prompts.outro("Done") + return + } + const updateData: Record<string, unknown> = { json_content: jsonContent } if (local.title) updateData.title = local.title if (local.seo_title) updateData.seo_title = local.seo_title @@ -657,6 +669,47 @@ const RollbackCmd = cmd({ }, }) +// ============================================================================ +// Component Validation — reject invalid types before push/create +// ============================================================================ + +const VALID_COMPONENT_TYPES = new Set([ + "Hero", "SiteNavigation", "SiteFooter", "AnnouncementBanner", + "TestimonialsSection", "TeamSection", "ContactSection", "LogoMarquee", + "FeatureShowcase", "ComparisonMatrix", "ClientGrid", "CareersListing", + "PortfolioGallery", "ProductGrid", "ServiceMenu", "EventGrid", + "FundingTiers", "BeforeAfter", "MapSection", "NewsletterSignup", + "StepWizard", "FileUpload", "ShoppingCart", "OrderConfirmation", + "ProtectionPicker", "VehicleGrid", +]) + +function validateComponents(jsonContent: any): { valid: boolean; errors: string[] } { + const components = jsonContent?.components ?? [] + const errors: string[] = [] + + for (let i = 0; i < components.length; i++) { + const c = components[i] + if (!c?.type) { + errors.push(`components[${i}]: missing "type" field`) + continue + } + if (!VALID_COMPONENT_TYPES.has(c.type)) { + errors.push(`components[${i}]: "${c.type}" is not a valid component type`) + } + if (!c.id) { + errors.push(`components[${i}] (${c.type}): missing "id" field`) + } + } + + if (errors.length > 0) { + errors.push("") + errors.push(`Valid types: ${[...VALID_COMPONENT_TYPES].join(", ")}`) + errors.push(`Run: iris pages component-registry`) + } + + return { valid: errors.length === 0, errors } +} + // ============================================================================ // Component Registry — available component types for the page builder // ============================================================================ From be975ed9a286a4d21f71898614ea471d918c7033 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Sun, 12 Apr 2026 23:01:29 -0500 Subject: [PATCH 13/35] =?UTF-8?q?feat:=20iris=20pages=20compose=20?= =?UTF-8?q?=E2=80=94=20AI=20page=20builder=20with=20self-healing=20validat?= =?UTF-8?q?ion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New CLI command: iris pages compose "build a streaming page for my channel" - Sends description to iris-api compose endpoint - Shows page URL, component count, self-heal attempts - Supports --slug, --title, --theme, --style, --model flags Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../opencode/src/cli/cmd/platform-pages.ts | 83 ++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/platform-pages.ts b/packages/opencode/src/cli/cmd/platform-pages.ts index d50c3423ffb0..5dc2c7332f16 100644 --- a/packages/opencode/src/cli/cmd/platform-pages.ts +++ b/packages/opencode/src/cli/cmd/platform-pages.ts @@ -1,7 +1,7 @@ import { cmd } from "./cmd" import * as prompts from "@clack/prompts" import { UI } from "../ui" -import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bold, success, highlight, IRIS_API } from "./iris-api" +import { irisFetch, requireAuth, requireUserId, resolveUserId, handleApiError, printDivider, printKV, dim, bold, success, highlight, IRIS_API } from "./iris-api" import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs" import { join } from "path" @@ -741,6 +741,86 @@ const COMPONENT_REGISTRY: { type: string; description: string; requiredProps: st { type: "OrderConfirmation", description: "Order confirmation/receipt page", requiredProps: [] }, ] +const ComposeCmd = cmd({ + command: "compose <description..>", + describe: "AI-compose a page from a text description (uses Gemini)", + builder: (y) => + y + .positional("description", { describe: "what the page should be", type: "string", array: true }) + .option("slug", { describe: "page slug (auto-generated if omitted)", type: "string" }) + .option("title", { describe: "page title", type: "string" }) + .option("theme", { describe: "dark or light", type: "string", default: "dark", choices: ["dark", "light"] }) + .option("style", { describe: "page style", type: "string", default: "landing", choices: ["landing", "dashboard", "product", "portfolio"] }) + .option("model", { describe: "AI model override", type: "string" }) + .option("json", { type: "boolean" }), + async handler(args) { + UI.empty() + const desc = (args.description as string[]).join(" ") + prompts.intro(`◈ Compose Page`) + if (!(await requireAuth())) { prompts.outro("Done"); return } + + const userId = await resolveUserId() + if (!userId) { prompts.outro("Done"); return } + + const sp = prompts.spinner() + sp.start("Composing with AI (this may take 10-30s)…") + + try { + const payload: Record<string, unknown> = { + description: desc, + user_id: userId, + style: args.style, + theme_mode: args.theme, + } + if (args.slug) payload.slug = args.slug + if (args.title) payload.title = args.title + if (args.model) payload.model = args.model + + const res = await pagesFetch("/api/v1/pages/compose", { + method: "POST", + body: JSON.stringify(payload), + }) + + if (!res.ok) { + const body = await res.json().catch(() => ({})) as any + sp.stop("Failed", 1) + prompts.log.error(body.error ?? body.message ?? `HTTP ${res.status}`) + prompts.outro("Done") + return + } + + const data = (await res.json()) as any + if (!data.success) { + sp.stop("Failed", 1) + prompts.log.error(data.error ?? "Composition failed") + prompts.outro("Done") + return + } + + sp.stop(success(`Created "${data.slug}"`)) + printDivider() + printKV("Page ID", data.page_id) + printKV("Slug", data.slug) + printKV("URL", data.url) + printKV("Components", data.component_count ?? data.components?.length) + if (data.self_heal_attempts) printKV("Self-heal attempts", data.self_heal_attempts) + printDivider() + + if (args.json) { + console.log(JSON.stringify(data, null, 2)) + } + + prompts.log.info(`View: ${dim(`iris pages view ${data.slug}`)}`) + prompts.log.info(`Edit: ${dim(`iris pages pull ${data.slug}`)}`) + prompts.outro("Done") + } catch (err) { + sp.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, +}) + const ComponentRegistryCmd = cmd({ command: "component-registry", aliases: ["registry", "available-components"], @@ -796,6 +876,7 @@ export const PlatformPagesCommand = cmd({ .command(UnpublishCmd) .command(CreateCmd) .command(ComponentsCmd) + .command(ComposeCmd) .command(ComponentRegistryCmd) .command(VersionsCmd) .command(RollbackCmd) From 3f77341d81b8afdb34fed800c05164a14b24d51d Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Sun, 12 Apr 2026 23:12:19 -0500 Subject: [PATCH 14/35] Add --rev-share flag to iris proposals create Passes rev_share_percent to the payment-gate API. Output shows "Rev Share: X% of net platform revenue" in the billing summary. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- packages/opencode/src/cli/cmd/platform-proposals.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/opencode/src/cli/cmd/platform-proposals.ts b/packages/opencode/src/cli/cmd/platform-proposals.ts index dd5b7b614d79..e3d2dad12c9c 100644 --- a/packages/opencode/src/cli/cmd/platform-proposals.ts +++ b/packages/opencode/src/cli/cmd/platform-proposals.ts @@ -18,6 +18,7 @@ const ProposalsCreateCommand = cmd({ .option("interval", { alias: "i", describe: "billing interval: month|quarter|year|one-time", type: "string" }) .option("duration", { alias: "d", describe: "duration in months (default: 12)", type: "number" }) .option("deposit", { describe: "deposit percentage 0-100", type: "number" }) + .option("rev-share", { describe: "revenue share percentage (e.g. 5)", type: "number" }) .option("brand-logo", { describe: "brand logo URL for proposal header", type: "string" }) .option("package", { alias: "p", describe: "service package ID (auto-fills amount + scope)", type: "number" }) .option("template", { alias: "t", describe: "proposal template name", type: "string" }) @@ -84,6 +85,7 @@ const ProposalsCreateCommand = cmd({ if (args.duration) body.duration_months = args.duration if (args.deposit !== undefined) body.deposit_percent = args.deposit if (args["brand-logo"]) body.brand_logo_url = args["brand-logo"] + if (args["rev-share"] !== undefined) body.rev_share_percent = args["rev-share"] const spinner = prompts.spinner() spinner.start("Generating proposal...") @@ -128,6 +130,9 @@ const ProposalsCreateCommand = cmd({ const dep = total * (args.deposit / 100) printKV("Deposit", `${args.deposit}% = $${dep.toFixed(2)} upfront`) } + if (args["rev-share"]) { + printKV("Rev Share", `${args["rev-share"]}% of net platform revenue`) + } } printKV("Scope", String(scope)) printDivider() From fe61871d15ed820ea543accbbbfe64b8baaba3b3 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Mon, 13 Apr 2026 09:04:20 -0500 Subject: [PATCH 15/35] Add --pass-fees flag to iris proposals create Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- packages/opencode/src/cli/cmd/platform-proposals.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/opencode/src/cli/cmd/platform-proposals.ts b/packages/opencode/src/cli/cmd/platform-proposals.ts index e3d2dad12c9c..c68bfcce96ec 100644 --- a/packages/opencode/src/cli/cmd/platform-proposals.ts +++ b/packages/opencode/src/cli/cmd/platform-proposals.ts @@ -19,6 +19,7 @@ const ProposalsCreateCommand = cmd({ .option("duration", { alias: "d", describe: "duration in months (default: 12)", type: "number" }) .option("deposit", { describe: "deposit percentage 0-100", type: "number" }) .option("rev-share", { describe: "revenue share percentage (e.g. 5)", type: "number" }) + .option("pass-fees", { describe: "pass processing fees to client (Stripe 2.9% + $0.30)", type: "boolean" }) .option("brand-logo", { describe: "brand logo URL for proposal header", type: "string" }) .option("package", { alias: "p", describe: "service package ID (auto-fills amount + scope)", type: "number" }) .option("template", { alias: "t", describe: "proposal template name", type: "string" }) @@ -86,6 +87,7 @@ const ProposalsCreateCommand = cmd({ if (args.deposit !== undefined) body.deposit_percent = args.deposit if (args["brand-logo"]) body.brand_logo_url = args["brand-logo"] if (args["rev-share"] !== undefined) body.rev_share_percent = args["rev-share"] + if (args["pass-fees"]) body.processing_fee_mode = "pass_to_client" const spinner = prompts.spinner() spinner.start("Generating proposal...") From 0d4ab585d60508651c69a8e0c855735fbb1af3ab Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Mon, 13 Apr 2026 09:21:08 -0500 Subject: [PATCH 16/35] =?UTF-8?q?feat:=20improved=20schedules=20list=20?= =?UTF-8?q?=E2=80=94=20table=20view,=20countdown=20timer,=20--active=20fil?= =?UTF-8?q?ter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Table format: ID, Status, Frequency, Next Run (countdown), Name, Task Type - --active flag: hides 670+ completed one-offs, shows only scheduled/running/paused - Countdown: shows time until next run (7h, 45m, overdue) - Task type labels from data.task_type (discover, heartbeat, etc.) - Prompt preview on second line - --json flag with time_until field Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../src/cli/cmd/platform-schedules.ts | 106 +++++++++++++++--- 1 file changed, 93 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-schedules.ts b/packages/opencode/src/cli/cmd/platform-schedules.ts index efe3d2a21383..171352bc7936 100644 --- a/packages/opencode/src/cli/cmd/platform-schedules.ts +++ b/packages/opencode/src/cli/cmd/platform-schedules.ts @@ -21,15 +21,48 @@ function statusColor(status: string): string { return `${c}${status}${UI.Style.TEXT_NORMAL}` } -function printSchedule(s: Record<string, unknown>): void { - const name = bold(String(s.name ?? s.title ?? `Schedule #${s.id}`)) +function timeUntil(dateStr: string | null | undefined): string { + if (!dateStr) return "" + const now = Date.now() + const target = new Date(String(dateStr)).getTime() + const diff = target - now + if (isNaN(target)) return "" + if (diff < 0) return "overdue" + if (diff < 60_000) return `${Math.round(diff / 1000)}s` + if (diff < 3600_000) return `${Math.round(diff / 60_000)}m` + if (diff < 86400_000) return `${Math.round(diff / 3600_000)}h` + return `${Math.round(diff / 86400_000)}d` +} + +function taskLabel(s: Record<string, any>): string { + const data = s.data ?? {} + return data.task_type ?? data.type ?? s.task_name ?? "" +} + +function printSchedule(s: Record<string, any>, showCountdown = true): void { + const name = bold(String(s.name ?? s.title ?? s.task_name ?? `Schedule #${s.id}`)) const id = dim(`#${s.id}`) const status = s.status ? ` ${statusColor(String(s.status))}` : "" const freq = s.frequency ?? s.cron_expression ?? s.interval const freqStr = freq ? ` ${dim(String(freq))}` : "" - console.log(` ${name} ${id}${status}${freqStr}`) - if (s.description) { - console.log(` ${dim(String(s.description).slice(0, 100))}`) + + // Task type label + const tl = taskLabel(s) + const typeStr = tl ? ` ${dim(`[${tl}]`)}` : "" + + // Countdown to next run + let countdown = "" + if (showCountdown && s.next_run_at && s.status === "scheduled") { + const until = timeUntil(String(s.next_run_at)) + countdown = until ? ` ${UI.Style.TEXT_HIGHLIGHT}⏱ ${until}${UI.Style.TEXT_NORMAL}` : "" + } + + console.log(` ${name} ${id}${status}${freqStr}${typeStr}${countdown}`) + + // Show prompt/description on second line + const prompt = s.data?.prompt ?? s.prompt ?? s.description ?? "" + if (prompt) { + console.log(` ${dim(String(prompt).slice(0, 100))}`) } } @@ -43,8 +76,10 @@ const SchedulesListCommand = cmd({ describe: "list scheduled jobs", builder: (yargs) => yargs - .option("limit", { describe: "max results", type: "number", default: 20 }) + .option("limit", { describe: "max results", type: "number", default: 50 }) + .option("active", { describe: "show only active/scheduled/running jobs (hide completed one-offs)", type: "boolean", default: false }) .option("agent-id", { describe: "filter by agent ID", type: "number" }) + .option("json", { describe: "JSON output", type: "boolean" }) .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), async handler(args) { UI.empty() @@ -68,24 +103,65 @@ const SchedulesListCommand = cmd({ if (!ok) { spinner.stop("Failed", 1); prompts.outro("Done"); return } const data = (await res.json()) as { data?: any[]; total?: number } - const schedules: any[] = data?.data ?? [] - spinner.stop(`${schedules.length} schedule(s)`) + let schedules: any[] = data?.data ?? [] + + // --active: filter to scheduled/running/paused only (skip completed/cancelled one-offs) + if (args.active) { + schedules = schedules.filter((s: any) => { + const status = String(s.status ?? "").toLowerCase() + return ["scheduled", "running", "paused", "active", "enabled"].includes(status) + }) + } + + spinner.stop(`${schedules.length} schedule(s)${args.active ? " (active)" : ""}`) + + if (args.json) { + console.log(JSON.stringify(schedules.map((s: any) => ({ + id: s.id, + name: s.name ?? s.title ?? s.task_name, + status: s.status, + frequency: s.frequency, + task_type: taskLabel(s), + prompt: s.data?.prompt ?? s.prompt, + next_run_at: s.next_run_at, + time_until: timeUntil(s.next_run_at), + last_run_at: s.last_run_at, + })), null, 2)) + prompts.outro("Done") + return + } if (schedules.length === 0) { - prompts.log.warn("No schedules found") + prompts.log.warn(args.active ? "No active schedules. Use without --active to see all." : "No schedules found") prompts.outro("Done") return } + // Table header + printDivider() + console.log(` ${dim("ID".padEnd(6))} ${dim("Status".padEnd(12))} ${dim("Freq".padEnd(14))} ${dim("Next Run".padEnd(8))} ${dim("Name / Task")}`) printDivider() + for (const s of schedules) { - printSchedule(s) - console.log() + const id = String(s.id ?? "").padEnd(6) + const status = String(s.status ?? "?").padEnd(12) + const freq = String(s.frequency ?? "-").padEnd(14) + const until = s.next_run_at && s.status === "scheduled" ? timeUntil(s.next_run_at).padEnd(8) : "-".padEnd(8) + const name = String(s.name ?? s.title ?? s.task_name ?? `Schedule #${s.id}`).slice(0, 40) + const tl = taskLabel(s) + const typeTag = tl ? dim(` [${tl}]`) : "" + + const statusStr = statusColor(String(s.status ?? "")) + console.log(` ${dim(id)} ${statusStr.padEnd(12)} ${dim(freq)} ${UI.Style.TEXT_HIGHLIGHT}${until}${UI.Style.TEXT_NORMAL} ${bold(name)}${typeTag}`) + + const prompt = s.data?.prompt ?? s.prompt ?? "" + if (prompt) console.log(` ${" ".repeat(6)} ${dim(String(prompt).slice(0, 80))}`) } printDivider() + prompts.log.info(`${dim("Tip: iris schedules list --active")} — show only running/scheduled jobs`) prompts.outro( - `${dim("iris schedules get <id>")} · ${dim("iris schedules run <id>")}`, + `${dim("iris schedules get <id>")} · ${dim("iris schedules history <id>")}`, ) } catch (err) { spinner.stop("Error", 1) @@ -299,7 +375,7 @@ const SchedulesCreateCommand = cmd({ .option("type", { describe: "job type", type: "string", - choices: ["agent_task", "heartbeat", "competitor_intelligence", "seo_rank_check", "hive_task_dispatch", "custom_script", "code_workflow"], + choices: ["agent_task", "heartbeat", "competitor_intelligence", "seo_rank_check", "hive_task_dispatch", "custom_script", "code_workflow", "browser_workflow", "agentic_browser"], demandOption: true, }) .option("frequency", { @@ -347,6 +423,8 @@ const SchedulesCreateCommand = cmd({ hive_task_dispatch: "Hive Task Dispatch", custom_script: "Custom Script", code_workflow: "Code Workflow", + browser_workflow: "Browser Workflow", + agentic_browser: "AI Browser Agent", } const taskName = args.name ?? typeNames[args.type] ?? args.type @@ -359,6 +437,8 @@ const SchedulesCreateCommand = cmd({ hive_task_dispatch: "Dispatch Hive campaign task", custom_script: "Execute custom script on Hive node", code_workflow: "Execute code workflow on Hive node", + browser_workflow: "Execute Playwright browser automation on Hive node", + agentic_browser: "AI browser agent — give it a goal, it navigates autonomously", } const prompt = args.prompt ?? typePrompts[args.type] ?? taskName From a23e00d8f10b993976fe99363ec88dec57d7e9da Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Mon, 13 Apr 2026 09:32:03 -0500 Subject: [PATCH 17/35] =?UTF-8?q?feat:=20schedules=20list=20shows=20?= =?UTF-8?q?=E2=AC=A1=20hive=20/=20=E2=98=81=20cloud=20execution=20environm?= =?UTF-8?q?ent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Infers from data.type and task_name: - ⬡ hive: hive_task_dispatch, discover, som_batch, social_stats_sync - ☁ cloud: heartbeats, agent tasks, everything else Shows in table + JSON output (env field). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../src/cli/cmd/platform-schedules.ts | 34 ++++++++++++++++--- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-schedules.ts b/packages/opencode/src/cli/cmd/platform-schedules.ts index 171352bc7936..0bb3ee5e78b0 100644 --- a/packages/opencode/src/cli/cmd/platform-schedules.ts +++ b/packages/opencode/src/cli/cmd/platform-schedules.ts @@ -39,6 +39,28 @@ function taskLabel(s: Record<string, any>): string { return data.task_type ?? data.type ?? s.task_name ?? "" } +function executionEnv(s: Record<string, any>): { label: string; icon: string } { + const data = s.data ?? {} + const taskName = String(s.task_name ?? "") + const dataType = String(data.type ?? "") + + // Hive = dispatched to local daemon on user's machine + if (taskName === "hive_task_dispatch" || dataType === "hive_task_dispatch" + || data.task_type === "discover" || data.task_type === "som_batch" + || data.task_type === "som" || data.task_type === "social_stats_sync" + || data.task_type === "leadgen") { + return { label: "hive", icon: "⬡" } + } + + // Heartbeat = runs on iris-api (cloud) + if (dataType === "heartbeat" || taskName === "heartbeat") { + return { label: "cloud", icon: "☁" } + } + + // Agent tasks = runs on fl-api queue worker (cloud) + return { label: "cloud", icon: "☁" } +} + function printSchedule(s: Record<string, any>, showCountdown = true): void { const name = bold(String(s.name ?? s.title ?? s.task_name ?? `Schedule #${s.id}`)) const id = dim(`#${s.id}`) @@ -120,6 +142,7 @@ const SchedulesListCommand = cmd({ id: s.id, name: s.name ?? s.title ?? s.task_name, status: s.status, + env: executionEnv(s).label, frequency: s.frequency, task_type: taskLabel(s), prompt: s.data?.prompt ?? s.prompt, @@ -139,23 +162,24 @@ const SchedulesListCommand = cmd({ // Table header printDivider() - console.log(` ${dim("ID".padEnd(6))} ${dim("Status".padEnd(12))} ${dim("Freq".padEnd(14))} ${dim("Next Run".padEnd(8))} ${dim("Name / Task")}`) + console.log(` ${dim("ID".padEnd(6))} ${dim("Env".padEnd(6))} ${dim("Status".padEnd(12))} ${dim("Freq".padEnd(14))} ${dim("Next".padEnd(8))} ${dim("Name / Task")}`) printDivider() for (const s of schedules) { const id = String(s.id ?? "").padEnd(6) - const status = String(s.status ?? "?").padEnd(12) + const env = executionEnv(s) + const envStr = `${env.icon} ${dim(env.label)}`.padEnd(6) const freq = String(s.frequency ?? "-").padEnd(14) const until = s.next_run_at && s.status === "scheduled" ? timeUntil(s.next_run_at).padEnd(8) : "-".padEnd(8) - const name = String(s.name ?? s.title ?? s.task_name ?? `Schedule #${s.id}`).slice(0, 40) + const name = String(s.name ?? s.title ?? s.task_name ?? `Schedule #${s.id}`).slice(0, 36) const tl = taskLabel(s) const typeTag = tl ? dim(` [${tl}]`) : "" const statusStr = statusColor(String(s.status ?? "")) - console.log(` ${dim(id)} ${statusStr.padEnd(12)} ${dim(freq)} ${UI.Style.TEXT_HIGHLIGHT}${until}${UI.Style.TEXT_NORMAL} ${bold(name)}${typeTag}`) + console.log(` ${dim(id)} ${envStr} ${statusStr} ${dim(freq)} ${UI.Style.TEXT_HIGHLIGHT}${until}${UI.Style.TEXT_NORMAL} ${bold(name)}${typeTag}`) const prompt = s.data?.prompt ?? s.prompt ?? "" - if (prompt) console.log(` ${" ".repeat(6)} ${dim(String(prompt).slice(0, 80))}`) + if (prompt) console.log(` ${" ".repeat(13)} ${dim(String(prompt).slice(0, 75))}`) } printDivider() From 21131dc501636eccdb13f545f25c5d8cfba2049c Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Mon, 13 Apr 2026 09:35:04 -0500 Subject: [PATCH 18/35] feat: LaunchAgent auto-start + schedule list UX improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Installer: - macOS LaunchAgent now installed by main installer (not just standalone) - Daemon auto-starts on login — users never need to manually start it - Wrapper script + plist copied and configured for ~/.iris/bridge path Schedule CLI: - Added countdown timer (⏱ 3h) showing time until next run - Added task type label [competitor_intelligence] on each schedule - Shows prompt/description on second line Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- install | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/install b/install index 6ad275e13b09..833f826ce76b 100755 --- a/install +++ b/install @@ -1157,6 +1157,34 @@ DCTL ln -sf "$bridge_dir/bridgectl" "$INSTALL_DIR/iris-bridge" ln -sf "$bridge_dir/daemonctl" "$INSTALL_DIR/iris-daemon" + # ── macOS LaunchAgent: auto-start daemon on login ── + if [ "$(uname)" = "Darwin" ]; then + local plist_name="io.heyiris.daemon.plist" + local plist_src="$bridge_dir/installers/macos/$plist_name" + local plist_dest="$HOME/Library/LaunchAgents/$plist_name" + local wrapper_src="$bridge_dir/installers/macos/iris-daemon-wrapper.sh" + local wrapper_dest="$HOME/.iris/iris-daemon-wrapper.sh" + + # Copy and configure wrapper script + if [ -f "$wrapper_src" ]; then + cp "$wrapper_src" "$wrapper_dest" + # Point wrapper to ~/.iris/bridge (where main installer puts daemon) + sed -i '' "s|DAEMON_DIR=\"\${IRIS_DIR}/daemon\"|DAEMON_DIR=\"\${IRIS_DIR}/bridge\"|g" "$wrapper_dest" 2>/dev/null || true + chmod 755 "$wrapper_dest" + fi + + # Install plist with HOME path substitution + if [ -f "$plist_src" ]; then + sed "s|__HOME__|$HOME|g" "$plist_src" > "$plist_dest" + # Also fix daemon path to bridge + sed -i '' "s|/.iris/daemon|/.iris/bridge|g" "$plist_dest" 2>/dev/null || true + + # Load (unload first if already loaded) + launchctl unload "$plist_dest" 2>/dev/null || true + launchctl load "$plist_dest" 2>/dev/null || true + fi + fi + if [ "$bridge_updated" = "true" ]; then print_message info "${GREEN}[5/7]${NC} Agent Bridge ${MUTED}.......................${NC} ${GREEN}updated${NC}" else @@ -1164,6 +1192,9 @@ DCTL fi print_message info "${MUTED} Bridge: ${NC}iris-bridge start|stop|status" print_message info "${MUTED} Daemon: ${NC}iris-daemon start|stop|status|register${MUTED} (Hive compute node)${NC}" + if [ "$(uname)" = "Darwin" ]; then + print_message info "${MUTED} Auto-start: ${NC}enabled${MUTED} (starts on login)${NC}" + fi } if [ "$skip_bridge" = "false" ]; then From 5f2772385ada9662bd578a6d4c4d5b66653bf7a6 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Mon, 13 Apr 2026 09:38:12 -0500 Subject: [PATCH 19/35] =?UTF-8?q?feat:=20schedules=20list=20=E2=80=94=204?= =?UTF-8?q?=20execution=20environments=20with=20distinct=20icons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⬡ hive — runs on local machine via daemon (discover, SOM, social stats) ◉ iris — runs on iris-api Railway (heartbeats) ☁ cloud — runs on fl-api Railway (agent tasks, workflows) ⟳ auto — spawned by heartbeat agent during execution ⚡ hook — event listener / webhook triggered Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../src/cli/cmd/platform-schedules.ts | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-schedules.ts b/packages/opencode/src/cli/cmd/platform-schedules.ts index 0bb3ee5e78b0..579e1c6791f0 100644 --- a/packages/opencode/src/cli/cmd/platform-schedules.ts +++ b/packages/opencode/src/cli/cmd/platform-schedules.ts @@ -43,21 +43,32 @@ function executionEnv(s: Record<string, any>): { label: string; icon: string } { const data = s.data ?? {} const taskName = String(s.task_name ?? "") const dataType = String(data.type ?? "") + const taskType = String(data.task_type ?? "") // Hive = dispatched to local daemon on user's machine - if (taskName === "hive_task_dispatch" || dataType === "hive_task_dispatch" - || data.task_type === "discover" || data.task_type === "som_batch" - || data.task_type === "som" || data.task_type === "social_stats_sync" - || data.task_type === "leadgen") { + if (dataType === "hive_task_dispatch" || taskName === "hive_task_dispatch" + || ["discover", "som_batch", "som", "social_stats_sync", "leadgen"].includes(taskType)) { return { label: "hive", icon: "⬡" } } - // Heartbeat = runs on iris-api (cloud) + // Heartbeat = runs on iris-api if (dataType === "heartbeat" || taskName === "heartbeat") { + return { label: "iris", icon: "◉" } + } + + // Agent task = spawned by heartbeat or scheduler, runs on fl-api + if (dataType === "agent_task") { + const source = String(data.source ?? data.created_from ?? "") + if (source.includes("heartbeat")) return { label: "auto", icon: "⟳" } return { label: "cloud", icon: "☁" } } - // Agent tasks = runs on fl-api queue worker (cloud) + // Listener = event-driven + if (dataType === "listener") { + return { label: "hook", icon: "⚡" } + } + + // Unknown / legacy (no type set) return { label: "cloud", icon: "☁" } } From 3389594fc04ef399f7385c9c22d2e05cab90f3bf Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Mon, 13 Apr 2026 09:39:02 -0500 Subject: [PATCH 20/35] feat: auto-start daemon on any iris command Checks localhost:3200 health (500ms timeout). If daemon isn't running, silently spawns iris-daemon start in background. Non-blocking, no output. Users never need to manually start the daemon again. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- packages/opencode/src/index.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 145402a590a9..10a4845315f0 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -275,6 +275,21 @@ if (hasHelp && hasNoCommand) { process.exit(0) } +// Auto-start daemon if not running (silent, non-blocking) +try { + const { join: pathJoin } = await import("path") + const { homedir: osHome } = await import("os") + const { existsSync } = await import("fs") + const daemonCtl = pathJoin(osHome(), ".iris", "bin", "iris-daemon") + if (existsSync(daemonCtl)) { + const health = await fetch("http://localhost:3200/health", { signal: AbortSignal.timeout(500) }).catch(() => null) + if (!health?.ok) { + const { spawn } = await import("child_process") + spawn(daemonCtl, ["start"], { detached: true, stdio: "ignore" }).unref() + } + } +} catch {} + try { await cli.parse() } catch (e) { From b128cbaac31a13db4b637886c7da8addeead37af Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Mon, 13 Apr 2026 09:44:37 -0500 Subject: [PATCH 21/35] =?UTF-8?q?feat:=20iris=20bridge=20(alias:=20iris=20?= =?UTF-8?q?daemon)=20=E2=80=94=20manage=20Hive=20from=20the=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iris bridge start|stop|status|restart|logs|register iris daemon start|stop|status|restart|logs|register (alias) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../opencode/src/cli/cmd/platform-daemon.ts | 149 ++++++++++++++++++ packages/opencode/src/index.ts | 2 + 2 files changed, 151 insertions(+) create mode 100644 packages/opencode/src/cli/cmd/platform-daemon.ts diff --git a/packages/opencode/src/cli/cmd/platform-daemon.ts b/packages/opencode/src/cli/cmd/platform-daemon.ts new file mode 100644 index 000000000000..d747f3580de4 --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-daemon.ts @@ -0,0 +1,149 @@ +import { cmd } from "./cmd" +import * as prompts from "@clack/prompts" +import { UI } from "../ui" +import { dim, bold, success } from "./iris-api" +import { join } from "path" +import { homedir } from "os" +import { existsSync } from "fs" +import { execSync, spawn } from "child_process" + +function printDivider() { + console.log(` ${dim("─".repeat(56))}`) +} + +function printKV(k: string, v: unknown) { + if (v === null || v === undefined || v === "") return + console.log(` ${dim(k + ":")} ${String(v)}`) +} + +function getDaemonCtl(): string | null { + const p = join(homedir(), ".iris", "bin", "iris-daemon") + return existsSync(p) ? p : null +} + +function getBridgeCtl(): string | null { + const p = join(homedir(), ".iris", "bin", "iris-bridge") + return existsSync(p) ? p : null +} + +function runCtl(ctl: string, action: string): string { + try { + return execSync(`${ctl} ${action} 2>&1`, { timeout: 10000 }).toString().trim() + } catch (e: any) { + return e.stdout?.toString?.() || e.stderr?.toString?.() || e.message + } +} + +const DaemonStartCommand = cmd({ + command: "start", + describe: "start the Hive daemon", + async handler() { + const ctl = getDaemonCtl() + if (!ctl) { + prompts.log.error("Daemon not installed. Run: curl -fsSL https://heyiris.io/install-code | bash") + return + } + prompts.log.info("Starting daemon...") + const out = runCtl(ctl, "start") + if (out) console.log(out) + }, +}) + +const DaemonStopCommand = cmd({ + command: "stop", + describe: "stop the Hive daemon", + async handler() { + const ctl = getDaemonCtl() + if (!ctl) { prompts.log.error("Daemon not installed"); return } + const out = runCtl(ctl, "stop") + if (out) console.log(out) + }, +}) + +const DaemonStatusCommand = cmd({ + command: "status", + describe: "show daemon and bridge status", + async handler() { + UI.empty() + prompts.intro("◈ IRIS Daemon") + + // Check daemon health + let daemonUp = false + try { + const res = await fetch("http://localhost:3200/health", { signal: AbortSignal.timeout(2000) }) + if (res.ok) { + daemonUp = true + const data = await res.json() as Record<string, any> + printDivider() + printKV("Status", success("online")) + printKV("Node", data.node_name ?? data.hostname) + printKV("Tasks", data.active_tasks ?? 0) + printKV("Schedules", data.schedules ?? 0) + printKV("CPU", data.cpu_percent ? `${data.cpu_percent}%` : null) + printKV("Memory", data.memory_free ? `${data.memory_free} free` : null) + printKV("API", data.api_url) + printDivider() + } + } catch {} + + if (!daemonUp) { + printDivider() + printKV("Status", "offline") + printDivider() + prompts.log.info(dim("Start with: iris daemon start")) + } + + prompts.outro("Done") + }, +}) + +const DaemonRestartCommand = cmd({ + command: "restart", + describe: "restart the Hive daemon", + async handler() { + const ctl = getDaemonCtl() + if (!ctl) { prompts.log.error("Daemon not installed"); return } + prompts.log.info("Restarting daemon...") + const out = runCtl(ctl, "restart") + if (out) console.log(out) + }, +}) + +const DaemonLogsCommand = cmd({ + command: "logs", + describe: "show daemon logs", + async handler() { + const ctl = getDaemonCtl() + if (!ctl) { prompts.log.error("Daemon not installed"); return } + const out = runCtl(ctl, "logs") + if (out) console.log(out) + }, +}) + +const DaemonRegisterCommand = cmd({ + command: "register", + describe: "register this machine as a Hive compute node", + async handler() { + const ctl = getDaemonCtl() + if (!ctl) { prompts.log.error("Daemon not installed"); return } + prompts.log.info("Registering node...") + const out = runCtl(ctl, "register") + if (out) console.log(out) + }, +}) + +export const PlatformDaemonCommand = cmd({ + command: "bridge", + aliases: ["daemon"], + describe: "manage the IRIS bridge — start, stop, status, restart, logs, register", + builder: (yargs) => + yargs + .command(DaemonStartCommand) + .command(DaemonStopCommand) + .command(DaemonStatusCommand) + .command(DaemonRestartCommand) + .command(DaemonLogsCommand) + .command(DaemonRegisterCommand) + .demandCommand(), + async handler() {}, +}) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 10a4845315f0..adc25f44aacd 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -67,6 +67,7 @@ import { PlatformBugCommand } from "./cli/cmd/platform-bug" import { PlatformAtlasMeetingsCommand } from "./cli/cmd/platform-atlas-meetings" import { PlatformAtlasBrandKitCommand } from "./cli/cmd/platform-atlas-brand-kit" import { PlatformLeadsMeetingCommand } from "./cli/cmd/platform-leads-meeting" +import { PlatformDaemonCommand } from "./cli/cmd/platform-daemon" import { PlatformOnboardCommand } from "./cli/cmd/platform-onboard" import { PlatformProposalsCommand } from "./cli/cmd/platform-proposals" import { PlatformContractsCommand } from "./cli/cmd/platform-contracts" @@ -225,6 +226,7 @@ const cli = yargs(rawArgs) .command(reg(PlatformAtlasMeetingsCommand)) .command(reg(PlatformAtlasBrandKitCommand)) .command(reg(PlatformLeadsMeetingCommand)) + .command(reg(PlatformDaemonCommand)) .command(reg(PlatformOnboardCommand)) .command(reg(PlatformProposalsCommand)) .command(reg(PlatformContractsCommand)) From c839a4d8ae25223271a0f37d447dd754240b69b0 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Mon, 13 Apr 2026 09:47:09 -0500 Subject: [PATCH 22/35] =?UTF-8?q?feat:=20schedules=20list=20=E2=80=94=20gr?= =?UTF-8?q?ouped=20card=20layout=20by=20execution=20environment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clean grouped display: - ⬡ HIVE (local machine) — discover, SOM, social stats - ◉ IRIS (cloud heartbeats) — agent heartbeat cycles - ☁ CLOUD (agent tasks) — scheduled + heartbeat-spawned tasks - ⟳ AUTO (heartbeat-spawned) — tasks created during heartbeat - ⚡ HOOKS (event listeners) — webhook-triggered tasks Shows agent name (not raw task_name), countdown timers inline, deduplicates repeated prompt text, status badges (⏱ 14m, ⚠ overdue). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../src/cli/cmd/platform-schedules.ts | 81 ++++++++++++++----- 1 file changed, 62 insertions(+), 19 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-schedules.ts b/packages/opencode/src/cli/cmd/platform-schedules.ts index 579e1c6791f0..31c498ea24c4 100644 --- a/packages/opencode/src/cli/cmd/platform-schedules.ts +++ b/packages/opencode/src/cli/cmd/platform-schedules.ts @@ -171,30 +171,73 @@ const SchedulesListCommand = cmd({ return } - // Table header - printDivider() - console.log(` ${dim("ID".padEnd(6))} ${dim("Env".padEnd(6))} ${dim("Status".padEnd(12))} ${dim("Freq".padEnd(14))} ${dim("Next".padEnd(8))} ${dim("Name / Task")}`) - printDivider() + // Group by execution environment + const groups: Record<string, { icon: string; label: string; description: string; items: any[] }> = { + hive: { icon: "⬡", label: "HIVE", description: "local machine", items: [] }, + iris: { icon: "◉", label: "IRIS", description: "cloud heartbeats", items: [] }, + auto: { icon: "⟳", label: "AUTO", description: "spawned by heartbeat", items: [] }, + cloud: { icon: "☁", label: "CLOUD", description: "agent tasks", items: [] }, + hook: { icon: "⚡", label: "HOOKS", description: "event listeners", items: [] }, + } for (const s of schedules) { - const id = String(s.id ?? "").padEnd(6) const env = executionEnv(s) - const envStr = `${env.icon} ${dim(env.label)}`.padEnd(6) - const freq = String(s.frequency ?? "-").padEnd(14) - const until = s.next_run_at && s.status === "scheduled" ? timeUntil(s.next_run_at).padEnd(8) : "-".padEnd(8) - const name = String(s.name ?? s.title ?? s.task_name ?? `Schedule #${s.id}`).slice(0, 36) - const tl = taskLabel(s) - const typeTag = tl ? dim(` [${tl}]`) : "" - - const statusStr = statusColor(String(s.status ?? "")) - console.log(` ${dim(id)} ${envStr} ${statusStr} ${dim(freq)} ${UI.Style.TEXT_HIGHLIGHT}${until}${UI.Style.TEXT_NORMAL} ${bold(name)}${typeTag}`) - - const prompt = s.data?.prompt ?? s.prompt ?? "" - if (prompt) console.log(` ${" ".repeat(13)} ${dim(String(prompt).slice(0, 75))}`) + const group = groups[env.label] ?? groups["cloud"] + group.items.push(s) } - printDivider() - prompts.log.info(`${dim("Tip: iris schedules list --active")} — show only running/scheduled jobs`) + // Render each group + for (const [, group] of Object.entries(groups)) { + if (group.items.length === 0) continue + + console.log() + console.log(` ${group.icon} ${bold(group.label)} ${dim(`(${group.description})`)}`) + printDivider() + + for (const s of group.items) { + const id = dim(`#${s.id}`) + const status = String(s.status ?? "").toLowerCase() + + // Status badge + let badge = "" + if (status === "running") badge = `${UI.Style.TEXT_HIGHLIGHT}running${UI.Style.TEXT_NORMAL}` + else if (status === "paused") badge = `${UI.Style.TEXT_WARNING}paused${UI.Style.TEXT_NORMAL}` + else if (status === "scheduled") { + const until = timeUntil(s.next_run_at) + badge = until === "overdue" + ? `${UI.Style.TEXT_DANGER}⚠ overdue${UI.Style.TEXT_NORMAL}` + : `${UI.Style.TEXT_HIGHLIGHT}⏱ ${until}${UI.Style.TEXT_NORMAL}` + } else { + badge = statusColor(status) + } + + // Frequency — clean up ugly underscores + const freq = String(s.frequency ?? "").replace(/_/g, " ") + + // Name — prefer agent name from data, avoid repeating task_name as prompt + const agentName = s.data?.agent_name ?? "" + const name = agentName || String(s.name ?? s.title ?? s.task_name ?? "").slice(0, 50) + + // Description — short, one line, no repeats + const tl = taskLabel(s) + const prompt = s.data?.prompt ?? s.prompt ?? "" + let desc = "" + if (tl && tl !== name && tl !== s.task_name) desc = tl + if (prompt && prompt !== name && prompt !== s.task_name && !prompt.startsWith(name)) { + // Deduplicate — if prompt just repeats task_name multiple times, skip it + const unique = [...new Set(prompt.split(/[.!?\n]+/).map((s: string) => s.trim()).filter(Boolean))] + const cleaned = unique.slice(0, 2).join(". ").slice(0, 60) + if (cleaned && cleaned !== name) { + desc = desc ? `${desc}: ${cleaned}` : cleaned + } + } + + console.log(` ${id} ${badge.padEnd(12)} ${dim(freq.padEnd(12))} ${bold(name)}`) + if (desc) console.log(` ${dim(desc)}`) + } + } + + console.log() prompts.outro( `${dim("iris schedules get <id>")} · ${dim("iris schedules history <id>")}`, ) From ef24b6fe022e4dd4898dbc8ccd3d97f48d64a7ca Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Mon, 13 Apr 2026 10:01:54 -0500 Subject: [PATCH 23/35] =?UTF-8?q?fix:=20schedules=20list=20=E2=80=94=20det?= =?UTF-8?q?ect=20stuck=20jobs,=20show=20origin=20tags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ⚠ stuck: one-time jobs running >1 hour (dead, needs cleanup) - (via heartbeat): shows when task was spawned by heartbeat agent - Clearer than "running once" which looks like it's working Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../src/cli/cmd/platform-schedules.ts | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-schedules.ts b/packages/opencode/src/cli/cmd/platform-schedules.ts index 31c498ea24c4..ddad31344f7d 100644 --- a/packages/opencode/src/cli/cmd/platform-schedules.ts +++ b/packages/opencode/src/cli/cmd/platform-schedules.ts @@ -198,11 +198,21 @@ const SchedulesListCommand = cmd({ const id = dim(`#${s.id}`) const status = String(s.status ?? "").toLowerCase() - // Status badge + // Status badge — detect stuck jobs (running + once + old) let badge = "" - if (status === "running") badge = `${UI.Style.TEXT_HIGHLIGHT}running${UI.Style.TEXT_NORMAL}` - else if (status === "paused") badge = `${UI.Style.TEXT_WARNING}paused${UI.Style.TEXT_NORMAL}` - else if (status === "scheduled") { + if (status === "running") { + const freq = String(s.frequency ?? "").toLowerCase() + const createdAt = s.created_at ? new Date(String(s.created_at)).getTime() : 0 + const age = Date.now() - createdAt + const isStuck = freq === "once" && age > 3600_000 // once + older than 1 hour + if (isStuck) { + badge = `${UI.Style.TEXT_DANGER}⚠ stuck${UI.Style.TEXT_NORMAL}` + } else { + badge = `${UI.Style.TEXT_HIGHLIGHT}running${UI.Style.TEXT_NORMAL}` + } + } else if (status === "paused") { + badge = `${UI.Style.TEXT_WARNING}paused${UI.Style.TEXT_NORMAL}` + } else if (status === "scheduled") { const until = timeUntil(s.next_run_at) badge = until === "overdue" ? `${UI.Style.TEXT_DANGER}⚠ overdue${UI.Style.TEXT_NORMAL}` @@ -218,6 +228,10 @@ const SchedulesListCommand = cmd({ const agentName = s.data?.agent_name ?? "" const name = agentName || String(s.name ?? s.title ?? s.task_name ?? "").slice(0, 50) + // Origin tag — show where this task came from + const createdFrom = String(s.data?.created_from ?? s.data?.source ?? "") + const originTag = createdFrom.includes("heartbeat") ? dim(" (via heartbeat)") : "" + // Description — short, one line, no repeats const tl = taskLabel(s) const prompt = s.data?.prompt ?? s.prompt ?? "" @@ -232,7 +246,7 @@ const SchedulesListCommand = cmd({ } } - console.log(` ${id} ${badge.padEnd(12)} ${dim(freq.padEnd(12))} ${bold(name)}`) + console.log(` ${id} ${badge.padEnd(12)} ${dim(freq.padEnd(12))} ${bold(name)}${originTag}`) if (desc) console.log(` ${dim(desc)}`) } } From 5ec07392fa20f97cc1b72c968f721bd7785d5fd1 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Mon, 13 Apr 2026 10:12:04 -0500 Subject: [PATCH 24/35] feat: schedules list shows bloq (knowledge base) context per job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reads bloq name from eager-loaded relationship (s.bloq.name) with fallback to batch bloq ID lookup. Shows as → BLOQ NAME on the description line so you can see what knowledge base each job is working against. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../src/cli/cmd/platform-schedules.ts | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/platform-schedules.ts b/packages/opencode/src/cli/cmd/platform-schedules.ts index ddad31344f7d..56631e4e6da4 100644 --- a/packages/opencode/src/cli/cmd/platform-schedules.ts +++ b/packages/opencode/src/cli/cmd/platform-schedules.ts @@ -146,6 +146,21 @@ const SchedulesListCommand = cmd({ }) } + // Resolve bloq names in one batch + const bloqIds = [...new Set(schedules.map((s: any) => s.bloq_id).filter(Boolean))] + const bloqNames: Record<number, string> = {} + if (bloqIds.length > 0) { + try { + const bloqRes = await irisFetch(`/api/v1/users/${userId}/bloqs?ids=${bloqIds.join(",")}`) + if (bloqRes.ok) { + const bloqData = (await bloqRes.json()) as any + for (const b of (bloqData?.data ?? bloqData ?? [])) { + if (b?.id && b?.name) bloqNames[b.id] = b.name + } + } + } catch {} + } + spinner.stop(`${schedules.length} schedule(s)${args.active ? " (active)" : ""}`) if (args.json) { @@ -154,6 +169,8 @@ const SchedulesListCommand = cmd({ name: s.name ?? s.title ?? s.task_name, status: s.status, env: executionEnv(s).label, + bloq_id: s.bloq_id ?? null, + bloq_name: s.bloq_id ? (bloqNames[s.bloq_id] ?? null) : null, frequency: s.frequency, task_type: taskLabel(s), prompt: s.data?.prompt ?? s.prompt, @@ -246,8 +263,16 @@ const SchedulesListCommand = cmd({ } } + // Bloq context — try eager-loaded relationship first, then batch lookup + const bloqId = s.bloq_id as number | null + const bloqName = s.bloq?.name ?? (bloqId ? bloqNames[bloqId] : null) + const bloqTag = bloqName ? dim(` → ${bloqName}`) : bloqId ? dim(` → bloq #${bloqId}`) : "" + console.log(` ${id} ${badge.padEnd(12)} ${dim(freq.padEnd(12))} ${bold(name)}${originTag}`) - if (desc) console.log(` ${dim(desc)}`) + if (bloqTag || desc) { + const parts = [bloqTag, desc ? dim(desc) : ""].filter(Boolean) + console.log(` ${parts.join(" ")}`) + } } } From 83ce354428bbe2ca81a42e4cbd94b38b9b30eaa2 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Mon, 13 Apr 2026 10:22:17 -0500 Subject: [PATCH 25/35] =?UTF-8?q?feat:=20schedules=20list=20--latest=20?= =?UTF-8?q?=E2=80=94=20show=20last=20execution=20result=20per=20job?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fetches latest execution in parallel for each active job. Shows: - ✓/✗ status, time ago, model used, token count - Response preview (first 70 chars) Usage: iris schedules list --active --latest Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../src/cli/cmd/platform-schedules.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/packages/opencode/src/cli/cmd/platform-schedules.ts b/packages/opencode/src/cli/cmd/platform-schedules.ts index 56631e4e6da4..0dd532694b9e 100644 --- a/packages/opencode/src/cli/cmd/platform-schedules.ts +++ b/packages/opencode/src/cli/cmd/platform-schedules.ts @@ -111,6 +111,7 @@ const SchedulesListCommand = cmd({ yargs .option("limit", { describe: "max results", type: "number", default: 50 }) .option("active", { describe: "show only active/scheduled/running jobs (hide completed one-offs)", type: "boolean", default: false }) + .option("latest", { describe: "include latest execution result for each job", type: "boolean", default: false }) .option("agent-id", { describe: "filter by agent ID", type: "number" }) .option("json", { describe: "JSON output", type: "boolean" }) .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), @@ -161,6 +162,24 @@ const SchedulesListCommand = cmd({ } catch {} } + // --latest: fetch last execution for each job (parallel) + const latestExecs: Record<number, any> = {} + if (args.latest && schedules.length > 0) { + const execPromises = schedules.map(async (s: any) => { + try { + const execRes = await irisFetch( + `/api/v1/users/${userId}/bloqs/scheduled-jobs/${s.id}/executions?per_page=1` + ) + if (execRes.ok) { + const execData = (await execRes.json()) as any + const execs = execData?.data ?? [] + if (execs.length > 0) latestExecs[s.id] = execs[0] + } + } catch {} + }) + await Promise.all(execPromises) + } + spinner.stop(`${schedules.length} schedule(s)${args.active ? " (active)" : ""}`) if (args.json) { @@ -273,6 +292,25 @@ const SchedulesListCommand = cmd({ const parts = [bloqTag, desc ? dim(desc) : ""].filter(Boolean) console.log(` ${parts.join(" ")}`) } + + // Latest execution result + const exec = latestExecs[s.id] + if (exec) { + const execStatus = exec.status === "completed" + ? `${UI.Style.TEXT_SUCCESS}✓${UI.Style.TEXT_NORMAL}` + : exec.status === "failed" + ? `${UI.Style.TEXT_DANGER}✗${UI.Style.TEXT_NORMAL}` + : dim(exec.status ?? "?") + const when = exec.completed_at + ? timeUntil(exec.completed_at) === "overdue" ? dim("just now") : dim(`${timeUntil(exec.completed_at)} ago`) + : "" + const model = exec.model_used ? dim(`[${exec.model_used}]`) : "" + const tokens = exec.tokens_used ? dim(`${Number(exec.tokens_used).toLocaleString()} tok`) : "" + const preview = String(exec.response_preview ?? exec.response ?? "").replace(/\n/g, " ").slice(0, 70) + + console.log(` ${execStatus} ${when} ${model} ${tokens}`) + if (preview) console.log(` ${dim(`"${preview}${preview.length >= 70 ? "…" : ""}"`)}`) + } } } From 6f5eb70dc7f245c3e213cb1e88c63b877c4f4cfe Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Mon, 13 Apr 2026 11:07:31 -0500 Subject: [PATCH 26/35] =?UTF-8?q?feat:=20schedules=20history=20+=20inspect?= =?UTF-8?q?=20=E2=80=94=20full=20execution=20debug=20+=20agent=20config=20?= =?UTF-8?q?view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit history <id>: model, tokens, duration, tools, response preview history <id> --full: complete response output inspect <id>: agent config, system prompt, heartbeat mode, bloq context, task prompt, edit command. Everything you need to debug + tune. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../src/cli/cmd/platform-schedules.ts | 216 +++++++++++++++++- 1 file changed, 209 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-schedules.ts b/packages/opencode/src/cli/cmd/platform-schedules.ts index 0dd532694b9e..cef5a887a38c 100644 --- a/packages/opencode/src/cli/cmd/platform-schedules.ts +++ b/packages/opencode/src/cli/cmd/platform-schedules.ts @@ -431,6 +431,8 @@ const SchedulesHistoryCommand = cmd({ yargs .positional("id", { describe: "schedule ID", type: "number", demandOption: true }) .option("limit", { describe: "max results", type: "number", default: 10 }) + .option("full", { describe: "show full response (not just preview)", type: "boolean", default: false }) + .option("json", { describe: "JSON output", type: "boolean" }) .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), async handler(args) { UI.empty() @@ -461,18 +463,217 @@ const SchedulesHistoryCommand = cmd({ return } - printDivider() + if (args.json) { + console.log(JSON.stringify(runs, null, 2)) + prompts.outro("Done") + return + } + for (const r of runs) { - const status = statusColor(String(r.status ?? "unknown")) - const created = r.created_at ? dim(String(r.created_at)) : "" - console.log(` ${bold(String(r.id))} ${status} ${created}`) - if (r.summary ?? r.response) { - console.log(` ${dim(String(r.summary ?? r.response).slice(0, 120))}`) - } + const statusBadge = r.status === "completed" + ? `${UI.Style.TEXT_SUCCESS}✓ completed${UI.Style.TEXT_NORMAL}` + : r.status === "failed" + ? `${UI.Style.TEXT_DANGER}✗ failed${UI.Style.TEXT_NORMAL}` + : statusColor(String(r.status ?? "?")) + + const when = r.completed_at ?? r.started_at ?? r.created_at + const ago = when ? timeUntil(String(when)) : "" + const agoStr = ago === "overdue" ? dim("just now") : ago ? dim(`${ago} ago`) : "" + console.log() + printDivider() + console.log(` ${bold(`Run #${r.id}`)} ${statusBadge} ${agoStr}`) + + // Metadata line + const meta: string[] = [] + if (r.model_used) meta.push(`model: ${r.model_used}`) + if (r.tokens_used) meta.push(`${Number(r.tokens_used).toLocaleString()} tokens`) + if (r.started_at && r.completed_at) { + const dur = Math.round((new Date(r.completed_at).getTime() - new Date(r.started_at).getTime()) / 1000) + meta.push(`${dur}s`) + } + if (r.execution_source) meta.push(r.execution_source) + if (meta.length) console.log(` ${dim(meta.join(" · "))}`) + + // Tools used + if (r.functions_executed) { + try { + const tools = JSON.parse(r.functions_executed) + if (Array.isArray(tools) && tools.length > 0) { + console.log(` ${dim("tools: " + tools.join(", "))}`) + } + } catch {} + } + + // Error + if (r.error_message) { + console.log(` ${UI.Style.TEXT_DANGER}error: ${String(r.error_message).slice(0, 200)}${UI.Style.TEXT_NORMAL}`) + } + + // Response + const response = String(r.response ?? r.response_preview ?? r.summary ?? "") + if (response) { + console.log() + if (args.full) { + // Full response with word wrap + const lines = response.split("\n") + for (const line of lines) { + console.log(` ${dim(line)}`) + } + } else { + // Preview (first 3 lines or 200 chars) + const preview = response.replace(/\n/g, " ").slice(0, 200) + console.log(` ${dim(`"${preview}${response.length > 200 ? "…" : ""}"`)}`) + } + } } + console.log() printDivider() + if (!args.full) { + prompts.log.info(dim(`Tip: iris schedules history ${args.id} --full — show complete responses`)) + } + prompts.outro("Done") + } catch (err) { + spinner.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, +}) + +// ============================================================================ +// Inspect — show agent config, system prompt, model, tools for a schedule +// ============================================================================ + +const SchedulesInspectCommand = cmd({ + command: "inspect <id>", + describe: "show the agent config, system prompt, and tools for a scheduled job", + builder: (yargs) => + yargs + .positional("id", { describe: "schedule ID", type: "number", demandOption: true }) + .option("json", { describe: "JSON output", type: "boolean" }) + .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Inspect Schedule #${args.id}`) + + const token = await requireAuth() + if (!token) { prompts.outro("Done"); return } + + const userId = await requireUserId(args["user-id"]) + if (!userId) { prompts.outro("Done"); return } + + const spinner = prompts.spinner() + spinner.start("Loading…") + + try { + // Fetch all schedules and find the one we want (the individual GET returns only data column) + const res = await irisFetch(`/api/v1/users/${userId}/bloqs/scheduled-jobs?per_page=200`) + const ok = await handleApiError(res, "Get schedules") + if (!ok) { spinner.stop("Failed", 1); prompts.outro("Done"); return } + + const allData = (await res.json()) as { data?: any[] } + const schedule = (allData?.data ?? []).find((s: any) => s.id === args.id) + if (!schedule) { + spinner.stop("Not found", 1) + prompts.log.error(`Schedule #${args.id} not found`) + prompts.outro("Done") + return + } + + // Fetch agent details if agent_id exists + let agent: any = null + if (schedule.agent_id) { + try { + const agentRes = await irisFetch(`/api/v1/users/${userId}/bloqs/agents/${schedule.agent_id}`) + if (agentRes.ok) { + const agentData = (await agentRes.json()) as any + agent = agentData?.data ?? agentData + } + } catch {} + } + + spinner.stop(bold(schedule.task_name ?? `Schedule #${args.id}`)) + + if (args.json) { + console.log(JSON.stringify({ schedule, agent }, null, 2)) + prompts.outro("Done") + return + } + + // Schedule info + printDivider() + printKV("ID", schedule.id) + printKV("Status", schedule.status) + printKV("Frequency", schedule.frequency) + printKV("Next run", schedule.next_run_at) + printKV("Bloq", schedule.bloq?.name ?? (schedule.bloq_id ? `#${schedule.bloq_id}` : null)) + printKV("Created", schedule.created_at) + printDivider() + + // Agent config + if (agent) { + console.log() + console.log(` ${bold("Agent Config")}`) + printDivider() + printKV("Agent ID", agent.id) + printKV("Name", agent.name) + printKV("Model", agent.model ?? agent.settings?.model) + printKV("Heartbeat mode", agent.heartbeat_mode) + printKV("Heartbeat freq", agent.settings?.heartbeat_frequency ?? agent.settings?.frequency) + + // System prompt + const systemPrompt = agent.system_prompt ?? agent.instructions ?? agent.settings?.system_prompt + if (systemPrompt) { + console.log() + console.log(` ${bold("System Prompt")}`) + printDivider() + const lines = String(systemPrompt).split("\n").slice(0, 20) + for (const line of lines) { + console.log(` ${dim(line)}`) + } + if (String(systemPrompt).split("\n").length > 20) { + console.log(` ${dim(`... (${String(systemPrompt).split("\\n").length} total lines)`)}`) + } + } + + // Tools / capabilities + const tools = agent.settings?.tools ?? agent.capabilities ?? agent.settings?.capabilities + if (tools && (Array.isArray(tools) ? tools.length : Object.keys(tools).length)) { + console.log() + console.log(` ${bold("Tools / Capabilities")}`) + printDivider() + const toolList = Array.isArray(tools) ? tools : Object.keys(tools) + console.log(` ${dim(toolList.join(", "))}`) + } + + // Integrations + const integrations = agent.settings?.integrations ?? [] + if (Array.isArray(integrations) && integrations.length) { + console.log() + console.log(` ${bold("Integrations")}`) + printDivider() + console.log(` ${dim(integrations.join(", "))}`) + } + + printDivider() + } + + // Task prompt + const prompt = schedule.prompt ?? schedule.data?.prompt + if (prompt) { + console.log() + console.log(` ${bold("Task Prompt")}`) + printDivider() + for (const line of String(prompt).split("\n").slice(0, 10)) { + console.log(` ${dim(line)}`) + } + } + + console.log() + prompts.log.info(dim(`Edit agent: iris agents update ${schedule.agent_id}`)) + prompts.log.info(dim(`Run history: iris schedules history ${args.id} --full`)) prompts.outro("Done") } catch (err) { spinner.stop("Error", 1) @@ -708,6 +909,7 @@ export const PlatformSchedulesCommand = cmd({ .command(SchedulesGetCommand) .command(SchedulesRunCommand) .command(SchedulesHistoryCommand) + .command(SchedulesInspectCommand) .command(SchedulesToggleCommand) .command(SchedulesDeleteCommand) .demandCommand(), From 5bbbabb494bf161860065893194d8ff9ee7836c2 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Mon, 13 Apr 2026 11:30:16 -0500 Subject: [PATCH 27/35] proposals CLI: add --list-price and --discount flags for discount pricing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- packages/opencode/src/cli/cmd/platform-proposals.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/opencode/src/cli/cmd/platform-proposals.ts b/packages/opencode/src/cli/cmd/platform-proposals.ts index c68bfcce96ec..cb0d38ed9076 100644 --- a/packages/opencode/src/cli/cmd/platform-proposals.ts +++ b/packages/opencode/src/cli/cmd/platform-proposals.ts @@ -23,6 +23,8 @@ const ProposalsCreateCommand = cmd({ .option("brand-logo", { describe: "brand logo URL for proposal header", type: "string" }) .option("package", { alias: "p", describe: "service package ID (auto-fills amount + scope)", type: "number" }) .option("template", { alias: "t", describe: "proposal template name", type: "string" }) + .option("list-price", { describe: "list price before discount (shows strikethrough on proposal)", type: "number" }) + .option("discount", { describe: "discount percentage (auto-calculated from list-price vs amount if omitted)", type: "number" }) .option("skip-contract", { describe: "skip contract attachment", type: "boolean" }) .option("skip-send", { describe: "generate but don't send to client", type: "boolean" }) .option("json", { describe: "JSON output", type: "boolean" }), @@ -88,6 +90,8 @@ const ProposalsCreateCommand = cmd({ if (args["brand-logo"]) body.brand_logo_url = args["brand-logo"] if (args["rev-share"] !== undefined) body.rev_share_percent = args["rev-share"] if (args["pass-fees"]) body.processing_fee_mode = "pass_to_client" + if (args["list-price"] !== undefined) body.list_price = args["list-price"] + if (args["discount"] !== undefined) body.discount_percent = args["discount"] const spinner = prompts.spinner() spinner.start("Generating proposal...") @@ -122,6 +126,11 @@ const ProposalsCreateCommand = cmd({ console.log(success("Proposal created!")) printDivider() printKV("Lead", `${leadData.name ?? leadData.first_name ?? ""} (#${leadId})`) + if (args["list-price"]) { + const discPct = args["discount"] ?? Math.round((1 - (Number(amount) / Number(args["list-price"]))) * 100) + printKV("List Price", `$${Number(args["list-price"]).toFixed(2)}${args.interval && args.interval !== "one-time" ? "/" + args.interval : ""}`) + printKV("Discount", `${discPct}% off`) + } printKV("Amount", `$${Number(amount).toFixed(2)}${args.interval && args.interval !== "one-time" ? "/" + args.interval : ""}`) if (args.interval && args.interval !== "one-time") { const dur = args.duration ?? 12 From d0923680a2c082805ed4a0b4ca25b5d14415fe5e Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Mon, 13 Apr 2026 11:49:05 -0500 Subject: [PATCH 28/35] proposals CLI: add cancel command to clear active payment gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iris proposals cancel <lead-id> — finds and completes all active payment gate steps so a new proposal can be created. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../src/cli/cmd/platform-proposals.ts | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/packages/opencode/src/cli/cmd/platform-proposals.ts b/packages/opencode/src/cli/cmd/platform-proposals.ts index cb0d38ed9076..8a90e43627e1 100644 --- a/packages/opencode/src/cli/cmd/platform-proposals.ts +++ b/packages/opencode/src/cli/cmd/platform-proposals.ts @@ -263,6 +263,62 @@ const ProposalsListCommand = cmd({ }, }) +// ============================================================================ +// Proposals Cancel — cancel/clear an active proposal +// ============================================================================ + +const ProposalsCancelCommand = cmd({ + command: "cancel <lead-id>", + aliases: ["clear", "delete"], + describe: "cancel the active proposal/payment gate for a lead", + builder: (yargs) => + yargs + .positional("lead-id", { describe: "lead ID", type: "number", demandOption: true }) + .option("json", { describe: "JSON output", type: "boolean" }), + async handler(args) { + if (!(await requireAuth())) return + + const leadId = args["lead-id"] + + // Find the active payment gate step + const stepsRes = await irisFetch(`/api/v1/leads/${leadId}/outreach-steps`) + if (!(await handleApiError(stepsRes, "Fetch outreach steps"))) return + + const stepsData = await stepsRes.json().catch(() => ({})) + const steps = stepsData?.data ?? stepsData?.steps ?? stepsData ?? [] + + if (!Array.isArray(steps)) { + prompts.log.error("Could not read outreach steps") + return + } + + const activeGates = steps.filter((s: any) => s.type === "payment_gate" && !s.is_completed) + + if (activeGates.length === 0) { + prompts.log.info(`No active proposal for lead #${leadId}`) + return + } + + // Complete each active payment gate + its reminder steps + let cancelled = 0 + for (const gate of activeGates) { + const res = await irisFetch(`/api/v1/leads/${leadId}/outreach-steps/${gate.id}/complete`, { + method: "POST", + }) + if (res.ok) cancelled++ + } + + if (args.json) { + console.log(JSON.stringify({ cancelled, lead_id: leadId })) + return + } + + console.log("") + console.log(success(`Cancelled ${cancelled} active proposal(s) for lead #${leadId}`)) + console.log(dim(`Create a new one: iris proposals create ${leadId}`)) + }, +}) + // ============================================================================ // Root command // ============================================================================ @@ -276,6 +332,7 @@ export const PlatformProposalsCommand = cmd({ .command(ProposalsCreateCommand) .command(ProposalsStatusCommand) .command(ProposalsListCommand) + .command(ProposalsCancelCommand) .demandCommand(), async handler() {}, }) From 09d203608613d4d83310adf5a8aa335d10651d1f Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Mon, 13 Apr 2026 11:52:41 -0500 Subject: [PATCH 29/35] proposals CLI: add --sender-name flag for "From" line on proposals Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- packages/opencode/src/cli/cmd/platform-proposals.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/opencode/src/cli/cmd/platform-proposals.ts b/packages/opencode/src/cli/cmd/platform-proposals.ts index 8a90e43627e1..2153ea2fbbbc 100644 --- a/packages/opencode/src/cli/cmd/platform-proposals.ts +++ b/packages/opencode/src/cli/cmd/platform-proposals.ts @@ -25,6 +25,7 @@ const ProposalsCreateCommand = cmd({ .option("template", { alias: "t", describe: "proposal template name", type: "string" }) .option("list-price", { describe: "list price before discount (shows strikethrough on proposal)", type: "number" }) .option("discount", { describe: "discount percentage (auto-calculated from list-price vs amount if omitted)", type: "number" }) + .option("sender-name", { describe: "sender name shown on proposal (e.g. 'Alex Mayo')", type: "string" }) .option("skip-contract", { describe: "skip contract attachment", type: "boolean" }) .option("skip-send", { describe: "generate but don't send to client", type: "boolean" }) .option("json", { describe: "JSON output", type: "boolean" }), @@ -92,6 +93,7 @@ const ProposalsCreateCommand = cmd({ if (args["pass-fees"]) body.processing_fee_mode = "pass_to_client" if (args["list-price"] !== undefined) body.list_price = args["list-price"] if (args["discount"] !== undefined) body.discount_percent = args["discount"] + if (args["sender-name"]) body.sender_name = args["sender-name"] const spinner = prompts.spinner() spinner.start("Generating proposal...") From 26d8ad20a868309e3838a65b0a0506bc03485ba0 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Mon, 13 Apr 2026 12:29:30 -0500 Subject: [PATCH 30/35] =?UTF-8?q?feat:=20iris=20bridge=20runs=20=E2=80=94?= =?UTF-8?q?=20show=20scheduled=20script=20runs,=20history,=20and=20stats?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Queries localhost:3200/daemon/schedules for local cron run data. Shows: script name, cron expression, status, run count, last run time, duration. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../opencode/src/cli/cmd/platform-daemon.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/packages/opencode/src/cli/cmd/platform-daemon.ts b/packages/opencode/src/cli/cmd/platform-daemon.ts index d747f3580de4..f3c3684a8c7c 100644 --- a/packages/opencode/src/cli/cmd/platform-daemon.ts +++ b/packages/opencode/src/cli/cmd/platform-daemon.ts @@ -120,6 +120,50 @@ const DaemonLogsCommand = cmd({ }, }) +const DaemonRunsCommand = cmd({ + command: "runs", + aliases: ["schedules"], + describe: "show scheduled script runs and history", + async handler() { + UI.empty() + prompts.intro("◈ Bridge Schedules") + try { + const res = await fetch("http://localhost:3200/daemon/schedules", { signal: AbortSignal.timeout(3000) }) + if (!res.ok) { prompts.log.error(`HTTP ${res.status}`); prompts.outro("Done"); return } + const data = await res.json() as { schedules?: any[] } + const schedules = data.schedules ?? [] + + if (schedules.length === 0) { + prompts.log.warn("No schedules. Create one with: iris hive schedule add <script> --cron \"...\"") + prompts.outro("Done") + return + } + + console.log(` ${dim("─".repeat(56))}`) + for (const s of schedules) { + const status = s.running + ? `${UI.Style.TEXT_HIGHLIGHT}running${UI.Style.TEXT_NORMAL}` + : s.last_status === "completed" + ? `${UI.Style.TEXT_SUCCESS}completed${UI.Style.TEXT_NORMAL}` + : s.last_status === "failed" + ? `${UI.Style.TEXT_DANGER}failed${UI.Style.TEXT_NORMAL}` + : dim("pending") + + console.log(` ${bold(s.filename)} ${dim(s.cron)} ${status}`) + console.log(` ${dim("Runs:")} ${s.run_count} ${dim("Last:")} ${s.last_run ? new Date(s.last_run).toLocaleString() : "never"} ${dim("Duration:")} ${s.last_duration_ms ? `${s.last_duration_ms}ms` : "—"}`) + console.log(` ${dim("ID:")} ${dim(s.id)}`) + console.log() + } + console.log(` ${dim("─".repeat(56))}`) + prompts.outro(dim("iris hive schedule list | iris bridge logs")) + } catch (err) { + prompts.log.error("Daemon not reachable on :3200. Is it running?") + prompts.log.info(dim("Start with: iris bridge start")) + prompts.outro("Done") + } + }, +}) + const DaemonRegisterCommand = cmd({ command: "register", describe: "register this machine as a Hive compute node", @@ -143,6 +187,7 @@ export const PlatformDaemonCommand = cmd({ .command(DaemonStatusCommand) .command(DaemonRestartCommand) .command(DaemonLogsCommand) + .command(DaemonRunsCommand) .command(DaemonRegisterCommand) .demandCommand(), async handler() {}, From 13f537dd1f839016cd9f8c1e29e67fcc170bc98c Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Mon, 13 Apr 2026 12:37:06 -0500 Subject: [PATCH 31/35] =?UTF-8?q?feat:=20iris=20bridge=20runs=20-o/-c/-a?= =?UTF-8?q?=20=E2=80=94=20show=20output,=20source=20code,=20and=20full=20d?= =?UTF-8?q?etails?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -o show last run stdout/stderr -c show script source code (checks ~/.iris/scripts, data/scripts, bridge/scripts) -a show everything (output + code + metadata) Schedule registry now stores last_stdout, last_stderr, last_exit_code for each run, exposed via /daemon/schedules API. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../opencode/src/cli/cmd/platform-daemon.ts | 73 ++++++++++++++++--- 1 file changed, 64 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-daemon.ts b/packages/opencode/src/cli/cmd/platform-daemon.ts index f3c3684a8c7c..49d917783ce9 100644 --- a/packages/opencode/src/cli/cmd/platform-daemon.ts +++ b/packages/opencode/src/cli/cmd/platform-daemon.ts @@ -123,10 +123,19 @@ const DaemonLogsCommand = cmd({ const DaemonRunsCommand = cmd({ command: "runs", aliases: ["schedules"], - describe: "show scheduled script runs and history", - async handler() { + describe: "show scheduled script runs, output, and source code", + builder: (yargs) => + yargs + .option("output", { alias: "o", describe: "show last run stdout", type: "boolean", default: false }) + .option("code", { alias: "c", describe: "show script source code", type: "boolean", default: false }) + .option("all", { alias: "a", describe: "show everything (output + code)", type: "boolean", default: false }), + async handler(args) { UI.empty() prompts.intro("◈ Bridge Schedules") + + const showOutput = args.all || args.output + const showCode = args.all || args.code + try { const res = await fetch("http://localhost:3200/daemon/schedules", { signal: AbortSignal.timeout(3000) }) if (!res.ok) { prompts.log.error(`HTTP ${res.status}`); prompts.outro("Done"); return } @@ -139,23 +148,69 @@ const DaemonRunsCommand = cmd({ return } - console.log(` ${dim("─".repeat(56))}`) + console.log(` ${dim("─".repeat(60))}`) for (const s of schedules) { const status = s.running - ? `${UI.Style.TEXT_HIGHLIGHT}running${UI.Style.TEXT_NORMAL}` + ? `${UI.Style.TEXT_HIGHLIGHT}● running${UI.Style.TEXT_NORMAL}` : s.last_status === "completed" - ? `${UI.Style.TEXT_SUCCESS}completed${UI.Style.TEXT_NORMAL}` + ? `${UI.Style.TEXT_SUCCESS}● completed${UI.Style.TEXT_NORMAL}` : s.last_status === "failed" - ? `${UI.Style.TEXT_DANGER}failed${UI.Style.TEXT_NORMAL}` - : dim("pending") + ? `${UI.Style.TEXT_DANGER}● failed${UI.Style.TEXT_NORMAL}` + : dim("○ pending") console.log(` ${bold(s.filename)} ${dim(s.cron)} ${status}`) console.log(` ${dim("Runs:")} ${s.run_count} ${dim("Last:")} ${s.last_run ? new Date(s.last_run).toLocaleString() : "never"} ${dim("Duration:")} ${s.last_duration_ms ? `${s.last_duration_ms}ms` : "—"}`) + if (s.last_exit_code !== undefined && s.last_exit_code !== null) { + console.log(` ${dim("Exit:")} ${s.last_exit_code === 0 ? "0" : `${UI.Style.TEXT_DANGER}${s.last_exit_code}${UI.Style.TEXT_NORMAL}`}`) + } console.log(` ${dim("ID:")} ${dim(s.id)}`) + + // Show last stdout + if (showOutput && s.last_stdout) { + console.log() + console.log(` ${dim("── Last Output ──────────────────────────────────")}`) + for (const line of s.last_stdout.trim().split("\n").slice(-20)) { + console.log(` ${line}`) + } + if (s.last_stderr) { + console.log(` ${dim("── Stderr ──")}`) + for (const line of s.last_stderr.trim().split("\n").slice(-5)) { + console.log(` ${UI.Style.TEXT_DANGER}${line}${UI.Style.TEXT_NORMAL}`) + } + } + } + + // Show script source code + if (showCode) { + const { existsSync, readFileSync } = await import("fs") + const { join: pathJoin } = await import("path") + const { homedir: osHome } = await import("os") + // Check multiple script locations (data/scripts from daemon, scripts/ from user) + const candidates = [ + pathJoin(osHome(), ".iris", "data", "scripts", s.filename), + pathJoin(osHome(), ".iris", "scripts", s.filename), + pathJoin(osHome(), ".iris", "bridge", "scripts", s.filename), + ] + const scriptPath = candidates.find(p => existsSync(p)) ?? candidates[0] + if (existsSync(scriptPath)) { + const code = readFileSync(scriptPath, "utf-8") + console.log() + console.log(` ${dim("── Source (" + s.filename + ") ──────────────────────")}`) + for (const line of code.split("\n")) { + console.log(` ${dim(line)}`) + } + } + } + console.log() } - console.log(` ${dim("─".repeat(56))}`) - prompts.outro(dim("iris hive schedule list | iris bridge logs")) + console.log(` ${dim("─".repeat(60))}`) + if (!showOutput && !showCode) { + prompts.log.info(dim("iris bridge runs -o show last output")) + prompts.log.info(dim("iris bridge runs -c show script code")) + prompts.log.info(dim("iris bridge runs -a show everything")) + } + prompts.outro("Done") } catch (err) { prompts.log.error("Daemon not reachable on :3200. Is it running?") prompts.log.info(dim("Start with: iris bridge start")) From 9913f0fb4fec137a2a8ef2ea04cce42fe931168d Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Mon, 13 Apr 2026 15:19:52 -0500 Subject: [PATCH 32/35] =?UTF-8?q?docs:=20AGENTS.md=20+=20iris-cli=20skill?= =?UTF-8?q?=20=E2=80=94=20autonomous=20scheduling,=20agent-first=20archite?= =?UTF-8?q?cture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added to AGENTS.md (loaded every session): - Schedules commands (list --active, inspect, history --full) - Agent-first architecture docs (initial_prompt, system_prompt, heartbeat_tools) - How to create specialized agents + debug with inspect Updated iris-cli SKILL.md: - Full TypeScript CLI command reference (schedules, pages, integrations, health) - Agent-first heartbeat architecture section with examples - 3-phase page compose documentation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- scaffold/AGENTS.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/scaffold/AGENTS.md b/scaffold/AGENTS.md index c6664a82fd7c..77b02393aaac 100644 --- a/scaffold/AGENTS.md +++ b/scaffold/AGENTS.md @@ -77,6 +77,28 @@ iris pages pull <slug> # download to pages/<slug>.json iris pages push <slug> # upload back ``` +## Autonomous Agent Scheduling + +Manage scheduled heartbeat agents, hive tasks, and workflows: + +```bash +iris schedules list --active # Grouped: ⬡ hive / ◉ iris / ☁ cloud +iris schedules list --active --latest # + last execution result +iris schedules inspect <id> # Agent config, system prompt, tools +iris schedules history <id> --full # Full execution output +iris schedules run <id> # Trigger manually +iris schedules toggle <id> # Pause/resume +iris schedules delete <id> # Remove +``` + +### Creating Specialized Agents (Agent-First Architecture) +Agents define their own mission and tools via database fields: +- `initial_prompt` → agent's mission (injected as `<agent_mission>` in heartbeat) +- `settings.system_prompt` → agent's identity (overrides generic prompt) +- `settings.heartbeat_tools` → tool filter (e.g. `["manageLeads", "agent_memory"]`) + +Debug with: `iris schedules inspect <id>` to see the resolved config. + ## Integration Functions When running `iris integrations exec <type>` without a function, the CLI shows available functions. From 5a3e301871106cb21ed2db1bb2ecbc035955f0ff Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Mon, 13 Apr 2026 19:02:07 -0500 Subject: [PATCH 33/35] =?UTF-8?q?feat:=20iris=20schedules=20diagnose=20?= =?UTF-8?q?=E2=80=94=20full=20chain=20diagnostic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests every link: fl-api, iris-api, daemon, Redis queue, Pusher. For specific jobs: checks status, agent config, mission, tools, bloq context, last execution, and error messages. Usage: iris schedules diagnose # system-wide check iris schedules diagnose 727 # specific job diagnosis Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../src/cli/cmd/platform-schedules.ts | 180 +++++++++++++++++- 1 file changed, 179 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/platform-schedules.ts b/packages/opencode/src/cli/cmd/platform-schedules.ts index cef5a887a38c..38e5c29a812c 100644 --- a/packages/opencode/src/cli/cmd/platform-schedules.ts +++ b/packages/opencode/src/cli/cmd/platform-schedules.ts @@ -1,7 +1,7 @@ import { cmd } from "./cmd" import * as prompts from "@clack/prompts" import { UI } from "../ui" -import { irisFetch, requireAuth, requireUserId, handleApiError, printDivider, printKV, dim, bold, success } from "./iris-api" +import { irisFetch, requireAuth, requireUserId, handleApiError, printDivider, printKV, dim, bold, success, IRIS_API } from "./iris-api" // ============================================================================ // Display helpers @@ -898,6 +898,183 @@ const SchedulesDeleteCommand = cmd({ // Root command // ============================================================================ +// ============================================================================ +// Diagnose — test the full execution chain for a scheduled job +// ============================================================================ + +const SchedulesDiagnoseCommand = cmd({ + command: "diagnose [id]", + describe: "test the full execution chain — scheduler, dispatch, worker, daemon", + builder: (yargs) => + yargs + .positional("id", { describe: "schedule ID to diagnose (or omit for full system check)", type: "number" }) + .option("user-id", { describe: "user ID", type: "number" }), + async handler(args) { + UI.empty() + prompts.intro("◈ Schedule Diagnostics") + + const token = await requireAuth() + if (!token) { prompts.outro("Done"); return } + + const userId = await requireUserId(args["user-id"]) + if (!userId) { prompts.outro("Done"); return } + + const checks: { name: string; status: "pass" | "fail" | "warn"; detail: string }[] = [] + + const check = (name: string, status: "pass" | "fail" | "warn", detail: string) => { + checks.push({ name, status, detail }) + const icon = status === "pass" ? `${UI.Style.TEXT_SUCCESS}✓${UI.Style.TEXT_NORMAL}` : + status === "fail" ? `${UI.Style.TEXT_DANGER}✗${UI.Style.TEXT_NORMAL}` : + `${UI.Style.TEXT_WARNING}⚠${UI.Style.TEXT_NORMAL}` + console.log(` ${icon} ${bold(name)}: ${dim(detail)}`) + } + + console.log() + + // 1. fl-api health (scheduler runs here) + try { + const res = await irisFetch("/api/v1/pages?per_page=1") + check("fl-api", res.ok ? "pass" : "fail", res.ok ? "reachable" : `HTTP ${res.status}`) + } catch (e) { + check("fl-api", "fail", `unreachable: ${e instanceof Error ? e.message : String(e)}`) + } + + // 2. iris-api health (heartbeat executor runs here) + try { + const res = await irisFetch("/api/health", {}, IRIS_API) + if (res.ok) { + const data = await res.json() as any + check("iris-api", "pass", `DB: ${data.database ?? "?"}, AI: ${Object.keys(data).filter(k => k.startsWith("ai_")).map(k => `${k.replace("ai_","")}=${(data[k] as any)?.status ?? "?"}`).join(", ") || "not checked"}`) + } else { + check("iris-api", "fail", `HTTP ${res.status}`) + } + } catch (e) { + check("iris-api", "fail", `unreachable: ${e instanceof Error ? e.message : String(e)}`) + } + + // 3. Local daemon (hive tasks execute here) + try { + const res = await fetch("http://localhost:3200/health", { signal: AbortSignal.timeout(3000) }) + if (res.ok) { + const data = await res.json() as any + const daemon = data.daemon ?? {} + check("daemon", "pass", `node: ${daemon.node_id?.slice(0, 12) ?? "?"}, status: ${daemon.status ?? "?"}`) + } else { + check("daemon", "fail", `HTTP ${res.status}`) + } + } catch { + check("daemon", "fail", "not running on localhost:3200 — run: iris-daemon start") + } + + // 4. Daemon Pusher connection + try { + const res = await fetch("http://localhost:3200/health", { signal: AbortSignal.timeout(3000) }) + if (res.ok) { + const data = await res.json() as any + const configRes = await fetch("http://localhost:3200/daemon/queue", { signal: AbortSignal.timeout(3000) }) + if (configRes.ok) { + const q = await configRes.json() as any + check("daemon-queue", q.paused ? "warn" : "pass", `active: ${q.active_tasks ?? 0}, paused: ${q.paused ? "YES" : "no"}, capacity: ${q.capacity ?? "?"}`) + } + } + } catch {} + + // 5. Redis (queue backend) + try { + const res = await irisFetch("/api/health", {}, IRIS_API) + if (res.ok) { + check("redis-queue", "pass", "iris-api is up (Redis is the queue backend)") + } + } catch { + check("redis-queue", "warn", "could not verify") + } + + // 6. If specific job ID given, check its state + if (args.id) { + console.log() + console.log(` ${bold("Job #" + args.id)}`) + printDivider() + + try { + const res = await irisFetch(`/api/v1/users/${userId}/bloqs/scheduled-jobs?per_page=200`) + if (res.ok) { + const all = ((await res.json()) as any)?.data ?? [] + const job = all.find((s: any) => s.id === args.id) + if (job) { + const env = executionEnv(job) + check("job-exists", "pass", `${job.task_name ?? "?"} | ${job.frequency ?? "?"} | ${env.icon} ${env.label}`) + check("job-status", job.status === "scheduled" ? "pass" : job.status === "running" ? "warn" : "fail", + `${job.status}${job.status === "running" ? " (may be stuck — check run_count)" : ""}`) + + const nextRun = job.next_run_at ? new Date(job.next_run_at) : null + if (nextRun) { + const until = timeUntil(job.next_run_at) + check("next-run", until === "overdue" ? "warn" : "pass", `${job.next_run_at} (${until})`) + } + + // Agent check + if (job.agent_id) { + const agent = job.agent + if (agent) { + check("agent", "pass", `#${agent.id} ${agent.name} | mode: ${agent.heartbeat_mode} | model: ${agent.config?.model ?? agent.settings?.model ?? "default"}`) + if (agent.initial_prompt) check("agent-mission", "pass", `${String(agent.initial_prompt).slice(0, 80)}...`) + else check("agent-mission", "warn", "no initial_prompt set — using generic heartbeat prompt") + if (agent.settings?.system_prompt) check("agent-identity", "pass", "custom system_prompt set") + if (agent.settings?.heartbeat_tools) check("agent-tools", "pass", `filtered: ${JSON.stringify(agent.settings.heartbeat_tools)}`) + } + } + + // Bloq check + if (job.bloq) { + check("bloq", "pass", `#${job.bloq.id} ${job.bloq.name}`) + } else if (job.bloq_id) { + check("bloq", "warn", `#${job.bloq_id} (name not loaded)`) + } + + // Execution check + try { + const execRes = await irisFetch(`/api/v1/users/${userId}/bloqs/scheduled-jobs/${args.id}/executions?per_page=1`) + if (execRes.ok) { + const execs = ((await execRes.json()) as any)?.data ?? [] + if (execs.length > 0) { + const e = execs[0] + check("last-execution", e.status === "completed" ? "pass" : "fail", + `#${e.id} ${e.status} | ${e.model_used ?? "?"} | ${e.tokens_used ? Number(e.tokens_used).toLocaleString() + " tok" : "?"}`) + if (e.error_message) check("last-error", "fail", String(e.error_message).slice(0, 120)) + } else { + check("last-execution", "warn", "no executions yet") + } + } + } catch {} + } else { + check("job-exists", "fail", `job #${args.id} not found`) + } + } + } catch (e) { + check("job-lookup", "fail", `${e instanceof Error ? e.message : String(e)}`) + } + } + + // Summary + console.log() + printDivider() + const passed = checks.filter(c => c.status === "pass").length + const failed = checks.filter(c => c.status === "fail").length + const warned = checks.filter(c => c.status === "warn").length + console.log(` ${passed} passed, ${warned} warnings, ${failed} failed`) + + if (failed > 0) { + console.log() + console.log(` ${bold("Fix these:")}`) + for (const c of checks.filter(c => c.status === "fail")) { + console.log(` ${UI.Style.TEXT_DANGER}✗${UI.Style.TEXT_NORMAL} ${c.name}: ${c.detail}`) + } + } + + prompts.outro("Done") + }, +}) + export const PlatformSchedulesCommand = cmd({ command: "schedules", aliases: ["schedule"], @@ -912,6 +1089,7 @@ export const PlatformSchedulesCommand = cmd({ .command(SchedulesInspectCommand) .command(SchedulesToggleCommand) .command(SchedulesDeleteCommand) + .command(SchedulesDiagnoseCommand) .demandCommand(), async handler() {}, }) From 13c29d2a37a49d956d56d000be9668f6b99d8d28 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Mon, 13 Apr 2026 22:30:56 -0500 Subject: [PATCH 34/35] fix: bug list + boards list fetch real data, add profile create/reassign - iris bug list: now fetches from API instead of just printing a URL - iris boards list: fixed endpoint path (/user/{id}/bloqs/{id}/items) - iris profile create: new command to create profiles via API - iris profile reassign-articles: move articles between profiles by keyword Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../opencode/src/cli/cmd/platform-boards.ts | 16 +- packages/opencode/src/cli/cmd/platform-bug.ts | 65 ++++++-- .../opencode/src/cli/cmd/platform-profile.ts | 145 +++++++++++++++++- 3 files changed, 212 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-boards.ts b/packages/opencode/src/cli/cmd/platform-boards.ts index 81d7d6d01491..c376234e346a 100644 --- a/packages/opencode/src/cli/cmd/platform-boards.ts +++ b/packages/opencode/src/cli/cmd/platform-boards.ts @@ -1,7 +1,7 @@ import { cmd } from "./cmd" import * as prompts from "@clack/prompts" import { UI } from "../ui" -import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bold, success, highlight } from "./iris-api" +import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bold, success, highlight, resolveUserId } from "./iris-api" import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs" import { join, basename } from "path" @@ -90,13 +90,21 @@ const BoardsListCommand = cmd({ spinner.start("Loading items…") try { + const userId = await resolveUserId() + if (!userId) { + spinner.stop("Failed", 1) + prompts.log.error("Could not resolve user ID. Set IRIS_USER_ID or run iris-login.") + prompts.outro("Done") + return + } const params = new URLSearchParams({ per_page: String(args.limit) }) - const res = await irisFetch(`/api/v1/bloqs/${args["bloq-id"]}/items?${params}`) + const res = await irisFetch(`/api/v1/user/${userId}/bloqs/${args["bloq-id"]}/items?${params}`) const ok = await handleApiError(res, "List items") if (!ok) { spinner.stop("Failed", 1); prompts.outro("Done"); return } - const data = (await res.json()) as { data?: any[] } - const items: any[] = data?.data ?? (Array.isArray(data) ? data : []) + const data = (await res.json()) as any + const rawItems = data?.data?.items ?? data?.data?.data ?? data?.data ?? [] + const items: any[] = Array.isArray(rawItems) ? rawItems : Object.values(rawItems) spinner.stop(`${items.length} item(s)`) if (items.length === 0) { diff --git a/packages/opencode/src/cli/cmd/platform-bug.ts b/packages/opencode/src/cli/cmd/platform-bug.ts index a39e397d4519..f59243bd5a6d 100644 --- a/packages/opencode/src/cli/cmd/platform-bug.ts +++ b/packages/opencode/src/cli/cmd/platform-bug.ts @@ -1,7 +1,7 @@ import { cmd } from "./cmd" import * as prompts from "@clack/prompts" import { UI } from "../ui" -import { irisFetch, dim, bold, success, IRIS_API } from "./iris-api" +import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bold, success, IRIS_API, resolveUserId } from "./iris-api" import { homedir, platform, release, arch, hostname, userInfo } from "os" import { join } from "path" import { existsSync, readFileSync } from "fs" @@ -214,16 +214,63 @@ const ReportCommand = cmd({ const ListCommand = cmd({ command: "list", aliases: ["ls"], - describe: "view bug reports (opens dashboard)", - async handler() { + describe: "list all bug reports", + builder: (yargs) => + yargs + .option("limit", { describe: "max results", type: "number", default: 20 }) + .option("json", { describe: "JSON output", type: "boolean", default: false }), + async handler(args) { + const token = await requireAuth() + if (!token) return + + const userId = await resolveUserId() + if (!userId) { + console.error("Could not resolve user ID. Set IRIS_USER_ID or run iris-login.") + return + } + + const params = new URLSearchParams({ per_page: String(args.limit) }) + const res = await irisFetch(`/api/v1/user/${userId}/bloqs/${BUG_BLOQ_ID}/items?${params}`) + const ok = await handleApiError(res, "List bug reports") + if (!ok) return + + const data = (await res.json()) as any + const rawItems = data?.data?.items ?? data?.data?.data ?? data?.data ?? [] + const items: any[] = Array.isArray(rawItems) ? rawItems : Object.values(rawItems) + + if (args.json) { + console.log(JSON.stringify(items, null, 2)) + return + } + console.log("") console.log(bold("📋 Bug Reports")) - console.log("") - console.log(` All reports go to ${dim("IRIS CLI Bug Reports")} (bloq #${BUG_BLOQ_ID})`) - console.log(` View at: ${success(`https://app.heyiris.io/iris?board=${BUG_BLOQ_ID}`)}`) - console.log("") - console.log(dim("To submit a new bug:")) - console.log(` iris bug report`) + console.log(` ${dim(`Bloq #${BUG_BLOQ_ID} — ${items.length} item(s)`)}`) + printDivider() + + if (items.length === 0) { + console.log(` ${dim("No bug reports found")}`) + } else { + for (const item of items) { + const contentStr = item.content ?? item.description ?? "" + const severity = contentStr.match(/Severity:\*?\*?\s*(\w+)/i)?.[1] ?? "" + const sevTag = severity ? ` [${severity.toUpperCase()}]` : "" + const status = item.status ? ` ${dim(item.status)}` : "" + console.log(` ${bold(String(item.title))} ${dim(`#${item.id}`)}${sevTag}${status}`) + if (contentStr) { + // Show first meaningful line (skip markdown headers) + const lines = String(contentStr).split("\n").filter((l: string) => l.trim() && !l.startsWith("**") && !l.startsWith("#")) + if (lines.length > 0) { + console.log(` ${dim(lines[0].slice(0, 100))}`) + } + } + console.log() + } + } + + printDivider() + console.log(dim(" iris bug report — submit a new bug")) + console.log(dim(" iris boards get <id> — view full details")) console.log("") }, }) diff --git a/packages/opencode/src/cli/cmd/platform-profile.ts b/packages/opencode/src/cli/cmd/platform-profile.ts index ea242be05828..cc40f47a54b0 100644 --- a/packages/opencode/src/cli/cmd/platform-profile.ts +++ b/packages/opencode/src/cli/cmd/platform-profile.ts @@ -294,9 +294,150 @@ const ProfileMembershipsCommand = cmd({ }, }) +// ============================================================================ +// profile create --name <name> [--bio] [--category] [--website] +// ============================================================================ + +const ProfileCreateCommand = cmd({ + command: "create", + describe: "create a new profile", + builder: (yargs) => + yargs + .option("name", { describe: "profile name", type: "string", demandOption: true }) + .option("bio", { describe: "profile bio", type: "string" }) + .option("category", { describe: "profile category", type: "string" }) + .option("instagram", { describe: "Instagram handle", type: "string" }) + .option("twitter", { describe: "Twitter handle", type: "string" }) + .option("website", { describe: "website URL", type: "string" }), + async handler(args) { + await requireAuth() + const name = args.name as string + const body: Record<string, any> = { name } + if (args.bio) body.bio = args.bio + if (args.category) body.category = args.category + if (args.instagram) body.instagram = args.instagram + if (args.twitter) body.twitter = args.twitter + if (args.website) body.website_url = args.website + + const res = await irisFetch("/api/v1/profile/create", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + const ok = await handleApiError(res, "Create profile") + if (!ok) { prompts.outro("Failed"); return } + const data = (await res.json()) as any + const profile = data?.data ?? data + printDivider() + printKV("PK", profile.pk ?? profile.id ?? "?") + printKV("Slug", profile.id ?? profile.slug ?? "?") + printKV("Name", profile.name ?? name) + if (profile.bio) printKV("Bio", profile.bio) + printDivider() + prompts.outro(success("Profile created")) + }, +}) + +// ============================================================================ +// profile reassign-articles --from <pk> --to <pk> --match <keyword> +// ============================================================================ + +const ProfileReassignArticlesCommand = cmd({ + command: "reassign-articles", + describe: "move articles from one profile to another by keyword match", + builder: (yargs) => + yargs + .option("from", { describe: "source profile slug or PK", type: "string", demandOption: true }) + .option("to", { describe: "target profile slug or PK", type: "string", demandOption: true }) + .option("match", { describe: "keyword to match in article titles (case-insensitive)", type: "string", demandOption: true }) + .option("dry-run", { describe: "preview without making changes", type: "boolean", default: false }) + .option("yes", { alias: "y", describe: "skip confirmation", type: "boolean", default: false }), + async handler(args) { + await requireAuth() + const fromSlug = args.from as string + const toSlug = args.to as string + const keyword = (args.match as string).toLowerCase() + const dryRun = args["dry-run"] as boolean + + // Resolve source profile + const fromProfile = await fetchProfile(fromSlug) + if (!fromProfile) { prompts.outro(`Source profile "${fromSlug}" not found`); return } + console.log(` Source: ${bold(fromProfile.name)} (pk: ${fromProfile.pk})`) + + // Resolve target profile + const toProfile = await fetchProfile(toSlug) + if (!toProfile) { prompts.outro(`Target profile "${toSlug}" not found`); return } + console.log(` Target: ${bold(toProfile.name)} (pk: ${toProfile.pk})`) + + // Fetch articles from source profile + const articlesRes = await irisFetch(`/api/v1/articles?profile_id=${fromProfile.pk}&limit=100`) + const articlesOk = await handleApiError(articlesRes, "Fetch articles") + if (!articlesOk) return + const articlesData = (await articlesRes.json()) as any + // Handle nested pagination (data.data) or flat array (data) + const rawArticles = articlesData?.data?.data ?? articlesData?.data ?? articlesData ?? [] + const articles: any[] = Array.isArray(rawArticles) ? rawArticles : Object.values(rawArticles) + + // Filter by keyword + const matching = articles.filter((a: any) => + (a.title || "").toLowerCase().includes(keyword) + ) + + printDivider() + console.log(` Found ${matching.length} articles matching "${args.match}" out of ${articles.length} total`) + printDivider() + + if (matching.length === 0) { + prompts.outro("No matching articles found") + return + } + + for (const article of matching) { + console.log(` [${article.id}] ${article.title}`) + } + + if (dryRun) { + printDivider() + prompts.outro(dim("Dry run — no changes made")) + return + } + + const skipConfirm = args.yes as boolean + if (!skipConfirm) { + printDivider() + const confirmed = await prompts.confirm({ + message: `Move ${matching.length} articles from "${fromProfile.name}" to "${toProfile.name}"?`, + }) + if (!confirmed || prompts.isCancel(confirmed)) { + prompts.outro("Cancelled") + return + } + } + + // Reassign each article + let moved = 0 + for (const article of matching) { + const updateRes = await irisFetch(`/api/v1/articles/${article.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ profile_id: toProfile.pk }), + }) + if (updateRes.ok) { + moved++ + console.log(` ✓ Moved: ${article.title}`) + } else { + console.log(` ✗ Failed: ${article.title} (${updateRes.status})`) + } + } + + printDivider() + prompts.outro(success(`Moved ${moved}/${matching.length} articles`)) + }, +}) + export const PlatformProfileCommand = cmd({ command: "profile", - describe: "manage profiles (show, get, set, links, memberships)", + describe: "manage profiles (show, get, set, create, links, memberships, reassign-articles)", builder: (yargs) => yargs .command(ProfileShowCommand) @@ -304,6 +445,8 @@ export const PlatformProfileCommand = cmd({ .command(ProfileSetCommand) .command(ProfileLinksCommand) .command(ProfileMembershipsCommand) + .command(ProfileCreateCommand) + .command(ProfileReassignArticlesCommand) .demandCommand(), async handler() {}, }) From 7501d07b7f095371c9afd6b13a9013ee7e74eb69 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Mon, 13 Apr 2026 22:36:35 -0500 Subject: [PATCH 35/35] chore: bump version to 1.1.22 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- packages/opencode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 074acf41c473..1ac10653e3ee 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.1.21", + "version": "1.1.22", "name": "opencode", "displayName": "iris-agent-cli", "type": "module",