diff --git a/.agents/skills/qa/SKILL.md b/.agents/skills/qa/SKILL.md index 7810c24fe..b7279ed60 100644 --- a/.agents/skills/qa/SKILL.md +++ b/.agents/skills/qa/SKILL.md @@ -19,6 +19,15 @@ Autonomous QA testing that spins up template apps, tests them with Playwright in /qa --focus "test form submission and compose" # prioritize specific flows ``` +## Browser MCP Readiness + +QA can use the framework's built-in browser MCP capabilities instead of a hand-written `mcp.config.json`. The built-ins are off by default and are toggled through `/_agent-native/mcp/builtin`. + +- Prefer `browser-playwright` for automated QA sweeps: it runs `npx -y @playwright/mcp@0.0.75`. +- Use `browser-chrome-devtools` only when the test specifically needs to attach to a live Chrome session. It runs `npx -y chrome-devtools-mcp@0.26.0 --autoConnect --no-usage-statistics` and requires Chrome 144+ with remote debugging enabled. Do not assume it signs into the user's Chrome profile. +- Browser built-ins are exclusive per scope: enabling Chrome disables Playwright and enabling Playwright disables Chrome. +- `computer-use` runs `npx -y computer-use-mcp@1.8.0` and is macOS-only. + **Args:** - `--apps` — comma-separated app names (default: `mail,calendar,content,forms`) diff --git a/.changeset/app-model-defaults.md b/.changeset/app-model-defaults.md new file mode 100644 index 000000000..8c03addb4 --- /dev/null +++ b/.changeset/app-model-defaults.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Add org-scoped per-app default model settings for agent chat. diff --git a/.changeset/brain-headless-distillation.md b/.changeset/brain-headless-distillation.md new file mode 100644 index 000000000..37338e94c --- /dev/null +++ b/.changeset/brain-headless-distillation.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Expose server-side agent loop helpers for template background workers. diff --git a/.changeset/brain-template-catalog.md b/.changeset/brain-template-catalog.md new file mode 100644 index 000000000..ef590a555 --- /dev/null +++ b/.changeset/brain-template-catalog.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Register the Brain template in the public catalog and docs. diff --git a/.changeset/builtin-mcp-capabilities.md b/.changeset/builtin-mcp-capabilities.md new file mode 100644 index 000000000..0612e358a --- /dev/null +++ b/.changeset/builtin-mcp-capabilities.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Add scoped built-in MCP capability toggles for browser and computer-use servers. diff --git a/.changeset/calm-host-bridges.md b/.changeset/calm-host-bridges.md new file mode 100644 index 000000000..9cebb8587 --- /dev/null +++ b/.changeset/calm-host-bridges.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": minor +--- + +Add host bridge, React iframe helpers, screen context snapshots, typed live client actions, session metadata, approval gates, and host tool adapters for embedding Agent-Native sidecars in existing SaaS apps. diff --git a/.changeset/clever-code-steering.md b/.changeset/clever-code-steering.md new file mode 100644 index 000000000..600aa7986 --- /dev/null +++ b/.changeset/clever-code-steering.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Record active Agent-Native Code follow-ups as steering or queued prompts. diff --git a/.changeset/code-agents-auto-mode.md b/.changeset/code-agents-auto-mode.md new file mode 100644 index 000000000..1638306fe --- /dev/null +++ b/.changeset/code-agents-auto-mode.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Default Agent-Native Code sessions to auto mode and add plan/auto CLI aliases. diff --git a/.changeset/code-agents-session-store.md b/.changeset/code-agents-session-store.md new file mode 100644 index 000000000..2371c66d7 --- /dev/null +++ b/.changeset/code-agents-session-store.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": minor +--- + +Document the next Agent-Native Code follow-up features: session picker/run controls, permission modes, project slash commands, and migration as a Code workspace slash command instead of a template. diff --git a/.changeset/code-agents-ui-template.md b/.changeset/code-agents-ui-template.md new file mode 100644 index 000000000..b570fbe22 --- /dev/null +++ b/.changeset/code-agents-ui-template.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": minor +--- + +Expose local Agent-Native Code run helpers and document the reusable Code UI/template flow. diff --git a/.changeset/dispatch-dream-settings-cli.md b/.changeset/dispatch-dream-settings-cli.md new file mode 100644 index 000000000..35190f1a5 --- /dev/null +++ b/.changeset/dispatch-dream-settings-cli.md @@ -0,0 +1,6 @@ +--- +"@agent-native/core": patch +"@agent-native/dispatch": patch +--- + +Expose package-provided actions through template action runners and add a full Dispatch Dreams settings editor. diff --git a/.changeset/dispatch-dreams.md b/.changeset/dispatch-dreams.md new file mode 100644 index 000000000..da1e46fc7 --- /dev/null +++ b/.changeset/dispatch-dreams.md @@ -0,0 +1,5 @@ +--- +"@agent-native/dispatch": minor +--- + +Add Dispatch dreaming backend tables, actions, proposals, and safe recurring dream job setup. diff --git a/.changeset/dispatch-ui-subpaths.md b/.changeset/dispatch-ui-subpaths.md new file mode 100644 index 000000000..543bf1fef --- /dev/null +++ b/.changeset/dispatch-ui-subpaths.md @@ -0,0 +1,5 @@ +--- +"@agent-native/dispatch": patch +--- + +Expose Dispatch shadcn UI primitives for workspace-owned Dispatch template routes. diff --git a/.changeset/embedded-agent-native-runtime.md b/.changeset/embedded-agent-native-runtime.md new file mode 100644 index 000000000..1911de2b7 --- /dev/null +++ b/.changeset/embedded-agent-native-runtime.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": minor +--- + +Add a batteries-included embedded Agent-Native runtime with host-auth server mounting, a React embedded sidebar/surface, and direct browser-session context/action registration. diff --git a/.changeset/explicit-composer-layout.md b/.changeset/explicit-composer-layout.md new file mode 100644 index 000000000..a6cb2a900 --- /dev/null +++ b/.changeset/explicit-composer-layout.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Add explicit shared composer layout variants and toolbar slot hooks. diff --git a/.changeset/first-class-code-packs.md b/.changeset/first-class-code-packs.md new file mode 100644 index 000000000..800109d41 --- /dev/null +++ b/.changeset/first-class-code-packs.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Expose Agent-Native Code project commands and skills as structured code-pack metadata. diff --git a/.changeset/fresh-core-local-pack.md b/.changeset/fresh-core-local-pack.md new file mode 100644 index 000000000..5c21016e2 --- /dev/null +++ b/.changeset/fresh-core-local-pack.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Build the core package before packing local file dependencies so generated framework workspaces install a fresh dist snapshot. diff --git a/.changeset/fresh-dispatch-local-pack.md b/.changeset/fresh-dispatch-local-pack.md new file mode 100644 index 000000000..313b97703 --- /dev/null +++ b/.changeset/fresh-dispatch-local-pack.md @@ -0,0 +1,6 @@ +--- +"@agent-native/core": patch +"@agent-native/dispatch": patch +--- + +Link the local Dispatch package during framework-development workspace creation and build Dispatch before local packing. diff --git a/.changeset/global-workspace-resources.md b/.changeset/global-workspace-resources.md new file mode 100644 index 000000000..ebd32d3f1 --- /dev/null +++ b/.changeset/global-workspace-resources.md @@ -0,0 +1,6 @@ +--- +"@agent-native/core": patch +"@agent-native/dispatch": patch +--- + +Inherit Dispatch-managed workspace instructions, skills, and reference resources at runtime; seed and restore starter company, brand, messaging, guardrail, and voice resources; show and inspect each app's effective workspace context stack; gate All-app resource edits through Dispatch approvals when enabled; preview global impact and overrides before save; and expose read-only inherited workspace resources in app panels. diff --git a/.changeset/live-browser-session-bridge.md b/.changeset/live-browser-session-bridge.md new file mode 100644 index 000000000..639daa90c --- /dev/null +++ b/.changeset/live-browser-session-bridge.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": minor +--- + +Add a SQL-backed browser-session bridge so embedded sidecars can register live host tabs, let backend agent tools inspect page context, run client actions, and send host refresh/navigation/remount commands. diff --git a/.changeset/migrate-dist-types.md b/.changeset/migrate-dist-types.md new file mode 100644 index 000000000..7643571d1 --- /dev/null +++ b/.changeset/migrate-dist-types.md @@ -0,0 +1,5 @@ +--- +"@agent-native/migrate": patch +--- + +Publish generated declaration files instead of TypeScript source entries. diff --git a/.changeset/migrate-slash-command-ux.md b/.changeset/migrate-slash-command-ux.md new file mode 100644 index 000000000..b31d9cdb8 --- /dev/null +++ b/.changeset/migrate-slash-command-ux.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Improve `/migrate` CLI handoff output with clearer Agent-Native Code resume commands and artifact guidance. diff --git a/.changeset/migration-cli-dossiers.md b/.changeset/migration-cli-dossiers.md new file mode 100644 index 000000000..a2e384b6c --- /dev/null +++ b/.changeset/migration-cli-dossiers.md @@ -0,0 +1,6 @@ +--- +"@agent-native/core": patch +"@agent-native/migrate": patch +--- + +Add the generic Agent-Native Code `/migrate` CLI entrypoint, any-input migration seeding, and own-agent dossier emit output for code-agent handoff. diff --git a/.changeset/page-chat-surface.md b/.changeset/page-chat-surface.md new file mode 100644 index 000000000..c0214deb8 --- /dev/null +++ b/.changeset/page-chat-surface.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Export a reusable full-page agent chat surface backed by AgentPanel internals. diff --git a/.changeset/portable-extension-slots.md b/.changeset/portable-extension-slots.md new file mode 100644 index 000000000..5e8fe4445 --- /dev/null +++ b/.changeset/portable-extension-slots.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": minor +--- + +Add portable extension iframe and slot primitives for embedding SDK hosts, including manifest-gated permissions and storage adapters. diff --git a/.changeset/public-agent-actions.md b/.changeset/public-agent-actions.md new file mode 100644 index 000000000..d857635dd --- /dev/null +++ b/.changeset/public-agent-actions.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Expose safe public-agent read-only actions in the unauthenticated agent surface. diff --git a/.changeset/quiet-brain-connections.md b/.changeset/quiet-brain-connections.md new file mode 100644 index 000000000..0a524e193 --- /dev/null +++ b/.changeset/quiet-brain-connections.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Expose shared workspace connection app-access semantics for reusable integrations. diff --git a/.changeset/remote-relay-core.md b/.changeset/remote-relay-core.md new file mode 100644 index 000000000..0d00ed256 --- /dev/null +++ b/.changeset/remote-relay-core.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Add SQL-backed remote integration relay device, command, run-event, management, and push-registration endpoints. diff --git a/.changeset/remove-workspace-resource-sync-actions.md b/.changeset/remove-workspace-resource-sync-actions.md new file mode 100644 index 000000000..a8da4c072 --- /dev/null +++ b/.changeset/remove-workspace-resource-sync-actions.md @@ -0,0 +1,6 @@ +--- +"@agent-native/dispatch": patch +"@agent-native/core": patch +--- + +Remove legacy workspace-resource sync actions and clarify runtime inheritance docs. diff --git a/.changeset/runtime-context-contract.md b/.changeset/runtime-context-contract.md new file mode 100644 index 000000000..c3a8cab84 --- /dev/null +++ b/.changeset/runtime-context-contract.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Add runtime inheritance contract coverage for workspace resources. diff --git a/.changeset/scheduling-libsql-peer.md b/.changeset/scheduling-libsql-peer.md new file mode 100644 index 000000000..1a55a874b --- /dev/null +++ b/.changeset/scheduling-libsql-peer.md @@ -0,0 +1,6 @@ +--- +"@agent-native/dispatch": patch +"@agent-native/scheduling": patch +--- + +Align local Drizzle peer resolution with the framework's libsql driver version. diff --git a/.changeset/secure-mcp-test-route.md b/.changeset/secure-mcp-test-route.md new file mode 100644 index 000000000..ea7b3a773 --- /dev/null +++ b/.changeset/secure-mcp-test-route.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Require authentication before dry-running arbitrary MCP server URLs. diff --git a/.changeset/shared-workspace-readiness.md b/.changeset/shared-workspace-readiness.md new file mode 100644 index 000000000..059f19603 --- /dev/null +++ b/.changeset/shared-workspace-readiness.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Add shared workspace connection app-grant and provider-readiness helpers for reusable integrations. diff --git a/.changeset/telegram-code-routing.md b/.changeset/telegram-code-routing.md new file mode 100644 index 000000000..b8db3212d --- /dev/null +++ b/.changeset/telegram-code-routing.md @@ -0,0 +1,6 @@ +--- +"@agent-native/dispatch": patch +"@agent-native/core": patch +--- + +Route Telegram `/code` commands from Dispatch to the remote code-agent relay. diff --git a/.changeset/workspace-connection-catalog.md b/.changeset/workspace-connection-catalog.md new file mode 100644 index 000000000..976a0ebdf --- /dev/null +++ b/.changeset/workspace-connection-catalog.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Add a typed workspace connection provider catalog for reusable integration metadata. diff --git a/.changeset/workspace-connection-grants.md b/.changeset/workspace-connection-grants.md new file mode 100644 index 000000000..bdacdee5b --- /dev/null +++ b/.changeset/workspace-connection-grants.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Add scoped workspace connection grant storage and helpers for connect-once, grant-to-app integrations. diff --git a/.changeset/workspace-connections-foundation.md b/.changeset/workspace-connections-foundation.md new file mode 100644 index 000000000..4819071af --- /dev/null +++ b/.changeset/workspace-connections-foundation.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Add scoped workspace connection metadata storage for connect-once-use-everywhere foundations. diff --git a/AGENTS.md b/AGENTS.md index acb6bfd78..fd7203daa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -141,9 +141,24 @@ Run `agent-native setup-agents` to create all symlinks (done automatically by `a - **Always use shadcn/ui components for standard UI** — `app/components/ui/` (templates) or `packages/core/src/client/components/ui/` (framework). Available primitives include `Button`, `Dialog`, `AlertDialog`, `Popover`, `DropdownMenu`, `Tooltip`, `Sheet`, `Tabs`, `Select`, `Collapsible`, `Accordion`, `HoverCard`, `Command`, etc. **Never build a custom dropdown / menu / popover with `position: absolute` + a manual click-outside `useEffect`** — those get clipped by ancestor `overflow-hidden` / stacking contexts (no `z-index` will save them) and lack the keyboard nav, focus trap, and animations users expect. Use `` for action menus (Rename / Delete / "⋯" overflow), `` for transient panels (color pickers, share dialogs, filters), `` for modals, `` for confirmations. If a needed shadcn primitive is missing in a package, install it via `npx shadcn@latest add ` (templates) or copy from another template + add the matching `@radix-ui/react-*` dep (framework packages) — don't roll your own. - **Tabler Icons** (`@tabler/icons-react`) for all icons. **Never use emojis as icons** — not in buttons, not in avatars, not in labels, not in toasts/notifications, not in outbound messages (Slack, email). No other icon libraries, no inline SVGs. Avoid sparkle and wand icons in first-party UI; they are overused. For chat / agent affordances, use a message-style icon instead. Emojis are fine when they are _user-authored content_ (a document title emoji picker, a reaction the user chose, a user-picked space icon) — the rule is about icons the UI picks, not data the user picks. - **No browser dialogs** — use shadcn AlertDialog instead of `window.confirm/alert/prompt`. +- **Agent input surfaces use the shared composer.** Every UI that accepts a prompt + for an agent must reuse the framework composer stack: + `AgentComposerFrame`, `PromptComposer`, and `TiptapComposer`. Do not build a + one-off textarea/chatfield, upload picker, voice control, model/mode picker, + slash-command parser, or Enter-to-submit behavior. Host-specific UIs such as + Agent-Native Code may add narrow slots around the shared composer — for + example Auto/Plan controls or cwd/project metadata — but the input field, + attachment pipeline, references, skills, voice dictation, draft behavior, and + keyboard semantics stay shared. Slash commands come from project + `.agents/commands` and `.agents/skills`; do not hardcode a separate command + registry in a prompt surface. +- **Background agents reuse the shared run harness.** Hosted/background agent work + must go through the core `run-manager` / `agent-teams` infrastructure so runs + share SQL persistence, streaming, aborts, heartbeats, resume, and stuck-run + behavior. Do not introduce a second background-agent harness for a new UI. - **Template UX stays clean, minimal, and intuitive** — this is a high priority across all templates. Treat every important screen as a focused working surface, not a place to accumulate fixes as extra visible controls. When acting on feedback, especially broad prompts like "fix what you agree with," judge each suggestion through visual hierarchy, user intent, and progressive disclosure before changing the UI. Prefer clarifying primary actions, reducing competing elements, tightening layout, and moving secondary or rare actions into menus, sheets, tabs, or advanced sections. Do not solve feedback by adding more buttons, toolbars, badges, panels, helper text, filters, or always-visible options to important screens unless that added surface is genuinely the clearest path for the main workflow. If a fix would make a core screen busier, look for a cleaner interaction model or ask before adding clutter. - **Progressive disclosure by default** — UIs should reveal complexity gradually, not dump every option on screen at once. Lead with the primary action and most-used info; hide the rest behind reveals. Concrete patterns: shadcn `Collapsible` / `Accordion` for grouped settings, `Popover` for secondary actions (share, filters, color pickers, "more options"), `DropdownMenu` overflow (`⋯`) for tertiary toolbar items, `Sheet` / side drawer for full-detail editing of a row, `HoverCard` or expand-on-click for card details, "Show advanced" toggles for optional form fields, tabs to split a long surface into focused sections. Anti-patterns we keep regressing into: a settings page that dumps 20 fields in one flat column, a form that shows every optional field upfront, a toolbar where every button has equal visual weight, a card that prints every metadata field instead of summary + expandable details, a dialog the size of the screen because the form has 15 fields, an empty state that scaffolds the full UI instead of one clear CTA. Rule of thumb: if a first-time user wouldn't need it in the first 5 seconds, collapse it. When in doubt, default to hiding — it's much cheaper to expose later than to declutter a busy screen. -- **Public template list is a strict allow-list — never widen it without flipping `hidden:false` first.** The single source of truth is `packages/shared-app-config/templates.ts` (entries with `hidden: false`). Today the public allow-list is exactly: **calendar, content, slides, videos, clips, analytics, mail, dispatch, forms, design** — plus `starter` for the CLI only. The featured/default set is narrower: **calendar, content, slides, clips, analytics, mail, dispatch, forms, design** — plus `starter` for the CLI/default-app fallback. Videos is scaffoldable by explicit slug, but it is not featured on the homepage, `/templates`, docs sidebar, CLI picker, or desktop/mobile default tabs. Hidden templates (calls, meeting-notes, voice, scheduling, issues, recruiting, images, macros) MUST NOT appear on the homepage, in the docs sidebar, in docs pages, or in the CLI catalog. Surfaces that hardcode their own list — `packages/docs/app/components/TemplateCard.tsx`, `packages/docs/app/components/docsNavItems.ts`, docs pages `packages/core/docs/content/template-*.md`, and the CLI duplicate `packages/core/src/cli/templates-meta.ts` — must only reference allow-listed slugs. To make a hidden template public: flip `hidden: false` in `packages/shared-app-config/templates.ts` AND `packages/core/src/cli/templates-meta.ts`, then add it to the surfaces above. To hide one: flip `hidden: true` in both files; the guard will then point you at every surface that still mentions it. `scripts/guard-template-list.mjs` (CI + `pnpm prep`) enforces this — adding a slug that isn't in the allow-list will fail the build. _This guard exists because agents kept re-adding the hidden templates (calls, meeting-notes, voice, scheduling, issues, recruiting, images, macros) to the homepage and sidebar during overnight sweeps. Do not disable it._ +- **Public template list is a strict allow-list — never widen it without flipping `hidden:false` first.** The single source of truth is `packages/shared-app-config/templates.ts` (entries with `hidden: false`). Today the public allow-list is exactly: **calendar, content, slides, videos, clips, analytics, mail, dispatch, forms, design, brain** — plus `starter` for the CLI only. The featured/default set is narrower: **calendar, content, slides, clips, analytics, mail, dispatch, forms, design, brain** — plus `starter` for the CLI/default-app fallback. Videos is scaffoldable by explicit slug, but it is not featured on the homepage, `/templates`, docs sidebar, CLI picker, or desktop/mobile default tabs. Hidden templates (calls, meeting-notes, voice, scheduling, issues, recruiting, images, macros) MUST NOT appear on the homepage, in the docs sidebar, in docs pages, or in the CLI catalog. Surfaces that hardcode their own list — `packages/docs/app/components/TemplateCard.tsx`, `packages/docs/app/components/docsNavItems.ts`, docs pages `packages/core/docs/content/template-*.md`, and the CLI duplicate `packages/core/src/cli/templates-meta.ts` — must only reference allow-listed slugs. To make a hidden template public: flip `hidden: false` in `packages/shared-app-config/templates.ts` AND `packages/core/src/cli/templates-meta.ts`, then add it to the surfaces above. To hide one: flip `hidden: true` in both files; the guard will then point you at every surface that still mentions it. `scripts/guard-template-list.mjs` (CI + `pnpm prep`) enforces this — adding a slug that isn't in the allow-list will fail the build. _This guard exists because agents kept re-adding the hidden templates (calls, meeting-notes, voice, scheduling, issues, recruiting, images, macros) to the homepage and sidebar during overnight sweeps. Do not disable it._ - **No breaking database changes — ever.** Hosted templates share their prod DB across every deploy context (preview, branch, prod). Any destructive SQL that runs in any build will overwrite live user data. Symptoms we've already hit in production: users losing accounts, dashboards silently emptied, sessions invalidated. Hard rules: - **Schema edits must be strictly additive.** Add new columns/tables, never rename or drop. If a column is wrong, add the replacement alongside it, dual-write from the application, migrate readers, and only retire the old column once every deploy that reads it is gone. Same for tables. - **Never rename an existing table or column** in a single step — not via Drizzle, not via raw SQL, not via `drizzle-kit push`. A rename looks like drop+create to the diff tool and wipes the table. diff --git a/README.md b/README.md index 79d10ac6d..9b7be7486 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,43 @@ Want a single app, no monorepo? Use `--standalone`: npx @agent-native/core create my-app --standalone --template mail ``` +Need a coding agent workspace? `agent-native` or `agent-native code` opens an open-source Claude Code/Codex-like Code workspace with no prompt required. From there, type a task, run slash goals interactively, or call them directly from your shell: + +```bash +npx @agent-native/core@latest +npx @agent-native/core@latest "fix the failing auth tests" +npx @agent-native/core@latest code +npx @agent-native/core@latest code "fix the failing auth tests" +npx @agent-native/core@latest code exec "fix the failing auth tests" +npx @agent-native/core@latest code -p "fix the failing auth tests" +npx @agent-native/core@latest code --plan "explain the auth test failures" +npx @agent-native/core@latest code --auto "fix the failing auth tests" +npx @agent-native/core@latest code /migrate ./my-next-app --out ../migrated-app +npx @agent-native/core@latest code /migrate ./my-next-app --emit ../migration-dossier +npx @agent-native/core@latest code list +npx @agent-native/core@latest code attach --last +npx @agent-native/core@latest code logs --last +npx @agent-native/core@latest code approve --last +npx @agent-native/core@latest code resume --last +npx @agent-native/core@latest code --continue "check the auth edge cases next" +npx @agent-native/core@latest code resume --last "check the auth edge cases next" +``` + +Slash goals can run from the interactive shell or directly from the command line, and `agent-native code goals` shows the goals registered in your checkout. A bare prompt starts a local coding-agent session, streams work, records transcript/status/tool events, and accepts follow-up prompts; `/migrate` is one specialized capability inside that general Code workspace. Project-specific slash commands live in `.agents/commands/*.md`, so teams can add prompts such as `/release-check` or `/migrate-commerce` without changing the framework. Bare `agent-native` launches the Code workspace in builds with the top-level entrypoint, while a bare prompt such as `agent-native "fix tests"` starts an Agent-Native Code task directly. + +The Code workspace is adding the familiar Codex/Claude-style session loop: pick a previous session, list runs, attach to live output, print logs, resume with context, and continue the same run from Desktop or CLI. The primary modes are intentionally simple: + +- **Plan mode** (`--plan`) inspects, explains, and proposes without writing files. +- **Auto mode** (`--auto`, default) edits files, runs checks, and only pauses for genuinely destructive file, git, publish, or data operations. + +`agent-native migrate` still works as a direct shortcut; `code /migrate` is the Agent-Native Code entrypoint for the migration goal. By default it creates an Agent-Native Code session and portable migration dossier, not a scaffolded app/template. `resume --last` reopens the latest run handoff; adding a quoted prompt records it as a follow-up transcript event for that run so the next active coding agent can pick it up. If a high-risk command is paused for approval, `code approve --last` runs that one pending command and then points you back to resume the session. Use `--app-surface` only when you want the legacy hidden migration detail app for assessment, approval, tasks, artifacts, and verification. +Use `--emit` when you want only the portable dossier for Codex, Claude Code, Cursor, or another coding agent. +Agent-Native Code also includes lightweight goals such as `/audit`: + +```bash +npx @agent-native/core@latest code /audit --url https://example.com +``` + ## Workspaces (Monorepo) A workspace is the default shape of an agent-native project. Every app sits under `apps/`, and `packages/shared/` is available for the small amount of code, instructions, skills, or branding that should truly apply to every app. diff --git a/package.json b/package.json index 1fbc27f49..ea571b1c0 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,11 @@ "typecheck": "pnpm -r --reporter-hide-prefix typecheck", "fmt": "prettier --write --log-level warn .", "fmt:check": "prettier --check --log-level warn .", - "test": "pnpm --filter @agent-native/core test && pnpm --filter @agent-native/migrate test && pnpm --filter @agent-native/docs test && pnpm --filter dispatch test", + "test": "pnpm --filter @agent-native/core test && pnpm --filter @agent-native/migrate test && pnpm --filter @agent-native/docs test && pnpm --filter dispatch test && pnpm test:brain-evals", + "test:brain-evals": "pnpm --filter brain eval:ci", "qa:cli": "tsx scripts/qa-cli-smoke.ts", + "qa:composer-geometry": "tsx scripts/qa-composer-geometry-smoke.ts", + "qa:dispatch-workspace-resources": "tsx scripts/qa-dispatch-workspace-resources-smoke.ts", "qa:template-routes": "tsx scripts/qa-template-route-matrix.ts", "lint": "pnpm fmt:check && pnpm typecheck", "guard:no-drizzle-push": "node scripts/guard-no-drizzle-push.mjs", @@ -46,6 +49,7 @@ "dev:lazy:desktop": "node scripts/dev-lazy.ts --desktop", "dev:docs": "pnpm --filter @agent-native/docs dev", "dev:electron": "node scripts/dev-electron.ts", + "dev:electron:lazy": "node scripts/dev-lazy.ts --electron", "dev:electron:apps": "node scripts/dev-electron.ts --apps" }, "devDependencies": { diff --git a/packages/code-agents-ui/package.json b/packages/code-agents-ui/package.json new file mode 100644 index 000000000..d2d07ef73 --- /dev/null +++ b/packages/code-agents-ui/package.json @@ -0,0 +1,39 @@ +{ + "name": "@agent-native/code-agents-ui", + "version": "0.1.0", + "private": true, + "type": "module", + "repository": { + "type": "git", + "url": "https://github.com/BuilderIO/agent-native", + "directory": "packages/code-agents-ui" + }, + "main": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": "./src/index.ts", + "./code-agents": "./src/code-agents.ts", + "./styles.css": "./src/styles.css", + "./types": "./src/types.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@agent-native/core": "workspace:*", + "@agent-native/shared-app-config": "workspace:*", + "@radix-ui/react-dropdown-menu": "2.1.16", + "@radix-ui/react-select": "2.2.6", + "@tabler/icons-react": "^3.44.0", + "sonner": "^2.0.7" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "typescript": "^6.0.3" + } +} diff --git a/packages/code-agents-ui/src/CodeAgentsApp.tsx b/packages/code-agents-ui/src/CodeAgentsApp.tsx new file mode 100644 index 000000000..ffd6a1ac1 --- /dev/null +++ b/packages/code-agents-ui/src/CodeAgentsApp.tsx @@ -0,0 +1,2709 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + IconAlertCircle, + IconClock, + IconCode, + IconDots, + IconExternalLink, + IconFolder, + IconFolderPlus, + IconListCheck, + IconPinned, + IconPinnedOff, + IconPlus, + IconPlayerPlay, + IconRefresh, + IconRoute, + IconTerminal2, +} from "@tabler/icons-react"; +import { + PromptComposer, + type PromptComposerFile, + type SlashCommand, + type TiptapComposerHandle, +} from "@agent-native/core/client"; +import { toast } from "sonner"; +import { readCodeAgentPromptAttachment } from "./composer-primitives.js"; +import { + CODE_AGENT_GOALS, + DEFAULT_CODE_AGENT_PERMISSION_MODE, + getCodeAgentAppConfig, + getCodeAgentGoal, + getCodeAgentPermissionMode, + getDefaultCodeAgentGoal, + type CodeAgentGoalDefinition, + type CodeAgentGoalId, + type CodeAgentPermissionMode, +} from "./code-agents.js"; +import type { AppConfig } from "@agent-native/shared-app-config"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "./ui/dropdown-menu.js"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "./ui/select.js"; +import type { + CodeAgentCodePack, + CodeAgentCodePackResult, + CodeAgentControlCommand, + CodeAgentControlResult, + CodeAgentCreateRunRequest, + CodeAgentCreateRunResult, + CodeAgentFollowUpMode, + CodeAgentFollowUpRequest, + CodeAgentFollowUpResult, + CodeAgentMigrationRun, + CodeAgentModelListResult, + CodeAgentModelOption, + CodeAgentModelSelection, + CodeAgentPromptAttachment, + CodeAgentProjectFolder, + CodeAgentProjectListResult, + CodeAgentProjectSelectResult, + CodeAgentReasoningEffort, + CodeAgentRemoteConnectorControlResult, + CodeAgentRemoteConnectorStatus, + CodeAgentRerunRequest, + CodeAgentRerunResult, + CodeAgentRetryRunRequest, + CodeAgentRetryRunResult, + CodeAgentRun, + CodeAgentRunDetail, + CodeAgentRunListResult, + CodeAgentTerminalRequest, + CodeAgentTerminalResult, + CodeAgentTranscriptEvent, + CodeAgentTranscriptEventType, + CodeAgentTranscriptRequest, + CodeAgentTranscriptResult, + CodeAgentUpdateRunRequest, + CodeAgentUpdateRunResult, + CodeAgentsOpenRequest, +} from "./types.js"; + +export interface CodeAgentsHost { + listRuns(goalId?: string): Promise; + listModels?(): Promise; + listCodePacks?(cwd?: string): Promise; + listProjects?(): Promise; + selectProject?(cwd: string): Promise; + chooseProject?(): Promise; + createRun( + request: CodeAgentCreateRunRequest, + ): Promise; + readTranscript( + request: CodeAgentTranscriptRequest, + ): Promise; + appendFollowUp( + request: CodeAgentFollowUpRequest, + ): Promise; + updateRun( + request: CodeAgentUpdateRunRequest, + ): Promise; + retryRun?( + request: CodeAgentRetryRunRequest, + ): Promise; + rerunRun?(request: CodeAgentRerunRequest): Promise; + controlRun( + goalId: string, + runId: string, + command: CodeAgentControlCommand, + permissionMode?: CodeAgentPermissionMode, + ): Promise; + openTerminal?( + request?: CodeAgentTerminalRequest, + ): Promise; + getRemoteConnectorStatus?(): Promise; + setRemoteConnectorEnabled?( + enabled: boolean, + ): Promise; +} + +export type CodeAgentsRenderAppSurface = (input: { + goal: CodeAgentGoalDefinition; + app: AppConfig; + urlParams?: Record; + refreshKey: number; +}) => React.ReactNode; + +export interface CodeAgentsAppProps { + apps: AppConfig[]; + host: CodeAgentsHost; + openRequest?: CodeAgentsOpenRequest; + refreshKey?: number; + onOpenSettings?: () => void; + renderAppSurface?: CodeAgentsRenderAppSurface; +} + +type RunListStatus = CodeAgentRunListResult["status"]; +type CodeAgentRunMode = "plan" | "auto"; + +const CODE_AGENT_RUN_MODES: Array<{ + id: CodeAgentRunMode; + label: string; + description: string; +}> = [ + { + id: "plan", + label: "Plan", + description: "Read the workspace and propose a plan before editing.", + }, + { + id: "auto", + label: "Auto", + description: + "Edit, run checks, and only pause for destructive file, git, or data operations.", + }, +]; + +const CODE_AGENT_REASONING_EFFORTS: Array<{ + id: CodeAgentReasoningEffort; + label: string; +}> = [ + { id: "auto", label: "Auto" }, + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + { id: "xhigh", label: "Extra High" }, + { id: "max", label: "Max" }, +]; + +const DEFAULT_CODE_AGENT_MODEL_OPTIONS: CodeAgentModelOption[] = [ + { + engine: "auto", + engineLabel: "Auto", + model: "auto", + label: "Default model", + description: "Use the connected provider and saved default.", + }, + { + engine: "builder", + engineLabel: "Builder.io", + model: "claude-sonnet-4-6", + label: "Claude Sonnet 4.6", + description: "Balanced default through Builder.io", + }, + { + engine: "builder", + engineLabel: "Builder.io", + model: "claude-opus-4-7", + label: "Claude Opus 4.7", + description: "Deeper reasoning for larger changes", + }, + { + engine: "ai-sdk:openai", + engineLabel: "OpenAI", + model: "gpt-5.5", + label: "GPT-5.5", + description: "OpenAI reasoning model", + }, + { + engine: "ai-sdk:google", + engineLabel: "Gemini", + model: "gemini-3.1-pro-preview", + label: "Gemini 3.1 Pro", + description: "Gemini reasoning model", + }, +]; + +const CODE_AGENT_MODEL_SELECTION_KEY = "agent-native-code:model-selection"; +const CODE_AGENT_PINNED_AT_METADATA_KEY = "pinnedAt"; + +export default function CodeAgentsApp({ + apps, + host, + openRequest, + refreshKey = 0, + onOpenSettings, + renderAppSurface, +}: CodeAgentsAppProps) { + const [selectedGoalId, setSelectedGoalId] = useState("task"); + const selectedGoal = + getCodeAgentGoal(selectedGoalId) ?? getDefaultCodeAgentGoal(); + const [runs, setRuns] = useState([]); + const [selectedRunId, setSelectedRunId] = useState(null); + const selectedRun = useMemo( + () => runs.find((run) => run.id === selectedRunId) ?? null, + [runs, selectedRunId], + ); + const selectedRunUsesAppSurface = selectedRun + ? isMigrationRun(selectedRun) + : false; + const selectedGoalApp = useMemo( + () => + selectedGoal.surfaceKind === "app" && selectedRunUsesAppSurface + ? getCodeAgentAppConfig(selectedGoal, apps) + : null, + [apps, selectedGoal, selectedRunUsesAppSurface], + ); + const [status, setStatus] = useState("unavailable"); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [workbenchOpen, setWorkbenchOpen] = useState(false); + const [newPrompt, setNewPrompt] = useState(""); + const [newPromptSeed, setNewPromptSeed] = useState(0); + const [creatingRun, setCreatingRun] = useState(false); + const [transcriptEvents, setTranscriptEvents] = useState< + CodeAgentTranscriptEvent[] + >([]); + const [transcriptLoading, setTranscriptLoading] = useState(false); + const [transcriptError, setTranscriptError] = useState(null); + const [followUpPrompt, setFollowUpPrompt] = useState(""); + const [submittingFollowUp, setSubmittingFollowUp] = useState(false); + const [newRunPermissionMode, setNewRunPermissionMode] = + useState(DEFAULT_CODE_AGENT_PERMISSION_MODE); + const [selectedPermissionMode, setSelectedPermissionMode] = + useState(DEFAULT_CODE_AGENT_PERMISSION_MODE); + const [updatingPermissionMode, setUpdatingPermissionMode] = useState(false); + const [modelOptions, setModelOptions] = useState( + DEFAULT_CODE_AGENT_MODEL_OPTIONS, + ); + const [projects, setProjects] = useState([]); + const [selectedProjectPath, setSelectedProjectPath] = useState(""); + const [loadingProjects, setLoadingProjects] = useState(false); + const [codePack, setCodePack] = useState(null); + const [modelSelection, setModelSelection] = useState( + () => readStoredModelSelection(), + ); + const [followUpMode, setFollowUpMode] = + useState("immediate"); + const [remoteConnectorStatus, setRemoteConnectorStatus] = + useState(null); + const [remoteConnectorError, setRemoteConnectorError] = useState< + string | null + >(null); + const selectedModelSelection = useMemo( + () => normalizeModelSelection(modelSelection, modelOptions), + [modelOptions, modelSelection], + ); + const newPromptRef = useRef(null); + + const seedNewPrompt = useCallback((value: string) => { + setNewPrompt(value); + setNewPromptSeed((seed) => seed + 1); + window.requestAnimationFrame(() => { + newPromptRef.current?.focus(); + }); + }, []); + + const loadRuns = useCallback( + async (busy = false) => { + if (busy) setRefreshing(true); + try { + const result = await host.listRuns(selectedGoal.id); + setStatus(result.status); + setError(result.error ?? null); + setRuns(result.runs); + } catch (err) { + setStatus("unavailable"); + setError(err instanceof Error ? err.message : String(err)); + setRuns([]); + } finally { + setLoading(false); + setRefreshing(false); + } + }, + [host, selectedGoal.id], + ); + + const loadTranscript = useCallback( + async (runId: string | null = selectedRunId, busy = false) => { + if (!runId) { + setTranscriptEvents([]); + setTranscriptError(null); + setTranscriptLoading(false); + return; + } + if (busy) setTranscriptLoading(true); + try { + const result = await host.readTranscript({ + goalId: selectedGoal.id, + runId, + }); + setTranscriptEvents(result.events); + setTranscriptError(result.error ?? null); + } catch (err) { + setTranscriptEvents([]); + setTranscriptError(err instanceof Error ? err.message : String(err)); + } finally { + setTranscriptLoading(false); + } + }, + [host, selectedGoal.id, selectedRunId], + ); + + const loadProjects = useCallback(async () => { + setLoadingProjects(true); + try { + const result = await host.listProjects?.(); + if (!result || result.status !== "ok") { + const fallbackRoot = codePack?.root; + setProjects( + fallbackRoot + ? [ + { + id: fallbackRoot, + name: baseNameForPath(fallbackRoot), + path: fallbackRoot, + }, + ] + : [], + ); + if (fallbackRoot) { + setSelectedProjectPath((current) => current || fallbackRoot); + } + return; + } + setProjects(result.projects); + setSelectedProjectPath( + (current) => current || result.selectedPath || result.defaultPath || "", + ); + } catch { + setProjects([]); + } finally { + setLoadingProjects(false); + } + }, [codePack?.root, host]); + + const loadRemoteConnectorStatus = useCallback(async () => { + if (!host.getRemoteConnectorStatus) return; + try { + const result = await host.getRemoteConnectorStatus(); + setRemoteConnectorStatus(result); + setRemoteConnectorError(null); + } catch (err) { + setRemoteConnectorError(err instanceof Error ? err.message : String(err)); + } + }, [host]); + + useEffect(() => { + if (!host.getRemoteConnectorStatus) return; + void loadRemoteConnectorStatus(); + const timer = window.setInterval( + () => void loadRemoteConnectorStatus(), + 5000, + ); + return () => window.clearInterval(timer); + }, [host.getRemoteConnectorStatus, loadRemoteConnectorStatus]); + + useEffect(() => { + if (refreshKey <= 0) return; + void loadRuns(true); + }, [loadRuns, refreshKey]); + + useEffect(() => { + if (!openRequest) return; + const nextGoal = getCodeAgentGoal(openRequest.goalId); + if (nextGoal) setSelectedGoalId(nextGoal.id); + setSelectedRunId(openRequest.runId ?? null); + setWorkbenchOpen(true); + void loadRuns(true); + }, [loadRuns, openRequest]); + + const hasActiveRuns = useMemo(() => runs.some(isRunActive), [runs]); + const selectedRunIsActive = selectedRun ? isRunActive(selectedRun) : false; + const workbenchUrlParams = selectedRunId ? { run: selectedRunId } : undefined; + const selectedRunStoredPermissionMode = selectedRun + ? getRunPermissionMode(selectedRun) + : DEFAULT_CODE_AGENT_PERMISSION_MODE; + const slashCommands = useMemo( + () => buildCodeAgentSlashCommands(codePack), + [codePack], + ); + + useEffect(() => { + setSelectedPermissionMode(selectedRunStoredPermissionMode); + }, [selectedRunId, selectedRunStoredPermissionMode]); + + useEffect(() => { + let cancelled = false; + void host + .listModels?.() + .then((result) => { + if (cancelled || result.status !== "ok" || result.models.length === 0) { + return; + } + setModelOptions(result.models); + if (!modelSelection.model && result.selected) { + setModelSelection(result.selected); + } + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [host, modelSelection.model]); + + useEffect(() => { + void loadProjects(); + }, [loadProjects]); + + useEffect(() => { + let cancelled = false; + void host + .listCodePacks?.(selectedProjectPath || undefined) + .then((result) => { + if (cancelled || result.status !== "ok") return; + setCodePack(result.pack ?? null); + if (!selectedProjectPath && result.pack?.root) { + setSelectedProjectPath(result.pack.root); + } + }) + .catch(() => { + if (!cancelled) setCodePack(null); + }); + return () => { + cancelled = true; + }; + }, [host, selectedProjectPath]); + + useEffect(() => { + writeStoredModelSelection(selectedModelSelection); + }, [selectedModelSelection]); + + useEffect(() => { + void loadRuns(); + const interval = window.setInterval( + () => void loadRuns(), + hasActiveRuns ? 2_000 : 10_000, + ); + return () => window.clearInterval(interval); + }, [hasActiveRuns, loadRuns]); + + useEffect(() => { + void loadTranscript(selectedRunId, true); + if (!selectedRunId) return; + const interval = window.setInterval( + () => void loadTranscript(selectedRunId), + selectedRunIsActive ? 1_000 : 5_000, + ); + return () => window.clearInterval(interval); + }, [loadTranscript, selectedRunId, selectedRunIsActive]); + + async function selectProjectFolder(pathValue: string) { + if (!pathValue) return; + setSelectedProjectPath(pathValue); + try { + const result = await host.selectProject?.(pathValue); + if (result?.ok) { + setProjects(result.projects); + setSelectedProjectPath(result.selectedPath ?? pathValue); + } + } catch { + // Local selection still works; host persistence is best-effort. + } + } + + async function chooseProjectFolder() { + if (!host.chooseProject) { + toast("Folder picker is not available here", { + description: + "Open Agent-Native Desktop to choose folders from the native picker.", + duration: 3200, + }); + return; + } + try { + const result = await host.chooseProject(); + if (!result.ok || !result.selectedPath) { + if (result.error && result.error !== "No folder selected.") { + toast("Could not choose folder", { + description: result.error, + duration: 3200, + }); + } + return; + } + setProjects(result.projects); + setSelectedProjectPath(result.selectedPath); + } catch (err) { + toast("Could not choose folder", { + description: err instanceof Error ? err.message : String(err), + duration: 3200, + }); + } + } + + function handleSlashCommand(commandName: string) { + const normalized = commandName.replace(/^\/+/, "").toLowerCase(); + const matchingGoal = CODE_AGENT_GOALS.find( + (goal) => goal.slashCommand?.replace(/^\/+/, "") === normalized, + ); + if (matchingGoal) { + setSelectedGoalId(matchingGoal.id); + setSelectedRunId(null); + setWorkbenchOpen(false); + seedNewPrompt( + matchingGoal.id === "task" ? "" : `${matchingGoal.slashCommand} `, + ); + return; + } + const matchingSkill = codePack?.skills.find( + (skill) => skill.name.toLowerCase() === normalized, + ); + setSelectedGoalId("task"); + setSelectedRunId(null); + setWorkbenchOpen(false); + seedNewPrompt( + matchingSkill + ? `Use the ${matchingSkill.name} skill to ` + : `/${normalized} `, + ); + } + + async function openTerminal() { + const terminalRequest = selectedRun + ? getRunTerminalRequest(selectedRun) + : selectedProjectPath + ? { cwd: selectedProjectPath } + : undefined; + let result: CodeAgentTerminalResult | undefined; + try { + result = await host.openTerminal?.(terminalRequest); + } catch (err) { + toast("Terminal was not opened", { + description: err instanceof Error ? err.message : String(err), + duration: 3200, + }); + return; + } + if (result?.ok) { + toast("Terminal opened", { duration: 1600 }); + return; + } + toast("Terminal was not opened", { + description: result?.error ?? "This platform has no terminal launcher.", + duration: 3200, + }); + } + + function openSelectedGoal() { + setWorkbenchOpen(false); + window.requestAnimationFrame(() => { + newPromptRef.current?.focus(); + }); + } + + async function controlRun(command: CodeAgentControlCommand) { + if (!selectedRunId) { + toast("Select a session first", { duration: 1800 }); + return; + } + if (command === "resume" && selectedRunUsesAppSurface) { + setWorkbenchOpen(true); + } + + let result: CodeAgentControlResult; + try { + result = await host.controlRun( + selectedGoal.id, + selectedRunId, + command, + selectedPermissionMode, + ); + } catch (err) { + toast("Could not control the session", { + description: err instanceof Error ? err.message : String(err), + duration: 3600, + }); + return; + } + if (result.action === "open-ui") setWorkbenchOpen(true); + if (result.action === "refresh") await loadRuns(true); + toast(result.message, { + duration: result.ok ? 2200 : 3600, + description: result.error, + }); + } + + async function retrySelectedRun() { + if (!selectedRunId || !host.retryRun) { + toast("Retry is not available here", { duration: 2200 }); + return; + } + try { + const result = await host.retryRun({ + goalId: selectedGoal.id, + runId: selectedRunId, + permissionMode: selectedPermissionMode, + engine: selectedModelSelection.engine, + model: selectedModelSelection.model, + effort: selectedModelSelection.effort, + }); + if (result.run) { + setRuns((current) => + current.map((run) => (run.id === result.run!.id ? result.run! : run)), + ); + } + await loadRuns(true); + await loadTranscript(selectedRunId, true); + toast(result.message, { + duration: result.ok ? 2200 : 3600, + description: result.error, + }); + } catch (err) { + toast("Could not retry the session", { + description: err instanceof Error ? err.message : String(err), + duration: 3600, + }); + } + } + + async function rerunSelectedRun() { + if (!selectedRunId || !host.rerunRun) { + toast("Re-run is not available here", { duration: 2200 }); + return; + } + try { + const result = await host.rerunRun({ + goalId: selectedGoal.id, + runId: selectedRunId, + permissionMode: selectedPermissionMode, + engine: selectedModelSelection.engine, + model: selectedModelSelection.model, + effort: selectedModelSelection.effort, + }); + if (result.run) { + setRuns((current) => [result.run!, ...current]); + setSelectedRunId(result.run.id); + setWorkbenchOpen(false); + if (result.event) setTranscriptEvents([result.event]); + } + await loadRuns(true); + if (result.run) await loadTranscript(result.run.id, true); + toast(result.message, { + duration: result.ok ? 2200 : 3600, + description: result.error, + }); + } catch (err) { + toast("Could not re-run the session", { + description: err instanceof Error ? err.message : String(err), + duration: 3600, + }); + } + } + + async function createRunFromPrompt( + preparedPrompt: string, + attachments: CodeAgentPromptAttachment[], + ) { + const typedGoal = + CODE_AGENT_GOALS.find( + (goal) => + goal.id !== "task" && + preparedPrompt.trim().startsWith(goal.slashCommand), + ) ?? selectedGoal; + const prompt = normalizePromptForSelectedGoal(typedGoal, preparedPrompt); + if (!prompt) { + toast("Enter a coding task first", { duration: 1800 }); + return; + } + setCreatingRun(true); + try { + const result = await host.createRun({ + goalId: typedGoal.id, + prompt, + cwd: selectedProjectPath || undefined, + permissionMode: newRunPermissionMode, + engine: selectedModelSelection.engine, + model: selectedModelSelection.model, + effort: selectedModelSelection.effort, + attachments, + }); + if (!result.ok || !result.run) { + toast(result.message, { + description: result.error, + duration: 3600, + }); + return; + } + setNewPrompt(""); + setNewPromptSeed((seed) => seed + 1); + setRuns((current) => [result.run!, ...current]); + setSelectedRunId(result.run.id); + if (typedGoal.id !== selectedGoal.id) { + setSelectedGoalId(typedGoal.id); + } + setWorkbenchOpen(false); + if (result.event) setTranscriptEvents([result.event]); + toast(result.message, { duration: 2200 }); + if (typedGoal.id === selectedGoal.id) { + await loadRuns(true); + } else { + const refreshed = await host.listRuns(typedGoal.id); + setStatus(refreshed.status); + setError(refreshed.error ?? null); + setRuns(refreshed.runs); + } + await loadTranscript(result.run.id, true); + } catch (err) { + toast("Could not start the session", { + description: err instanceof Error ? err.message : String(err), + duration: 3600, + }); + } finally { + setCreatingRun(false); + } + } + + async function submitFollowUp( + preparedPrompt: string, + attachments: CodeAgentPromptAttachment[], + deliveryMode: CodeAgentFollowUpMode = "immediate", + ) { + if (!selectedRun) { + toast("Select a session first", { duration: 1800 }); + return; + } + const prompt = preparedPrompt.trim(); + if (!prompt) { + toast("Enter a follow-up prompt", { duration: 1800 }); + return; + } + const optimisticEvent: CodeAgentTranscriptEvent = { + id: `pending-${Date.now()}`, + runId: selectedRun.id, + type: "user", + title: "User prompt", + text: prompt, + createdAt: new Date().toISOString(), + metadata: { + source: "desktop", + queued: true, + pending: true, + followUpMode: selectedRunIsActive ? deliveryMode : "immediate", + }, + }; + setFollowUpPrompt(""); + setTranscriptEvents((current) => [...current, optimisticEvent]); + setSubmittingFollowUp(true); + try { + const result = await host.appendFollowUp({ + goalId: selectedGoal.id, + runId: selectedRun.id, + prompt, + followUpMode: selectedRunIsActive ? deliveryMode : "immediate", + permissionMode: selectedPermissionMode, + engine: selectedModelSelection.engine, + model: selectedModelSelection.model, + effort: selectedModelSelection.effort, + attachments, + }); + if (!result.ok) { + setTranscriptEvents((current) => + current.filter((item) => item.id !== optimisticEvent.id), + ); + toast(result.message, { + description: result.error, + duration: 3600, + }); + return; + } + toast(result.message, { duration: 1800 }); + await loadRuns(true); + await loadTranscript(selectedRun.id, true); + } catch (err) { + setTranscriptEvents((current) => + current.filter((item) => item.id !== optimisticEvent.id), + ); + toast("Could not record the follow-up", { + description: err instanceof Error ? err.message : String(err), + duration: 3600, + }); + } finally { + setSubmittingFollowUp(false); + } + } + + async function changeSelectedPermissionMode( + nextMode: CodeAgentPermissionMode, + ) { + if (!selectedRun) { + setSelectedPermissionMode(nextMode); + return; + } + const previousMode = selectedPermissionMode; + setSelectedPermissionMode(nextMode); + setRuns((current) => + current.map((run) => + run.id === selectedRun.id ? withRunPermissionMode(run, nextMode) : run, + ), + ); + + setUpdatingPermissionMode(true); + try { + const result = await host.updateRun({ + goalId: selectedGoal.id, + runId: selectedRun.id, + permissionMode: nextMode, + }); + if (!result.ok) { + setSelectedPermissionMode(previousMode); + setRuns((current) => + current.map((run) => + run.id === selectedRun.id + ? withRunPermissionMode(run, previousMode) + : run, + ), + ); + toast(result.message, { + description: result.error, + duration: 3600, + }); + return; + } + if (result.run) { + setRuns((current) => + current.map((run) => + run.id === result.run!.id + ? withRunPermissionMode(result.run!, nextMode) + : run, + ), + ); + } + toast("Mode updated", { duration: 1600 }); + } catch (err) { + setSelectedPermissionMode(previousMode); + setRuns((current) => + current.map((run) => + run.id === selectedRun.id + ? withRunPermissionMode(run, previousMode) + : run, + ), + ); + toast("Could not update mode", { + description: err instanceof Error ? err.message : String(err), + duration: 3600, + }); + } finally { + setUpdatingPermissionMode(false); + } + } + + async function toggleRunPinned(run: CodeAgentRun) { + const pinned = isRunPinned(run); + const nextPinnedAt = pinned ? null : new Date().toISOString(); + const optimisticRun = withRunPinnedAt(run, nextPinnedAt); + setRuns((current) => + current.map((item) => (item.id === run.id ? optimisticRun : item)), + ); + + try { + const result = await host.updateRun({ + goalId: selectedGoal.id, + runId: run.id, + metadata: { + [CODE_AGENT_PINNED_AT_METADATA_KEY]: nextPinnedAt, + }, + }); + if (!result.ok) { + setRuns((current) => + current.map((item) => (item.id === run.id ? run : item)), + ); + toast(result.message, { + description: result.error, + duration: 3200, + }); + return; + } + if (result.run) { + setRuns((current) => + current.map((item) => + item.id === result.run!.id ? result.run! : item, + ), + ); + } + toast(pinned ? "Session unpinned" : "Session pinned", { + duration: 1600, + }); + } catch (err) { + setRuns((current) => + current.map((item) => (item.id === run.id ? run : item)), + ); + toast(pinned ? "Could not unpin session" : "Could not pin session", { + description: err instanceof Error ? err.message : String(err), + duration: 3200, + }); + } + } + + return ( +
+ + +
+ {workbenchOpen ? ( +
+
+
+

+ {selectedGoal.surfaceKind === "app" + ? "App-backed detail surface" + : "Native feedback surface"} +

+

+ {getRunTitle(selectedRun) ?? + (selectedRunId + ? `Session ${selectedRunId}` + : selectedGoal.primaryActionLabel)} +

+
+
+ + +
+
+
+ {selectedGoalApp && renderAppSurface ? ( + renderAppSurface({ + goal: selectedGoal, + app: selectedGoalApp, + urlParams: workbenchUrlParams, + refreshKey, + }) + ) : ( + + )} +
+
+ ) : ( +
+ {status !== "ok" && ( +
+ + + {status === "unauthorized" + ? `Open ${selectedGoal.surfaceLabel} and sign in to see sessions.` + : (error ?? + `${selectedGoal.surfaceLabel} is not reporting sessions yet.`)} + +
+ )} + + {selectedRun ? ( + setWorkbenchOpen(true)} + onOpenTerminal={openTerminal} + onResume={() => controlRun("resume")} + onRefreshStatus={() => controlRun("status")} + onStop={() => controlRun("stop")} + onApprove={() => controlRun("approve")} + onRetry={host.retryRun ? retrySelectedRun : undefined} + onRerun={host.rerunRun ? rerunSelectedRun : undefined} + onOpenSettings={onOpenSettings} + /> + ) : ( +
+

+ {selectedProjectPath + ? `What should we build in ${baseNameForPath(selectedProjectPath)}?` + : "What should we work on?"} +

+ + +
+ + + +
+
+ )} +
+ )} +
+
+ ); +} + +function isMigrationRun(run: CodeAgentRun): run is CodeAgentMigrationRun { + return ( + typeof (run as Partial).sourceRoot === "string" && + typeof (run as Partial).outputRoot === "string" && + typeof (run as Partial).target === "string" && + typeof (run as Partial).phase === "string" + ); +} + +function ProjectFolderPicker({ + variant = "rail", + projects, + selectedPath, + loading, + onSelect, + onChoose, +}: { + variant?: "rail" | "bar"; + projects: CodeAgentProjectFolder[]; + selectedPath: string; + loading: boolean; + onSelect: (path: string) => void; + onChoose: () => void; +}) { + const active = projects.find((project) => project.path === selectedPath); + + return ( +
+

Folder

+
+ + +
+

+ {active?.path ?? "Runs use the selected folder as cwd."} +

+
+ ); +} + +function NewSessionComposer({ + prompt, + promptSeed, + inputRef, + creating, + permissionMode, + modelSelection, + modelOptions, + slashCommands, + onPromptChange, + onPermissionModeChange, + onModelSelectionChange, + onSlashCommand, + onSubmit, +}: { + prompt: string; + promptSeed: number; + inputRef: React.RefObject; + creating: boolean; + permissionMode: CodeAgentPermissionMode; + modelSelection: CodeAgentModelSelection; + modelOptions: CodeAgentModelOption[]; + slashCommands: SlashCommand[]; + onPromptChange: (value: string) => void; + onPermissionModeChange: (value: CodeAgentPermissionMode) => void; + onModelSelectionChange: (value: CodeAgentModelSelection) => void; + onSlashCommand: (command: string) => void; + onSubmit: ( + preparedPrompt: string, + attachments: CodeAgentPromptAttachment[], + ) => void; +}) { + return ( + + ); +} + +function CodeAgentComposer({ + prompt, + promptSeed, + inputRef, + submitting, + permissionMode, + followUpMode = "immediate", + showFollowUpMode = false, + modelSelection, + modelOptions, + slashCommands = [], + placeholder, + variant = "compact", + onPromptChange, + onPermissionModeChange, + onFollowUpModeChange, + onModelSelectionChange, + onSlashCommand, + onSubmit, +}: { + prompt: string; + promptSeed?: string | number; + inputRef?: React.RefObject; + submitting: boolean; + permissionMode: CodeAgentPermissionMode; + followUpMode?: CodeAgentFollowUpMode; + showFollowUpMode?: boolean; + modelSelection: CodeAgentModelSelection; + modelOptions: CodeAgentModelOption[]; + slashCommands?: SlashCommand[]; + placeholder: string; + variant?: "hero" | "compact"; + onPromptChange: (value: string) => void; + onPermissionModeChange: (value: CodeAgentPermissionMode) => void; + onFollowUpModeChange?: (value: CodeAgentFollowUpMode) => void; + onModelSelectionChange: (value: CodeAgentModelSelection) => void; + onSlashCommand?: (command: string) => void; + onSubmit: ( + preparedPrompt: string, + attachments: CodeAgentPromptAttachment[], + followUpMode?: CodeAgentFollowUpMode, + ) => void; +}) { + const composerModelGroups = useMemo( + () => modelOptionsToComposerGroups(modelOptions), + [modelOptions], + ); + const normalizedModel = normalizeModelSelection(modelSelection, modelOptions); + const selectedModel = normalizedModel.model ?? "auto"; + const selectedEngine = normalizedModel.engine ?? "auto"; + const selectedEffort = normalizeReasoningEffort( + normalizedModel.effort ?? "auto", + ); + + const handleModelChange = useCallback( + (model: string, engine: string) => { + if (engine === "auto" && model === "auto") { + onModelSelectionChange({ effort: selectedEffort }); + return; + } + onModelSelectionChange({ + engine, + model, + effort: selectedEffort, + }); + }, + [onModelSelectionChange, selectedEffort], + ); + + const handleEffortChange = useCallback( + (effort: CodeAgentReasoningEffort) => { + onModelSelectionChange({ + ...normalizedModel, + effort: normalizeReasoningEffort(effort), + }); + }, + [normalizedModel, onModelSelectionChange], + ); + + const readPromptFiles = useCallback( + async (files: PromptComposerFile[]) => + Promise.all(files.map((file) => readCodeAgentPromptAttachment(file))), + [], + ); + + const modeControl = ( +
+ + {showFollowUpMode && onFollowUpModeChange && ( + + )} +
+ ); + + return ( + { + const attachments = await readPromptFiles(files); + onSubmit(text, attachments, followUpMode); + }} + attachmentsEnabled + voiceEnabled + preserveDraftOnSubmit={false} + /> + ); +} + +function modelOptionsToComposerGroups(models: CodeAgentModelOption[]): Array<{ + engine: string; + label: string; + models: string[]; + configured: boolean; +}> { + const groups = new Map< + string, + { + engine: string; + label: string; + models: string[]; + configured: boolean; + } + >(); + + for (const option of models) { + const key = `${option.engine}:${option.engineLabel}`; + const group = groups.get(key) ?? { + engine: option.engine, + label: option.engineLabel, + models: [], + configured: true, + }; + if (!group.models.includes(option.model)) { + group.models.push(option.model); + } + groups.set(key, group); + } + + return [...groups.values()]; +} + +function buildCodeAgentSlashCommands( + pack: CodeAgentCodePack | null, +): SlashCommand[] { + const commands: SlashCommand[] = [ + ...CODE_AGENT_GOALS.filter( + (goal) => goal.id !== "task" && goal.slashCommand, + ).map((goal) => ({ + name: goal.slashCommand.replace(/^\/+/, ""), + description: goal.description, + icon: "terminal", + })), + ]; + for (const command of pack?.commands ?? []) { + if (command.reserved) continue; + commands.push({ + name: command.name, + description: command.description ?? "Project command", + icon: "terminal", + }); + } + for (const skill of pack?.skills ?? []) { + commands.push({ + name: skill.name, + description: skill.description ?? "Project skill", + icon: "skill", + }); + } + return commands; +} + +function baseNameForPath(value: string): string { + const parts = value.split(/[\\/]/).filter(Boolean); + return parts[parts.length - 1] ?? value; +} + +function normalizeModelSelection( + value: CodeAgentModelSelection, + models: CodeAgentModelOption[], +): CodeAgentModelSelection { + const first = models[0] ?? DEFAULT_CODE_AGENT_MODEL_OPTIONS[0]; + const selected = + models.find( + (model) => model.engine === value.engine && model.model === value.model, + ) ?? first; + if (selected.engine === "auto" && selected.model === "auto") { + return { + effort: normalizeReasoningEffort(value.effort ?? "auto"), + }; + } + return { + engine: selected.engine, + model: selected.model, + effort: normalizeReasoningEffort(value.effort ?? "auto"), + }; +} + +function normalizeReasoningEffort(value: unknown): CodeAgentReasoningEffort { + return CODE_AGENT_REASONING_EFFORTS.some((effort) => effort.id === value) + ? (value as CodeAgentReasoningEffort) + : "auto"; +} + +function readStoredModelSelection(): CodeAgentModelSelection { + if (typeof window === "undefined") return {}; + try { + const raw = window.localStorage.getItem(CODE_AGENT_MODEL_SELECTION_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw) as Record; + return { + engine: typeof parsed.engine === "string" ? parsed.engine : undefined, + model: typeof parsed.model === "string" ? parsed.model : undefined, + effort: normalizeReasoningEffort(parsed.effort), + }; + } catch { + return {}; + } +} + +function writeStoredModelSelection(value: CodeAgentModelSelection): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem( + CODE_AGENT_MODEL_SELECTION_KEY, + JSON.stringify(value), + ); + } catch { + // Ignore private-mode storage failures. + } +} + +function RunModeSelect({ + value, + onChange, + disabled = false, + title = "Mode", + compact = false, +}: { + value: CodeAgentPermissionMode; + onChange: (value: CodeAgentPermissionMode) => void; + disabled?: boolean; + title?: string; + compact?: boolean; +}) { + const selectedMode = runModeFromPermissionMode(value); + const selected = getRunModeDefinition(selectedMode); + return ( +
+ {!compact && ( + + {title} + {selected.description} + + )} + +
+ ); +} + +function FollowUpModeSelect({ + value, + onChange, +}: { + value: CodeAgentFollowUpMode; + onChange: (value: CodeAgentFollowUpMode) => void; +}) { + return ( + + ); +} + +function runModeFromPermissionMode( + permissionMode: CodeAgentPermissionMode, +): CodeAgentRunMode { + return permissionMode === "read-only" ? "plan" : "auto"; +} + +function permissionModeFromRunMode(value: string): CodeAgentPermissionMode { + return value === "plan" ? "read-only" : "full-auto"; +} + +function getRunModeDefinition(mode: CodeAgentRunMode) { + return ( + CODE_AGENT_RUN_MODES.find((definition) => definition.id === mode) ?? + CODE_AGENT_RUN_MODES[1] + ); +} + +function NativeGoalSurface({ + goal, + onOpenTerminal, +}: { + goal: CodeAgentGoalDefinition; + onOpenTerminal: () => void; +}) { + return ( +
+
+ +

{goal.label}

+

{goal.description}

+
+ {exampleCommandForGoal(goal)} +
+ +
+
+ ); +} + +function exampleCommandForGoal(goal: CodeAgentGoalDefinition): string { + if (goal.id === "task") { + return 'agent-native code "Implement the settings polish"'; + } + if (goal.id === "migrate") { + return "agent-native code /migrate ./legacy-app --out ../migrated-app"; + } + return `agent-native code ${goal.slashCommand} --url https://example.com`; +} + +function normalizePromptForSelectedGoal( + goal: CodeAgentGoalDefinition, + prompt: string, +): string { + const trimmed = prompt.trim(); + if (!trimmed || goal.id === "task") return trimmed; + if (trimmed.startsWith(goal.slashCommand)) return trimmed; + return `${goal.slashCommand} ${trimmed}`.trim(); +} + +function isRunActive(run: CodeAgentRun): boolean { + return !( + run.status === "completed" || + run.status === "errored" || + run.status === "paused" || + run.phase === "complete" || + run.phase === "error" || + run.phase === "paused" || + run.phase === "missing-credentials" || + run.phase === "stopped" + ); +} + +function GroupedRunList({ + runs, + selectedRunId, + onSelect, + onOpen, + onTogglePin, +}: { + runs: CodeAgentRun[]; + selectedRunId: string | null; + onSelect: (run: CodeAgentRun) => void; + onOpen: (run: CodeAgentRun) => void; + onTogglePin: (run: CodeAgentRun) => void; +}) { + const groups = groupRunsForRail(runs); + return ( + <> + {groups.map((group) => ( +
+

{group.label}

+ {group.runs.map((run) => ( + onSelect(run)} + onOpen={() => onOpen(run)} + onTogglePin={() => onTogglePin(run)} + /> + ))} +
+ ))} + + ); +} + +function groupRunsForRail(runs: CodeAgentRun[]): Array<{ + label: string; + runs: CodeAgentRun[]; +}> { + const pinned = sortPinnedRuns(runs.filter(isRunPinned)); + const unpinned = runs.filter((run) => !isRunPinned(run)); + const needsInput = unpinned.filter(runNeedsInput); + const running = unpinned.filter( + (run) => !runNeedsInput(run) && isRunActive(run), + ); + const recent = unpinned.filter( + (run) => !runNeedsInput(run) && !isRunActive(run), + ); + return [ + { label: "Pinned", runs: pinned }, + { label: "Needs input", runs: needsInput }, + { label: "Running", runs: running }, + { label: "Recent", runs: recent }, + ].filter((group) => group.runs.length > 0); +} + +function runNeedsInput(run: CodeAgentRun): boolean { + return Boolean( + run.needsApproval || + run.status === "needs-approval" || + run.phase === "approval-required" || + run.phase === "missing-credentials", + ); +} + +function RunRailItem({ + run, + selected, + onSelect, + onOpen, + onTogglePin, +}: { + run: CodeAgentRun; + selected: boolean; + onSelect: () => void; + onOpen: () => void; + onTogglePin: () => void; +}) { + const progress = getRunProgressPercent(run); + const progressLabel = getRunProgressLabel(run); + const pinned = isRunPinned(run); + return ( +
+ + + + + + + + {pinned ? ( + + ) : ( + + )} + {pinned ? "Unpin from top" : "Pin to top"} + + + +
+ ); +} + +function RemoteConnectorRailStatus({ + status, + error, + onOpenSettings, +}: { + status: CodeAgentRemoteConnectorStatus | null; + error: string | null; + onOpenSettings?: () => void; +}) { + const copy = remoteConnectorCopy(status, error); + return ( + + ); +} + +function remoteConnectorCopy( + status: CodeAgentRemoteConnectorStatus | null, + error: string | null, +): { + label: string; + description: string; + tone: "ok" | "pending" | "offline" | "error"; +} { + if (error) { + return { label: "Remote error", description: error, tone: "error" }; + } + if (!status) { + return { + label: "Remote checking", + description: "Reading connector state", + tone: "pending", + }; + } + if (!status.configured) { + return { + label: "Remote offline", + description: "Pair in settings", + tone: "offline", + }; + } + if (!status.enabled) { + return { + label: "Remote off", + description: "Paused on this computer", + tone: "offline", + }; + } + if (status.state === "error") { + return { + label: "Remote error", + description: status.error ?? "Connector needs attention", + tone: "error", + }; + } + if (status.state === "running") { + return { + label: "Remote polling", + description: `Connected to ${hostForDisplay(status.relayUrl)}`, + tone: "ok", + }; + } + if (status.state === "starting") { + return { + label: "Remote connecting", + description: "Retrying connector", + tone: "pending", + }; + } + return { + label: "Remote offline", + description: "Connector is stopped", + tone: "offline", + }; +} + +function hostForDisplay(url: string | undefined): string { + if (!url) return "relay"; + try { + return new URL(url).host; + } catch { + return url; + } +} + +function RunDetailCard({ + run, + selectedRunId, + goal, + transcriptEvents, + transcriptLoading, + transcriptError, + followUpPrompt, + followUpMode, + submittingFollowUp, + permissionMode, + modelSelection, + modelOptions, + updatingPermissionMode, + onFollowUpPromptChange, + onFollowUpModeChange, + onPermissionModeChange, + onModelSelectionChange, + onSubmitFollowUp, + onOpenWorkbench, + onOpenTerminal, + onResume, + onRefreshStatus, + onStop, + onApprove, + onRetry, + onRerun, + onOpenSettings, +}: { + run: CodeAgentRun | null; + selectedRunId: string | null; + goal: CodeAgentGoalDefinition; + transcriptEvents: CodeAgentTranscriptEvent[]; + transcriptLoading: boolean; + transcriptError: string | null; + followUpPrompt: string; + followUpMode: CodeAgentFollowUpMode; + submittingFollowUp: boolean; + permissionMode: CodeAgentPermissionMode; + modelSelection: CodeAgentModelSelection; + modelOptions: CodeAgentModelOption[]; + updatingPermissionMode: boolean; + onFollowUpPromptChange: (value: string) => void; + onFollowUpModeChange: (value: CodeAgentFollowUpMode) => void; + onPermissionModeChange: (value: CodeAgentPermissionMode) => void; + onModelSelectionChange: (value: CodeAgentModelSelection) => void; + onSubmitFollowUp: ( + preparedPrompt: string, + attachments: CodeAgentPromptAttachment[], + followUpMode?: CodeAgentFollowUpMode, + ) => void; + onOpenWorkbench: () => void; + onOpenTerminal: () => void; + onResume: () => void; + onRefreshStatus: () => void; + onStop: () => void; + onApprove: () => void; + onRetry?: () => void; + onRerun?: () => void; + onOpenSettings?: () => void; +}) { + if (!run) { + return ( +
+ +

{selectedRunId ? "Session link ready" : "No session selected"}

+

+ {selectedRunId + ? `Open ${goal.surfaceLabel} to load the linked slash-command session.` + : `Start ${goal.slashCommand} or select a session to review transcript events, artifacts, and follow-ups.`} +

+ +
+ ); + } + + const progress = getRunProgressPercent(run); + const details = getRunDetails(run, goal); + const hasCredentialGap = hasMissingCredentialSignal(run, transcriptEvents); + const pendingApproval = getPendingApproval(run); + + return ( +
+
+
+

Selected session

+

{getRunTitle(run)}

+
+ +
+ + {hasCredentialGap && ( +
+ +
+ Credentials needed + + Connect a provider in settings, or run from a terminal with + ANTHROPIC_API_KEY, OPENAI_API_KEY, or + GOOGLE_GENERATIVE_AI_API_KEY. + +
+ {onOpenSettings && ( + + )} +
+ )} + + {pendingApproval && ( +
+ +
+ Approval pending + {pendingApproval.reason} + {pendingApproval.command && {pendingApproval.command}} +
+ +
+ )} + +
+
+ +
+ + +
+
+ ); +} + +function TranscriptPanel({ + events, + loading, + error, + followUpPrompt, + followUpMode, + runIsActive, + submitting, + permissionMode, + modelSelection, + modelOptions, + onFollowUpPromptChange, + onFollowUpModeChange, + onPermissionModeChange, + onModelSelectionChange, + onSubmitFollowUp, +}: { + events: CodeAgentTranscriptEvent[]; + loading: boolean; + error: string | null; + followUpPrompt: string; + followUpMode: CodeAgentFollowUpMode; + runIsActive: boolean; + submitting: boolean; + permissionMode: CodeAgentPermissionMode; + modelSelection: CodeAgentModelSelection; + modelOptions: CodeAgentModelOption[]; + onFollowUpPromptChange: (value: string) => void; + onFollowUpModeChange: (value: CodeAgentFollowUpMode) => void; + onPermissionModeChange: (value: CodeAgentPermissionMode) => void; + onModelSelectionChange: (value: CodeAgentModelSelection) => void; + onSubmitFollowUp: ( + preparedPrompt: string, + attachments: CodeAgentPromptAttachment[], + followUpMode?: CodeAgentFollowUpMode, + ) => void; +}) { + const timelineRef = useRef(null); + + useEffect(() => { + const timeline = timelineRef.current; + if (!timeline) return; + timeline.scrollTo({ top: timeline.scrollHeight, behavior: "smooth" }); + }, [events.length]); + + return ( +
+
+
+

Transcript

+

Session events

+
+ {loading && ( + + + Loading + + )} +
+ + {error && ( +
+ + {error} +
+ )} + +
+ {events.length === 0 ? ( +
+ +

No transcript events recorded for this session yet.

+
+ ) : ( + events.map((event) => ( + + )) + )} +
+ + +
+ ); +} + +function TranscriptEventItem({ event }: { event: CodeAgentTranscriptEvent }) { + const toolName = getTranscriptToolName(event); + const toolInput = getMetadataPreview(event.metadata?.input); + const toolResult = getMetadataPreview(event.metadata?.result); + return ( +
+
+ +
+
+
+ {event.title ?? transcriptEventLabel(event.type)} + +
+

{event.text}

+ {toolName && ( +
+ + {toolName} + {toolEventLabel(event)} + + {(toolInput || toolResult) && ( +
+ {toolInput && ( +
+                    input
+                    {toolInput}
+                  
+ )} + {toolResult && ( +
+                    result
+                    {toolResult}
+                  
+ )} +
+ )} +
+ )} + {(event.artifactPath || event.artifactUrl) && ( +
+ {event.artifactPath && {event.artifactPath}} + {event.artifactUrl && ( + + + Open artifact + + )} +
+ )} +
+
+ ); +} + +function getTranscriptToolName(event: CodeAgentTranscriptEvent): string | null { + const value = event.metadata?.tool; + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function toolEventLabel(event: CodeAgentTranscriptEvent): string { + const value = event.metadata?.type; + if (value === "tool_start") return "started"; + if (value === "tool_done") return "finished"; + if (value === "activity") return "activity"; + return "tool event"; +} + +function getMetadataPreview(value: unknown): string | null { + if (value === undefined || value === null) return null; + const text = + typeof value === "string" ? value : (JSON.stringify(value, null, 2) ?? ""); + const trimmed = text.trim(); + if (!trimmed) return null; + return trimmed.length > 1800 ? `${trimmed.slice(0, 1800)}\n...` : trimmed; +} + +function TranscriptEventIcon({ type }: { type: CodeAgentTranscriptEventType }) { + if (type === "user") return ; + if (type === "artifact") { + return ; + } + if (type === "status") return ; + return ; +} + +function transcriptEventLabel(type: CodeAgentTranscriptEventType): string { + if (type === "user") return "User prompt"; + if (type === "artifact") return "Artifact"; + if (type === "status") return "Status"; + return "System"; +} + +function Field({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +function PhasePill({ run }: { run: CodeAgentRun }) { + const tone = + run.status === "completed" || run.phase === "complete" + ? "complete" + : hasPendingApproval(run) + ? "approval" + : "active"; + return ( + + {run.phase ?? run.status} + + ); +} + +function RunListSkeleton() { + return ( + <> +
+
+
+ + ); +} + +function getRunProgressPercent(run: CodeAgentRun): number { + if (typeof run.progress?.percent === "number") { + return Math.max(0, Math.min(100, Math.round(run.progress.percent))); + } + if (isMigrationRun(run) && run.taskCount > 0) { + return Math.round((run.passedTaskCount / run.taskCount) * 100); + } + return run.status === "completed" || run.phase === "complete" ? 100 : 0; +} + +function getRunProgressLabel(run: CodeAgentRun): string { + if (run.progress?.total && run.progress.total > 0) { + const label = run.progress.label ?? "tasks"; + return `${run.progress.completed}/${run.progress.total} ${label.toLowerCase()}`; + } + if (isMigrationRun(run)) return `${run.taskCount} tasks`; + return run.status; +} + +function hasMissingCredentialSignal( + run: CodeAgentRun, + transcriptEvents: CodeAgentTranscriptEvent[], +): boolean { + if (run.phase === "missing-credentials") return true; + return transcriptEvents.some((event) => + /No LLM provider key was found|Missing credentials/i.test(event.text), + ); +} + +function hasPendingApproval(run: CodeAgentRun): boolean { + return Boolean(run.needsApproval || getPendingApproval(run)); +} + +function getPendingApproval( + run: CodeAgentRun, +): { reason: string; command?: string } | null { + const value = run.metadata?.pendingApproval; + if (!value || typeof value !== "object" || Array.isArray(value)) { + return run.needsApproval ? { reason: "Review the pending action." } : null; + } + + const record = value as Record; + const reason = + typeof record.reason === "string" && record.reason.trim() + ? record.reason.trim() + : "Review the pending action."; + const command = + typeof record.command === "string" && record.command.trim() + ? record.command.trim() + : undefined; + return { reason, command }; +} + +function getRunTitle(run: CodeAgentRun | null): string | null { + if (!run) return null; + if (isMigrationRun(run)) return run.name; + return run.title || run.id; +} + +function getRunPinnedAt(run: CodeAgentRun): string | null { + const value = run.metadata?.[CODE_AGENT_PINNED_AT_METADATA_KEY]; + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function isRunPinned(run: CodeAgentRun): boolean { + return Boolean(getRunPinnedAt(run)); +} + +function withRunPinnedAt( + run: CodeAgentRun, + pinnedAt: string | null, +): CodeAgentRun { + return { + ...run, + metadata: { + ...(run.metadata ?? {}), + [CODE_AGENT_PINNED_AT_METADATA_KEY]: pinnedAt, + }, + }; +} + +function sortPinnedRuns(runs: CodeAgentRun[]): CodeAgentRun[] { + return [...runs].sort((a, b) => { + const aPinnedAt = getRunPinnedAt(a) ?? a.updatedAt; + const bPinnedAt = getRunPinnedAt(b) ?? b.updatedAt; + return bPinnedAt.localeCompare(aPinnedAt); + }); +} + +function getRunSubtitle(run: CodeAgentRun): string { + if (run.subtitle) return run.subtitle; + if (isMigrationRun(run)) return run.sourceRoot; + return run.goalId ? `${run.goalId} session` : "Agent-Native Code session"; +} + +function getRunDetails( + run: CodeAgentRun, + goal: CodeAgentGoalDefinition, +): CodeAgentRunDetail[] { + const permissionMode = getRunPermissionMode(run); + const details = + run.details?.filter((detail) => detail.value.length > 0) ?? []; + if (details.length > 0) { + return [ + ...withPermissionDetail(details, permissionMode), + { label: "Updated", value: formatRelativeTime(run.updatedAt) }, + ]; + } + if (isMigrationRun(run)) { + return [ + { label: "Source", value: run.sourceRoot }, + { label: "Output", value: run.outputRoot }, + { label: "Target", value: run.target }, + { label: "Mode", value: formatPermissionMode(permissionMode) }, + { label: "Updated", value: formatRelativeTime(run.updatedAt) }, + ]; + } + return [ + { label: "Goal", value: goal.slashCommand }, + { label: "Status", value: run.status }, + { label: "Mode", value: formatPermissionMode(permissionMode) }, + { label: "Updated", value: formatRelativeTime(run.updatedAt) }, + ]; +} + +function getRunPermissionMode(run: CodeAgentRun): CodeAgentPermissionMode { + const metadataMode = getCodeAgentPermissionMode( + getStringMetadata(run, "permissionMode"), + ); + if (metadataMode) return metadataMode; + + const detailMode = getCodeAgentPermissionMode( + run.details?.find((detail) => isPermissionDetail(detail.label))?.value, + ); + return detailMode ?? DEFAULT_CODE_AGENT_PERMISSION_MODE; +} + +function withRunPermissionMode( + run: CodeAgentRun, + permissionMode: CodeAgentPermissionMode, +): CodeAgentRun { + return { + ...run, + metadata: { + ...(run.metadata ?? {}), + permissionMode, + }, + details: withPermissionDetail(run.details ?? [], permissionMode), + }; +} + +function withPermissionDetail( + details: CodeAgentRunDetail[], + permissionMode: CodeAgentPermissionMode, +): CodeAgentRunDetail[] { + const displayValue = formatPermissionMode(permissionMode); + let found = false; + const next = details.map((detail) => { + if (!isPermissionDetail(detail.label)) return detail; + found = true; + return { ...detail, label: "Mode", value: displayValue }; + }); + return found ? next : [...next, { label: "Mode", value: displayValue }]; +} + +function isPermissionDetail(label: string): boolean { + const normalized = label.toLowerCase(); + return normalized.includes("permission") || normalized === "mode"; +} + +function formatPermissionMode(value: CodeAgentPermissionMode): string { + return getRunModeDefinition(runModeFromPermissionMode(value)).label; +} + +function getRunTerminalRequest( + run: CodeAgentRun, +): CodeAgentTerminalRequest | undefined { + if (isMigrationRun(run)) { + return { sourceRoot: run.sourceRoot, outputRoot: run.outputRoot }; + } + const sourceRoot = getStringMetadata(run, "sourceRoot"); + const outputRoot = getStringMetadata(run, "outputRoot"); + const cwd = getStringMetadata(run, "cwd"); + return sourceRoot || outputRoot || cwd + ? { sourceRoot, outputRoot, cwd } + : undefined; +} + +function getStringMetadata(run: CodeAgentRun, key: string): string | undefined { + const value = run.metadata?.[key]; + return typeof value === "string" ? value : undefined; +} + +function formatRelativeTime(value: string): string { + const date = new Date(value); + const time = date.getTime(); + if (!Number.isFinite(time)) return "recently"; + + const diffMs = time - Date.now(); + const abs = Math.abs(diffMs); + const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [ + ["day", 86_400_000], + ["hour", 3_600_000], + ["minute", 60_000], + ]; + const formatter = new Intl.RelativeTimeFormat(undefined, { + numeric: "auto", + }); + for (const [unit, ms] of units) { + if (abs >= ms || unit === "minute") { + return formatter.format(Math.round(diffMs / ms), unit); + } + } + return "recently"; +} diff --git a/packages/code-agents-ui/src/code-agents.ts b/packages/code-agents-ui/src/code-agents.ts new file mode 100644 index 000000000..a370e7de5 --- /dev/null +++ b/packages/code-agents-ui/src/code-agents.ts @@ -0,0 +1,166 @@ +import { + getTemplate, + templateToAppConfig, + type AppConfig, +} from "@agent-native/shared-app-config"; + +export const CODE_AGENTS_SURFACE_ID = "code-agents"; +export const MIGRATION_APP_ID = "migration"; + +export type CodeAgentGoalId = "task" | "migrate" | "audit"; +export type CodeAgentPermissionMode = + | "read-only" + | "ask-before-edit" + | "auto-edit" + | "full-auto"; + +export interface CodeAgentPermissionModeDefinition { + id: CodeAgentPermissionMode; + label: string; + shortLabel: string; + description: string; +} + +export interface CodeAgentGoalDefinition { + id: CodeAgentGoalId; + label: string; + slashCommand: string; + description: string; + cliCommand: string; + appId?: string; + templateId?: string; + listRunsAction?: string; + runNoun: string; + surfaceLabel: string; + primaryActionLabel: string; + surfaceKind: "app" | "native"; +} + +export const CODE_AGENT_GOALS: CodeAgentGoalDefinition[] = [ + { + id: "task", + label: "New session", + slashCommand: "/task", + description: + "Start a general coding session from a prompt, then keep transcript events and follow-ups attached to the same run.", + cliCommand: "task", + runNoun: "coding session", + surfaceLabel: "Native transcript", + primaryActionLabel: "New Session", + surfaceKind: "native", + }, + { + id: "migrate", + label: "App migration", + slashCommand: "/migrate", + description: + "Start a slash-command session that ports an existing path, URL, or described product into agent-native.", + cliCommand: "migrate", + runNoun: "slash-command session", + surfaceLabel: "Native migration session", + primaryActionLabel: "Start /migrate", + surfaceKind: "native", + }, + { + id: "audit", + label: "Agent web audit", + slashCommand: "/audit", + description: + "Start a slash-command session that checks a public URL for agent-readable surfaces such as llms.txt, sitemap, and Markdown mirrors.", + cliCommand: "audit-agent-web", + runNoun: "slash-command session", + surfaceLabel: "Native audit feedback", + primaryActionLabel: "Start /audit", + surfaceKind: "native", + }, +]; + +export const CODE_AGENT_PERMISSION_MODES: CodeAgentPermissionModeDefinition[] = + [ + { + id: "read-only", + label: "Plan mode", + shortLabel: "Plan", + description: "Inspect files and propose a plan before editing.", + }, + { + id: "ask-before-edit", + label: "Ask mode", + shortLabel: "Ask", + description: "Ask before changing files or running write commands.", + }, + { + id: "auto-edit", + label: "Edit mode", + shortLabel: "Edit", + description: "Make focused edits and run verification.", + }, + { + id: "full-auto", + label: "Auto mode", + shortLabel: "Auto", + description: + "Edit, run checks, and only pause for destructive file, git, or data operations.", + }, + ]; + +export const DEFAULT_CODE_AGENT_PERMISSION_MODE: CodeAgentPermissionMode = + "full-auto"; + +export function getCodeAgentPermissionMode( + value: string | null | undefined, +): CodeAgentPermissionMode | undefined { + return CODE_AGENT_PERMISSION_MODES.find((mode) => mode.id === value)?.id; +} + +export function getCodeAgentPermissionModeDefinition( + value: string | null | undefined, +): CodeAgentPermissionModeDefinition { + return ( + CODE_AGENT_PERMISSION_MODES.find((mode) => mode.id === value) ?? + CODE_AGENT_PERMISSION_MODES.find( + (mode) => mode.id === DEFAULT_CODE_AGENT_PERMISSION_MODE, + )! + ); +} + +export function getCodeAgentGoal( + id: string | null | undefined, +): CodeAgentGoalDefinition | undefined { + return CODE_AGENT_GOALS.find((goal) => goal.id === id); +} + +export function getDefaultCodeAgentGoal(): CodeAgentGoalDefinition { + return CODE_AGENT_GOALS[0]; +} + +export function getMigrationWorkbenchAppConfig( + apps: AppConfig[] = [], +): AppConfig { + const existing = apps.find((app) => app.id === MIGRATION_APP_ID); + if (existing) return existing; + + const template = getTemplate(MIGRATION_APP_ID); + if (!template) { + throw new Error("Migration detail surface template is not registered."); + } + + return { + ...templateToAppConfig(template, { isBuiltIn: true, enabled: true }), + devCommand: "pnpm --filter migration dev", + mode: "dev", + }; +} + +export function getCodeAgentAppConfig( + goal: CodeAgentGoalDefinition, + apps: AppConfig[] = [], +): AppConfig { + if (goal.surfaceKind !== "app") { + throw new Error(`${goal.label} does not use an app surface.`); + } + if (goal.id === "migrate") { + return getMigrationWorkbenchAppConfig(apps); + } + throw new Error(`Unknown Agent-Native Code goal: ${goal.id}`); +} diff --git a/packages/code-agents-ui/src/composer-primitives.ts b/packages/code-agents-ui/src/composer-primitives.ts new file mode 100644 index 000000000..ea09d093c --- /dev/null +++ b/packages/code-agents-ui/src/composer-primitives.ts @@ -0,0 +1,55 @@ +import type { CodeAgentPromptAttachment } from "./types.js"; + +export const CODE_AGENT_MAX_INLINE_TEXT_CHARS = 60_000; + +export async function readCodeAgentPromptAttachment( + file: File, +): Promise { + const attachment: CodeAgentPromptAttachment = { + name: file.name, + type: file.type || undefined, + size: file.size, + }; + if ( + isInlineableCodeAgentFile(file) && + file.size <= CODE_AGENT_MAX_INLINE_TEXT_CHARS + ) { + try { + attachment.text = await file.text(); + } catch { + // Keep the filename-only attachment if the browser cannot read it. + } + } + return attachment; +} + +export function isInlineableCodeAgentFile(file: File): boolean { + if (file.type.startsWith("text/")) return true; + return /\.(cjs|css|csv|html|js|json|jsx|md|mdx|mjs|sql|tsx?|txt|xml|yaml|yml)$/i.test( + file.name, + ); +} + +export function formatCodeAgentPromptWithAttachments( + prompt: string, + attachments: CodeAgentPromptAttachment[], +): string { + if (attachments.length === 0) return prompt; + const attachmentText = attachments + .map((attachment) => { + const size = attachment.size ? ` size="${attachment.size}"` : ""; + const type = attachment.type + ? ` type="${escapeCodeAgentXmlAttribute(attachment.type)}"` + : ""; + const body = + attachment.text?.trim() || + "Selected in the UI. If this file is needed, inspect it from the workspace or ask for a readable copy."; + return `\n${body}\n`; + }) + .join("\n\n"); + return `${prompt.trimEnd()}\n\nAttached context:\n${attachmentText}`; +} + +export function escapeCodeAgentXmlAttribute(value: string): string { + return value.replace(/&/g, "&").replace(/"/g, """); +} diff --git a/packages/code-agents-ui/src/index.ts b/packages/code-agents-ui/src/index.ts new file mode 100644 index 000000000..30e9ca1c7 --- /dev/null +++ b/packages/code-agents-ui/src/index.ts @@ -0,0 +1,9 @@ +export { default as CodeAgentsApp } from "./CodeAgentsApp.js"; +export type { + CodeAgentsAppProps, + CodeAgentsHost, + CodeAgentsRenderAppSurface, +} from "./CodeAgentsApp.js"; +export * from "./composer-primitives.js"; +export * from "./code-agents.js"; +export * from "./types.js"; diff --git a/packages/code-agents-ui/src/lib/utils.ts b/packages/code-agents-ui/src/lib/utils.ts new file mode 100644 index 000000000..d943a9384 --- /dev/null +++ b/packages/code-agents-ui/src/lib/utils.ts @@ -0,0 +1,5 @@ +export function cn( + ...values: Array +): string { + return values.filter(Boolean).join(" "); +} diff --git a/packages/code-agents-ui/src/styles.css b/packages/code-agents-ui/src/styles.css new file mode 100644 index 000000000..c0b2c4ddd --- /dev/null +++ b/packages/code-agents-ui/src/styles.css @@ -0,0 +1,1752 @@ +/* ─── Agent-Native Code shadcn/template alignment ─────────────── */ +.code-agents-surface { + --background: 220 6% 6%; + --foreground: 0 0% 90%; + --card: 220 5% 6%; + --card-foreground: 0 0% 90%; + --popover: 220 5% 8%; + --popover-foreground: 0 0% 90%; + --primary: 0 0% 75%; + --primary-foreground: 220 6% 6%; + --secondary: 220 4% 12%; + --secondary-foreground: 0 0% 90%; + --muted: 220 4% 10%; + --muted-foreground: 220 4% 60%; + --accent: 220 4% 13%; + --accent-foreground: 0 0% 90%; + --destructive: 0 63% 51%; + --destructive-foreground: 0 0% 98%; + --border: 220 4% 14%; + --input: 220 4% 14%; + --ring: 0 0% 60%; + --radius: 0.5rem; + --sidebar-background: 220 6% 4%; + --sidebar-foreground: 220 4% 60%; + --sidebar-accent: 220 4% 10%; + --sidebar-accent-foreground: 0 0% 90%; + --sidebar-border: 220 4% 10%; + --color-background: hsl(var(--background)); + --color-foreground: hsl(var(--foreground)); + --color-muted-foreground: hsl(var(--muted-foreground)); + --color-border: hsl(var(--border)); + --color-primary: hsl(var(--primary)); + --color-primary-foreground: hsl(var(--primary-foreground)); + height: 100%; + min-width: 0; + display: grid; + grid-template-columns: 248px minmax(0, 1fr); + overflow: hidden; + background: hsl(var(--background)); + color: hsl(var(--foreground)); + font-family: + "Inter", + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; + font-feature-settings: "cv02", "cv03", "cv04", "cv11"; +} + +.code-agents-rail { + min-width: 0; + display: flex; + flex-direction: column; + gap: 0; + overflow: hidden; + padding: 0; + background: hsl(var(--sidebar-background)); + border-right: 1px solid hsl(var(--sidebar-border)); +} + +.code-agents-rail__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + min-height: 48px; + padding: 0 12px; + border-bottom: 1px solid hsl(var(--sidebar-border)); +} + +.code-agents-title-block { + min-width: 0; +} + +.code-agents-title-block h1 { + margin: 0; + color: hsl(var(--foreground)); + font-size: 13px; + font-weight: 600; +} + +.code-agents-title-block p { + margin-top: 1px; + color: hsl(var(--muted-foreground) / 0.72); + font-size: 11px; +} + +.code-agents-remote-status { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 8px; + align-items: center; + margin: 6px; + padding: 8px; + border: 1px solid hsl(var(--sidebar-border)); + border-radius: calc(var(--radius) - 2px); + background: hsl(var(--card)); + color: hsl(var(--muted-foreground)); + text-align: left; + cursor: pointer; +} + +.code-agents-remote-status:disabled { + cursor: default; +} + +.code-agents-remote-status:hover:not(:disabled) { + background: hsl(var(--accent) / 0.5); + color: hsl(var(--foreground)); +} + +.code-agents-remote-status span:last-child { + min-width: 0; + display: grid; + gap: 1px; +} + +.code-agents-remote-status strong, +.code-agents-remote-status em { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.code-agents-remote-status strong { + color: hsl(var(--foreground) / 0.82); + font-size: 11.5px; + font-style: normal; + font-weight: 500; +} + +.code-agents-remote-status em { + color: hsl(var(--muted-foreground) / 0.62); + font-size: 10.5px; + font-style: normal; +} + +.code-agents-remote-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: hsl(var(--muted-foreground) / 0.65); + box-shadow: 0 0 0 3px hsl(var(--muted-foreground) / 0.08); +} + +.code-agents-remote-dot--ok { + background: #22c55e; + box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.14); +} + +.code-agents-remote-dot--pending { + background: #f59e0b; + box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.14); +} + +.code-agents-remote-dot--error { + background: #ef4444; + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.14); +} + +.code-agents-new-session-link { + width: calc(100% - 12px); + height: 32px; + display: flex; + align-items: center; + gap: 8px; + margin: 6px; + padding: 0 10px; + border: 0; + border-radius: calc(var(--radius) - 2px); + background: transparent; + color: hsl(var(--foreground) / 0.82); + font-size: 13px; + font-weight: 500; + cursor: pointer; +} + +.code-agents-rail-label { + margin: 12px 12px 6px; + color: hsl(var(--muted-foreground) / 0.65); + font-size: 11px; + font-weight: 500; + letter-spacing: 0; +} + +.code-agents-goal-list { + display: grid; + gap: 1px; + padding: 0 6px 8px; + border-bottom: 1px solid hsl(var(--sidebar-border)); +} + +.code-agents-project-picker { + display: grid; + gap: 6px; + padding: 0 6px 10px; + border-bottom: 1px solid hsl(var(--sidebar-border)); +} + +.code-agents-project-picker .code-agents-rail-label { + margin-left: 6px; + margin-right: 6px; +} + +.code-agents-project-picker__row { + display: grid; + grid-template-columns: minmax(0, 1fr) 28px; + gap: 6px; + align-items: center; +} + +.code-agents-project-select { + height: 30px; + width: 100%; + border: 1px solid hsl(var(--border)); + border-radius: calc(var(--radius) - 2px); + background: hsl(var(--card)); + color: hsl(var(--foreground) / 0.84); + font-size: 12px; + font-weight: 500; +} + +.code-agents-project-select:hover, +.code-agents-project-select[data-state="open"] { + background: hsl(var(--accent) / 0.5); + color: hsl(var(--foreground)); +} + +.code-agents-project-select__item { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 8px; +} + +.code-agents-project-select__item span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.code-agents-project-path { + min-width: 0; + margin: -2px 6px 0; + overflow: hidden; + color: hsl(var(--muted-foreground) / 0.55); + font-size: 10px; + line-height: 1.3; + text-overflow: ellipsis; + white-space: nowrap; +} + +.code-agents-project-picker--bar { + width: min(100%, 880px); + display: flex; + align-items: center; + gap: 8px; + padding: 0 4px; + border-bottom: 0; + margin-top: -8px; +} + +.code-agents-project-picker--bar .code-agents-rail-label, +.code-agents-project-picker--bar .code-agents-project-path { + display: none; +} + +.code-agents-project-picker--bar .code-agents-project-picker__row { + display: flex; + align-items: center; + gap: 4px; +} + +.code-agents-project-picker--bar .code-agents-project-select { + width: auto; + min-width: 150px; + max-width: 260px; + height: 28px; + border: 0; + background: transparent; + color: hsl(var(--muted-foreground)); + padding: 0 8px; +} + +.code-agents-project-picker--bar .code-agents-project-select:hover, +.code-agents-project-picker--bar + .code-agents-project-select[data-state="open"] { + background: hsl(var(--accent) / 0.45); + color: hsl(var(--foreground)); +} + +.code-agents-project-picker--bar .code-agents-icon-button { + border: 0; + background: transparent; +} + +.code-agents-goal { + min-height: 30px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0 8px; + border: 0; + border-radius: calc(var(--radius) - 2px); + background: transparent; + color: hsl(var(--muted-foreground)); + text-align: left; + cursor: pointer; +} + +.code-agents-goal strong { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: inherit; + font-size: 12px; + font-weight: 500; +} + +.code-agents-goal span { + color: hsl(var(--muted-foreground) / 0.54); + font-size: 11px; +} + +.code-agents-new-session-link:hover, +.code-agents-goal:hover, +.code-agents-run-row:hover { + background: hsl(var(--accent) / 0.5); + color: hsl(var(--foreground)); +} + +.code-agents-goal--active, +.code-agents-run-row--active { + background: hsl(var(--accent)); + color: hsl(var(--accent-foreground)); + box-shadow: none; +} + +.code-agents-run-list { + min-height: 0; + overflow-y: auto; + padding: 0 6px 8px; +} + +.code-agents-run-group { + display: grid; + gap: 1px; +} + +.code-agents-run-group + .code-agents-run-group { + margin-top: 8px; +} + +.code-agents-run-group__label { + margin: 4px 6px 3px; + color: hsl(var(--muted-foreground) / 0.54); + font-size: 10px; + font-weight: 500; +} + +.code-agents-run-row { + position: relative; + border-radius: calc(var(--radius) - 2px); + color: hsl(var(--muted-foreground)); +} + +.code-agents-run-row--active .code-agents-run__name, +.code-agents-run-row--active .code-agents-run__path, +.code-agents-run-row--active .code-agents-run__meta { + color: hsl(var(--accent-foreground)); +} + +.code-agents-run-row--pinned:not(.code-agents-run-row--active) + .code-agents-run__name { + color: hsl(var(--foreground)); +} + +.code-agents-run { + min-height: 44px; + width: 100%; + display: grid; + gap: 3px; + padding: 7px 34px 7px 8px; + border: 0; + border-radius: inherit; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} + +.code-agents-run-menu { + position: absolute; + top: 7px; + right: 4px; + width: 26px; + height: 26px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + border-radius: calc(var(--radius) - 3px); + background: transparent; + color: hsl(var(--muted-foreground) / 0.72); + opacity: 0; + cursor: pointer; +} + +.code-agents-run-menu:hover, +.code-agents-run-menu[data-state="open"] { + background: hsl(var(--accent)); + color: hsl(var(--foreground)); +} + +.code-agents-run-row:hover .code-agents-run-menu, +.code-agents-run-row:focus-within .code-agents-run-menu, +.code-agents-run-menu--pinned { + opacity: 1; +} + +.code-agents-run-menu--pinned { + color: hsl(var(--foreground) / 0.74); +} + +.code-agents-run__topline, +.code-agents-run__meta { + min-width: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.code-agents-run__name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: hsl(var(--foreground) / 0.86); + font-size: 12px; + font-weight: 500; +} + +.code-agents-run__path, +.code-agents-run__meta { + color: hsl(var(--muted-foreground) / 0.62); + font-size: 11px; +} + +.code-agents-empty-rail { + display: flex; + align-items: center; + gap: 8px; + padding: 8px; + color: hsl(var(--muted-foreground) / 0.65); + font-size: 12px; +} + +.code-agents-main, +.code-agents-overview { + min-width: 0; + min-height: 0; + background: hsl(var(--background)); +} + +.code-agents-overview { + height: 100%; + overflow-y: auto; + padding: 16px; +} + +.code-agents-start { + min-height: calc(100vh - 150px); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 18px; + max-width: 920px; + margin: 0 auto; +} + +.code-agents-start h2 { + color: hsl(var(--foreground)); + font-size: 24px; + font-weight: 500; +} + +.code-agents-standard-composer.agent-composer-area { + width: min(100%, 760px); + color: hsl(var(--foreground)); +} + +.code-agents-standard-composer[data-agent-composer-variant="hero"] { + width: min(100%, 880px); +} + +.code-agents-standard-composer[data-agent-composer-variant="compact"] { + width: 100%; +} + +.code-agents-standard-composer [data-agent-composer-slot="root"] { + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid hsl(var(--input)); + border-radius: calc(var(--radius) + 2px); + background: hsl(var(--background)); +} + +.code-agents-standard-composer [data-agent-composer-slot="root"]:focus-within { + box-shadow: 0 0 0 1px hsl(var(--ring) / 0.42); +} + +.code-agents-standard-composer [data-agent-composer-slot="editor-wrap"] { + padding: 8px 8px 4px; +} + +.code-agents-standard-composer [data-agent-composer-slot="editor"] { + min-width: 0; +} + +.code-agents-standard-composer [data-agent-composer-slot="editor-input"] { + min-height: 24px; + max-height: 220px; + overflow-y: auto; + outline: none; + color: hsl(var(--foreground)); + font-size: 13px; + line-height: 1.45; + white-space: pre-wrap; +} + +.code-agents-standard-composer + [data-agent-composer-slot="editor-input"][data-agent-composer-variant="hero"] { + min-height: 92px; + font-size: 14px; + line-height: 1.55; +} + +.code-agents-standard-composer [data-agent-composer-slot="editor-input"] p { + margin: 0; +} + +.code-agents-standard-composer [data-agent-composer-slot="toolbar"] { + min-height: 40px; + width: 100%; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 4px; + padding: 8px; +} + +.code-agents-standard-composer + [data-agent-composer-slot="toolbar"] + > [data-agent-composer-slot="toolbar-spacer"] { + flex: 1 1 auto; + min-width: 12px; +} + +.code-agents-standard-composer [data-agent-composer-slot="toolbar"] button { + font: inherit; + margin: 0; +} + +.code-agents-standard-composer [data-agent-composer-slot="toolbar"] button, +.code-agents-standard-composer [data-agent-composer-slot="model-button"], +.code-agents-standard-composer [data-agent-composer-slot="mode-button"] { + min-width: 0; + border: 0; + border-radius: calc(var(--radius) - 2px); + background: transparent; + color: hsl(var(--muted-foreground)); + cursor: pointer; +} + +.code-agents-standard-composer .desktop-select-trigger { + height: 28px; + border: 0; + background: transparent; + color: hsl(var(--muted-foreground)); + padding: 0 8px; + font-size: 11px !important; + line-height: 16px; +} + +.code-agents-standard-composer .desktop-select-trigger:hover, +.code-agents-standard-composer .desktop-select-trigger[data-state="open"] { + background: hsl(var(--accent) / 0.5); + color: hsl(var(--foreground)); +} + +.code-agents-standard-composer + [data-agent-composer-slot="toolbar"] + button:not([data-agent-composer-slot="model-button"]):not( + .desktop-select-trigger + ) { + width: 30px; + height: 30px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; +} + +.code-agents-standard-composer + [data-agent-composer-slot="toolbar"] + button:hover:not(:disabled), +.code-agents-standard-composer + [data-agent-composer-slot="model-button"]:hover:not(:disabled), +.code-agents-standard-composer + [data-agent-composer-slot="mode-button"]:hover:not(:disabled) { + background: hsl(var(--accent) / 0.5); + color: hsl(var(--foreground)); +} + +.code-agents-standard-composer + [data-agent-composer-slot="toolbar"] + button:disabled { + cursor: default; + opacity: 0.4; +} + +.code-agents-standard-composer [data-agent-composer-slot="model-button"] { + max-width: 180px; + height: 30px; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 0 8px; + font-size: 11px !important; + line-height: 16px; + font-weight: 500; +} + +.code-agents-standard-composer[data-agent-composer-variant="hero"] + [data-agent-composer-slot="model-button"] { + max-width: 190px; +} + +.code-agents-standard-composer [data-agent-composer-slot="model-button"] span { + min-width: 0; + font-size: inherit !important; + line-height: inherit; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.code-agents-standard-composer [data-agent-composer-slot="send-button"] { + width: 28px; + height: 28px; + flex: 0 0 auto; + display: grid; + place-items: center; + border-radius: calc(var(--radius) - 2px); + background: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); +} + +.code-agents-standard-composer + [data-agent-composer-slot="send-button"]:hover:not(:disabled) { + opacity: 0.9; +} + +.code-agents-composer-mode-slot { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.code-agents-standard-composer [class~="hidden"] { + display: none; +} + +.code-agents-standard-composer [class~="shrink-0"] { + flex-shrink: 0; +} + +.code-agents-standard-composer svg { + flex: 0 0 auto; + width: 16px; + height: 16px; +} + +.code-agents-standard-composer .agent-composer-attachment-strip { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 8px 10px 0; +} + +.code-agents-standard-composer .agent-composer-attachment-chip { + max-width: 220px; + display: inline-flex; + align-items: center; + gap: 7px; + border: 1px solid hsl(var(--border)); + border-radius: calc(var(--radius) - 2px); + background: hsl(var(--muted) / 0.55); + color: hsl(var(--muted-foreground)); + padding: 6px 8px; + font-size: 11px; +} + +.code-agents-standard-composer .agent-composer-attachment-chip button { + width: 20px; + height: 20px; +} + +.code-agents-standard-composer .agent-composer-attachment-image { + width: 64px; + height: 64px; + overflow: hidden; + border: 1px solid hsl(var(--border)); + border-radius: var(--radius); + background: hsl(var(--muted) / 0.55); +} + +[data-agent-native-tooltip="true"], +[data-agent-native-composer-popover="true"] { + --background: 220 6% 6%; + --foreground: 0 0% 90%; + --popover: 220 5% 8%; + --popover-foreground: 0 0% 90%; + --muted: 220 4% 10%; + --muted-foreground: 220 4% 60%; + --accent: 220 4% 13%; + --accent-foreground: 0 0% 90%; + --border: 220 4% 16%; + --primary: 0 0% 75%; + --primary-foreground: 220 6% 6%; + --radius: 0.5rem; +} + +[data-agent-native-tooltip="true"] { + z-index: 300; + overflow: hidden; + border: 1px solid hsl(var(--border)); + border-radius: calc(var(--radius) - 2px); + background: hsl(var(--popover)); + color: hsl(var(--popover-foreground)); + padding: 4px 7px; + font-size: 11px; + box-shadow: 0 14px 34px rgba(0, 0, 0, 0.38); +} + +[data-agent-native-composer-popover="true"] { + z-index: 300; + max-height: 500px; + overflow-y: auto; + border: 1px solid hsl(var(--border)); + border-radius: calc(var(--radius) + 2px); + background: hsl(var(--popover)); + color: hsl(var(--popover-foreground)); + padding: 5px; + font-family: + "Inter", + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; + font-size: 13px; + line-height: 1.35; + box-shadow: + 0 18px 44px rgba(0, 0, 0, 0.42), + inset 0 1px 0 hsl(var(--foreground) / 0.04); +} + +[data-agent-native-composer-popover="true"] button { + width: 100%; + min-height: 30px; + display: flex; + align-items: center; + gap: 8px; + border: 0; + border-radius: calc(var(--radius) - 3px); + background: transparent; + color: hsl(var(--foreground) / 0.88); + font: inherit; + padding: 6px 9px; + text-align: left; + cursor: pointer; +} + +[data-agent-native-composer-popover="true"] svg { + width: 14px; + height: 14px; + flex: 0 0 auto; +} + +[data-agent-native-composer-popover="true"] span, +[data-agent-native-composer-popover="true"] p { + min-width: 0; +} + +[data-agent-native-composer-popover="true"] p { + margin: 2px 0 0; + color: hsl(var(--muted-foreground)); + font-size: 11px; + line-height: 1.25; +} + +[data-agent-native-composer-popover="true"] [class~="flex"] { + display: flex; +} + +[data-agent-native-composer-popover="true"] [class~="items-center"] { + align-items: center; +} + +[data-agent-native-composer-popover="true"] [class~="items-start"] { + align-items: flex-start; +} + +[data-agent-native-composer-popover="true"] [class~="flex-1"] { + flex: 1 1 auto; +} + +[data-agent-native-composer-popover="true"] [class~="min-w-0"] { + min-width: 0; +} + +[data-agent-native-composer-popover="true"] [class~="truncate"] { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +[data-agent-native-composer-popover="true"] [class~="block"] { + display: block; +} + +[data-agent-native-composer-popover="true"] [class~="uppercase"] { + text-transform: uppercase; + letter-spacing: 0.04em; + color: hsl(var(--muted-foreground)); + font-size: 10px; +} + +[data-agent-native-composer-popover="true"] [class~="text-[10px]"] { + font-size: 10px; +} + +[data-agent-native-composer-popover="true"] [class~="text-[11px]"], +[data-agent-native-composer-popover="true"] [class~="text-xs"] { + font-size: 11px; +} + +[data-agent-native-composer-popover="true"] [class~="text-[12px]"] { + font-size: 12px; +} + +[data-agent-native-composer-popover="true"] [class~="text-[13px]"], +[data-agent-native-composer-popover="true"] [class~="text-sm"] { + font-size: 13px; +} + +[data-agent-native-composer-popover="true"] [class~="border-t"] { + border-top: 1px solid hsl(var(--border)); +} + +[data-agent-native-composer-popover="true"] [class~="my-1"] { + margin-top: 4px; + margin-bottom: 4px; +} + +[data-agent-native-composer-popover="true"] [class~="px-3"] { + padding-left: 10px; + padding-right: 10px; +} + +[data-agent-native-composer-popover="true"] [class~="pl-7"] { + padding-left: 26px; +} + +[data-agent-native-composer-popover="true"] [class~="font-medium"] { + font-weight: 500; +} + +[data-agent-native-composer-popover="true"] [class~="text-left"] { + text-align: left; +} + +[data-agent-native-composer-popover="true"] [class~="bg-accent"] { + background: hsl(var(--accent)); +} + +[data-agent-native-composer-popover="true"] [class~="text-accent-foreground"], +[data-agent-native-composer-popover="true"] [class~="text-foreground"] { + color: hsl(var(--foreground)); +} + +[data-agent-native-composer-popover="true"] [class~="text-muted-foreground"], +[data-agent-native-composer-popover="true"] [class~="text-muted-foreground/70"], +[data-agent-native-composer-popover="true"] + [class~="text-muted-foreground/80"] { + color: hsl(var(--muted-foreground)); +} + +[data-agent-native-composer-popover="true"] [class~="rotate-90"] { + transform: rotate(90deg); +} + +[data-agent-native-composer-popover="true"] button:hover:not(:disabled) { + background: hsl(var(--accent)); + color: hsl(var(--foreground)); +} + +.code-agents-select-content { + --background: 220 6% 6%; + --foreground: 0 0% 90%; + --popover: 220 5% 8%; + --popover-foreground: 0 0% 90%; + --muted: 220 4% 10%; + --muted-foreground: 220 4% 60%; + --accent: 220 4% 13%; + --accent-foreground: 0 0% 90%; + --border: 220 4% 16%; + --radius: 0.5rem; + border: 1px solid hsl(var(--border)); + border-radius: calc(var(--radius) + 1px); + background: hsl(var(--popover)); + color: hsl(var(--popover-foreground)); + font-family: + "Inter", + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; + font-size: 12px; + box-shadow: 0 18px 44px rgba(0, 0, 0, 0.42); +} + +.code-agents-suggestions { + width: min(100%, 560px); + display: grid; +} + +.code-agents-suggestions button { + height: 34px; + border: 0; + border-top: 1px solid hsl(var(--border) / 0.65); + background: transparent; + color: hsl(var(--muted-foreground) / 0.72); + font-size: 12px; + text-align: left; + cursor: pointer; +} + +.code-agents-suggestions button:hover { + color: hsl(var(--foreground)); +} + +.code-agents-button, +.code-agents-button--primary, +.code-agents-icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 32px; + padding: 0 10px; + border: 1px solid hsl(var(--border)); + border-color: hsl(var(--border)); + border-radius: calc(var(--radius) - 2px); + background: hsl(var(--card)); + color: hsl(var(--muted-foreground)); + font-size: 12px; + font-weight: 500; + cursor: pointer; +} + +.code-agents-icon-button { + width: 28px; + height: 28px; + padding: 0; +} + +.code-agents-button:hover, +.code-agents-button--primary:hover, +.code-agents-icon-button:hover { + border-color: hsl(var(--border)); + background: hsl(var(--accent) / 0.5); + color: hsl(var(--foreground)); +} + +.code-agents-callout, +.code-agents-callout--degraded, +.code-agents-callout--unauthorized, +.code-agents-credential-callout, +.code-agents-approval-callout { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + margin: 12px 0; + padding: 10px 12px; + border: 1px solid hsl(45 90% 50% / 0.26); + border-color: hsl(45 90% 50% / 0.26); + border-radius: var(--radius); + background: hsl(45 90% 50% / 0.08); + color: hsl(45 92% 82%); +} + +.code-agents-kicker { + margin: 0; + color: hsl(var(--muted-foreground) / 0.7); + font-size: 11px; + font-weight: 500; +} + +.code-agents-detail { + max-width: 920px; + margin: 0 auto; + padding: 8px; + color: hsl(var(--foreground)); +} + +.code-agents-detail--empty { + min-height: 320px; + display: grid; + place-items: center; + align-content: center; + gap: 10px; + text-align: center; +} + +.code-agents-detail--empty h3 { + margin: 0; + color: hsl(var(--foreground)); + font-size: 16px; + font-weight: 600; +} + +.code-agents-detail--empty p { + max-width: 420px; + margin: 0; + color: hsl(var(--muted-foreground)); + font-size: 13px; + line-height: 1.5; +} + +.code-agents-detail__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding-bottom: 14px; + border-bottom: 1px solid hsl(var(--border)); + border-bottom-color: hsl(var(--border)); +} + +.code-agents-detail__header h3 { + color: hsl(var(--foreground)); + font-size: 17px; + font-weight: 600; +} + +.code-agents-session-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 260px; + gap: 16px; + margin-top: 16px; +} + +.code-agents-session-main, +.code-agents-session-aside { + min-width: 0; +} + +.code-agents-session-aside { + align-self: start; + display: grid; + gap: 12px; + padding-left: 16px; + border-left: 1px solid hsl(var(--border)); +} + +.code-agents-progress__label, +.code-agents-field span, +.code-agents-permission__header, +.code-agents-transcript__header, +.code-agents-transcript-event__meta { + color: hsl(var(--muted-foreground)); +} + +.code-agents-progress__track { + height: 4px; + background: hsl(var(--muted)); +} + +.code-agents-progress__track span { + display: block; + height: 100%; + border-radius: inherit; + background: hsl(var(--primary)); +} + +.code-agents-progress { + display: grid; + gap: 8px; + margin-top: 0; +} + +.code-agents-progress__label { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + font-size: 11px; +} + +.code-agents-progress__track { + overflow: hidden; + border-radius: 999px; +} + +.code-agents-detail-grid { + display: grid; + grid-template-columns: 1fr; + gap: 8px; + margin-top: 0; +} + +.code-agents-field, +.code-agents-tool-event, +.code-agents-transcript-event__artifact code, +.code-agents-command-line { + border: 1px solid hsl(var(--border)); + border-color: hsl(var(--border)); + border-radius: var(--radius); + background: hsl(var(--card)); + color: hsl(var(--foreground) / 0.78); +} + +.code-agents-field { + min-width: 0; + padding: 10px; +} + +.code-agents-field span, +.code-agents-field strong { + display: block; +} + +.code-agents-field strong { + margin-top: 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: hsl(var(--foreground) / 0.86); + font-size: 12px; + font-weight: 500; +} + +.code-agents-permission { + margin: 0; + padding: 0; + border: 0; +} + +.code-agents-permission__header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-bottom: 7px; + font-size: 11px; +} + +.code-agents-permission__header em { + font-style: normal; +} + +.code-agents-permission__options { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 6px; +} + +.code-agents-permission--compact { + margin: 0; +} + +.code-agents-mode-select { + width: 132px; +} + +.code-agents-follow-up-mode-select { + width: 112px; +} + +.desktop-select-trigger { + display: inline-flex; + height: 30px; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 8px; + border: 1px solid hsl(var(--border, 220 4% 14%)); + border-radius: calc(var(--radius, 0.5rem) - 2px); + background: hsl(var(--card, 220 5% 7%)); + color: hsl(var(--foreground, 0 0% 90%) / 0.86); + padding: 0 9px; + font: inherit; + font-size: 12px; + font-weight: 500; + outline: none; + cursor: pointer; + transition: + background var(--ease), + border-color var(--ease), + color var(--ease), + box-shadow var(--ease); +} + +.desktop-select-trigger:hover, +.desktop-select-trigger[data-state="open"] { + border-color: hsl(var(--border, 220 4% 14%)); + background: hsl(var(--accent, 220 4% 13%)); + color: hsl(var(--foreground, 0 0% 90%)); +} + +.desktop-select-trigger:focus-visible { + box-shadow: 0 0 0 2px hsl(var(--ring, 0 0% 60%) / 0.28); +} + +.desktop-select-trigger:disabled { + cursor: default; + opacity: 0.55; +} + +.desktop-select-trigger svg { + flex: 0 0 auto; + color: hsl(var(--muted-foreground, 220 4% 60%)); +} + +.desktop-select-content { + z-index: 300; + min-width: 184px; + max-width: 260px; + overflow: hidden; + border: 1px solid hsl(var(--border, 220 4% 16%)); + border-radius: calc(var(--radius, 0.5rem) + 2px); + background: hsl(var(--popover, 220 5% 8%)); + color: hsl(var(--popover-foreground, 0 0% 90%)); + box-shadow: + 0 18px 44px rgba(0, 0, 0, 0.42), + inset 0 1px 0 hsl(var(--foreground, 0 0% 90%) / 0.04); +} + +.desktop-select-viewport { + padding: 5px; +} + +.desktop-select-viewport--popper { + min-width: var(--radix-select-trigger-width); +} + +.desktop-select-scroll-button { + display: grid; + place-items: center; + height: 22px; + color: hsl(var(--muted-foreground, 220 4% 60%)); +} + +.desktop-select-item { + position: relative; + display: grid; + min-height: 50px; + cursor: default; + user-select: none; + grid-template-columns: minmax(0, 1fr); + gap: 3px; + border-radius: calc(var(--radius, 0.5rem) - 3px); + padding: 8px 9px 8px 30px; + color: hsl(var(--foreground, 0 0% 90%) / 0.88); + outline: none; +} + +.desktop-select-item[data-highlighted] { + background: hsl(var(--accent, 220 4% 13%)); + color: hsl(var(--foreground, 0 0% 90%)); +} + +.desktop-select-item[data-disabled] { + pointer-events: none; + opacity: 0.5; +} + +.desktop-select-item__indicator { + position: absolute; + left: 9px; + top: 10px; + display: grid; + width: 14px; + height: 14px; + place-items: center; + color: hsl(var(--foreground, 0 0% 90%)); +} + +.desktop-select-item__label { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + font-weight: 600; + line-height: 1.2; +} + +.desktop-select-item__description { + display: block; + color: hsl(var(--muted-foreground, 220 4% 60%)); + font-size: 11px; + line-height: 1.35; +} + +.desktop-dropdown-content { + z-index: 300; + min-width: 210px; + overflow: hidden; + border: 1px solid hsl(var(--border, 220 4% 16%)); + border-radius: calc(var(--radius, 0.5rem) + 2px); + background: hsl(var(--popover, 220 5% 8%)); + color: hsl(var(--popover-foreground, 0 0% 90%)); + padding: 5px; + box-shadow: + 0 18px 44px rgba(0, 0, 0, 0.42), + inset 0 1px 0 hsl(var(--foreground, 0 0% 90%) / 0.04); +} + +.desktop-dropdown-label { + padding: 7px 9px 5px; + color: hsl(var(--muted-foreground, 220 4% 60%)); + font-size: 11px; + font-weight: 500; +} + +.desktop-dropdown-item { + position: relative; + display: grid; + min-height: 36px; + grid-template-columns: minmax(0, 1fr); + gap: 3px; + align-items: center; + border-radius: calc(var(--radius, 0.5rem) - 3px); + padding: 7px 9px; + color: hsl(var(--foreground, 0 0% 90%) / 0.88); + font-size: 12px; + outline: none; + user-select: none; +} + +.desktop-dropdown-item > svg { + position: absolute; + left: 9px; + top: 10px; + color: hsl(var(--muted-foreground, 220 4% 60%)); +} + +.desktop-dropdown-item__main { + display: flex; + align-items: center; + gap: 8px; + padding-left: 22px; + font-weight: 500; +} + +.desktop-dropdown-item__description { + padding-left: 22px; + color: hsl(var(--muted-foreground, 220 4% 60%)); + font-size: 11px; + line-height: 1.35; +} + +.desktop-dropdown-item[data-highlighted] { + background: hsl(var(--accent, 220 4% 13%)); + color: hsl(var(--foreground, 0 0% 90%)); +} + +.desktop-dropdown-item[data-disabled] { + pointer-events: none; + opacity: 0.5; +} + +.desktop-dropdown-separator { + height: 1px; + margin: 5px 4px; + background: hsl(var(--border, 220 4% 16%)); +} + +.code-agents-permission--compact .code-agents-mode-select { + min-width: 112px; + width: 112px; + height: 26px; + font-size: 11px; +} + +.code-agents-permission--compact .code-agents-permission__options { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.code-agents-permission__option { + min-height: 30px; + display: grid; + place-items: center; + gap: 1px; + padding: 4px 8px; + border: 1px solid hsl(var(--border)); + border-color: hsl(var(--border)); + border-radius: calc(var(--radius) - 2px); + background: hsl(var(--card)); + color: hsl(var(--muted-foreground)); + text-align: center; + cursor: pointer; +} + +.code-agents-permission--compact .code-agents-permission__option { + min-height: 26px; + padding: 0 7px; + font-size: 11px; +} + +.code-agents-permission__option:hover:not(:disabled) { + background: hsl(var(--accent) / 0.5); + color: hsl(var(--foreground)); +} + +.code-agents-permission__option--active { + border-color: hsl(var(--border)); + background: hsl(var(--accent)); + color: hsl(var(--accent-foreground)); +} + +.code-agents-permission__option strong, +.code-agents-permission__option span { + color: inherit; + font-size: 11px; +} + +.code-agents-phase, +.code-agents-phase--active, +.code-agents-phase--complete { + flex: 0 0 auto; + font-size: 11px; + font-weight: 500; + color: hsl(var(--muted-foreground) / 0.72); +} + +.code-agents-phase--approval { + color: hsl(45 92% 62%); +} + +.code-agents-transcript { + display: grid; + gap: 12px; + margin-top: 0; + padding-top: 0; + border-top: 0; +} + +.code-agents-transcript__header, +.code-agents-detail__footer, +.code-agents-follow-up__actions, +.code-agents-toolbar-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.code-agents-transcript__header, +.code-agents-detail__footer, +.code-agents-follow-up__actions { + justify-content: space-between; +} + +.code-agents-detail__footer { + justify-content: flex-start; + flex-wrap: wrap; + margin-top: 0; +} + +.code-agents-toolbar-actions { + justify-content: flex-end; +} + +.code-agents-transcript__loading { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; +} + +.code-agents-transcript__error { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border: 1px solid hsl(var(--destructive) / 0.24); + border-radius: var(--radius); + background: hsl(var(--destructive) / 0.08); + color: hsl(var(--destructive-foreground)); + font-size: 12px; +} + +.code-agents-transcript__timeline::-webkit-scrollbar-thumb, +.code-agents-run-list::-webkit-scrollbar-thumb { + background: hsl(var(--border)); +} + +.code-agents-transcript__timeline { + display: grid; + gap: 10px; + max-height: 360px; + overflow-y: auto; + padding-right: 4px; +} + +.code-agents-transcript__empty { + min-height: 96px; + display: grid; + place-items: center; + gap: 6px; + border-color: hsl(var(--border)); + background: hsl(var(--muted) / 0.5); + color: hsl(var(--muted-foreground)); + font-size: 12px; +} + +.code-agents-transcript-event { + display: grid; + grid-template-columns: 26px minmax(0, 1fr); + gap: 10px; +} + +.code-agents-transcript-event__icon { + width: 26px; + height: 26px; + display: grid; + place-items: center; + border: 1px solid hsl(var(--border)); + border-radius: calc(var(--radius) - 2px); + border-color: hsl(var(--border)); + background: hsl(var(--muted)); + color: hsl(var(--muted-foreground)); +} + +.code-agents-transcript-event__body { + min-width: 0; + padding-bottom: 12px; + border-bottom: 1px solid hsl(var(--border) / 0.72); +} + +.code-agents-transcript-event:last-child .code-agents-transcript-event__body { + padding-bottom: 0; + border-bottom: 0; +} + +.code-agents-transcript-event__meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + font-size: 11px; +} + +.code-agents-transcript-event__meta span { + color: hsl(var(--foreground) / 0.82); + font-weight: 500; +} + +.code-agents-transcript-event__body p { + margin-top: 5px; + color: hsl(var(--foreground) / 0.78); + font-size: 12px; + line-height: 1.5; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.code-agents-tool-event summary, +.code-agents-tool-event summary span:first-child, +.code-agents-transcript-event__artifact a { + color: hsl(var(--muted-foreground)); +} + +.code-agents-tool-event summary:hover, +.code-agents-transcript-event__artifact a:hover { + color: hsl(var(--foreground)); +} + +.code-agents-tool-event { + margin-top: 8px; +} + +.code-agents-tool-event summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 30px; + padding: 0 9px; + cursor: pointer; + font-size: 11px; +} + +.code-agents-tool-event__body { + display: grid; + gap: 8px; + padding: 0 9px 9px; +} + +.code-agents-tool-event pre { + max-height: 180px; + overflow: auto; + margin: 0; + padding: 8px; + border-radius: calc(var(--radius) - 2px); + background: hsl(var(--muted)); + color: hsl(var(--foreground) / 0.72); + font-size: 11px; + line-height: 1.45; + white-space: pre-wrap; +} + +.code-agents-tool-event pre strong { + display: block; + margin-bottom: 5px; + color: hsl(var(--foreground) / 0.86); + font-size: 10px; +} + +.code-agents-transcript-event__artifact { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 8px; +} + +.code-agents-transcript-event__artifact code { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 5px 7px; + font-size: 11px; +} + +.code-agents-transcript-event__artifact a { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11px; + text-decoration: none; +} + +.code-agents-follow-up { + display: grid; + gap: 8px; +} + +.code-agents-workbench__toolbar { + min-height: 48px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 10px 12px; + border-bottom: 1px solid hsl(var(--border)); + background: hsl(var(--card)); + border-bottom-color: hsl(var(--border)); +} + +.code-agents-workbench { + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; +} + +.code-agents-workbench-frame { + position: relative; + flex: 1 1 auto; + min-height: 0; +} + +.code-agents-native-surface { + height: 100%; + display: grid; + place-items: center; + padding: 24px; +} + +.code-agents-command-line { + max-width: min(100%, 520px); + overflow-x: auto; + padding: 9px 10px; + font-family: + ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", + monospace; + font-size: 11px; + white-space: nowrap; +} + +.code-agents-run-skeleton { + height: 42px; + border-radius: calc(var(--radius) - 2px); + background: linear-gradient( + 90deg, + hsl(var(--muted) / 0.42), + hsl(var(--muted) / 0.8), + hsl(var(--muted) / 0.42) + ); +} + +.code-agents-spin { + animation: spin 1s linear infinite; +} + +@media (max-width: 980px) { + .code-agents-surface { + grid-template-columns: 224px minmax(0, 1fr); + } + + .code-agents-session-layout { + grid-template-columns: 1fr; + } + + .code-agents-session-aside { + padding-left: 0; + border-left: 0; + } + + .code-agents-permission__options { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} diff --git a/packages/code-agents-ui/src/types.ts b/packages/code-agents-ui/src/types.ts new file mode 100644 index 000000000..abb9b5ab4 --- /dev/null +++ b/packages/code-agents-ui/src/types.ts @@ -0,0 +1,338 @@ +import type { CodeAgentPermissionMode } from "./code-agents.js"; + +export type CodeAgentReasoningEffort = + | "auto" + | "none" + | "minimal" + | "low" + | "medium" + | "high" + | "xhigh" + | "max"; + +export interface CodeAgentModelSelection { + engine?: string; + model?: string; + effort?: CodeAgentReasoningEffort; +} + +export interface CodeAgentModelOption { + engine: string; + engineLabel: string; + model: string; + label: string; + description?: string; +} + +export interface CodeAgentModelListResult { + status: "ok" | "unavailable"; + models: CodeAgentModelOption[]; + selected?: CodeAgentModelSelection; + error?: string; +} + +export interface CodeAgentPromptAttachment { + name: string; + type?: string; + size?: number; + text?: string; +} + +export type CodeAgentFollowUpMode = "immediate" | "queued"; + +export interface CodeAgentProjectCommand { + kind: "command"; + name: string; + path: string; + relativePath: string; + description?: string; + argumentHint?: string; + reserved: boolean; + body?: string; +} + +export interface CodeAgentProjectSkill { + kind: "skill"; + name: string; + path: string; + relativePath: string; + description?: string; + body?: string; +} + +export interface CodeAgentCodePack { + schemaVersion: 1; + root: string; + commands: CodeAgentProjectCommand[]; + skills: CodeAgentProjectSkill[]; +} + +export interface CodeAgentCodePackResult { + status: "ok" | "unavailable"; + pack?: CodeAgentCodePack; + error?: string; +} + +export interface CodeAgentProjectFolder { + id: string; + path: string; + name: string; + updatedAt?: string; +} + +export interface CodeAgentProjectListResult { + status: "ok" | "unavailable"; + projects: CodeAgentProjectFolder[]; + selectedPath?: string; + defaultPath?: string; + error?: string; +} + +export interface CodeAgentProjectSelectResult { + ok: boolean; + project?: CodeAgentProjectFolder; + projects: CodeAgentProjectFolder[]; + selectedPath?: string; + error?: string; +} + +export type CodeAgentRunStatus = + | "queued" + | "running" + | "paused" + | "needs-approval" + | "completed" + | "errored" + | "unknown"; + +export interface CodeAgentRunProgress { + label?: string; + completed: number; + total: number; + failed?: number; + percent: number; +} + +export interface CodeAgentRunDetail { + label: string; + value: string; +} + +export interface CodeAgentRun { + id: string; + goalId: string; + title: string; + subtitle?: string; + status: CodeAgentRunStatus; + phase?: string; + needsApproval?: boolean; + progress?: CodeAgentRunProgress; + details?: CodeAgentRunDetail[]; + surfaceUrl?: string; + createdAt: string; + updatedAt: string; + metadata?: Record; +} + +export interface CodeAgentMigrationRun extends CodeAgentRun { + name: string; + sourceRoot: string; + outputRoot: string; + target: string; + phase: string; + approved: boolean; + taskCount: number; + passedTaskCount: number; + failedTaskCount: number; + createdAt: string; + updatedAt: string; +} + +export interface CodeAgentRunListResult< + TRun extends CodeAgentRun = CodeAgentRun, +> { + status: "ok" | "unauthorized" | "unavailable"; + goalId?: string; + runs: TRun[]; + workbenchUrl?: string; + error?: string; +} + +export type CodeAgentTranscriptEventType = + | "user" + | "system" + | "artifact" + | "status"; + +export interface CodeAgentTranscriptEvent { + id: string; + runId: string; + type: CodeAgentTranscriptEventType; + title?: string; + text: string; + createdAt: string; + artifactPath?: string; + artifactUrl?: string; + metadata?: Record; +} + +export interface CodeAgentTranscriptRequest { + goalId?: string; + runId: string; +} + +export interface CodeAgentTranscriptResult { + status: "ok" | "unavailable"; + runId?: string; + events: CodeAgentTranscriptEvent[]; + eventFile?: string; + error?: string; +} + +export interface CodeAgentCreateRunRequest { + goalId?: string; + prompt: string; + cwd?: string; + permissionMode?: CodeAgentPermissionMode; + engine?: string; + model?: string; + effort?: CodeAgentReasoningEffort; + attachments?: CodeAgentPromptAttachment[]; +} + +export interface CodeAgentCreateRunResult { + ok: boolean; + run?: CodeAgentRun; + event?: CodeAgentTranscriptEvent; + eventFile?: string; + message: string; + error?: string; +} + +export interface CodeAgentFollowUpRequest { + goalId?: string; + runId: string; + prompt: string; + followUpMode?: CodeAgentFollowUpMode; + permissionMode?: CodeAgentPermissionMode; + engine?: string; + model?: string; + effort?: CodeAgentReasoningEffort; + attachments?: CodeAgentPromptAttachment[]; +} + +export interface CodeAgentFollowUpResult { + ok: boolean; + event?: CodeAgentTranscriptEvent; + eventFile?: string; + message: string; + error?: string; +} + +export interface CodeAgentUpdateRunRequest { + goalId?: string; + runId: string; + permissionMode?: CodeAgentPermissionMode; + engine?: string; + model?: string; + effort?: CodeAgentReasoningEffort; + metadata?: Record; +} + +export interface CodeAgentUpdateRunResult { + ok: boolean; + run?: CodeAgentRun; + message: string; + error?: string; +} + +export interface CodeAgentTerminalRequest { + cwd?: string; + sourceRoot?: string; + outputRoot?: string; +} + +export interface CodeAgentTerminalResult { + ok: boolean; + cwd: string; + error?: string; +} + +export type CodeAgentRemoteConnectorState = + | "disabled" + | "unconfigured" + | "starting" + | "running" + | "stopped" + | "error"; + +export interface CodeAgentRemoteConnectorStatus { + state: CodeAgentRemoteConnectorState; + enabled: boolean; + configured: boolean; + configPath: string; + relayUrl?: string; + pid?: number; + startedAt?: string; + lastExitAt?: string; + lastExitCode?: number | null; + lastExitSignal?: string | null; + restartCount: number; + nextRestartAt?: string; + error?: string; +} + +export interface CodeAgentRemoteConnectorControlResult { + ok: boolean; + status: CodeAgentRemoteConnectorStatus; + error?: string; +} + +export type CodeAgentControlCommand = "resume" | "status" | "stop" | "approve"; + +export interface CodeAgentControlResult { + ok: boolean; + command: CodeAgentControlCommand; + action?: "open-ui" | "refresh" | "none" | "select-run"; + run?: CodeAgentRun; + message: string; + error?: string; +} + +export interface CodeAgentRetryRunRequest { + goalId?: string; + runId: string; + permissionMode?: CodeAgentPermissionMode; + engine?: string; + model?: string; + effort?: CodeAgentReasoningEffort; +} + +export interface CodeAgentRetryRunResult { + ok: boolean; + run?: CodeAgentRun; + message: string; + error?: string; +} + +export interface CodeAgentRerunRequest { + goalId?: string; + runId: string; + prompt?: string; + cwd?: string; + permissionMode?: CodeAgentPermissionMode; + engine?: string; + model?: string; + effort?: CodeAgentReasoningEffort; + attachments?: CodeAgentPromptAttachment[]; +} + +export interface CodeAgentRerunResult extends CodeAgentCreateRunResult { + sourceRunId?: string; +} + +export interface CodeAgentsOpenRequest { + goalId?: string; + runId?: string; + nonce: number; +} diff --git a/packages/code-agents-ui/src/ui/dropdown-menu.tsx b/packages/code-agents-ui/src/ui/dropdown-menu.tsx new file mode 100644 index 000000000..62b58ac1d --- /dev/null +++ b/packages/code-agents-ui/src/ui/dropdown-menu.tsx @@ -0,0 +1,157 @@ +import * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { IconCheck, IconChevronRight } from "@tabler/icons-react"; + +import { cn } from "../lib/utils.js"; + +const DropdownMenu = DropdownMenuPrimitive.Root; +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; +const DropdownMenuGroup = DropdownMenuPrimitive.Group; +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; +const DropdownMenuSub = DropdownMenuPrimitive.Sub; +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 6, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + description?: string; + } +>(({ className, inset, children, description, ...props }, ref) => ( + + {children} + {description && ( + {description} + )} + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +export { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuRadioGroup, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +}; diff --git a/packages/code-agents-ui/src/ui/select.tsx b/packages/code-agents-ui/src/ui/select.tsx new file mode 100644 index 000000000..5222c4d5a --- /dev/null +++ b/packages/code-agents-ui/src/ui/select.tsx @@ -0,0 +1,116 @@ +import * as React from "react"; +import * as SelectPrimitive from "@radix-ui/react-select"; +import { IconCheck, IconChevronDown, IconChevronUp } from "@tabler/icons-react"; + +import { cn } from "../lib/utils.js"; + +const Select = SelectPrimitive.Root; +const SelectGroup = SelectPrimitive.Group; +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {children} + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + description?: string; + } +>(({ className, children, description, ...props }, ref) => ( + + + + + + + + {children} + + {description && ( + {description} + )} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +}; diff --git a/packages/code-agents-ui/tsconfig.json b/packages/code-agents-ui/tsconfig.json new file mode 100644 index 000000000..26ea9768f --- /dev/null +++ b/packages/code-agents-ui/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "ignoreDeprecations": "6.0", + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "jsx": "react-jsx", + "lib": ["ESNext", "DOM"], + "skipLibCheck": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true + }, + "include": ["src/**/*"] +} diff --git a/packages/core/README.md b/packages/core/README.md index 79d10ac6d..9b7be7486 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -164,6 +164,43 @@ Want a single app, no monorepo? Use `--standalone`: npx @agent-native/core create my-app --standalone --template mail ``` +Need a coding agent workspace? `agent-native` or `agent-native code` opens an open-source Claude Code/Codex-like Code workspace with no prompt required. From there, type a task, run slash goals interactively, or call them directly from your shell: + +```bash +npx @agent-native/core@latest +npx @agent-native/core@latest "fix the failing auth tests" +npx @agent-native/core@latest code +npx @agent-native/core@latest code "fix the failing auth tests" +npx @agent-native/core@latest code exec "fix the failing auth tests" +npx @agent-native/core@latest code -p "fix the failing auth tests" +npx @agent-native/core@latest code --plan "explain the auth test failures" +npx @agent-native/core@latest code --auto "fix the failing auth tests" +npx @agent-native/core@latest code /migrate ./my-next-app --out ../migrated-app +npx @agent-native/core@latest code /migrate ./my-next-app --emit ../migration-dossier +npx @agent-native/core@latest code list +npx @agent-native/core@latest code attach --last +npx @agent-native/core@latest code logs --last +npx @agent-native/core@latest code approve --last +npx @agent-native/core@latest code resume --last +npx @agent-native/core@latest code --continue "check the auth edge cases next" +npx @agent-native/core@latest code resume --last "check the auth edge cases next" +``` + +Slash goals can run from the interactive shell or directly from the command line, and `agent-native code goals` shows the goals registered in your checkout. A bare prompt starts a local coding-agent session, streams work, records transcript/status/tool events, and accepts follow-up prompts; `/migrate` is one specialized capability inside that general Code workspace. Project-specific slash commands live in `.agents/commands/*.md`, so teams can add prompts such as `/release-check` or `/migrate-commerce` without changing the framework. Bare `agent-native` launches the Code workspace in builds with the top-level entrypoint, while a bare prompt such as `agent-native "fix tests"` starts an Agent-Native Code task directly. + +The Code workspace is adding the familiar Codex/Claude-style session loop: pick a previous session, list runs, attach to live output, print logs, resume with context, and continue the same run from Desktop or CLI. The primary modes are intentionally simple: + +- **Plan mode** (`--plan`) inspects, explains, and proposes without writing files. +- **Auto mode** (`--auto`, default) edits files, runs checks, and only pauses for genuinely destructive file, git, publish, or data operations. + +`agent-native migrate` still works as a direct shortcut; `code /migrate` is the Agent-Native Code entrypoint for the migration goal. By default it creates an Agent-Native Code session and portable migration dossier, not a scaffolded app/template. `resume --last` reopens the latest run handoff; adding a quoted prompt records it as a follow-up transcript event for that run so the next active coding agent can pick it up. If a high-risk command is paused for approval, `code approve --last` runs that one pending command and then points you back to resume the session. Use `--app-surface` only when you want the legacy hidden migration detail app for assessment, approval, tasks, artifacts, and verification. +Use `--emit` when you want only the portable dossier for Codex, Claude Code, Cursor, or another coding agent. +Agent-Native Code also includes lightweight goals such as `/audit`: + +```bash +npx @agent-native/core@latest code /audit --url https://example.com +``` + ## Workspaces (Monorepo) A workspace is the default shape of an agent-native project. Every app sits under `apps/`, and `packages/shared/` is available for the small amount of code, instructions, skills, or branding that should truly apply to every app. diff --git a/packages/core/docs/content/agent-teams.md b/packages/core/docs/content/agent-teams.md index 2629d77cf..b901adf67 100644 --- a/packages/core/docs/content/agent-teams.md +++ b/packages/core/docs/content/agent-teams.md @@ -18,6 +18,12 @@ This keeps the main thread focused, lets sub-agents run in parallel, and gives y Sub-agent state is persisted in the `application_state` SQL table (under `agent-task:`), so tasks survive serverless cold starts and work across multiple processes. +Agent Teams runs on the same core `run-manager` harness as hosted agent chat: +events are streamed and persisted, aborts propagate through SQL, heartbeats mark +active work, and stale runs can be reaped consistently. New background-agent UIs +should reuse `run-manager` or `spawnTask()` instead of introducing a separate +runner with its own lifecycle rules. + ## When to spawn a sub-agent {#when-to-spawn} Spawn when the task: diff --git a/packages/core/docs/content/cloneable-saas.md b/packages/core/docs/content/cloneable-saas.md index ad603cdfa..7cc2450f6 100644 --- a/packages/core/docs/content/cloneable-saas.md +++ b/packages/core/docs/content/cloneable-saas.md @@ -18,6 +18,7 @@ Each one is a real app you could use today, and the launching pad for your own v | **Mail** | An agent-native Superhuman. Inbox, labels, AI triage, keyboard-first, drafts and sends through the agent. | | **Calendar** | An agent-native Google Calendar. Events, sync, public booking links, agent-driven scheduling. | | **Content** | An agent-native Notion / Google Docs. Markdown + Tiptap editor, Notion sync, real-time multi-user collab. | +| **Brain** | A whole-company memory app: full-page company chat, cited decisions, review queue, and approved sources. | | **Slides** | An agent-native Google Slides. React-based decks the agent generates and edits directly. | | **Video** | An agent-native video editor on Remotion. Prompt for a cut, the agent assembles it. | | **Analytics** | An agent-native Amplitude/Mixpanel. Connect data sources, prompt for charts, pin to dashboards. | diff --git a/packages/core/docs/content/code-agents-ui.md b/packages/core/docs/content/code-agents-ui.md new file mode 100644 index 000000000..42ecedc76 --- /dev/null +++ b/packages/core/docs/content/code-agents-ui.md @@ -0,0 +1,261 @@ +--- +title: "Agent-Native Code UI" +description: "Build and customize Agent-Native Code surfaces with the shared UI package, Desktop host bridge, CLI run store, and hidden code template." +--- + +# Agent-Native Code UI + +Agent-Native Code is the Agent-Native coding surface: a local Claude Code/Codex-style workspace for coding sessions, slash commands, migrations, audits, transcripts, and follow-ups. + +There are three layers: + +- **CLI**: `npx @agent-native/core@latest code` starts and resumes runs. +- **Desktop**: the left-sidebar Code surface adds native terminal launch, app webviews, and desktop deep links. +- **Shared UI**: `@agent-native/code-agents-ui` renders the reusable React surface. + +The shared UI is host-driven. It does not know whether it is running in Electron, a browser template, or a future hosted shell. Hosts provide a `CodeAgentsHost` implementation. + +```ts +import { CodeAgentsApp, type CodeAgentsHost } from "@agent-native/code-agents-ui"; +import "@agent-native/code-agents-ui/styles.css"; + +const host: CodeAgentsHost = { + listRuns: (goalId) => listRunsSomehow(goalId), + listCodePacks: () => listCodePacksSomehow(), + createRun: (request) => createRunSomehow(request), + readTranscript: (request) => readTranscriptSomehow(request), + appendFollowUp: (request) => appendFollowUpSomehow(request), + updateRun: (request) => updateRunSomehow(request), + retryRun: (request) => retryRunSomehow(request), + rerunRun: (request) => rerunRunSomehow(request), + controlRun: (goalId, runId, command, permissionMode) => + controlRunSomehow({ goalId, runId, command, permissionMode }), +}; + +export function CodeSurface() { + return ; +} +``` + +## Desktop Host + +Desktop uses the shared UI but keeps privileged capabilities in Electron: + +- opening a native terminal +- rendering optional app-backed surfaces with `AppWebview` +- handling `agentnative://open?...` links +- tracking local run processes +- recording steering vs queued follow-ups for active runs +- retrying and re-running native Code sessions, including `/migrate` and `/audit` +- stopping a process it started + +That separation matters. The UI can be reused by templates, but native process control should stay in Desktop or CLI. + +## Browser Template + +The hidden `code` template is a starting point for building your own Agent-Native Code UI: + +```bash +npx @agent-native/core@latest create my-code-ui --template code +cd my-code-ui +pnpm install +pnpm dev +``` + +Inside the framework repo, run it directly with: + +```bash +cd templates/code +pnpm install +pnpm dev +``` + +The template wraps the local run store through normal actions: + +- `list-code-agent-runs` +- `list-code-agent-packs` +- `create-code-agent-run` +- `read-code-agent-transcript` +- `append-code-agent-follow-up` +- `update-code-agent-run` +- `control-code-agent-run` + +It uses `@agent-native/core/code-agents`, which exposes the same file-backed run store and executor used by the CLI. + +## Run Store + +Local Agent-Native Code runs are stored at: + +```text +~/.agent-native/code-agents +``` + +Set `AGENT_NATIVE_CODE_AGENTS_HOME` to isolate a template or test run store. + +```bash +AGENT_NATIVE_CODE_AGENTS_HOME=./data/code-agents pnpm dev +``` + +## Host Contract + +`CodeAgentsHost` is intentionally small: + +| Method | Purpose | +| ----------------------------------------------------- | ------------------------------------------------------ | +| `listRuns(goalId?)` | List sessions for the selected goal | +| `listCodePacks?()` | List `.agents/commands` and `.agents/skills` | +| `createRun(request)` | Start a new run | +| `readTranscript(request)` | Read transcript/tool/status events | +| `appendFollowUp(request)` | Add a follow-up, either steering active work or queued | +| `updateRun(request)` | Update mode or run metadata | +| `retryRun?(request)` | Retry the selected run in place | +| `rerunRun?(request)` | Start a new run from a previous prompt | +| `controlRun(goalId, runId, command, permissionMode?)` | Resume, approve, refresh, or stop | +| `openTerminal?(request)` | Optional native terminal hook | + +Browser hosts should return a graceful `openTerminal` error instead of trying to emulate native terminal launch. + +## Shared Composer + +Agent-Native Code uses the same `AgentComposerFrame` + `PromptComposer` / +`TiptapComposer` stack as the framework agent sidebar. Do not fork a separate +textarea, shell, upload picker, voice button, model picker, or Enter-to-submit +implementation for Code-like surfaces. If a host needs one extra control, pass +it through the shared composer extension points so the sidebar, Code UI, and +Brain chat keep the same interaction model and visual field. + +Brain's Ask route uses `AgentChatSurface`, which is already backed by the +standard sidebar composer. Code uses `PromptComposer` directly because the host +owns run creation, transcripts, and follow-up delivery. + +Code-specific UI belongs around the composer, not inside a forked chatfield. The +shared Code UI may add slots for: + +- Auto / Plan mode controls. +- The selected cwd, project picker, and run metadata. +- Host-only affordances such as opening a terminal. + +Everything else stays in the shared composer: attachments, references, slash and +skill insertion, pasted-text handling, voice dictation, drafts, keyboard +shortcuts, and submission semantics. + +## Slash Commands + +Agent-Native Code treats migration as a capability, not a separate app category. `/migrate` can be a built-in goal, a project command, or a custom instruction pack on top of the same host contract. + +Project-specific commands live in: + +```text +.agents/commands/*.md +``` + +Use these for team workflows such as release checks, migration variants, framework upgrades, or audits. + +Project skills live in: + +```text +.agents/skills/*/SKILL.md +``` + +When the host implements `listCodePacks`, the shared UI shows project commands and skills in the rail. Command rows insert `/`, and skill rows insert a focused “Use the skill…” prompt so the rail stays actionable. Built-in names such as `/migrate`, `/audit`, `/status`, and `/resume` stay reserved for the global Agent-Native Code controls. + +Do not create a separate slash-command registry for a new Code host. Project +commands and skills are discovered from `.agents/commands/*.md` and +`.agents/skills/*/SKILL.md`; the UI should render those packs and insert prompts +through the shared composer. + +## Background Agent Harness + +Background coding-agent work should reuse the same harness as the rest of +Agent-Native: + +- Use the Code run store/executor for local Code sessions. +- Use core `run-manager` for hosted agent runs so streams, aborts, heartbeats, + resumability, soft timeouts, and stuck-run cleanup behave consistently. +- Use `agent-teams` / `spawnTask()` when the UI is delegating work to a + background sub-agent from a normal app chat. + +Do not add a parallel background-agent runner just because a new surface needs a +different layout. Build a host adapter or UI slot on top of the shared harness +instead. + +## Follow-Ups + +Follow-ups on active runs support two delivery modes: + +- **Send now** records a steering prompt that the active runner applies at the next safe continuation point. +- **Queue** runs after the current turn finishes. + +Inactive runs keep the compatible behavior: the follow-up is appended and the run resumes immediately. + +## Remote Dispatch + +Desktop can expose the local Code Agent runner to a deployed Dispatch relay so a +phone or Telegram chat can start, monitor, and continue sessions while the +computer is awake. + +The connection is outbound-only from Desktop: + +1. Desktop pairs with Dispatch and stores a device token locally. +2. Desktop long-polls `/_agent-native/integrations/remote/poll`. +3. Mobile Sessions and Telegram `/code` enqueue commands in the relay database. +4. Desktop claims commands, drives the local run store, and posts results and + transcript events back to Dispatch. +5. Mobile reads `hosts`, `runs`, and `transcript` from Dispatch; it never talks + directly to the desktop. + +The canonical remote relay endpoints are: + +| Method | Route | Caller | Purpose | +| ---------- | -------------------------------------------------------- | --------------- | ------------------------------------------- | +| `POST` | `/_agent-native/integrations/remote/register` | Desktop session | Pair a desktop host and return a token once | +| `GET` | `/_agent-native/integrations/remote/hosts` | Mobile/session | List paired hosts | +| `DELETE` | `/_agent-native/integrations/remote/devices/:id` | Mobile/session | Revoke a paired host | +| `POST` | `/_agent-native/integrations/remote/devices/:id/revoke` | Mobile/session | Revoke a paired host | +| `POST/GET` | `/_agent-native/integrations/remote/poll` | Desktop token | Claim work | +| `POST` | `/_agent-native/integrations/remote/result` | Desktop token | Complete or fail work | +| `POST` | `/_agent-native/integrations/remote/run-events` | Desktop token | Mirror transcript events | +| `GET` | `/_agent-native/integrations/remote/runs` | Mobile/session | List sessions | +| `GET` | `/_agent-native/integrations/remote/runs/:id` | Mobile/session | Read session summary | +| `GET` | `/_agent-native/integrations/remote/runs/:id/transcript` | Mobile/session | Read mirrored transcript | +| `POST` | `/_agent-native/integrations/remote/push/register` | Mobile/session | Register Expo/mobile push token | + +Telegram uses the same relay through Dispatch. Supported commands are: + +```text +/code +/code list +/code status +/code continue +/code approve +/code deny +/code stop +``` + +### Smoke checklist + +Before shipping a remote-control change, run the automated relay route smoke in +`remote-plugin.spec.ts`, then do one real-device pass: + +1. Pair Desktop from Settings and confirm the host appears in mobile Sessions. +2. Start a session from mobile and confirm Desktop claims it. +3. Send `/code ` from Telegram and confirm it queues to the same host. +4. Verify transcript mirroring, follow-up, approve or deny, and stop. +5. Revoke the host from mobile and confirm new commands stay queued/offline + instead of being sent to the revoked device. +6. Enable mobile push alerts and confirm command completion creates a push + outbox row. + +## Styling + +Import the package stylesheet: + +```ts +import "@agent-native/code-agents-ui/styles.css"; +``` + +The stylesheet uses the same shadcn-style HSL custom properties as the templates and Desktop shell. Prefer changing tokens or small class overrides in the host app before forking the shared UI. + +## Limits + +The browser template is local-first. It can start and resume runs while its local Node server is alive. For native process lifecycle, terminal launch, and app webviews, use Desktop. diff --git a/packages/core/docs/content/dispatch.md b/packages/core/docs/content/dispatch.md index adf02e84e..a4467250f 100644 --- a/packages/core/docs/content/dispatch.md +++ b/packages/core/docs/content/dispatch.md @@ -47,11 +47,48 @@ The behavioral rule lives in the dispatch agent's instructions: domain work belo ### Workspace resources -Skills, agent profiles, and instructions can be authored once in Dispatch and granted out to the rest of the workspace. `sync-workspace-resources-to-all` pushes them to every app's `.agents/` directory so every agent in every app picks them up. This is how a team-wide change ("always use British English in customer-facing replies") propagates without editing ten repos. +Skills, guardrail instructions, agent profiles, and reference resources can be authored once in Dispatch and inherited by the rest of the workspace. Resources with **All apps** scope are global: Dispatch stores them once at workspace scope, and every app agent reads them at runtime. They are not copied into each app, and there is no manual workspace-resource sync step. App shared resources and personal resources can override or narrow the workspace defaults locally. Selected resources use explicit per-app grants for app-specific exceptions. + +Use the canonical paths to control how agents consume them: + +- `AGENTS.md` or `instructions/.md` for always-on guardrails loaded by every app agent +- `skills//SKILL.md` for on-demand skills available through `/` commands and the prompt skill index +- `context/.md` for brand, persona, positioning, messaging, company facts, and other reference material the agent reads when relevant +- `agents/.md` for reusable custom agent profiles + +Starter global resources usually look like: + +```text +context/company.md +context/brand.md +context/messaging.md +instructions/guardrails.md +skills/company-voice/SKILL.md +``` + +Set these to **All apps** when every app should inherit the same company facts, brand rules, messaging, safety constraints, and customer-facing writing style. Use selected-app grants only for resources that are genuinely app-specific. + +The **Resources** page highlights this starter pack in a Global context section so admins can quickly see which files exist, whether they are scoped to all apps, restore missing starter files without overwriting existing ones, and edit their contents. Expand any resource to preview its effective runtime stack for a selected app/user: workspace default, organization/app override, then personal override. Each app card also has a **Context** view that shows exactly what that app receives: inherited workspace resources, selected grants, and auto-loaded instructions. Use a resource row's **Stack** control to inspect which layer wins for that app. + +This is how a team-wide change ("always use British English in customer-facing replies") or a shared brand guideline propagates without editing ten repos. + +### Dreams + +Dispatch Dreams review prior agent runs, feedback, evals, and repeated failures to propose durable improvements. A dream report is a review surface, not a silent rewrite: it can suggest personal memory updates, stale-memory cleanup, shared `LEARNINGS.md` edits, workspace instruction/skill/knowledge/agent resources, or recurring jobs, and each proposal links back to the runs that justify it. Shared instructions and team-wide resources require review before they are applied, especially when the evidence came from inbound Slack, email, Telegram, WhatsApp, or web content. + +Before proposing a write, Dreams compare the evidence against the personal memory index, existing `memory/*.md` notes, and shared `LEARNINGS.md`. If a lesson is already captured, the report records that it was skipped; if a related personal memory looks stale, the proposal targets that existing note instead of creating a duplicate. Dream reports deduplicate repeated evidence by thread, signal type, and normalized quote, strip injected context from correction detection, and summarize raw eval/tool rows into readable bullets. If a pass finds signals but creates no proposals, guardrail notes explain which evidence was suppressed. + +Use Dreams as the workspace's offline reflection loop: "what did agents keep getting wrong this week?", "what should we remember?", and "which repeated workflow should become a skill or scheduled job?" + +Start from the **Dreams** tab in Dispatch. Run a manual pass first, open a proposal review sheet to compare the current target with the proposed content and source evidence, then apply only the changes you want to keep. Once the reports are consistently useful, Dispatch can create a recurring dream job that keeps producing proposals without auto-applying shared or instruction-level changes. Workspace-instruction proposals require durable evidence from at least two threads or two source apps, while eval-only noise, account setup issues, quota limits, and single-app UI wording corrections remain out of all-app instructions. + +When a workspace has several thread-debug sources, Dreams can scan them together with `sourceId: "all"` or an explicit `sourceIds` list. Each source gets its own timeout, start stagger, concurrency cap, per-thread timeout, and persisted health row, so a slow or unavailable production database produces a partial result instead of blocking the whole dream pass. + +Recurring dream settings are stored at user or org scope and can be edited from the Dreams settings sheet. They control the cron schedule, source selection, per-source timeout, source concurrency, source start stagger, per-thread timeout, candidate limit, and minimum candidate threshold. The default recurring shape is a weekly all-source review that writes proposals only; applying shared or workspace-resource proposals still goes through review and approval. ### Approval flow -Dispatch can gate sensitive runtime changes behind admin review. Today this covers **saved destinations** (the Slack channels and email addresses the agent can proactively send to) and **dispatch approval policy** itself. When the policy is enabled, the change is queued and the agent surfaces an inline approval preview directly in chat — admins approve or reject without leaving the conversation. Resource-wide approval interception is planned but not yet shipped. +Dispatch can gate sensitive runtime changes behind admin review. Today this covers **saved destinations** (the Slack channels and email addresses the agent can proactively send to), shared/team **dream proposals**, All-app **workspace resource** creates/updates/deletes, and **dispatch approval policy** itself. When the policy is enabled, the change is queued and the agent surfaces an inline approval preview directly in chat — admins approve or reject without leaving the conversation. ## How a Slack message flows through Dispatch {#flow} @@ -83,7 +120,7 @@ Three short steps: 2. **Connect messaging.** Open **Settings → Messaging** in Dispatch and click connect for Slack, Email, Telegram, or WhatsApp. The form fields match the env vars in the [Messaging](/docs/messaging) doc — refer there for what each platform needs. 3. **Add other apps.** Run `npx @agent-native/core add-app` from the workspace root for each domain app. They auto-appear as A2A peers in Dispatch's `list-workspace-apps` — no manual registration, no agent-card editing. Dispatch will start delegating to them as soon as their agent cards are reachable. -Then add credentials to the vault, sync them to apps, and (optionally) author workspace skills under **Resources** and sync them out. If you need per-app secret isolation, switch the vault access setting to manual before granting individual apps. +Then add credentials to the vault and (optionally) author global workspace resources under **Resources**. Vault keys can still be synced or granted depending on access mode; All-app workspace resources are inherited automatically. If you need per-app secret isolation, switch the vault access setting to manual before granting individual apps. ## See also {#see-also} diff --git a/packages/core/docs/content/embedding-sdk.md b/packages/core/docs/content/embedding-sdk.md new file mode 100644 index 000000000..06916d24a --- /dev/null +++ b/packages/core/docs/content/embedding-sdk.md @@ -0,0 +1,459 @@ +--- +title: "Embedding SDK" +description: "Embed an Agent-Native sidecar into an existing SaaS app with page context and host commands." +--- + +# Embedding SDK + +The embedding SDK is for the CLAW-style shape: keep your existing SaaS app, add a durable agent sidecar, and let that agent see and operate on the page the user is already using. + +Use it when you want an assistant that can: + +- Read current page context: route, selected resource, highlighted text, active filters, user/org, and app-specific state. +- Call durable backend actions, MCP tools, or integration-backed tools from the sidecar app. +- Ask the host app to navigate, refresh data, remount a view, or open a resource after durable work completes. +- Run as an iframe/sidebar now, while leaving room for a no-iframe package or hosted template later. + +## Batteries-Included Embedded Mode + +For most SaaS hosts, use the full embedded runtime. The host mounts Agent-Native server routes into its existing app, passes its logged-in user to Agent-Native, and then renders the React sidebar/surface in the product UI. Agent-Native uses the host deployment, host session, and the configured `DATABASE_URL` to manage its own framework tables: chat threads, settings, application state, extensions, extension data, secrets, browser sessions, and action routes. + +On the server: + +```ts +// server/plugins/agent-native.ts +import { createAgentNativeEmbeddedPlugin } from "@agent-native/core/server"; +import { builderActions } from "../agent-native/actions"; +import { getBuilderSession } from "../auth"; + +export default createAgentNativeEmbeddedPlugin({ + databaseUrl: process.env.DATABASE_URL, + auth: async (event) => { + const session = await getBuilderSession(event); + if (!session) return null; + return { + userId: session.user.id, + email: session.user.email, + name: session.user.name, + orgId: session.organization.id, + orgRole: session.organization.role, + }; + }, + actions: builderActions, + agentChat: { + appId: "builder", + systemPrompt: + "You are Builder's embedded agent. Use Builder actions for durable work.", + }, +}); +``` + +On the client: + +```tsx +import { + AgentNativeEmbedded, + defineClientAction, +} from "@agent-native/core/client"; + +export function BuilderAppShell({ children, content, editor }) { + return ( + ({ + route: { + name: "builder-editor", + pathname: window.location.pathname, + params: { contentId: content.id }, + }, + resource: { + type: "content", + id: content.id, + name: content.name, + }, + user: currentUser(), + organization: currentOrganization(), + })} + actions={[ + defineClientAction({ + name: "select-element", + description: "Select an element in the visual editor", + schema: { + type: "object", + properties: { elementId: { type: "string" } }, + required: ["elementId"], + }, + run: ({ elementId }) => editor.select(elementId), + }), + ]} + onRefresh={() => queryClient.invalidateQueries()} + onNavigate={(payload) => + router.navigate((payload as { path: string }).path) + } + onRemount={() => setAppKey((key) => key + 1)} + > + {children} + + ); +} +``` + +This mode is the recommended default because it reuses the full framework: backend actions are mounted under `/_agent-native/actions`, the agent can call the same actions as the UI, user-created extensions are stored in SQL, `extensionData` is durable and user/org scoped, and browser-session tools let the backend agent inspect or operate the currently open tab. + +Host auth is server-side. Do not pass identity from the browser as the source of truth; use the host's request/session object or a short-lived server-verified token. If the host does not expose emails, return a stable `userId` and Agent-Native will use it as the owner key. + +### Database Isolation + +Embedded mode manages Agent-Native tables in SQL. For a mature SaaS product, the safest default is **same hosting and auth, dedicated Agent-Native database/schema**: + +```ts +export default createAgentNativeEmbeddedPlugin({ + databaseUrl: process.env.AGENT_NATIVE_DATABASE_URL, + auth: getHostSession, + actions: hostActions, +}); +``` + +Using the host product's main `DATABASE_URL` is supported, but make that an explicit choice. Agent-Native creates framework tables such as `settings`, `application_state`, `tools`, `tool_data`, browser-session tables, secrets, chat threads, and related indexes. A dedicated DB/schema avoids table-name collisions, keeps ownership of managed tables clear, and makes backup/retention policy easier to reason about. If you intentionally share the host DB, review existing table names first and treat Agent-Native tables as framework-owned. + +## Host App + +For standalone sidecar apps or cross-origin iframes, use the lower-level ``. It renders the iframe sidecar and wires page context, live client actions, and host refresh/navigation commands in one place: + +```tsx +import { AgentNative, defineClientAction } from "@agent-native/core/client"; + +export function AssistantDock({ customer, sessionToken }) { + return ( + ({ token: sessionToken })} + screen={{ includeVisibleText: true }} + getContext={() => ({ + route: { + name: "customer-detail", + pathname: window.location.pathname, + params: { customerId: customer.id }, + }, + resource: { + type: "customer", + id: customer.id, + name: customer.name, + }, + selection: { + ids: getSelectedRowIds(), + text: window.getSelection()?.toString() || undefined, + }, + user: currentUser(), + organization: currentOrganization(), + })} + actions={[ + defineClientAction<{ contentId: string }, { published: true }>({ + name: "publish-content", + description: "Publish a Builder content entry", + schema: { + type: "object", + properties: { contentId: { type: "string" } }, + required: ["contentId"], + }, + destructive: true, + approval: { title: "Publish this entry?", risk: "medium" }, + run: async ({ contentId }, { refresh }) => { + await builderApi.publish(contentId); + await refresh({ queryKey: ["content", contentId] }); + return { published: true }; + }, + }), + defineClientAction<{ elementId: string }, void>({ + name: "select-element", + description: "Select an element in the live visual editor", + schema: { + type: "object", + properties: { elementId: { type: "string" } }, + required: ["elementId"], + }, + run: ({ elementId }) => editor.select(elementId), + }), + ]} + onNavigate={(payload) => { + const { path } = payload as { path: string }; + router.navigate(path); + }} + onRefresh={(payload) => { + const { queryKey } = payload as { queryKey?: readonly unknown[] }; + queryClient.invalidateQueries({ queryKey }); + }} + onRemount={() => setAppKey((key) => key + 1)} + onOpenResource={(payload) => openResource(payload)} + onRequestApproval={(payload) => approvalDialog.confirm(payload)} + /> + ); +} +``` + +Use `screen={false}` if you only want explicit semantic context. Use `screen={{ includeDomHtml: true }}` as a fallback for apps that have not yet mapped their UI into semantic IDs and selection state. The host bridge only accepts messages from `agentUrl`'s origin by default. Pass `agentOrigin` if the iframe URL is a routed/proxied URL whose trusted origin differs. + +For non-React hosts, call `createAgentNativeHostBridge()` directly and pass the same `getContext`, `actions`, and `commands` options. + +## Iframe Side + +Inside the Agent-Native sidecar, use the frame helpers to request host context, discover live browser-session actions, run them, or ask the host to do UI work. Always pass the expected `hostOrigin` in production: + +```ts +import { + announceAgentNativeFrameReady, + createAgentNativeHostTools, + requestAgentNativeHostActions, + requestAgentNativeHostContext, + runAgentNativeHostAction, + sendAgentNativeHostCommand, +} from "@agent-native/core/client"; + +announceAgentNativeFrameReady({ hostOrigin: "https://app.example.com" }); + +const context = await requestAgentNativeHostContext({ + hostOrigin: "https://app.example.com", +}); + +const liveActions = await requestAgentNativeHostActions({ + hostOrigin: "https://app.example.com", +}); + +await runAgentNativeHostAction( + "select-element", + { elementId: context.selection?.ids?.[0] }, + { hostOrigin: "https://app.example.com" }, +); + +await sendAgentNativeHostCommand( + "refreshData", + { queryKey: ["customer", context.resource?.id] }, + { hostOrigin: "https://app.example.com" }, +); + +const hostTools = createAgentNativeHostTools({ + hostOrigin: "https://app.example.com", +}); +``` + +## Server-Mediated Tool Bridge + +For a CLAW-style coworker, the iframe can also register its live browser tab with the sidecar backend. The agent then gets normal backend tools that enqueue a request, the iframe claims it, the host page executes it, and the backend returns the result to the agent. + +In the sidecar app, start the browser-session bridge once when the iframe mounts: + +```tsx +import { useEffect } from "react"; +import { startAgentNativeBrowserSessionBridge } from "@agent-native/core/client"; + +export function SidecarRuntime() { + useEffect(() => { + const bridge = startAgentNativeBrowserSessionBridge({ + hostOrigin: "https://app.example.com", + label: "Builder editor", + }); + return () => bridge.stop(); + }, []); + + return null; +} +``` + +The framework mounts `/_agent-native/browser-sessions` automatically. Once the bridge is running, the sidecar agent can use: + +| Tool | Purpose | +| ------------------------------ | --------------------------------------------------------------- | +| `list-browser-sessions` | See connected host tabs for the current user. | +| `view-browser-session` | Ask a live tab for current page context and screen snapshot. | +| `list-browser-session-actions` | Ask a live tab for current client-side action manifests. | +| `run-browser-session-action` | Run one current client action through the live tab. | +| `send-browser-session-command` | Ask the host to refresh, navigate, remount, reload, or approve. | + +This is the bridge to use when the agent is running on the backend, in Slack/Telegram/email, or as an A2A callee but still needs to touch the user's current browser tab when it is open. If the browser is closed, backend actions should still handle durable work and the browser-session tools will report that no active tab is connected. + +## Actions + +There are two action classes: + +| Action kind | Where it runs | Works when browser is closed? | Best for | +| -------------- | ----------------------------------------------------------- | ----------------------------- | -------------------------------------------------------------------------------------------------------- | +| Backend action | Sidecar app, backend API, MCP, or integration adapter | Yes | Durable work like create, update, publish, sync, send, import. | +| Client action | Current browser tab through `` | No | Ephemeral UI work like select an element, read editor state, scroll to a row, copy current canvas state. | + +Backend actions should be the default for anything that must survive refreshes, closed browsers, retries, or integration-triggered runs. They belong in the sidecar app's normal Agent-Native action/tool layer, where the agent can call them from chat, automations, Slack/Telegram/email integrations, and background jobs. + +Client actions are a live bridge to one browser tab. The host advertises them with `source: "client"` and `availability: "browser-session"`, and the sidecar should treat that manifest as temporary. Re-list actions when route or selection changes, and fall back to backend actions when the tab disappears. + +## Portable Extensions + +The SDK also supports user-defined extensions: sandboxed Alpine.js mini-apps that a host SaaS can render in named slots. Use this when the customer wants to build their own small panels, calculators, dashboards, or workflow helpers against the same action/context surface that the agent uses. + +```tsx +import { + AgentNativeExtensionSlot, + createHttpAgentNativeExtensionStorage, + defineClientAction, +} from "@agent-native/core/client"; + +const storage = createHttpAgentNativeExtensionStorage({ + endpoint: "/api/agent-native/extensions/storage", + headers: () => ({ Authorization: `Bearer ${sessionToken()}` }), +}); + +const actions = [ + defineClientAction({ + name: "list-at-risk-customers", + description: "List customers currently at risk", + schema: { type: "object", properties: {} }, + run: () => crmApi.customers.list({ status: "at-risk" }), + }), +]; + +const customerHealthExtension = { + id: "customer-health", + name: "Customer health", + description: "Shows at-risk customers and quick notes.", + manifest: { + slots: ["crm.customer.sidebar"], + requestedActions: ["list-at-risk-customers"], + requestedCommands: ["openResource", "refreshData"], + storageScopes: ["user", "org"], + }, + content: ` +
+ + +
+ `, +}; + +export function CustomerSidebar({ customer, userExtensions }) { + return ( + ({ + resource: { type: "customer", id: customer.id, name: customer.name }, + })} + commands={{ + refreshData: async () => queryClient.invalidateQueries(), + }} + /> + ); +} +``` + +The manifest is the install contract. When `requestedActions`, `requestedCommands`, or `storageScopes` are present, the SDK enforces them in the host before an iframe request reaches the action bridge or storage adapter. When `slots` is present, `AgentNativeExtensionSlot` only renders the extension in matching slots. Hosts can still override policy per slot with `allowedActions`, `allowedCommands`, and `allowedStorageScopes`. + +An extension is plain HTML. The iframe runtime provides the same safe bridge primitives to the mini-app: + +```html +
+ +
+``` + +Available globals inside the iframe: + +| Helper | Purpose | +| ------------------------------ | ------------------------------------------------------ | +| `appAction(name, args)` | Run a host-declared action. | +| `agentNative.context()` | Read current host page, resource, slot, and user data. | +| `agentNative.command(name, p)` | Ask the host to navigate, refresh, remount, or open. | +| `agentNative.refresh(payload)` | Shortcut for `refreshData`. | +| `extensionData.*` | Persist extension-local data through the host adapter. | + +By default, `extensionData` uses browser `localStorage`, which is useful for prototypes and local widgets. Production SaaS hosts should pass a backend-backed `storage` adapter so user and org scoped extension data is durable, auditable, and governed by the app's permissions. The generic HTTP adapter sends POST bodies like `{ operation, extensionId, slotId, collection, id, data, options, context }` and expects either `{ result }` or the result JSON directly. + +This portable SDK layer is separate from the framework's built-in SQL-backed extension store. In an Agent-Native app, use the existing `ExtensionSlot`/`EmbeddedExtension` components and the `create-extension` action. In a hosted SaaS embedding scenario, prefer `createAgentNativeEmbeddedPlugin()` plus `AgentNativeEmbedded` when you want Agent-Native to manage extension definitions, approval, storage, and agent-created extensions out of the box. Use `AgentNativeExtensionSlot` only when the SaaS already owns extension definitions, approval, marketplace, storage, and billing. + +Security model: + +- Extension iframes are sandboxed without `allow-same-origin`; the mini-app cannot read the parent DOM, cookies, or app runtime directly. +- Extensions can only call the actions and commands allowed by the host and extension manifest. +- Risky actions should set `destructive` or `requiresApproval` so the host can show an approval flow. +- Treat user-authored extension HTML as untrusted. Review marketplace installs, log action usage, and scope backend storage by user/org. + +## Sessions And Tabs + +The host bridge is scoped to one iframe/host-window pair. If the same user opens multiple tabs, each tab has its own `session`, context, selection, client actions, and pending command responses. Do not assume a client action discovered in one tab can run in another tab, or that it will still exist after navigation. + +For multi-tab products, keep durable state in SQL/backend actions and use client actions only for the tab-local parts: focusing a row, copying visible editor state, selecting a canvas element, or refreshing the current React Query cache. Include enough `route`, `resource`, and `selection` context for the sidecar to decide whether the current tab is the right place to run a browser-session action. + +## Command Model + +Built-in command names are deliberately app-shaped, not database-shaped: + +| Command | Purpose | +| -------------------------------------- | ---------------------------------------------------------------------- | +| `navigate` | Move the host UI to a path/view/resource. | +| `refreshData` / `refresh-data` | Ask the host to invalidate client-side data. | +| `remountView` / `remount-view` | Ask the host to remount a subtree, e.g. ``. | +| `hardReload` / `hard-reload` | Full browser reload. | +| `openResource` / `open-resource` | Open a specific domain object in the host UI. | +| `requestApproval` / `request-approval` | Ask the host to show a confirmation flow. Register a handler for this. | + +If no handler is provided, safe defaults dispatch browser events like `agentNative:refresh-data` and `agentNative:remount-view`. `requestApproval` has no default handler; register one before relying on it. + +## Approval Guidance + +Mark risky client actions with `destructive: true` in their manifest and require host approval before running operations that delete, publish, send, charge, invite, share, or otherwise affect users outside the current view. Backend actions should enforce their own authorization and approval checks too; host approval is useful UX, not the security boundary. + +Prefer this shape: + +- Durable mutation runs in a backend action with validation, auth, audit logging, and retries. +- Host command opens an approval UI or focuses the affected resource. +- Client action handles only the live UI step that cannot happen on the backend. + +## Runtime Integration + +Use `createAgentNativeHostTools()` inside the sidecar iframe when your agent runtime accepts plain tool descriptors. It returns four framework-agnostic tools: + +| Tool | Purpose | +| ------------------- | ------------------------------------------------------------------- | +| `view-host-screen` | Read semantic host context and screen snapshot. | +| `list-host-actions` | List live browser-session actions exposed by the current tab. | +| `run-host-action` | Run one live client action by name. | +| `send-host-command` | Send host commands such as refresh, navigate, remount, or approval. | + +The helper intentionally returns plain `{ name, description, parameters, execute }` objects so sidecars can adapt them to the AI SDK, Anthropic, OpenAI function calling, or Agent-Native `ActionEntry` shape without coupling this SDK to one runtime. + +## Recommended Product Shape + +Start iframe-first. It works for Builder.io, customer SaaS apps, and internal admin tools without coupling release cycles or CSS/runtime assumptions. + +The sidecar itself should still be an Agent-Native app/template: actions are the backend API surface, SQL-backed app state is the agent's memory, and integrations such as Slack or Telegram can route into the same durable chat. The embedding SDK supplies the live membrane between that sidecar and the current host page. diff --git a/packages/core/docs/content/faq.md b/packages/core/docs/content/faq.md index f319bd277..2211361cd 100644 --- a/packages/core/docs/content/faq.md +++ b/packages/core/docs/content/faq.md @@ -62,6 +62,7 @@ The framework ships with production-ready templates you can use as daily drivers - **[Calendar](/templates/calendar)** — Google Calendar + Calendly-style booking links - **[Content](/templates/content)** — Notion-style documents +- **[Brain](/templates/brain)** — full-page company chat, cited memory, sources, and review queue - **[Slides](/templates/slides)** — presentation builder - **[Analytics](/templates/analytics)** — data platform (like Amplitude/Mixpanel) - **[Mail](/templates/mail)** — full-featured email client (like Superhuman) diff --git a/packages/core/docs/content/getting-started.md b/packages/core/docs/content/getting-started.md index c0c690315..3176f1a8c 100644 --- a/packages/core/docs/content/getting-started.md +++ b/packages/core/docs/content/getting-started.md @@ -95,6 +95,7 @@ Each template is a complete app with UI, agent actions, database schema, and AI | ----------------------------------- | ------------------------------------------------ | | [Calendar](/templates/calendar) | Google Calendar, Calendly | | [Content](/templates/content) | Notion, Google Docs | +| [Brain](/templates/brain) | Company chat, cited memory, and review queue | | [Slides](/templates/slides) | Google Slides, Pitch | | [Analytics](/templates/analytics) | Amplitude, Mixpanel, Looker | | [Mail](/templates/mail) | Superhuman, Gmail | diff --git a/packages/core/docs/content/mcp-clients.md b/packages/core/docs/content/mcp-clients.md index 4a93999c7..6ecfc7b23 100644 --- a/packages/core/docs/content/mcp-clients.md +++ b/packages/core/docs/content/mcp-clients.md @@ -7,10 +7,48 @@ description: "Connect your agent-native app to local MCP servers (claude-in-chro Agent-native apps can also act as MCP **clients** — connecting to locally installed MCP servers and exposing their tools to the agent chat. This is the symmetric counterpart to the [MCP Protocol](./mcp-protocol.md) (which makes your app an MCP server). -With one config file, every agent-native app in your workspace gains access to tools provided by MCP servers on your machine: `claude-in-chrome` for browser automation, `@modelcontextprotocol/server-filesystem` for reading files, `@modelcontextprotocol/server-playwright` for browser testing, and anything else that speaks MCP. +With one config file, every agent-native app in your workspace gains access to tools provided by MCP servers on your machine: `claude-in-chrome` for browser automation, `@modelcontextprotocol/server-filesystem` for reading files, `@playwright/mcp` for browser testing, and anything else that speaks MCP. You can also [connect remote (HTTP) MCP servers at runtime](#remote-via-ui) — individual users or whole organizations — without editing a config file. +## Built-in browser and computer-use capabilities {#built-in-capabilities} + +Agent-native includes built-in toggles for common local MCP servers. They are off by default and can be enabled per user or per organization: + +| Capability | Server id | Command | +| ------------------ | ----------------- | ----------------------------------------------------------------------- | +| Chrome DevTools | `chrome-devtools` | `npx -y chrome-devtools-mcp@0.26.0 --autoConnect --no-usage-statistics` | +| Playwright Browser | `playwright` | `npx -y @playwright/mcp@0.0.75` | +| Computer Use | `computer-use` | `npx -y computer-use-mcp@1.8.0` | + +Only one browser capability can be enabled in a scope at a time. Enabling Chrome DevTools disables Playwright for that same user or org, and enabling Playwright disables Chrome DevTools. + +Computer Use is macOS-only. On other platforms it is listed as unavailable and is skipped even if an old setting row contains it. + +Chrome DevTools uses `--autoConnect` by default. That attaches to an eligible running Chrome instance; it does not create an isolated browser profile or sign into the user's regular profile for you. It requires Chrome 144+ with remote debugging enabled. A manual `browser-url` configuration can be added later when a deployment needs a specific debugging endpoint. + +Built-ins are persisted in the framework's `settings` table under `u::mcp-builtin-capabilities` for personal toggles and `o::mcp-builtin-capabilities` for team toggles. When enabled, they merge into the runtime MCP manager with the same scoped visibility format as remote servers, for example `mcp__user__playwright__*` or `mcp__org__chrome-devtools__*`. + +### User-facing setup notes + +Use concise, explicit setup copy for the sensitive built-ins: + +- **Chrome DevTools** attaches to a running Chrome debugging target. Tell users + it is intended for browser testing and logged-in verification, and that it + may require enabling Chrome remote debugging before tools appear. +- **Playwright** launches an isolated browser. Recommend it for deterministic + QA when the user's live Chrome profile is not required. +- **Computer Use** can operate local apps. Keep it off by default, explain the + macOS Screen Recording and Accessibility prompts, and ask before taking + sensitive actions such as purchases, financial changes, or account changes. + +### Built-in endpoints + +| Method | Route | Purpose | +| ------ | ---------------------------- | -------------------------------------------------------------------------- | +| GET | `/_agent-native/mcp/builtin` | List built-in capabilities, enabled scopes, merged ids, and live status. | +| POST | `/_agent-native/mcp/builtin` | Update a scope. Body: `{ scope, enabledIds }` or `{ scope, id, enabled }`. | + ## Adding a local MCP server {#adding-a-server} Create `mcp.config.json` at your workspace root (or at an individual app root — workspace root wins when both exist): @@ -26,7 +64,7 @@ Create `mcp.config.json` at your workspace root (or at an individual app root }, "playwright": { "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-playwright"], + "args": ["-y", "@playwright/mcp@0.0.75"], }, "filesystem": { "command": "npx", @@ -64,7 +102,7 @@ MCP configuration is resolved in this order, first match wins: For production deploys set the full config shape (or the inner server map) as an environment variable: ```bash -MCP_SERVERS='{"servers":{"playwright":{"command":"npx","args":["-y","@modelcontextprotocol/server-playwright"]}}}' +MCP_SERVERS='{"servers":{"playwright":{"command":"npx","args":["-y","@playwright/mcp@0.0.75"]}}}' ``` MCP tools only activate in Node runtimes — Cloudflare Workers and other edge targets silently skip MCP and continue with the rest of the app working normally. diff --git a/packages/core/docs/content/migration-workbench.md b/packages/core/docs/content/migration-workbench.md index 4ed6f6cbd..94a522530 100644 --- a/packages/core/docs/content/migration-workbench.md +++ b/packages/core/docs/content/migration-workbench.md @@ -1,102 +1,283 @@ --- -title: "Migration Workbench" -description: "Use a local agent-native Workbench to migrate existing apps into agent-native with assessment, approval, generated output, and verification." +title: "Agent-Native Code Workspace and /migrate" +description: "Use the open-source Agent-Native Code workspace for coding sessions, including the built-in /migrate capability." --- -# Migration Workbench +# Agent-Native Code Workspace and /migrate -Migration Workbench is a local agent-native app for moving existing applications into the agent-native framework. It is designed for migrations where an agent can do useful work, but every important step should be auditable and verified. +Start from **Agent-Native Code**: -The product promise is: **let the agent run, but prove it**. +```bash +npx @agent-native/core@latest +npx @agent-native/core@latest "fix the failing auth tests" +npx @agent-native/core@latest code +npx @agent-native/core@latest code "fix the failing auth tests" +npx @agent-native/core@latest code attach --last +npx @agent-native/core@latest code /migrate ./my-next-app --out ../migrated-app +``` + +**Agent-Native Code** is the open-source Claude Code/Codex-like workspace for coding work in Agent-Native. `agent-native` or `agent-native code` launches it with no prompt required, and a bare prompt starts a generic coding task directly. `/migrate` is one built-in capability for moving an existing app, URL, or described product into agent-native. It uses the same session store, transcript, and desktop hub as the CLI `code` command, so migration behaves like a goal you can resume, attach to, inspect, and stop rather than a separate one-off product. + +By default `/migrate` creates a generic Agent-Native Code session plus a portable migration dossier. Migration is a slash command in the Code workspace, not a normal template to scaffold. The hidden `migration` app is now a legacy/internal detail surface, available with `--app-surface` when a run needs a richer assessment/approval/task/verifier dashboard. + +The direct `migrate` command remains a shortcut into the same goal: + +```bash +npx @agent-native/core@latest migrate ./my-next-app --out ../migrated-app +``` + +Both forms print the same handoff: run id, source, output, dossier directory, +important artifact files, and the exact Agent-Native Code commands to inspect or +resume the session: + +```bash +npx @agent-native/core@latest code attach --last +npx @agent-native/core@latest code logs --last +npx @agent-native/core@latest code resume --last +npx @agent-native/core@latest code status --last +``` + +## Code Workspace + +`agent-native code` opens the interactive Agent-Native Code shell for coding-agent work. You do not need to pass an initial prompt: + +```bash +npx @agent-native/core@latest code +``` + +Inside the shell, type a task or use slash goals as commands: + +```text +code> fix the failing auth tests +code> /migrate ./my-next-app --out ../migrated-app +code> /audit --url https://example.com +``` + +The same goals can run directly from the command line: + +```bash +npx @agent-native/core@latest "fix the failing auth tests" +npx @agent-native/core@latest code "fix the failing auth tests" +npx @agent-native/core@latest code exec "fix the failing auth tests" +npx @agent-native/core@latest code -p "fix the failing auth tests" +npx @agent-native/core@latest code --plan "explain the failing auth tests" +npx @agent-native/core@latest code --auto "fix the failing auth tests" +npx @agent-native/core@latest code /migrate ./my-next-app --out ../migrated-app +npx @agent-native/core@latest code /audit --url https://example.com +``` + +Run `agent-native code goals` to see the goals registered in your checkout. A bare prompt starts a local coding-agent session for open-ended code work, streams the run, records transcript/status/tool events, and accepts follow-up prompts through the same run record. + +Bare `agent-native` launches the Agent-Native Code workspace in this branch, and `agent-native "prompt"` starts a generic Agent-Native Code task directly, matching the Codex/Claude Code habit of treating unknown text as a coding prompt. If an installed version does not include that top-level entrypoint yet, run `agent-native code` directly. + +## Sessions and Modes + +The next Agent-Native Code follow-up features make the workspace feel like a local Codex/Claude Code session manager instead of a one-shot command. The CLI and Desktop hub share the same run store, so you can start work in one place and continue it in the other: + +```bash +npx @agent-native/core@latest code list +npx @agent-native/core@latest code attach --last +npx @agent-native/core@latest code logs --last +npx @agent-native/core@latest code approve --last +npx @agent-native/core@latest code resume --last +npx @agent-native/core@latest code resume --last "check the auth edge cases next" +``` + +`list` shows previous and active sessions for the current workspace. `attach` follows a live transcript. `logs` prints the transcript once. `resume` reopens a session with its prior context, and a quoted resume prompt records the next instruction against that same run. If a high-risk command pauses for approval, `approve --last` runs that one pending command and then points you back to resume the session. Desktop adds the visual session picker on top of the same data: choose a run, inspect status and tool events, then attach, resume, stop, or open the run workspace. + +Run modes make editing policy explicit per session: + +| Mode | CLI flag | Behavior | +| ------------- | -------- | -------------------------------------------------------------------------------------------------------- | +| **Plan mode** | `--plan` | Inspect, plan, and explain without writing files or running mutations. | +| **Auto mode** | `--auto` | Edit files, run checks, and pause only for genuinely destructive file, git, publish, or data operations. | + +Auto mode is the default for local Agent-Native Code sessions. Use Plan mode for assessment, architecture, review, or any task where you want a proposal before edits. + +## Project Slash Commands + +Built-in slash goals such as `/migrate` and `/audit` are framework commands. Projects can also define custom commands in `.agents/commands/*.md` using the same npx-first workflow: + +```bash +npx @agent-native/core@latest code /release-check +npx @agent-native/core@latest code /migrate-storefront ./legacy-shop --out ../agent-shop +``` + +Each Markdown file names the command and contains the prompt/instructions the Agent-Native Code should run. This keeps team-specific workflows close to the repository: release checks, migration variants, framework upgrade playbooks, security audits, or customer-specific handoffs can be versioned without adding code. Source-specific systems such as AEM or Builder.io should stay as optional instruction-pack examples inside those commands, not top-level migration assumptions. + +## Input Shapes + +Use a local source path when you have code: + +```bash +npx @agent-native/core@latest code /migrate ./my-next-app --out ../migrated-app +``` + +Use a URL when the first artifact is a live site or product surface: + +```bash +npx @agent-native/core@latest code /migrate https://example.com --describe "marketing site plus logged-in dashboard" +``` + +Use a description when the migration starts from requirements, screenshots, or a handoff brief: + +```bash +npx @agent-native/core@latest code /migrate --describe "A Rails admin app with reports, approvals, and CSV imports" --emit +``` -V1 focuses on **Next.js to standalone agent-native**. Builder.io Publish and AEM exits are designed into the adapter interfaces, but are intended as follow-on enterprise adapters rather than the first shipped path. +For local paths, the source is read-only. Generated output must live outside the source tree. -## How It Works +## Internal Run Surface -Run: +The normal command creates a generic Agent-Native Code session and writes artifacts under the Agent-Native Code run store. It does **not** scaffold an app/template. + +Open the legacy hidden `migration` detail surface only when you explicitly want that richer dashboard: ```bash -agent-native migrate ./my-next-app --out ../migrated-app +npx @agent-native/core@latest code /migrate ./my-next-app --app-surface +cd migration +pnpm install +pnpm dev +``` + +The local dev URL is printed by Vite. In first-party dev setups it is usually: + +```text +http://localhost:8101/ ``` -The command scaffolds the hidden `migration` template and writes a seed file with the source and output paths. The Workbench UI then guides the run: +Inside that optional internal surface, the flow is: -1. **Discover** reads the source project and creates `01-assessment.md`. +1. **Discover** reads the source and creates `01-assessment.md`. 2. **Plan** creates recipe tasks and writes `02-plan.md` plus `03-tasks.md`. 3. **Approve** unlocks generated output writes. 4. **Sweep** runs migration tasks against the generated output project. 5. **Verify** runs deterministic checks and writes `04-report.md`. -The source project is read-only. Generated output is written to a separate `outputRoot`. +Useful CLI helpers: -## Agent-Native Mapping +```bash +npx @agent-native/core@latest code status --last +npx @agent-native/core@latest code list +npx @agent-native/core@latest code attach --last +npx @agent-native/core@latest code logs --last +npx @agent-native/core@latest code approve --last +npx @agent-native/core@latest code resume --last +npx @agent-native/core@latest code --continue "check the auth edge cases next" +npx @agent-native/core@latest code resume --last "check the auth edge cases next" +npx @agent-native/core@latest code ui --last +npx @agent-native/core@latest code stop --last +``` -The V1 recipes are named after the framework contracts they enforce: +`attach --last` follows a live transcript until the run reaches a terminal state, while `logs --last` prints the transcript once. `resume --last` reopens the latest run handoff. Passing a quoted prompt, or using `--continue "prompt"`, records it as a follow-up transcript event and immediately runs that follow-up against the same session context for executable coding sessions. `approve --last` is intentionally narrow: it only runs the pending approved command for a session that paused on a high-risk command, then tells you to resume. -| Source pattern | Agent-native target | -| --------------------------- | ----------------------------------------------------------------- | -| API routes / server actions | `actions/`, except uploads, webhooks, OAuth, and streaming routes | -| app-owned data | Drizzle SQL tables plus actions | -| direct LLM calls | agent chat delegation | -| important client state | `application_state` navigation and selection | -| UI mutations | optimistic action mutations | -| shared resources | ownership, sharing, and access helpers | -| public pages | server rendering | -| logged-in workflows | persistent client app shell | +`stop` marks the run paused and sends SIGTERM when the run has a tracked Desktop/CLI runner process id. If the active work belongs to another terminal or external agent, stop that owner directly. -This is the difference between porting React code and actually migrating to agent-native. +## Long-Running Goals + +The `/migrate` goal has an action named `run-migration-goal`. It advances a run in bounded iterations: + +- before approval, it can assess and plan but cannot write generated output +- after approval, it scaffolds once, advances pending tasks, verifies, and records verifier results +- if verification fails, the critic policy returns `retry-with-more-context`, `tune-recipe`, `manual-decision-needed`, `rollback-generated-output`, or `accept` + +That gives the flow Claude Code `/goal`-style semantics without making migration a one-shot rewrite. The app state and disk artifacts let you resume after restarts, long pauses, or manual decisions. + +## Credentials + +The `/migrate` goal reuses the same credentials system as agent-native. There is no migration-specific key store and no `MIGRATION_*` secret namespace. + +In Agent-Native Code, Desktop, or the internal run surface, connect providers through the normal settings and onboarding surfaces. For headless CLI use, existing provider environment variables are detected, including `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GOOGLE_GENERATIVE_AI_API_KEY`, and other provider env vars supported by the framework. Secret values are never copied into migration artifacts. + +## Agent-Native Code + +Agent-Native Desktop includes a **Agent-Native Code** hub for long-running coding-agent sessions. It is the general Code app/surface in Desktop, and it pairs with the `agent-native code` shell as the primary CLI/Desktop coding experience. A bare prompt is the generic coding session, and `/migrate` is one specialized capability there: the hub shows recent and active runs, opens a transcript-first session view, renders tool events and artifacts, sends follow-up prompts, stops tracked runners, opens a terminal in the run workspace, and handles links like: + +```text +agentnative://open?goal=migrate&run= +``` + +The legacy app-style deep link still works and opens the internal run detail surface: + +```text +agentnative://open?app=migration&run= +``` + +The hub also includes `/audit`, a lightweight native goal backed by `agent-native audit-agent-web`, to keep the shell honest about more than one goal: + +```bash +npx @agent-native/core@latest code /audit --url https://example.com +``` -## Adapter Model +The hub exposes the same generic run controls the CLI does: the session picker opens past runs, `resume` opens the goal surface or reattaches to the run, a quoted resume prompt records and executes follow-up feedback for executable goals, status refreshes the run list, and stop reports or stops the owning process when one is known. Browser/Desktop approval remains the trust gate for generated output writes. Future coding goals can reuse the same CLI and desktop shell by registering another slash goal or a project command under `.agents/commands/*.md`. -`@agent-native/migrate` exposes a reusable engine: +## Emit Mode -- `SourceAdapter` detects and inventories existing projects. -- `TargetAdapter` scaffolds and verifies output. -- `MigrationRecipe` turns IR graph inventory into tasks. -- `Verifier` returns structured migration evidence. +Use `--emit` when you want Codex, Claude Code, another code agent, or Agent-Native Desktop to do the next phase without opening the internal run surface: -The intermediate representation is split into four graphs: +```bash +npx @agent-native/core@latest code /migrate ./my-next-app --emit ../migration-dossier +``` -- `SiteGraph`: routes, redirects, public/private classification, metadata. -- `ComponentGraph`: reusable UI components and design tokens. -- `ContentGraph`: CMS models, static content, and assets. -- `BehaviorGraph`: API endpoints, data stores, auth, jobs, client state, and LLM calls. +The dossier is always written outside `sourceRoot`. It includes: -## Builder.io And AEM +- `AGENTS.md` with migration-specific instructions +- `.agents/skills/migration*/SKILL.md` when migration skills are available from the template +- `MIGRATION_PLAYBOOK.md` +- `01-assessment.md` +- `ir.json` when file-level inventory is available -Builder.io is a target decision, not a source assumption. Builder Publish should be used for marketing, docs, landing, and content surfaces. Transactional SaaS state, dashboards, app-owned data, and workflows stay in agent-native SQL/actions. +Hand the dossier to your preferred coding agent with a prompt like: -AEM support should be implemented as a source adapter family: +```text +Use this migration dossier. Follow AGENTS.md and MIGRATION_PLAYBOOK.md, keep the source read-only, write the agent-native output outside the source tree, and record verification evidence before calling the migration complete. +``` -- `crawl`: URLs, sitemap, screenshots, SEO, redirects. -- `api`: AEM GraphQL Content Fragments and DAM metadata. -- `package`: Vault/JCR package parsing. -- `code`: HTL components, dialogs, templates, and policies. -- `enterprise`: combines available modes and emits confidence/gap reports. +When `@agent-native/migrate` helpers are installed, `--emit` uses them for Next.js assessment and IR. If they are not available, the CLI falls back to a safe local inventory pass. URL-only and description-only dossiers still include the playbook and assessment, but they do not claim file-level IR until an agent inspects source. -AEM output is two-pipeline: content extraction into Builder or SQL, plus frontend regeneration and component mapping into agent-native UI. +## Instruction Packs -## Verification +The `/migrate` goal is driven by instruction packs instead of one source-specific path. -The default verifier path is deterministic: +| Pack | What it tells the agent to do | +| ---------------- | ------------------------------------------------------------------- | +| Source intake | Normalize path, URL, or prose input into an assessment | +| Agent-native map | Convert operations to actions, SQL, app state, sharing, and SSR | +| Output safety | Keep generated code outside sourceRoot and require approval gates | +| Verification | Use deterministic checks and record manual gaps | +| Platform exits | Add source-specific guidance for systems such as AEM or CMS exports | -- output file smoke checks -- route inventory parity artifacts -- agent-native conformance checks -- future Playwright smoke tests -- future visual, a11y, Lighthouse, SEO, and redirect checks +Builder.io, AEM, crawls, package exports, and CMS APIs are optional instruction-pack concerns, not top-level assumptions. Builder Publish can be a target for marketing, docs, landing, and content surfaces. Transactional SaaS state, dashboards, app-owned data, and workflows stay in agent-native SQL/actions. -AI browser tools can help generate or repair flows, but deterministic Playwright-style checks should remain the truth oracle. +## Agent-Native Mapping + +The recipes are named after the framework contracts they enforce: + +| Source pattern | Agent-native target | +| --------------------------- | ----------------------------------------------------------------- | +| API routes / server actions | `actions/`, except uploads, webhooks, OAuth, and streaming routes | +| app-owned data | Drizzle SQL tables plus actions | +| direct LLM calls | agent chat delegation | +| important client state | `application_state` navigation and selection | +| UI mutations | optimistic action mutations | +| shared resources | ownership, sharing, and access helpers | +| public pages | server rendering | +| logged-in workflows | persistent client app shell | + +This is the difference between porting React code and actually migrating to agent-native. ## Package Exports -Use the engine directly when building adapters or custom migration workflows: +`@agent-native/migrate` exposes a reusable engine for adapters and custom workflows: ```ts import { createMigrationRun, discoverMigration, planMigration, + selectSourceAdapter, + createSkeletonProjectIR, + createBrowserVerifier, nextjsSourceAdapter, agentNativeTargetAdapter, } from "@agent-native/migrate"; @@ -108,3 +289,5 @@ Subpath exports are available for first-party V1 adapters: import { nextjsSourceAdapter } from "@agent-native/migrate/source-nextjs"; import { agentNativeTargetAdapter } from "@agent-native/migrate/target-agent-native"; ``` + +The intermediate representation is split into four graphs: site, components, content, and behavior. Verification starts with deterministic checks and can grow to Playwright, visual, accessibility, Lighthouse, SEO, and redirect checks. diff --git a/packages/core/docs/content/multi-app-workspace.md b/packages/core/docs/content/multi-app-workspace.md index c60377a2e..100b923b6 100644 --- a/packages/core/docs/content/multi-app-workspace.md +++ b/packages/core/docs/content/multi-app-workspace.md @@ -134,6 +134,47 @@ Everything cross-cutting you customize lives in `packages/shared/`. Export an `a Because the shared package is a `workspace:*` dependency, pnpm symlinks it into each app's `node_modules/`. You never build or publish it — the apps bundle whatever they need from it at build time. +## Runtime global resources {#runtime-global-resources} + +Use `packages/shared` for code-level defaults that should ship with the repo: plugins, shared actions, shared React code, filesystem `AGENTS.md`, and filesystem skills. Use Dispatch workspace resources for runtime-editable global context that admins want to manage without a code change. + +Dispatch resources support two scopes: + +- **All apps** — global resources for every app in the workspace. Dispatch stores them once at workspace scope and every app agent inherits them at runtime; no copy or sync step is required. +- **Selected apps** — resources granted per app for app-specific context. Use these sparingly; most company, brand, persona, positioning, messaging, and guardrail context should be All apps. + +Canonical paths control behavior: + +| Runtime resource | Path | How agents use it | +| ----------------------- | --------------------------------------- | ----------------------------------------------- | +| Guardrail instructions | `AGENTS.md` or `instructions/.md` | Loaded every turn in every app that receives it | +| Global skills | `skills//SKILL.md` | Listed as workspace skills and read on demand | +| Brand/company resources | `context/.md` | Indexed every turn, read when relevant | +| Custom agent profiles | `agents/.md` | Available as reusable local agent profiles | + +This is the right home for core personas, positioning, messaging, company facts, brand guidelines, support policies, or other shared knowledge that many apps should benefit from. + +For a starter workspace, create these Dispatch resources and scope them to **All apps**: + +```text +context/company.md # company overview, ICP, products, canonical links +context/brand.md # brand voice, visual identity, spelling, terms to avoid +context/messaging.md # value props, product pillars, proof points, objections +instructions/guardrails.md # rules that must be loaded every turn +skills/company-voice/SKILL.md # copywriting and review workflow for brand voice +``` + +Example `skills/company-voice/SKILL.md`: + +```markdown +--- +name: company-voice +description: Rewrite or review customer-facing copy using the workspace brand and messaging resources. +--- + +Before writing, read `context/brand.md` and `context/messaging.md`. Keep claims grounded in those resources, preserve required terminology, and flag missing proof instead of inventing it. +``` + ## Authentication and RBAC {#auth-and-rbac} Every agent-native app already ships with [Better Auth](/docs/authentication) and its organizations plugin — users, organizations, members, and the `owner` / `admin` / `member` roles are all first-class, shared across every template. In a workspace, you get that for free in every app, backed by the same database. diff --git a/packages/core/docs/content/multi-tenancy.md b/packages/core/docs/content/multi-tenancy.md index 41162a1ba..f2c5d7015 100644 --- a/packages/core/docs/content/multi-tenancy.md +++ b/packages/core/docs/content/multi-tenancy.md @@ -16,7 +16,7 @@ The framework uses [Better Auth](https://better-auth.com)'s organizations plugin - **Active organization** — the session tracks which org the user is currently working in (`session.orgId`). Switching orgs changes the data they see. - **Data isolation** — SQL queries are automatically scoped to the active org via `org_id` columns. Data tagged with one org is invisible to users in another org, including the agent. -All first-party templates (Mail, Calendar, Content, Slides, Video, Analytics) are multi-tenant out of the box. If you're building on any of these, your app already supports teams with no extra work. +All first-party templates (Mail, Calendar, Content, Brain, Slides, Video, Analytics, Clips, Design, Forms, and Dispatch) are multi-tenant out of the box. If you're building on any of these, your app already supports teams with no extra work. ## Organizations and members {#organizations-and-members} diff --git a/packages/core/docs/content/template-brain.md b/packages/core/docs/content/template-brain.md new file mode 100644 index 000000000..2a180f377 --- /dev/null +++ b/packages/core/docs/content/template-brain.md @@ -0,0 +1,418 @@ +--- +title: "Brain" +description: "A public first-party template for cited company memory, reviewable source ingestion, and the path toward universal workspace search." +--- + +# Brain + +Brain is a public first-party template for Company Brain: cited institutional +memory that agents and humans can search without pretending raw workplace data +is already clean, complete, or safe to publish. The first-run surface is a +full-page company chat with demo and eval controls, source health, review +counts, and cited answers from approved company knowledge. + +Brain ingests approved Slack channels, Clips recordings, Granola Team-space +notes, GitHub issues/PRs, and generic transcript/webhook payloads. It stores raw +captures, distills durable facts/decisions/processes, and routes sensitive or +low-confidence memories through review before they become company knowledge. + +Use Brain when your team wants agents to answer questions like "why did we make +this product decision?", "how does this in-development feature work?", or "what +changed in this process?" with links back to the source conversation, meeting, +or issue. + +Brain is intentionally on an open-source, Glean-shaped path, but it is not a +complete Glean replacement today. V1 is cited company memory over reviewed +knowledge. V1.5 adds universal Brain search across knowledge, captures, and +sources, plus reusable workspace connections for source credentials. V2 points +toward federated app/source search, permission-aware result filtering, ranking, +and an expertise graph as a future platform layer. + +## What It Includes + +- **Full-page company chat.** The Ask route is the main product surface. It + shows a compact demo CTA, source health, review count, and suggested + company-memory questions. It uses `AgentChatSurface`, so the Brain composer + stays on the same shared chat input stack as the agent sidebar and + Agent-Native Code. +- **Repeatable demo flow.** Load a product-decision corpus, run the demo eval, + ask a cited question immediately, then continue into Review or Knowledge so a + new workspace can see the trust loop before connecting real sources. +- **Approved sources.** Configure manual, generic webhook, Clips, Slack, + Granola, and GitHub source records. Slack is channel-oriented by design; DMs + and MPIMs are not scan targets. +- **Raw captures.** Store transcripts, channel exports, notes, and webhook imports in portable SQL with dedupe keys and source metadata. +- **Distilled knowledge.** Write atomic entries with kind, topic, entities, confidence, exact evidence quotes, and supersede links. +- **Review queue.** Proposed company memories have a first-class Review route + where reviewers edit wording, inspect evidence/source links, approve, or + reject. Reviewers can also choose whether an approved proposal becomes + canonical company context immediately. +- **Review gating.** High-confidence non-sensitive entries can publish immediately; company-tier or sensitive entries can queue as proposals for approval. +- **Cited retrieval.** V1 exposes `search-knowledge` and `get-knowledge` for + distilled company memory. The V1.5 expansion adds a Search route and + `search-everything` action for searching knowledge, raw captures, and source + records together, then drilling into `get-knowledge` / `get-capture`. +- **Pilot and Ops controls.** Slack pilots stay bounded by default, `get-pilot-report` summarizes source quality without raw bodies, and the Ops route tracks stale or failed distillation queue items with safe retry controls. +- **Shared integrations.** The Sources page shows Brain source records beside + reusable workspace connection grants and provider readiness, so Brain can use + Dispatch/workspace-managed credentials when a grant exists. +- **Ambient context.** Canonical approved entries can mirror into workspace + resources under `context/company-brain/...` for cross-app context. The Review + route exposes this as a per-proposal switch; the Knowledge route can publish + or unpublish approved memories later with `set-knowledge-canonical`. Both + flows preview the exact Markdown through `preview-canonical-resource` before + the resource is written or removed. + +Brain intentionally uses SQL text search and agentic query expansion for v1. +There is no vector database requirement, so the template stays portable across +SQLite, Postgres, Neon, D1, Turso, and similar hosts. Raw capture content is +redacted by default in review/search surfaces; editor-authorized distillation +can request exact raw text for quote validation. + +## Search Model + +Brain search has three layers: + +- **V1 Company Brain search:** answer from reviewed, distilled knowledge first. + This is the trust layer for decisions, policies, product facts, processes, + and durable summaries. +- **V1.5 universal Brain search:** use `search-everything` as the broad first + pass across knowledge, raw captures, and sources. Then call `get-knowledge` + for reviewed entries or `get-capture` for exact source context and links. +- **V2 federated workspace search:** reuse workspace connections and search + across apps/sources with permission-aware result filtering and ranking. The + expertise graph belongs to this future/platform layer. + +Agents should cite evidence links or source URLs whenever available. If Brain +does not return support for a question, the agent should report that honestly +instead of implying the company memory contains an answer. + +## Brain vs Dispatch + +Brain and Dispatch are complementary, but they do different jobs: + +- **Brain owns company memory.** It ingests sources, reviews raw captures, + distills durable facts/decisions/processes, answers from cited evidence, and + exposes approved knowledge to agents. +- **Dispatch owns the workspace control plane.** It centralizes messaging, + secrets, recurring jobs, approvals, A2A orchestration, and workspace-wide + resources. + +In a multi-app workspace, Dispatch can route a question to Brain over A2A and +can grant Brain shared provider credentials. Brain remains the specialist for +approved source ingestion, review, retrieval, and cited Company Brain answers. + +## Scaffolding + +```bash +pnpm dlx @agent-native/core create my-brain --template brain --standalone +``` + +Then open the app, add sources, import a transcript, and ask the agent to distill cited memories from the raw capture. + +For a public demo, open the Ask page and choose **Start demo**. Brain seeds the +product-decision corpus, runs the demo eval, asks the cited freemium question, +then offers Review and Knowledge follow-ups. The seeded corpus demonstrates +product-decision recall, citation links, supersede behavior, review gating, +redaction, personal-content exclusion, and honest not-found behavior without +connecting a real workspace. + +## Generic Ingest + +Brain exposes a signed webhook for Clips and generic transcript/capture imports +at: + +```txt +/api/_agent-native/brain/ingest +``` + +Create a source with a `sourceKey` to receive a bearer token, then send a `RawCapturePayload`: + +```json +{ + "sourceKey": "clips", + "externalId": "meeting-123", + "title": "Pricing decision review", + "participants": ["Ada", "Grace"], + "occurredAt": "2026-05-15T15:00:00.000Z", + "transcript": "We decided to keep annual pricing because...", + "sourceUrl": "https://example.com/share/meeting-123", + "tags": ["pricing", "product"], + "raw": {} +} +``` + +Set `Authorization: Bearer ` on the request. Clips can export to +that endpoint without Brain reading the Clips database directly. Generic sources +use the same payload shape for call transcripts, customer research, imported +notes, or any other source that can produce a bounded capture. + +## Slack Backfill + +Brain resolves `SLACK_BOT_TOKEN` from a granted Slack workspace connection +first, then from backward-compatible Brain-local or registered vault +credentials. It scans only channels that an admin configures on the source: + +```bash +pnpm --filter brain action create-source \ + --title "Slack product channels" \ + --provider slack \ + --visibility org \ + --config '{"channelIds":["C0123456789"],"historyLimit":15}' +``` + +The connector verifies each configured conversation before reading history and +rejects DMs and MPIMs. Cursor state is stored on the source so each sync can pick +up where the last one stopped, including after Slack rate limiting. + +Use `test-slack-connection` before a production backfill. It validates the +Slack bot token with `auth.test` and, when channel refs are provided, checks +channel metadata without reading message history. + +For Slack, grant the bot the smallest scopes needed for the source: + +- `auth.test` for credential validation. +- `conversations.info` for allow-list verification and DM/MPIM rejection. +- `conversations.history` for allow-listed channel history. +- `chat.getPermalink` for durable citations. +- `conversations.list` only when setup resolves channel names instead of IDs. + +Private channels require inviting the bot to the channel. Public channels may +also require joining or inviting the bot depending on the Slack app posture. + +For local CLI/action-runner QA, put `SLACK_BOT_TOKEN` in a workspace connection, +registered vault secret, or Brain-local app credential before running source +actions. Brain source connectors intentionally do not read process environment +variables directly, so `.env.local` alone is not a credential source. + +Use `run-slack-pilot` for a safer first-pass rollout report. The default action +validates the Slack credential and allow-listed channels, reports guardrails, +privacy exclusions, current knowledge/proposal counts, and next steps, and does +not call `conversations.history`. Only pass `readHistory: true` when the user +explicitly wants a tiny sample sync; the pilot caps the read to two validated +channels, one page per channel, ten messages per page, ten permalinks, +`autoSync: false`, and a recent default history window. + +After a sample sync succeeds, list the imported inventory before opening raw +message bodies: + +```bash +pnpm --filter brain action list-captures \ + --sourceId \ + --status queued +``` + +The listing omits raw capture content by default and includes each capture's +latest distillation queue state. Use `get-capture` for one specific record when +a reviewer or agent needs exact source context, then write only durable, cited +knowledge. Keep `autoSync` disabled until the channel allow-list, review gate, +and first distilled entries are validated. + +The Sources UI has the same flow: open **Captures** on a source card to review +queued records, opt into short previews only when needed, queue distillation, +see whether a capture is waiting on the distillation worker, or mark non-company +material ignored. + +Slack source cards expose this as a clean rollout flow: **Test** checks the +credential and allow-list without history reads, **Safe pilot** imports only a +tiny capped sample, **Review captures** opens the capture inventory, and +**Review queue** sends reviewers to approve proposals before they become +queryable company memory. + +Use `get-pilot-report` after a sample sync to inspect sync health, capture +counts, queue state, published knowledge, pending proposals, privacy notes, and +recommended rollout steps without returning raw capture bodies. + +Recommended production rollout: + +1. Start with one or two high-signal channels and channel IDs. +2. Keep `autoSync: false` until review quality is proven. +3. Run `test-slack-connection`, then `run-slack-pilot` without history. +4. Run one explicit `run-slack-pilot --readHistory true` sample when the report + is clean. +5. Review captures with previews only when needed; ignore social, personal, or + thin records. +6. Distill durable company context, approve proposal-gated memories, and verify + `ask-brain` returns cited Slack permalinks. +7. Expand with bounded manual `sync-source` runs before enabling background + polling. + +When approving a proposal, keep the company-context switch off unless the +memory should be ambient context for Dispatch and other apps. Turn it on for +canonical decisions, policies, product facts, or durable process notes that are +safe to place under `context/company-brain/...`; Brain shows the exact Markdown +preview before approval publishes it. Use the Knowledge route or +`set-knowledge-canonical --published=false` to remove a mirrored resource after +previewing what will be removed, without deleting the underlying Brain +knowledge. + +Distillation has two worker paths. When a Brain tab is open, the app shell +claims queued items with `claim-distillation` and delegates them to the app +agent in the background. When no tab is open, the `brain-distillation` server +sweep runs with `RUN_BACKGROUND_JOBS`, claims due queued rows, reclaims stale +`processing` rows, and invokes the same agent loop headlessly. Re-running +`enqueue-distillation` for an active queue item refreshes the handoff instead +of duplicating queue rows. The agent reads the capture, writes cited knowledge +or review proposals, then calls `mark-capture-distilled`, which marks the +active queue row done. If the agent does not close the queue, the worker +requeues the item with a short delay and eventually fails it after repeated +attempts. + +The Ops route is the operator view for distillation. It lists queued, +processing, failed, done, stale, and retryable handoffs, backed by +`list-distillation-queue` and `retry-distillation`. + +## Granola Polling + +Brain resolves `GRANOLA_API_KEY` from a granted Granola workspace connection +first, then from backward-compatible Brain-local or registered vault +credentials. It polls Granola's public API for notes, then fetches each note +with its transcript: + +```bash +pnpm --filter brain action create-source \ + --title "Granola team notes" \ + --provider granola \ + --visibility org \ + --config '{"pageSize":10,"updatedAfter":"2026-05-01T00:00:00.000Z"}' +``` + +Granola Enterprise API keys expose Team-space notes, not private notes or +private folders. Brain stores the note summary, transcript, attendees, calendar +metadata, and source URL as a raw capture before distillation. + +## GitHub Connector + +GitHub is Brain's first reusable connector proof. It resolves `GITHUB_TOKEN` +from a granted GitHub workspace connection first, then from backward-compatible +Brain-local or registered vault credentials, and imports bounded issue and pull +request context from approved repositories: + +```bash +pnpm --filter brain action create-source \ + --title "GitHub product repos" \ + --provider github \ + --visibility org \ + --config '{"repositories":["owner/repo"],"state":"all","limit":25}' +``` + +The connector accepts `repositories` or `repos`, optional `state`, `limit`, +`includeIssues`, and `includePullRequests`. Imported items become raw captures +with stable source URLs and can be distilled like Slack or meeting context. This +is intentionally Brain context ingestion, not a replacement for Analytics-style +GitHub reporting. + +## Shared Workspace Connections + +Brain sources can reuse shared workspace connections when Dispatch or another +workspace setup has already connected a provider and granted `appId=brain` +access. The source record still belongs to Brain: it stores channel ids, +repositories, sync cursors, review settings, and other source-specific choices, +while the provider credential stays in the workspace vault behind a connection +or grant credential ref. + +The `list-connection-providers` action returns each Brain provider with +connection counts, grant state, credential reference names, credential health, +and whether Brain has access. It never returns credential values. Source sync +resolves credentials in this order: + +1. Granted `workspace_connections` / `workspace_connection_grants` credential + refs for `appId=brain`. +2. Backward-compatible Brain-local SQL credentials. +3. Registered vault secrets for the same user/org/workspace scope. + +Brain source credentials do not fall back to deploy-level environment +variables. If a shared provider exists but has not been granted to Brain, grant +Brain access instead of copying the same secret into a Brain-specific setting. + +Keep the ownership model simple: + +- Dispatch or the workspace layer owns provider account metadata, credential + ref names, and app grants. +- The vault owns the secret values. +- Brain owns source-local choices such as Slack channels, GitHub repositories, + Granola polling windows, cursors, review posture, and distillation status. +- Agents should inspect connection readiness first, then request a grant or + source configuration instead of asking the user for another provider token. + +The Sources page surfaces the same provider catalog. A provider can be: + +- `connected` when an active workspace connection is already granted to Brain. +- `granted` when Brain can access the connection but it is not currently active. +- `needs_grant` when the workspace has a connection that has not been granted to + Brain. +- `not_connected` when Brain is using scoped credentials or has no connection + yet. + +The page also shows provider readiness: ready, grant needed, needs repair, +missing keys, or metadata only. Agents should inspect this same readiness via +`list-connection-providers` before asking users for duplicate Slack, Granola, +GitHub, or future provider credentials. + +## Scheduled Sync + +The Sources page includes a setup sheet for Slack, Granola, GitHub, Clips, +generic webhooks, and manual imports. Slack, Granola, and GitHub sources can +opt into `autoSync` with a `pollMinutes` cadence. Use `sync-source` for a +single source, `sync-due-sources` for all due accessible sources, or enable +`RUN_BACKGROUND_JOBS=1` locally to let the Brain background job poll due sources +from the Nitro process. + +## Demo and Eval + +Brain ships with a repeatable product-decision demo corpus. `seed-demo-data` +loads Slack, Clips, Granola, and webhook-style captures; creates cited knowledge +about retiring freemium, how Decision Digest works, and why product decisions +are the lead demo; queues a policy-sensitive proposal; redacts an email; and +keeps a personal aside out of queryable knowledge. + +`run-demo-eval` checks the behavior that matters most for trust: recall, +citations, supersede links, proposal gating, redaction, and personal-content +exclusion. The Ask page includes a compact **Start demo** CTA for empty +workspaces and reveals Review, Knowledge, and **Run eval** follow-ups once the +demo is ready. + +`run-retrieval-eval` checks an offline real-channel-style retrieval set. It +uses existing workspace Brain data when #dev-fusion stale Fusion branch answers +already have citation-backed support; otherwise, with `seedIfMissing` enabled, +it seeds a small Slack-style fallback corpus and re-runs the same checks. The +result covers Slack-style citations, branch-safety terms, and an unsupported +cleanup-cron not-found case. The same mode is available through `run-demo-eval` +with `mode: "retrieval"`. + +The repository-level `pnpm test` command includes `pnpm test:brain-evals`, which +runs Brain's product-demo and retrieval action evals against a disposable local +SQLite database. The CI/prep eval path is fully seeded and offline; it does not +require production Slack, Granola, Clips, or any external workspace data. + +## Privacy And Gating + +Brain is designed for company memory, not personal surveillance: + +- Slack sync only reads explicitly configured channels and rejects DMs/MPIMs. +- Granola sync reads Team-space notes exposed by Granola's API, not private + notes or private folders. +- Raw captures are redacted from listing/search surfaces by default; reviewers + and distillation flows request previews or raw content only when needed. +- Source configs can require review before distilled knowledge becomes durable + company memory. +- Settings control default publish tier, whether company-tier knowledge requires + approval, citation requirements, email redaction, and connector error + notifications. +- Demo/eval coverage checks proposal gating, PII redaction, personal-content + exclusion, citations, real-channel-style retrieval, and honest not-found + behavior. + +## Developer Notes + +The template follows the agent-native four-area contract: + +- **UI:** Ask, Search, Knowledge, Review, Sources, Ops, and Settings routes. +- **Actions:** imports, source management, pilot reports, distillation queueing/claiming/retry, proposal review, cited search, and navigation/context actions. +- **Skills/instructions:** Brain-specific guidance for distillation and retrieval. +- **Application state:** route, filters, and selected IDs mirror into `application_state` for agent context. + +See [Dispatch](/docs/dispatch) for the workspace control plane, the +[Dispatch template](/templates/dispatch) for the scaffolded app, +[Workspace](/docs/workspace) for shared resources, and +[A2A Protocol](/docs/a2a-protocol) for cross-app delegation. diff --git a/packages/core/docs/content/template-dispatch.md b/packages/core/docs/content/template-dispatch.md index b4bc00d8b..cc0d0db48 100644 --- a/packages/core/docs/content/template-dispatch.md +++ b/packages/core/docs/content/template-dispatch.md @@ -26,8 +26,10 @@ If you're running an [multi-app workspace](/docs/multi-app-workspace) with many - **Central inbox.** Slack DMs, Telegram messages, email notifications, A2A requests from other agents — all land in one place. The Dispatch agent triages and either handles them itself or delegates. See [Messaging](/docs/messaging) for how to wire Slack, email, and Telegram into your workspace. - **Orchestrator, not specialist.** Dispatch does _not_ try to be the email app or the analytics app. When someone asks "summarize last week's signups," Dispatch calls the analytics agent over A2A and returns the answer. When someone asks "draft a reply to Alice," Dispatch calls the mail agent. - **Secrets vault.** A central store for API keys, OAuth tokens, and shared credentials. Apps in the workspace resolve secrets from Dispatch instead of duplicating them in every `.env`. Requests + approvals for sensitive access. +- **Workspace resources.** Global skills, guardrail instructions, custom agent profiles, and reference resources can be created once in Dispatch. All-app resources are inherited at runtime by every app with no copy or manual sync step; selected grants are for app-specific exceptions. - **Integrations catalog.** One page showing every third-party integration — Slack, Telegram, SendGrid, Apollo, etc. — with a "configured / not configured / pending approval" status per app. - **Scheduled jobs hub.** Cross-app [recurring jobs](/docs/recurring-jobs) live here: "every weekday at 7, pull yesterday's key metrics from analytics and draft a morning summary email." +- **Dreams.** Dispatch can review recent agent runs, failures, feedback, and successful patterns to propose memory, skill, job, and instruction improvements before anything durable is applied. - **Approval flow.** Destructive or external actions (sending money, shipping an outbound email, posting to Slack at scale) can require an admin OK before they fire. Dispatch owns the queue. ## When to use it {#when-to-use} @@ -47,7 +49,9 @@ Day-to-day, Dispatch is the place admins and ops folks open to keep the workspac - **Connect Slack, email, and Telegram** so people can message your agent from wherever they already work. See [Messaging](/docs/messaging) for the wiring steps. - **Save shared secrets once.** API keys, OAuth tokens, and service credentials live in the vault and the other apps in your workspace pull from there instead of every team member juggling their own `.env`. +- **Keep company context global.** Put personas, positioning, messaging, company facts, brand guidelines, and guardrails in Dispatch Resources once, then preview the effective workspace -> app/org -> personal stack for any app/user or inspect the stack from an app card's Context view. - **Set up recurring jobs.** "Every Monday at 7am, ask the analytics agent for last week's signups and email me a summary." See [Recurring Jobs](/docs/recurring-jobs). +- **Review dream proposals.** Dispatch Dreams inspect prior agent runs and create source-backed proposals for what the workspace should remember, which stale notes should be cleaned up, and which repeated lessons should become skills or jobs. - **Approve outbound actions before they fire.** Sending money, mass-emailing customers, or posting to a public Slack channel can be gated behind an admin OK. - **See who has access to what.** Per-app grants, request queue, and an audit log of who used which secret when. - **Route messages to the right specialist.** A Slack DM about analytics goes to the analytics agent; one about email goes to the mail agent — Dispatch picks. @@ -62,6 +66,32 @@ _How it works under the hood (for developers)._ - **Slack / Telegram plugins.** Server plugins that register webhooks and forward incoming messages to the orchestrator agent. - **MCP hub mode.** Dispatch can act as the workspace's [MCP hub](/docs/mcp-clients#hub) so every other app in the workspace pulls the same org-scope MCP server list. +## Dreams {#dreams} + +Dreams are Dispatch's review loop for agent memory. A dream pass looks over existing agent runs, thread debug data, feedback, evals, and repeated tool failures, then writes a report with proposed changes. The proposals can target personal memory, shared `LEARNINGS.md`, workspace instructions, workspace skills, workspace knowledge, workspace agents, or recurring jobs, but shared and workspace-level changes stay reviewable rather than being applied silently. + +Dream proposals are checked against the personal memory index, existing `memory/*.md` files, and shared `LEARNINGS.md` before they are saved. Duplicate lessons are skipped in the report, while likely stale personal memories are updated in place instead of producing parallel notes. Within a report, Dreams also deduplicate repeated evidence by thread, signal type, and normalized quote, strip injected context from user-correction detection, and summarize raw eval/tool rows into human-readable bullets before they appear in proposal text. When a pass finds signals but intentionally creates no proposals, the report includes guardrail notes explaining which evidence was suppressed. + +When Dispatch approval policy is enabled, applying a shared or team-wide dream proposal creates a pending approval request instead of writing immediately. Creating, updating, or deleting an All-app workspace resource also queues an approval request. Personal memory proposals and selected-only resource edits can still be applied directly after review. + +Use Dreams when you want to answer questions like "what did agents keep getting wrong this week?", "what should we remember?", or "which repeated lesson deserves a skill?" Inbound Slack, email, Telegram, WhatsApp, and web-derived evidence is treated as untrusted input, so proposals from those sources require review and provenance before they affect shared memory. Workspace-instruction proposals require durable evidence spanning at least two threads or two source apps; eval-only noise, account setup issues, quota limits, and single-app UI wording corrections stay out of global instructions. + +In the Dispatch UI, open **Dreams** to run a manual pass, review candidate threads, inspect the report, and open each proposal's review sheet before applying or rejecting it. Use **Settings** to edit the recurring cron schedule, source scope, timeout/concurrency limits, candidate limit, and minimum candidate threshold; use **Ensure schedule** after saving when you want the `jobs/dispatch-dream.md` recurring job materialized from those settings. The review sheet shows approval behavior, the current target content, proposed content, and source evidence. Agents use the same workflow through actions: + +- `list-dream-candidates` finds recent threads with grounded signals such as explicit user corrections, failed runs, tool errors, feedback, eval failures, and successful checkpointed workflows. Pass `sourceId: "all"` or `sourceIds` to scan multiple thread-debug sources; `sourceTimeoutMs`, `sourceConcurrency`, `sourceStartStaggerMs`, `threadConcurrency`, and `threadTimeoutMs` keep production scans partial and bounded, and the response includes per-source health. +- `create-dream-report` creates the report and pending proposals. Multi-source reports include a Source Health section so partial scans are visible during review. Repeated corrections and recurring failures can become workspace-resource proposals such as `workspace-instruction`; repeated successful checkpointed workflows can become `workspace-skill` proposals. +- `get-dream-settings` and `set-dream-settings` read and update the recurring dream schedule, source scope, timeout/concurrency controls, limit, and minimum candidate threshold. +- `get-dream`, `preview-dream-proposal`, `apply-dream-proposal`, and `reject-dream-proposal` handle review. +- `ensure-dream-job` creates the safe recurring dream job once manual reports are useful. + +The Dispatch template's local action runner also exposes packaged Dispatch actions, so in development you can run the same workflow from `apps/dispatch`: + +```bash +pnpm action get-dream-settings +pnpm action set-dream-settings --enabled true --schedule "0 9 * * 1" --allSources true --limit 8 +pnpm action create-dream-report --allSources true --sourceTimeoutMs 30000 --limit 8 +``` + ## Scaffolding {#scaffolding} ```bash diff --git a/packages/core/docs/content/workspace-connections.md b/packages/core/docs/content/workspace-connections.md new file mode 100644 index 000000000..310cc55b8 --- /dev/null +++ b/packages/core/docs/content/workspace-connections.md @@ -0,0 +1,509 @@ +--- +title: "Workspace Connections" +description: "Shared provider metadata, grants, and credential refs for connect-once-use-everywhere integrations." +--- + +# Workspace Connections + +Workspace connections are the framework path toward "connect once, grant apps, +use everywhere" integrations. The workspace/Dispatch layer records provider +accounts once, grants apps such as Brain, Analytics, Mail, and Dispatch access, +and lets each app's UI and agent inspect safe integration metadata before +asking for another credential. + +They have two shared pieces: + +- A typed provider catalog that templates import to describe the external + systems they understand. +- A scoped SQL store for connected accounts plus per-app grants, so Dispatch or + another workspace setup flow can connect Slack, GitHub, Google Drive, Granola, + or another provider once and then grant individual apps access. + +The store records provider ids, account labels, non-secret config, credential +reference names, health state, and grant rows. It does not run OAuth and never +returns secret values. Secret values stay in the credential vault and are +resolved by actions at execution time from the request's user/org/workspace +scope. + +Dispatch exposes the first control-plane implementation through the +`list-workspace-connections`, `upsert-workspace-connection`, and +`set-workspace-connection-grant` actions. App-specific actions then consume the +same records. Brain uses `list-connection-providers`; Analytics uses +`data-source-status`; future apps should expose the same kind of readiness +summary before asking users for duplicate provider keys. + +## Provider Catalog + +Import the catalog from `@agent-native/core/connections`: + +```ts +import { + getWorkspaceConnectionProvider, + listWorkspaceConnectionProvidersForTemplate, + workspaceConnectionProviderSupports, +} from "@agent-native/core/connections"; + +const brainProviders = listWorkspaceConnectionProvidersForTemplate("brain"); +const slack = getWorkspaceConnectionProvider("slack"); + +if (workspaceConnectionProviderSupports("slack", "messages")) { + // Offer a Slack source, sync check, or onboarding step. +} +``` + +The initial provider ids are: + +| Provider | Capabilities | Common uses | +| -------------- | ------------------------------ | ------------------------------ | +| `slack` | search, import, messages | brain, dispatch, analytics | +| `github` | search, import, code, docs | brain, analytics, dispatch | +| `notion` | search, import, docs | brain, content, dispatch | +| `gmail` | search, import, messages | mail, brain, dispatch | +| `google_drive` | search, import, docs | brain, content, slides | +| `hubspot` | search, import, crm | analytics, brain, mail | +| `granola` | search, import, meetings, docs | brain, calendar, dispatch | +| `clips` | search, import, meetings | brain, clips, videos | +| `generic` | search, import, docs | custom webhooks and file drops | + +Credential keys are names only, such as `SLACK_BOT_TOKEN` or `GITHUB_TOKEN`. +Provider metadata must never include actual credential values. + +## Connection Store + +Import the shared store from `@agent-native/core/workspace-connections`: + +```ts +import { + listWorkspaceConnectionProviderCatalogForApp, + listWorkspaceConnectionGrants, + listWorkspaceConnections, + summarizeWorkspaceConnectionProviderForApp, + summarizeWorkspaceConnectionProviderReadiness, + upsertWorkspaceConnection, + upsertWorkspaceConnectionGrant, + revokeWorkspaceConnectionGrant, +} from "@agent-native/core/workspace-connections"; + +await upsertWorkspaceConnection({ + id: "team-slack", + provider: "slack", + label: "Team Slack", + accountLabel: "Acme", + credentialRefs: [{ key: "SLACK_BOT_TOKEN", scope: "org" }], +}); + +await upsertWorkspaceConnectionGrant({ + connectionId: "team-slack", + appId: "dispatch", +}); + +const connections = await listWorkspaceConnections({ includeDisabled: true }); +const grants = await listWorkspaceConnectionGrants({ appId: "brain" }); + +const appGrant = summarizeWorkspaceConnectionProviderForApp({ + providerId: "slack", + appId: "brain", + connections, + grants, +}); + +const readiness = summarizeWorkspaceConnectionProviderReadiness({ + provider: slack!, + appId: "brain", + connections, + grants, +}); + +const brainCatalog = await listWorkspaceConnectionProviderCatalogForApp({ + appId: "brain", + templateUse: "brain", +}); +``` + +The `credentialRefs` array points at vault keys; it is not credential storage. +For example, `{ key: "SLACK_BOT_TOKEN", scope: "org" }` tells a granted app to +look up the org-scoped vault secret named `SLACK_BOT_TOKEN` when it needs to +call Slack. Connection-level refs can describe the provider account, and +grant-level refs can narrow or override what a specific app should use. + +Connection rows are scoped to the active org when one is present. Without an +org, they are scoped to the authenticated user. Grant rows use the same scope, +which means any member of an org can see org-level grants while other orgs and +personal scopes cannot. + +`allowedApps` on a connection is still supported for compatibility: + +- `allowedApps: []` means every app in the same scope may use the connection. +- `allowedApps: ["dispatch"]` grants access through the legacy field. +- `workspace_connection_grants` rows add explicit per-app grants alongside the + legacy field. + +Use `revokeWorkspaceConnectionGrant(connectionId, appId)` to remove an explicit +grant. Revoking a grant does not change legacy `allowedApps`; if the app is +still listed there, the connection remains available to that app. + +Use `summarizeWorkspaceConnectionProviderForApp()` and +`summarizeWorkspaceConnectionProviderReadiness()` for app-facing status instead +of hand-rolling grant checks. The shared summaries return the stable contract +used by Brain, Analytics, and Dispatch: `grantState`, `grantAvailability`, +safe credential ref names, per-app connection rows, counts for granted/active +connections, and readiness fields such as `readyConnectionCount` and +`missingRequiredCredentialKeys`. + +For new app setup screens, prefer +`listWorkspaceConnectionProviderCatalogForApp()` as the higher-level boundary. +It combines the provider catalog, scoped connections, explicit grants, +per-app access summaries, and provider readiness into one safe shape. Apps can +add their own source counts, local health checks, and connector-specific +fields on top without duplicating grant logic. + +## How This Complements The Vault + +The credential vault answers: "Where is the secret stored, who can access it, +and which apps are granted it?" + +Workspace connection provider metadata answers: "Which provider is this, what +can it do, what credential keys might it need, and which templates should offer +it?" + +Use both together: + +1. Dispatch or another workspace setup flow creates/grants the underlying vault + secret. +2. The workspace connection store records the provider account, safe metadata, + credential refs, and app grants. +3. Each app reads provider metadata from the catalog and connection/grant + summaries from the shared store. +4. The app UI shows readiness: connected, granted but unhealthy, needs grant, + missing credentials, or metadata-only. +5. App-specific SQL stores only app-specific source ids, cursors, filters, and + user choices. +6. App actions resolve credentials at execution time through granted connection + refs and the vault, and never return secret values. + +App source connectors should not read deploy-level environment variables as a +fallback for user/org source credentials. Env vars are global to the deployment +and do not express workspace grants. Brain's current source resolver checks +granted workspace connection refs for `appId=brain` first, then backward +compatible Brain-local SQL credentials and registered vault secrets; it does not +fall back to `process.env`. + +Agents should use the same summaries as the UI. Before asking for a duplicate +Slack, GitHub, HubSpot, Google, or other provider key, an agent should inspect +the workspace connection catalog or the app's readiness action and prefer a +granted shared connection when one exists. If a connection exists with +`needs_grant`, ask for that app grant instead of asking the user to paste a new +secret. + +## Minimal Onboarding Flow + +Use a connect-once flow before app-specific source setup: + +1. Connect the provider account in Dispatch or the workspace integrations + surface. +2. Store safe metadata and credential ref names only; put secret values in the + vault. +3. Grant only the apps that need the provider, such as Brain, Analytics, Mail, + or Dispatch. +4. In each app, create the app-local source or data source with only the + provider-specific choices it owns: channels, repositories, polling windows, + filters, cursors, or sync cadence. +5. Agents inspect readiness and grants before asking for new credentials. + +This keeps the UX clean: users connect Slack, GitHub, HubSpot, Google Drive, +Granola, and similar providers once, then choose which apps may use that +connection without duplicating secrets or scattering account setup across every +template. + +## Build A Reusable Connector Once + +When a new provider should work across multiple templates, split the work into +three layers: + +1. **Provider metadata:** add or reuse a provider in + `@agent-native/core/connections`. This is the stable id, display label, + capability list, recommended template uses, and credential key names. +2. **Workspace connection:** Dispatch or another workspace setup surface stores + the connected account's safe metadata, status, scopes, `credentialRefs`, and + app grants through `@agent-native/core/workspace-connections`. +3. **App-local source:** Brain, Analytics, Mail, or another app stores only the + app-specific choices it owns, such as Slack channels, GitHub repositories, + HubSpot object filters, sync cursors, or polling cadence. + +Do not duplicate OAuth/token storage in each app. The connection record should +say "this is Acme Slack and its token lives at `SLACK_BOT_TOKEN`"; the app-local +source should say "Brain may ingest `#product` and `#dev-fusion` from that +Slack connection." + +### Dispatch control-plane setup + +Dispatch exposes the current control-plane actions. They write the same shared +store functions an app could call directly from server code: + +```ts +// templates/dispatch/actions/upsert-workspace-connection.ts delegates to this. +await upsertWorkspaceConnection({ + id: "team-slack", + provider: "slack", + label: "Acme Slack", + accountId: "T012345", + accountLabel: "acme", + status: "connected", + scopes: ["channels:history", "groups:history"], + config: { + teamDomain: "acme", + preferredChannels: ["product", "dev-fusion"], + }, + credentialRefs: [ + { + key: "SLACK_BOT_TOKEN", + scope: "org", + provider: "slack", + label: "Slack bot token", + }, + ], +}); +``` + +Then grant the apps that should reuse the provider: + +```ts +await upsertWorkspaceConnectionGrant({ + connectionId: "team-slack", + appId: "brain", +}); + +await upsertWorkspaceConnectionGrant({ + connectionId: "team-slack", + appId: "analytics", +}); +``` + +Use `allowedApps: []` only when a connection should be available to every app in +the same workspace scope. Prefer explicit grant rows for production setup, +because they make revocation, audit, and per-app readiness easier to explain. + +### App consumption boundary + +App setup screens and agents should use the high-level catalog helper whenever +they need provider readiness: + +```ts +import { listWorkspaceConnectionProviderCatalogForApp } from "@agent-native/core/workspace-connections"; + +const catalog = await listWorkspaceConnectionProviderCatalogForApp({ + appId: "brain", + templateUse: "brain", + provider: "slack", + includeConnections: "all", +}); + +const slack = catalog.providers[0]; +if (slack.workspaceConnection.grantState === "needs_grant") { + // Show "Grant Brain access" instead of asking for a second Slack token. +} +if (slack.readiness.status === "needs_credentials") { + // Show the missing credential ref names, never a secret value. +} +``` + +App execution code can then resolve credential values from granted +`credentialRefs` through the vault in the active request scope. Brain's +`source-credentials.ts` is the current reference implementation: it lists +workspace connections for the provider, checks `getWorkspaceConnectionAppAccess` +for `appId: "brain"`, merges connection-level and grant-level credential refs, +and reads the first matching scoped vault secret. Other apps should follow that +shape instead of reaching for `process.env`. + +## Concrete Provider Examples + +### Slack: Brain, Analytics, Dispatch + +Use one Slack workspace connection for channel history and messaging-related +workflows: + +```ts +await upsertWorkspaceConnection({ + id: "acme-slack", + provider: "slack", + label: "Acme Slack", + accountId: "T012345", + accountLabel: "Acme", + status: "connected", + scopes: ["channels:history", "groups:history", "chat:write"], + config: { + teamDomain: "acme", + channelHints: ["product", "dev-fusion", "customer-success"], + }, + credentialRefs: [{ key: "SLACK_BOT_TOKEN", scope: "org" }], +}); + +await upsertWorkspaceConnectionGrant({ + connectionId: "acme-slack", + appId: "brain", +}); +await upsertWorkspaceConnectionGrant({ + connectionId: "acme-slack", + appId: "analytics", +}); +await upsertWorkspaceConnectionGrant({ + connectionId: "acme-slack", + appId: "dispatch", +}); +``` + +- **Brain** stores allow-listed channels, exclusion rules, cursors, and source + status in `brain_sources`; it resolves `SLACK_BOT_TOKEN` from the granted + workspace connection before Brain-local credentials. +- **Analytics** should check `data-source-status` for the Slack provider and + use shared readiness before requesting a Slack credential for channel or + funnel analysis. +- **Dispatch** owns the setup/grant UX and can use the same connection for + Slack-triggered routing, notifications, and agent entrypoints. + +### HubSpot: Analytics, Brain, Mail + +Use one HubSpot private app token for CRM records that multiple apps can +interpret differently: + +```ts +await upsertWorkspaceConnection({ + id: "acme-hubspot", + provider: "hubspot", + label: "Acme HubSpot", + accountLabel: "Acme CRM", + status: "connected", + scopes: ["crm.objects.contacts.read", "crm.objects.companies.read"], + config: { + portalId: "1234567", + objectHints: ["companies", "contacts", "deals"], + }, + credentialRefs: [{ key: "HUBSPOT_PRIVATE_APP_TOKEN", scope: "org" }], +}); + +for (const appId of ["analytics", "brain", "mail"]) { + await upsertWorkspaceConnectionGrant({ + connectionId: "acme-hubspot", + appId, + }); +} +``` + +- **Analytics** is the first consumer for CRM metrics, lifecycle dashboards, and + customer segmentation. Its readiness action should show a HubSpot workspace + connection before asking for duplicate CRM secrets. +- **Brain** can ingest selected customer-facing context, policies, and product + rationale derived from CRM workflows while keeping Brain-specific allow-lists + and proposal gates in Brain SQL. +- **Mail** should use the same workspace connection pattern when adding CRM + enrichment to mailbox workflows. The provider catalog already recommends + `hubspot` for `mail`; a Mail readiness action should call + `listWorkspaceConnectionProviderCatalogForApp({ appId: "mail" })` before + prompting for a HubSpot token. + +### GitHub: Brain, Analytics, Dispatch + +Use one GitHub connection for repositories, issues, pull requests, and code +context: + +```ts +await upsertWorkspaceConnection({ + id: "acme-github", + provider: "github", + label: "Acme GitHub", + accountLabel: "acme", + status: "connected", + scopes: ["contents:read", "issues:read", "pull_requests:read"], + config: { + owner: "acme", + repositoryHints: ["agent-native", "website"], + }, + credentialRefs: [{ key: "GITHUB_TOKEN", scope: "org" }], +}); + +await upsertWorkspaceConnectionGrant({ + connectionId: "acme-github", + appId: "brain", +}); +await upsertWorkspaceConnectionGrant({ + connectionId: "acme-github", + appId: "analytics", +}); +await upsertWorkspaceConnectionGrant({ + connectionId: "acme-github", + appId: "dispatch", +}); +``` + +- **Brain** can turn issues, pull requests, and design discussions into cited + product memory, with app-local repo allow-lists and distillation rules. +- **Analytics** can use the same granted token for engineering throughput, + release, and operational dashboards. +- **Dispatch** can route GitHub-related questions to the right app or connected + agent without owning repository-specific ingestion state. + +## Consumer Guide By Surface + +| Surface | What it should read | What it should store locally | +| ------------- | ------------------------------------------------------- | ----------------------------------------------------------------- | +| **Dispatch** | Full provider catalog, connections, grants, app targets | Workspace setup policy, grant choices, safe account metadata | +| **Brain** | Catalog helper with `{ appId: "brain" }` | Sources, allow-lists, cursors, extraction rules, proposals | +| **Analytics** | `data-source-status` plus workspace provider summaries | Metric definitions, datasets, sync windows, dashboard choices | +| **Mail** | A Mail readiness action using the same catalog helper | Mailboxes, labels, reply rules, CRM enrichment preferences | +| **Agents** | App readiness actions before asking for secrets | No secret values; only cite provider ids, grant state, next steps | + +Agents should follow a simple rule: if a user asks to connect Slack, GitHub, +HubSpot, Gmail, Google Drive, Granola, or another shared provider, inspect the +workspace connection catalog first. If the provider is `connected`, use it. If +it is `needs_grant`, ask for or perform the app grant. If it is +`needs_credentials`, ask for the missing vault key. Only ask for a new raw key +when no reusable connection exists. + +## App Readiness Pattern + +Apps that consume shared provider credentials should expose a read-only +readiness action and a small setup surface: + +- **Provider catalog:** provider id, label, capabilities, recommended template + uses, and required credential key names from `@agent-native/core/connections`. +- **Workspace summary:** connection count, active/granted counts, connection + statuses, grant state, credential ref names, and non-secret account labels + from `@agent-native/core/workspace-connections`. Use + `summarizeWorkspaceConnectionProviderForApp()` for this shape. +- **Provider readiness:** use + `summarizeWorkspaceConnectionProviderReadiness()` when the UI needs the + provider-level `ready`, `needs_credentials`, `needs_attention`, `checking`, + `disabled`, or `not_configured` status. +- **Credential health:** whether required keys can be resolved without exposing + values. +- **Source state:** app-local configured sources, cursors, sync status, and + next action. + +Brain's Sources page is the reference implementation. It shows reusable +workspace connection providers beside Brain source records, labels grant states +as `connected`, `granted`, `needs_grant`, or `not_connected`, and shows provider +health as ready, missing keys, grant needed, needs repair, or metadata only. +That lets a Brain user create Slack, Granola, GitHub, Clips, generic, or manual +sources with a clear signal about whether the shared credential path is ready, +grantable, scoped locally, or missing. + +## Path To Connect Once, Use Everywhere + +The provider catalog and grant store are the foundation for a broader workspace +layer: + +- Shared provider ids and capability names keep templates aligned. +- Workspace-level inventory can show which providers are configured across + Brain, Mail, Analytics, Dispatch, and future apps. +- Connection rows record account labels, status, allowed apps, credential refs, + and health checks without changing template-facing provider ids. +- Grant rows let a workspace owner connect once, then enable individual apps as + the workspace adopts them. +- Agents can route work across apps knowing which providers are already + connected and which apps have grants. +- Federated search can ask for providers with `search`, `docs`, `messages`, + `meetings`, `crm`, or `code` capabilities instead of hardcoding every app's + connector list. + +Keep the boundary strict: provider metadata is safe to show; credential values +stay in the vault. diff --git a/packages/core/docs/content/workspace-management.md b/packages/core/docs/content/workspace-management.md index 3e3f3e007..f1099aaf9 100644 --- a/packages/core/docs/content/workspace-management.md +++ b/packages/core/docs/content/workspace-management.md @@ -181,16 +181,16 @@ When reviewing PRs in this environment: The [Dispatch](/docs/dispatch) app is the workspace's runtime control plane. It complements git-level governance with runtime governance: -| Concern | Git / GitHub | Dispatch | -| ------------------------------- | ----------------------------- | ------------------------------------------ | -| Who can change code | CODEOWNERS, branch protection | — | -| Who can access secrets | — | Vault policy, grants, request workflow | -| What instructions agents follow | — | Workspace resources (skills, instructions) | -| Which agents are shared | — | Workspace agent profiles | -| Integration inventory | — | Integrations catalog | -| Runtime change approval | — | Dispatch approval flow | -| Audit trail | `git log` / `git blame` | Vault audit + dispatch audit logs | -| Messaging & routing | — | Slack / Telegram integration | +| Concern | Git / GitHub | Dispatch | +| ------------------------------- | ----------------------------- | ------------------------------------------------------------ | +| Who can change code | CODEOWNERS, branch protection | — | +| Who can access secrets | — | Vault policy, grants, request workflow | +| What instructions agents follow | — | Global workspace resources (AGENTS.md, instructions, skills) | +| Which agents are shared | — | Workspace agent profiles | +| Integration inventory | — | Integrations catalog | +| Runtime change approval | — | Dispatch approval flow | +| Audit trail | `git log` / `git blame` | Vault audit + dispatch audit logs | +| Messaging & routing | — | Slack / Telegram integration | **Git handles code governance. Dispatch handles runtime governance.** Don't try to replicate git workflows inside dispatch or vice versa. They cover different surfaces. @@ -198,7 +198,7 @@ The [Dispatch](/docs/dispatch) app is the workspace's runtime control plane. It - **Vault** — store credentials centrally and sync on demand. The default policy makes all vault keys available to all workspace apps; manual mode requires specific app grants. Non-admins can request access; admins approve. - **Integrations catalog** — see which credentials each app needs, what's configured, what's missing, what's granted from the vault. -- **Workspace resources** — share skills, behavioral instructions, and reusable agent profiles across apps. Scope to all apps or grant per-app. +- **Workspace resources** — manage global skills, always-on guardrail instructions, reusable agent profiles, and reference resources inherited by apps. Use `AGENTS.md` or `instructions/.md` for instructions loaded every turn, `skills//SKILL.md` for on-demand skills, and `context/.md` for brand/company/product knowledge. Scope to All apps for workspace defaults; apps read those defaults at runtime with no copy or manual sync step, and app shared or personal resources can override locally. The Resources page highlights the starter global context files, can restore missing starter files, and each app card shows the exact inherited/granted resources that app receives. - **Approvals** — require review before runtime changes (destinations, settings) take effect. - **Audit** — full history of secret access, grants, syncs, and changes. @@ -253,7 +253,7 @@ For a new workspace, after running `agent-native create`: - [ ] Add shared secrets to the vault (API keys, OAuth credentials, etc.) - [ ] Keep the default all-apps vault policy or switch to manual per-app grants - [ ] Sync vault secrets to push them to apps -- [ ] Add workspace-wide skills and instructions via the Resources page +- [ ] Add workspace-wide skills, guardrail instructions, and brand/company reference resources via the Resources page: `context/company.md`, `context/brand.md`, `context/messaging.md`, `instructions/guardrails.md`, and `skills/company-voice/SKILL.md` - [ ] Configure the approval policy and approver emails - [ ] Set up SendGrid (`SENDGRID_API_KEY`, `SENDGRID_FROM_EMAIL`) for admin notifications - [ ] Connect Slack or Telegram for workspace messaging diff --git a/packages/core/docs/content/workspace.md b/packages/core/docs/content/workspace.md index 25f2c850f..53607cbe9 100644 --- a/packages/core/docs/content/workspace.md +++ b/packages/core/docs/content/workspace.md @@ -32,33 +32,39 @@ The **Workspace** tab in the agent sidebar is where you and the agent share pers - Create files with the `+` menu. Upload with the upload button. Edit inline (visual or code view). - **Personal** is just you. **Shared** is your team/org. - The agent can read, write, and rename any of these files as part of a conversation. -- Special files the agent preloads: shared `AGENTS.md`, shared `LEARNINGS.md`, and personal structured memory at `memory/MEMORY.md`. +- Special files the agent preloads: shared `AGENTS.md`, shared `instructions/*.md`, shared `LEARNINGS.md`, and personal structured memory at `memory/MEMORY.md`. +- Shared reference resources such as `context/brand-guidelines.md` are indexed for the agent; it reads the relevant file when a task may depend on company, brand, positioning, persona, product, or domain context. ## What goes in here? {#what-goes-in-here} -| File / path | What it's for | -| --------------------------- | ------------------------------------------------------------------------------------------------------ | -| `AGENTS.md` (Shared) | Team instructions the agent reads every turn — tone, rules, domain context, skill references. | -| `LEARNINGS.md` (Shared) | Shared corrections, conventions, and durable project memory the agent preloads. | -| `memory/MEMORY.md` | Personal structured memory the chat preloads for the current user. | -| `skills/.md` | Focused domain guidance the agent pulls in on demand (invoked with `/` slash commands). | -| `agents/.md` | **Custom agents** — reusable sub-agent profiles the agent can delegate to (invoked with `@` mentions). | -| `remote-agents/.json` | A2A manifests for connected remote agents — edited via a form, not raw JSON. | -| `jobs/.md` | Scheduled tasks that run on a cron (see the recurring-jobs docs). | -| Anything else | Notes, prompts, config, dataset snippets — any text file. | +| File / path | What it's for | +| --------------------------- | ------------------------------------------------------------------------------------------------------- | +| `AGENTS.md` (Shared) | Team instructions the agent reads every turn — tone, rules, domain context, skill references. | +| `instructions/.md` | Additional always-on shared guardrails loaded every turn. Good for compliance, brand voice, and policy. | +| `LEARNINGS.md` (Shared) | Shared corrections, conventions, and durable project memory the agent preloads. | +| `memory/MEMORY.md` | Personal structured memory the chat preloads for the current user. | +| `skills//SKILL.md` | Focused domain guidance the agent pulls in on demand (invoked with `/` slash commands). | +| `agents/.md` | **Custom agents** — reusable sub-agent profiles the agent can delegate to (invoked with `@` mentions). | +| `remote-agents/.json` | A2A manifests for connected remote agents — edited via a form, not raw JSON. | +| `jobs/.md` | Scheduled tasks that run on a cron (see the recurring-jobs docs). | +| `context/.md` | Shared reference material: brand guidelines, personas, positioning, product facts, messaging, etc. | +| Anything else | Notes, prompts, config, dataset snippets — any text file. | ## Overview {#overview} Every agent-native app has a built-in resource system. Resources are SQL-backed files that persist across sessions and deployments. Unlike code files, resources live in the database — not the filesystem — so they work in serverless environments, edge runtimes, and production deploys without any filesystem dependency. -Resources have two scopes: +Resources have three runtime scopes: - **Personal** — scoped to a single user (their email). Good for preferences, notes, and per-user context. -- **Shared** — visible to all users. Good for team instructions, skills, and shared config. +- **Shared / organization** — visible to all users in the app or organization. Good for app/team instructions, skills, and shared config. +- **Workspace** — inherited global defaults managed from Dispatch Resources. Good for company facts, positioning, brand guidelines, global guardrails, and workspace-wide skills. Apps read these at runtime; they are not copied into each app. + +The in-app Workspace panel shows all three scopes. Personal and shared/organization resources are editable there. Workspace-scope resources are read-only in app panels and edited centrally from Dispatch, so every app sees the same canonical files without a sync step. ## Workspace Panel {#workspace-panel} -The agent panel includes a **Workspace** tab alongside Chat and CLI. This panel lets users browse, create, edit, and delete workspace resources. It displays a tree view of all resources organized by folder path. +The agent panel includes a **Workspace** tab alongside Chat and CLI. This panel lets users browse workspace resources, create/edit/delete personal or organization resources, and view inherited workspace defaults. It displays a tree view of all resources organized by folder path. Resources can be any text file — Markdown, JSON, YAML, plain text. The panel includes an inline editor for viewing and modifying resource content directly. @@ -69,10 +75,23 @@ The `+` menu in Workspace supports typed creation flows for: - **Agents** — custom sub-agent profiles under `agents/*.md` - **Scheduled Tasks** — recurring jobs under `jobs/` -Workspace resources come in two scopes: +Workspace resources appear in three scopes: +- **Workspace** — inherited from Dispatch by every app; read-only in app panels +- **Organization** — visible across the team/org - **Personal** — visible only to the current user -- **Shared** — visible across the team/org + +When you open a resource, the editor shows an **Effective context** strip with the precedence stack: + +```text +workspace default -> organization/app override -> personal override +``` + +If the same path exists at multiple levels, the later level wins. For example, `instructions/guardrails.md` in Personal overrides the organization version, which overrides the workspace default. Workspace resources are still visible in the stack so users can see what was inherited and why an override is active. + +Dispatch shows the same model from the control-plane side. On the **Resources** page, expand a resource and use **Effective in app** to choose an app and optional user email. The preview reports whether the resource is inherited by all apps or selected-only, and which layer is active for that exact path. From an app card's **Context** dialog, expand **Stack** on any resource row to see the same winner/override chain for that app. + +When Dispatch approval policy is enabled, creating, updating, or deleting an **All apps** resource queues an approval request instead of applying immediately. The create/edit/delete dialogs show an impact preview before save: whether the change reaches all apps, whether approval is required, and whether the same path is overridden at the organization/app or personal layer. Click the `?` icon in the Workspace toolbar to jump back to these docs at any time. @@ -100,15 +119,15 @@ Change how the agent behaves, in 60 seconds. ## How the Agent Uses Resources {#how-the-agent-uses-resources} -The agent has built-in tools for managing resources: `resource-list`, `resource-read`, `resource-write`, and `resource-delete`. These are available in both dev and production modes. +The agent has built-in tools for managing resources: `resource-list`, `resource-read`, `resource-effective`, `resource-write`, and `resource-delete`. These are available in both dev and production modes. At the start of every conversation, the agent automatically reads: ### AGENTS.md {#agents-md} -A shared resource seeded by default. It contains custom instructions, preferences, and skill references. Edit this to change how the agent behaves for all users — tone, rules, domain context, and which skills to use. +An instruction resource seeded by default. The agent loads `AGENTS.md` from workspace, shared/organization, and personal scopes in that order. Edit the workspace version from Dispatch for company-wide defaults, the shared/app version for team or app-specific rules, and the personal version for per-user preferences. -```markdown +```text # Agent Instructions ## Tone @@ -122,9 +141,102 @@ Be concise. Lead with the answer. ## Skills -| Skill | Path | Description | -| ------------- | ------------------------- | --------------------------- | -| data-analysis | `skills/data-analysis.md` | BigQuery and data workflows | +| Skill | Path | Description | +| ------------- | ------------------------------- | --------------------------- | +| data-analysis | `skills/data-analysis/SKILL.md` | BigQuery and data workflows | +``` + +### Global Instructions {#global-instructions} + +Use workspace `AGENTS.md` for company-wide defaults, shared `AGENTS.md` for app/team rules, and personal `AGENTS.md` for per-user preferences. Use files under `instructions/` for separate guardrail documents that should also apply every turn, such as compliance rules, customer-facing tone, escalation policy, or brand voice. These files use the same workspace -> organization/app -> personal precedence. + +For example: + +```text +AGENTS.md +instructions/customer-support-guardrails.md +instructions/legal-review-policy.md +``` + +Both normal chat and integration-triggered agent runs load these instruction resources before responding. + +### Reference Resources {#reference-resources} + +Put reusable company context under `context/`: personas, positioning, messaging, product facts, customer proof points, brand guidelines, competitive notes, and similar material. The agent sees an index of workspace and shared reference resources and reads the relevant file with `resource-read` when a task may depend on it. Use `resource-effective --path "context/brand.md"` when you need to see whether a workspace default is overridden by an organization/app or personal resource. + +Examples: + +```text +context/core-positioning.md +context/buyer-personas.md +context/brand-guidelines.md +context/company-facts.md +``` + +For a new workspace, a useful starter pack is: + +```text +context/company.md # what the company does, ICP, products, links +context/brand.md # voice, visual identity, spelling, forbidden usage +context/messaging.md # positioning, value props, proof points, objections +instructions/guardrails.md # compliance, escalation, and approval rules +skills/company-voice/SKILL.md # on-demand guidance for customer-facing writing +``` + +Keep `context/` files factual and easy to skim. Put rules that must apply every turn in `instructions/guardrails.md`. Use `skills/company-voice/SKILL.md` when the agent should deliberately transform or review copy in the company's voice. + +To override a global default for one app or team, create a shared/organization resource in that app with the same path. To override it for one person, create a personal resource with the same path. Do not copy the workspace file into every app; the runtime resolves the stack on read: + +```text +workspace context/brand.md +-> shared/app context/brand.md +-> personal context/brand.md +``` + +Example contents: + +```text + + +# Company + +- Company: Example Co +- Product: Agent-native workspace for internal teams +- ICP: Operations, support, and GTM teams managing many small tools +- Canonical links: https://example.com, https://docs.example.com + + + +# Brand + +- Voice: direct, warm, concrete +- Use: "workspace", "agent", "team" +- Avoid: unsupported superlatives and vague AI claims + + + +# Messaging + +- Positioning: one control plane for every app agent +- Value props: shared context, shared credentials, cross-app delegation +- Proof points: fewer duplicated Slack bots, one vault, one policy surface + + + +# Guardrails + +- Do not invent customer names, metrics, or legal claims. +- Ask for approval before changing shared instructions or All-app resources. +- Escalate security, billing, and data-loss concerns to an admin. + + + +--- +name: company-voice +description: Rewrite or review customer-facing copy using the workspace brand and messaging resources. +--- + +Read `context/brand.md` and `context/messaging.md` before writing. Keep claims grounded in those files, preserve approved terminology, and flag missing proof instead of inventing it. ``` ### Memory {#memory} @@ -158,18 +270,38 @@ The resource system also seeds a personal `LEARNINGS.md` for compatibility with **Where it fits.** -| Surface | Scope | Written by | Read when | -| ------------------ | -------- | ------------------------- | ---------------------------- | -| `AGENTS.md` | Shared | Humans / agent on request | Every turn | -| `LEARNINGS.md` | Shared | Humans / agent on request | Every turn | -| `memory/MEMORY.md` | Personal | Agent / humans | Every turn | -| `skills/…` | Shared | Humans / agent on request | On demand (`/slash` command) | +| Surface | Scope | Written by | Read when | +| ------------------ | -------- | ------------------------- | -------------------------------------- | +| `AGENTS.md` | Shared | Humans / agent on request | Every turn | +| `LEARNINGS.md` | Shared | Humans / agent on request | Every turn | +| `memory/MEMORY.md` | Personal | Agent / humans | Every turn | +| `instructions/…` | Shared | Humans / agent on request | Every turn | +| `skills/…` | Shared | Humans / agent on request | On demand (`/slash` command) | +| `context/…` | Shared | Humans / agent on request | Indexed every turn, read when relevant | Users can edit these memory files directly in the Workspace tab — they're regular resources. Delete lines the agent got wrong, keep personal preferences in `memory/MEMORY.md`, or promote team-wide rules into `AGENTS.md`. +## Workspace Connections {#workspace-connections} + +Workspace Connections are the reusable integration metadata layer for apps that +need the same third-party account. A connection records the provider, account +label, status, scopes, allowed app slugs, and credential references in portable +SQL. Secrets stay in the scoped credential store; connection records should only +point at credential keys such as `SLACK_BOT_TOKEN` or `GITHUB_TOKEN`. + +This is the foundation for “connect once, use everywhere”: Brain can ingest +approved repositories, Analytics can analyze the same provider later, and +Dispatch can remain the control plane for sharing credentials and policy. The +initial API lives in `@agent-native/core/workspace-connections` and is scoped by +the active request user/org. + +See [Workspace Connections](/docs/workspace-connections) for the reusable +connector pattern, app grant/readiness APIs, and concrete Slack, HubSpot, and +GitHub examples. + ## Skills {#skills} -Skills are Markdown resource files that give the agent deep domain knowledge for specific tasks. They live under the `skills/` path prefix in resources (e.g. `skills/data-analysis.md`, `skills/code-review.md`). +Skills are Markdown resource files that give the agent deep domain knowledge for specific tasks. They live under the `skills/` path prefix in resources, preferably as `skills//SKILL.md` (e.g. `skills/data-analysis/SKILL.md`, `skills/code-review/SKILL.md`). Flat `skills/.md` files still work for compatibility. When the agent encounters a task that matches a skill, it reads the skill file and follows its guidance. Skills referenced in `AGENTS.md` are discovered automatically. @@ -177,7 +309,7 @@ When the agent encounters a task that matches a skill, it reads the skill file a There are two ways to add skills: -1. **Via Workspace tab** — Create a new resource with a path like `skills/my-skill.md`. This works in both dev and production. +1. **Via Workspace tab** — Create a new resource with a path like `skills/my-skill/SKILL.md`. This works in both dev and production. 2. **Via code (dev only)** — Add a Markdown file to `.agents/skills/` in your project. These are available when the app runs in dev mode. ## Custom Agents {#custom-agents} @@ -309,15 +441,17 @@ Resources can be managed from server code, actions, or the REST API. REST endpoints mounted automatically: -| Method | Endpoint | Description | -| -------- | ----------------------------------------- | ------------------------- | -| `GET` | `/_agent-native/resources?scope=all` | List resources | -| `GET` | `/_agent-native/resources/tree?scope=all` | Get folder tree | -| `POST` | `/_agent-native/resources` | Create a resource | -| `GET` | `/_agent-native/resources/:id` | Get resource with content | -| `PUT` | `/_agent-native/resources/:id` | Update a resource | -| `DELETE` | `/_agent-native/resources/:id` | Delete a resource | -| `POST` | `/_agent-native/resources/upload` | Upload a file as resource | +| Method | Endpoint | Description | +| -------- | --------------------------------------------- | ------------------------------------ | +| `GET` | `/_agent-native/resources?scope=all` | List resources | +| `GET` | `/_agent-native/resources?scope=workspace` | List inherited workspace resources | +| `GET` | `/_agent-native/resources/tree?scope=all` | Get folder tree | +| `GET` | `/_agent-native/resources/effective?path=...` | Show the effective inheritance stack | +| `POST` | `/_agent-native/resources` | Create a resource | +| `GET` | `/_agent-native/resources/:id` | Get resource with content | +| `PUT` | `/_agent-native/resources/:id` | Update a resource | +| `DELETE` | `/_agent-native/resources/:id` | Delete a resource | +| `POST` | `/_agent-native/resources/upload` | Upload a file as resource | ### Action API {#script-api} @@ -328,7 +462,13 @@ The agent uses these built-in actions. You can also call them from your own acti pnpm action resource-list --scope all # Read a resource -pnpm action resource-read --path "skills/my-skill.md" +pnpm action resource-read --path "skills/my-skill/SKILL.md" + +# Read inherited workspace context managed by Dispatch +pnpm action resource-read --scope workspace --path "context/brand.md" + +# Show workspace -> organization/app -> personal precedence for a path +pnpm action resource-effective --path "context/brand.md" # Write a resource pnpm action resource-write --path "notes/meeting.md" --content "# Meeting Notes..." diff --git a/packages/core/package.json b/packages/core/package.json index 865d23d33..8f5a4f3fe 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -76,6 +76,9 @@ "./mcp-client": "./dist/mcp-client/index.js", "./tracking": "./dist/tracking/index.js", "./usage": "./dist/usage/store.js", + "./connections": "./dist/connections/index.js", + "./code-agents": "./dist/code-agents/index.js", + "./workspace-connections": "./dist/workspace-connections/index.js", "./notifications": "./dist/notifications/index.js", "./client/notifications": "./dist/client/notifications/index.js", "./progress": "./dist/progress/index.js", @@ -99,7 +102,7 @@ "dev": "tsc --watch", "typecheck": "tsc --noEmit", "test": "vitest --run src", - "prepack": "cp ../../README.md ./README.md", + "prepack": "npm run build && cp ../../README.md ./README.md", "prepublishOnly": "npm run build", "release": "npm version patch && npm publish --access public" }, @@ -292,7 +295,9 @@ "express": "^5.2.1", "firebase-admin": "^13.0.0", "node-pty": "^1.1.0", + "playwright": "^1.60.0", "react": "^19.2.5", + "react-dom": "19.2.5", "react-router": "^7.13.1", "tailwindcss": "catalog:", "typescript": "^6.0.3", diff --git a/packages/core/src/agent/app-model-defaults.ts b/packages/core/src/agent/app-model-defaults.ts new file mode 100644 index 000000000..666ae4ba7 --- /dev/null +++ b/packages/core/src/agent/app-model-defaults.ts @@ -0,0 +1,201 @@ +import { + deleteOrgSetting, + deleteUserSetting, + getOrgSetting, + getUserSetting, + putOrgSetting, + putUserSetting, +} from "../settings/index.js"; +import { getDbExec } from "../db/client.js"; +import { + getRequestOrgId, + getRequestUserEmail, +} from "../server/request-context.js"; + +export const AGENT_APP_MODEL_DEFAULT_KEY_PREFIX = "agent-app-model-default"; + +export type AgentAppModelDefaultScope = "org" | "user" | "default"; +export type AgentAppModelDefaultSource = "org" | "user" | "default"; + +export interface AgentAppModelDefaultSelection { + engine: string; + model: string; + updatedAt?: number; + updatedBy?: string; +} + +export interface AgentAppModelDefaultSettings { + appId: string; + engine: string | null; + model: string | null; + scope: AgentAppModelDefaultScope; + source: AgentAppModelDefaultSource; +} + +export function normalizeAgentAppModelDefaultAppId( + appId: string | null | undefined, +): string | null { + const normalized = appId?.trim().toLowerCase() ?? ""; + if (!normalized) return null; + if (!/^[a-z][a-z0-9-]*$/.test(normalized)) return null; + return normalized; +} + +export function agentAppModelDefaultSettingsKey(appId: string): string { + return `${AGENT_APP_MODEL_DEFAULT_KEY_PREFIX}:${appId}`; +} + +function parseSelection( + stored: Record | null, +): AgentAppModelDefaultSelection | null { + if (!stored) return null; + const engine = typeof stored.engine === "string" ? stored.engine.trim() : ""; + const model = typeof stored.model === "string" ? stored.model.trim() : ""; + if (!engine || !model) return null; + return { + engine, + model, + updatedAt: + typeof stored.updatedAt === "number" && Number.isFinite(stored.updatedAt) + ? stored.updatedAt + : undefined, + updatedBy: + typeof stored.updatedBy === "string" ? stored.updatedBy : undefined, + }; +} + +function emptySettings( + appId: string, + scope: AgentAppModelDefaultScope, +): AgentAppModelDefaultSettings { + return { + appId, + engine: null, + model: null, + scope, + source: "default", + }; +} + +export async function readAgentAppModelDefaultSettings( + ctx: { userEmail?: string | null; orgId?: string | null }, + appIdInput: string | null | undefined, +): Promise { + const appId = normalizeAgentAppModelDefaultAppId(appIdInput); + if (!appId) { + throw new Error("A valid appId is required."); + } + + const key = agentAppModelDefaultSettingsKey(appId); + if (ctx.orgId) { + const stored = parseSelection(await getOrgSetting(ctx.orgId, key)); + return stored + ? { appId, ...stored, scope: "org", source: "org" } + : emptySettings(appId, "org"); + } + + if (ctx.userEmail) { + const stored = parseSelection(await getUserSetting(ctx.userEmail, key)); + return stored + ? { appId, ...stored, scope: "user", source: "user" } + : emptySettings(appId, "user"); + } + + return emptySettings(appId, "default"); +} + +export async function writeAgentAppModelDefaultSettings( + ctx: { userEmail?: string | null; orgId?: string | null }, + appIdInput: string | null | undefined, + selection: { engine: string; model: string; updatedBy?: string | null }, +): Promise { + const appId = normalizeAgentAppModelDefaultAppId(appIdInput); + if (!appId) throw new Error("A valid appId is required."); + + const engine = selection.engine.trim(); + const model = selection.model.trim(); + if (!engine) throw new Error("engine is required."); + if (!model) throw new Error("model is required."); + + const value: Record = { + engine, + model, + updatedAt: Date.now(), + }; + if (selection.updatedBy) value.updatedBy = selection.updatedBy; + + const key = agentAppModelDefaultSettingsKey(appId); + if (ctx.orgId) { + await putOrgSetting(ctx.orgId, key, value); + return readAgentAppModelDefaultSettings(ctx, appId); + } + + if (!ctx.userEmail) { + throw new Error("Authentication required to update model defaults."); + } + + await putUserSetting(ctx.userEmail, key, value); + return readAgentAppModelDefaultSettings(ctx, appId); +} + +export async function resetAgentAppModelDefaultSettings( + ctx: { userEmail?: string | null; orgId?: string | null }, + appIdInput: string | null | undefined, +): Promise { + const appId = normalizeAgentAppModelDefaultAppId(appIdInput); + if (!appId) throw new Error("A valid appId is required."); + + const key = agentAppModelDefaultSettingsKey(appId); + if (ctx.orgId) { + await deleteOrgSetting(ctx.orgId, key); + return readAgentAppModelDefaultSettings(ctx, appId); + } + + if (!ctx.userEmail) { + throw new Error("Authentication required to reset model defaults."); + } + + await deleteUserSetting(ctx.userEmail, key); + return readAgentAppModelDefaultSettings(ctx, appId); +} + +export async function canUpdateAgentAppModelDefaultSettings( + userEmail: string | null | undefined, + orgId: string | null | undefined, +): Promise { + if (!userEmail) return false; + if (!orgId) return true; + + try { + const exec = getDbExec(); + const { rows } = await exec.execute({ + sql: `SELECT role FROM org_members WHERE org_id = ? AND LOWER(email) = ? LIMIT 1`, + args: [orgId, userEmail.toLowerCase()], + }); + const role = String((rows[0] as any)?.role ?? ""); + return role === "owner" || role === "admin"; + } catch { + return false; + } +} + +export async function getAgentAppModelDefaultForCurrentRequest( + appIdInput: string | null | undefined, +): Promise { + const appId = normalizeAgentAppModelDefaultAppId(appIdInput); + if (!appId) return null; + + const userEmail = getRequestUserEmail(); + const orgId = getRequestOrgId(); + + const settings = await readAgentAppModelDefaultSettings( + { userEmail, orgId }, + appId, + ).catch(() => null); + + if (!settings?.engine || !settings.model) return null; + return { + engine: settings.engine, + model: settings.model, + }; +} diff --git a/packages/core/src/agent/engine/registry.spec.ts b/packages/core/src/agent/engine/registry.spec.ts index e2f3d215c..6efb0973a 100644 --- a/packages/core/src/agent/engine/registry.spec.ts +++ b/packages/core/src/agent/engine/registry.spec.ts @@ -212,6 +212,26 @@ describe("AgentEngine registry", () => { const fakeEngine = { name: "ai-sdk:openai" } as any; expect(await getStoredModelForEngine(fakeEngine)).toBe("gpt-4o"); }); + + it("prefers a current app default model over the global model", async () => { + vi.doMock("../../server/request-context.js", () => ({ + getRequestUserEmail: () => "owner@example.com", + getRequestOrgId: () => undefined, + })); + vi.doMock("../../settings/store.js", () => ({ + getSetting: vi.fn(async (key: string) => { + if (key === "u:owner@example.com:agent-app-model-default:analytics") { + return { engine: "builder", model: "gemini-3-1-pro" }; + } + return { engine: "builder", model: "claude-sonnet-4-6" }; + }), + })); + const { getStoredModelForEngine } = await import("./registry.js"); + + expect( + await getStoredModelForEngine("builder", { appId: "analytics" }), + ).toBe("gemini-3-1-pro"); + }); }); it("resolveEngine uses env AGENT_ENGINE when set", async () => { @@ -344,6 +364,69 @@ describe("AgentEngine registry", () => { expect(resolved).toBe(fakeEngine); }); + it("resolveEngine honors a usable app default before the global setting", async () => { + vi.doMock("../../server/request-context.js", () => ({ + getRequestUserEmail: () => "owner@example.com", + getRequestOrgId: () => undefined, + })); + vi.doMock("../../settings/store.js", () => ({ + getSetting: vi.fn(async (key: string) => { + if (key === "u:owner@example.com:agent-app-model-default:analytics") { + return { engine: "app-engine", model: "app-model" }; + } + if (key === "agent-engine") { + return { engine: "global-engine", model: "global-model" }; + } + return null; + }), + })); + + const { registerAgentEngine, resolveEngine } = + await import("./registry.js"); + + const appEngine = { name: "app-engine", stream: vi.fn() } as any; + const globalEngine = { name: "global-engine", stream: vi.fn() } as any; + const appCreate = vi.fn().mockReturnValue(appEngine); + const globalCreate = vi.fn().mockReturnValue(globalEngine); + + registerAgentEngine({ + name: "app-engine", + label: "App Engine", + description: "", + capabilities: {} as any, + defaultModel: "app-model", + supportedModels: [], + requiredEnvVars: [], + create: appCreate, + }); + registerAgentEngine({ + name: "global-engine", + label: "Global Engine", + description: "", + capabilities: {} as any, + defaultModel: "global-model", + supportedModels: [], + requiredEnvVars: [], + create: globalCreate, + }); + registerAgentEngine({ + name: "anthropic", + label: "Anthropic", + description: "", + capabilities: {} as any, + defaultModel: "m", + supportedModels: [], + requiredEnvVars: [], + create: vi.fn() as any, + }); + + const resolved = await resolveEngine({ appId: "analytics" }); + + expect(appCreate).toHaveBeenCalled(); + expect(globalCreate).not.toHaveBeenCalled(); + expect(resolved).toBe(appEngine); + }); + describe("detectEngineFromUserSecrets", () => { beforeEach(() => { vi.resetModules(); diff --git a/packages/core/src/agent/engine/registry.ts b/packages/core/src/agent/engine/registry.ts index b6a4422ea..8a8222af6 100644 --- a/packages/core/src/agent/engine/registry.ts +++ b/packages/core/src/agent/engine/registry.ts @@ -14,6 +14,7 @@ import type { EngineStreamOptions, } from "./types.js"; import { getSetting } from "../../settings/store.js"; +import { getAgentAppModelDefaultForCurrentRequest } from "../app-model-defaults.js"; import { canUseDeployCredentialFallbackForRequest, readDeployCredentialEnv, @@ -285,24 +286,27 @@ export interface ResolveEngineConfig { apiKey?: string; /** Model override (used as part of engine config) */ model?: string; + /** App/template id used for org-scoped per-app model defaults. */ + appId?: string; } /** - * Resolve an AgentEngine from options → explicit env → request credentials → - * settings → env → default. + * Resolve an AgentEngine from options → explicit env → app default → + * request credentials → settings → env → default. * * Resolution order: * 1. Explicit `engineOption` from plugin options (string name, instance, or {name, config}) * 2. Env var AGENT_ENGINE - * 3. Current request's app_secrets; Builder wins by default when connected - * 4. Settings store key "agent-engine" → { engine: string }, when usable - * 5. Auto-detect deployment env credentials - * 6. Default "anthropic" (requires ANTHROPIC_API_KEY) + * 3. Org/user app-template default, when usable + * 4. Current request's app_secrets; Builder wins by default when connected + * 5. Settings store key "agent-engine" → { engine: string }, when usable + * 6. Auto-detect deployment env credentials + * 7. Default "anthropic" (requires ANTHROPIC_API_KEY) */ export async function resolveEngine( config: ResolveEngineConfig, ): Promise { - const { engineOption, apiKey, model: _model } = config; + const { engineOption, apiKey, model: _model, appId } = config; // 1. Explicit instance passed directly if ( @@ -348,6 +352,14 @@ export async function resolveEngine( if (entry) return entry.create(engineCreateConfig(apiKey)); } + const appDefault = await getAgentAppModelDefaultForCurrentRequest(appId); + if (appDefault?.engine) { + const entry = _registry.get(appDefault.engine); + if (entry && (await isStoredEngineUsableForRequest(appDefault, entry))) { + return entry.create(engineCreateConfig(apiKey)); + } + } + let stored: | (Record & { engine?: unknown; config?: unknown }) | null = null; @@ -419,8 +431,24 @@ export async function resolveEngine( */ export async function getStoredModelForEngine( engine: AgentEngine | string, + options: { appId?: string } = {}, ): Promise { const engineName = typeof engine === "string" ? engine : engine.name; + try { + const appDefault = await getAgentAppModelDefaultForCurrentRequest( + options.appId, + ); + if ( + appDefault?.engine === engineName && + typeof appDefault.model === "string" && + appDefault.model.length > 0 + ) { + return appDefault.model; + } + } catch { + // Settings/request context may not be available — fall through. + } + try { const stored = await getSetting("agent-engine"); if ( diff --git a/packages/core/src/agent/production-agent.ts b/packages/core/src/agent/production-agent.ts index 7c108fc6e..1cc86d801 100644 --- a/packages/core/src/agent/production-agent.ts +++ b/packages/core/src/agent/production-agent.ts @@ -486,6 +486,8 @@ export interface ProductionAgentOptions { | { name: string; config: Record }; /** Model to use. Defaults to the resolved engine's default model. */ model?: string; + /** App/template id used for org-scoped per-app model defaults. */ + appId?: string; /** Default reasoning effort for requests that do not supply an override. */ reasoningEffort?: ReasoningEffort; /** Provider-specific options passed through to the engine */ @@ -1934,10 +1936,12 @@ export function createProductionAgentHandler( engineOption: requestEngine ?? options.engine, apiKey: effectiveApiKey, model: configuredModel, + appId: options.appId, }); } catch { engine = await resolveEngine({ apiKey: effectiveApiKey, + appId: options.appId, }); } @@ -1949,7 +1953,7 @@ export function createProductionAgentHandler( const model = requestModel ?? configuredModel ?? - (await getStoredModelForEngine(engine)) ?? + (await getStoredModelForEngine(engine, { appId: options.appId })) ?? engine.defaultModel; const reasoningEffort = normalizeReasoningEffortForModel( model, diff --git a/packages/core/src/browser-sessions/actions.ts b/packages/core/src/browser-sessions/actions.ts new file mode 100644 index 000000000..980e75c16 --- /dev/null +++ b/packages/core/src/browser-sessions/actions.ts @@ -0,0 +1,302 @@ +import type { ActionEntry } from "../agent/production-agent.js"; +import { + callBrowserSession, + getBrowserSession, + listBrowserSessions, +} from "./store.js"; + +export interface CreateBrowserSessionActionEntriesOptions { + getOwnerEmail: () => string | null | undefined; + getDefaultTimeoutMs?: () => number | undefined; +} + +function requireOwner( + options: CreateBrowserSessionActionEntriesOptions, +): string { + const owner = options.getOwnerEmail()?.trim(); + if (!owner) { + throw new Error("No authenticated user is available for browser sessions"); + } + return owner; +} + +function readString( + args: Record, + key: string, +): string | undefined { + const value = args[key]; + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function readTimeoutMs( + args: Record, + options: CreateBrowserSessionActionEntriesOptions, +): number | undefined { + const raw = args.timeoutMs; + if (typeof raw === "number" && raw > 0) return raw; + if (typeof raw === "string" && raw.trim()) { + const parsed = Number(raw); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + return options.getDefaultTimeoutMs?.(); +} + +async function resolveSessionId( + ownerEmail: string, + requestedSessionId: string | undefined, +): Promise { + if (requestedSessionId) return requestedSessionId; + const sessions = await listBrowserSessions(ownerEmail, { limit: 5 }); + if (sessions.length === 0) { + throw new Error( + "No active browser sessions are connected. Open the embedded Agent-Native sidecar in the host app first.", + ); + } + return sessions[0].sessionId; +} + +function compactSession( + session: Awaited>, +) { + if (!session) return null; + const route = + session.context && + typeof session.context.route === "object" && + session.context.route + ? session.context.route + : undefined; + const resource = + session.context && + typeof session.context.resource === "object" && + session.context.resource + ? session.context.resource + : undefined; + return { + sessionId: session.sessionId, + label: session.label, + url: session.url, + active: session.active, + connectedAt: session.connectedAt, + lastSeenAt: session.lastSeenAt, + expiresAt: session.expiresAt, + route, + resource, + actionCount: session.actions.length, + actions: session.actions.map((action) => ({ + name: action.name, + description: action.description, + source: action.source, + availability: action.availability, + destructive: action.destructive, + requiresApproval: action.requiresApproval, + schema: action.schema ?? action.parameters, + })), + }; +} + +export function createBrowserSessionActionEntries( + options: CreateBrowserSessionActionEntriesOptions, +): Record { + return { + "list-browser-sessions": { + readOnly: true, + tool: { + description: + "List active browser tabs connected through the Agent-Native embedding SDK. Use this when you need to choose which live host page to inspect or operate.", + parameters: { + type: "object", + properties: { + includeExpired: { + type: "boolean", + description: + "Include recently expired sessions for debugging. Defaults to false.", + }, + }, + }, + }, + run: async (args: Record) => { + const ownerEmail = requireOwner(options); + const sessions = await listBrowserSessions(ownerEmail, { + includeExpired: + args.includeExpired === true || args.includeExpired === "true", + }); + return { + ok: true, + sessions: sessions.map(compactSession), + }; + }, + }, + + "view-browser-session": { + readOnly: true, + tool: { + description: + "Read the current page context and screen snapshot from a connected browser session. Omit sessionId to use the most recently active tab.", + parameters: { + type: "object", + properties: { + sessionId: { + type: "string", + description: + "Browser session id from list-browser-sessions. Optional when only one tab is active.", + }, + timeoutMs: { + type: "number", + description: "How long to wait for the live tab to respond.", + }, + }, + }, + }, + run: async (args: Record) => { + const ownerEmail = requireOwner(options); + const sessionId = await resolveSessionId( + ownerEmail, + readString(args, "sessionId"), + ); + const context = await callBrowserSession( + ownerEmail, + sessionId, + { type: "get-context", timeoutMs: readTimeoutMs(args, options) }, + { timeoutMs: readTimeoutMs(args, options) }, + ); + return { ok: true, sessionId, context }; + }, + }, + + "list-browser-session-actions": { + readOnly: true, + tool: { + description: + "List live client actions currently exposed by a connected browser session. These actions can change after navigation or selection changes.", + parameters: { + type: "object", + properties: { + sessionId: { + type: "string", + description: + "Browser session id from list-browser-sessions. Optional when only one tab is active.", + }, + timeoutMs: { + type: "number", + description: "How long to wait for the live tab to respond.", + }, + }, + }, + }, + run: async (args: Record) => { + const ownerEmail = requireOwner(options); + const sessionId = await resolveSessionId( + ownerEmail, + readString(args, "sessionId"), + ); + const actions = await callBrowserSession( + ownerEmail, + sessionId, + { type: "list-actions", timeoutMs: readTimeoutMs(args, options) }, + { timeoutMs: readTimeoutMs(args, options) }, + ); + return { ok: true, sessionId, actions }; + }, + }, + + "run-browser-session-action": { + tool: { + description: + "Run a live client action in a connected browser tab. Use list-browser-session-actions first to discover the current action names and schemas. Omit sessionId to use the most recently active tab.", + parameters: { + type: "object", + properties: { + sessionId: { + type: "string", + description: + "Browser session id from list-browser-sessions. Optional when only one tab is active.", + }, + name: { + type: "string", + description: "The live client action name to run.", + }, + args: { + type: "object", + description: + "JSON-serializable arguments for the client action. Match the schema returned by list-browser-session-actions.", + }, + timeoutMs: { + type: "number", + description: "How long to wait for the live tab to respond.", + }, + }, + required: ["name"], + }, + }, + run: async (args: Record) => { + const ownerEmail = requireOwner(options); + const name = readString(args, "name"); + if (!name) throw new Error("name is required"); + const timeoutMs = readTimeoutMs(args, options); + const sessionId = await resolveSessionId( + ownerEmail, + readString(args, "sessionId"), + ); + const result = await callBrowserSession( + ownerEmail, + sessionId, + { type: "run-action", name, args: args.args, timeoutMs }, + { timeoutMs }, + ); + return { ok: true, sessionId, name, result }; + }, + }, + + "send-browser-session-command": { + tool: { + description: + "Send a command to a connected host app, such as refreshData, navigate, remountView, hardReload, openResource, or requestApproval. Omit command to ask the host to refresh visible data.", + parameters: { + type: "object", + properties: { + sessionId: { + type: "string", + description: + "Browser session id from list-browser-sessions. Optional when only one tab is active.", + }, + command: { + type: "string", + description: + "Built-in or custom host command. Defaults to refreshData.", + }, + payload: { + type: "object", + description: + "JSON-serializable payload for the command, such as a route target or refresh query key.", + }, + timeoutMs: { + type: "number", + description: "How long to wait for the live tab to respond.", + }, + }, + }, + }, + run: async (args: Record) => { + const ownerEmail = requireOwner(options); + const timeoutMs = readTimeoutMs(args, options); + const sessionId = await resolveSessionId( + ownerEmail, + readString(args, "sessionId"), + ); + const command = readString(args, "command") ?? "refreshData"; + const result = await callBrowserSession( + ownerEmail, + sessionId, + { + type: "command", + command, + payload: args.payload, + timeoutMs, + }, + { timeoutMs }, + ); + return { ok: true, sessionId, command, result }; + }, + }, + }; +} diff --git a/packages/core/src/browser-sessions/routes.ts b/packages/core/src/browser-sessions/routes.ts new file mode 100644 index 000000000..af5c5dc00 --- /dev/null +++ b/packages/core/src/browser-sessions/routes.ts @@ -0,0 +1,233 @@ +import { + defineEventHandler, + getMethod, + setResponseHeader, + setResponseStatus, +} from "h3"; +import type { H3Event } from "h3"; +import { getH3App } from "../server/framework-request-handler.js"; +import { readBody } from "../server/h3-helpers.js"; +import { getSession } from "../server/auth.js"; +import { + claimBrowserSessionRequest, + completeBrowserSessionRequest, + createBrowserSessionRequest, + disconnectBrowserSession, + getBrowserSession, + getBrowserSessionRequest, + listBrowserSessions, + registerBrowserSession, + waitForBrowserSessionRequest, +} from "./store.js"; +import type { + CreateAgentNativeBrowserSessionRequestInput, + RegisterAgentNativeBrowserSessionInput, +} from "./types.js"; + +export interface MountBrowserSessionRoutesOptions { + routePrefix?: string; + getOwnerFromEvent?: (event: H3Event) => string | Promise; +} + +function decodeSegment(value: string | undefined): string | undefined { + if (!value) return undefined; + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +async function defaultOwnerFromEvent(event: H3Event): Promise { + const session = await getSession(event); + if (!session?.email) { + throw Object.assign(new Error("Authentication required"), { + statusCode: 401, + }); + } + return session.email; +} + +async function ownerFromEvent( + event: H3Event, + options: MountBrowserSessionRoutesOptions, +): Promise { + return options.getOwnerFromEvent + ? options.getOwnerFromEvent(event) + : defaultOwnerFromEvent(event); +} + +function methodNotAllowed(event: H3Event) { + setResponseStatus(event, 405); + return { error: "Method not allowed" }; +} + +function badRequest(event: H3Event, error: string) { + setResponseStatus(event, 400); + return { error }; +} + +function notFound(event: H3Event, error: string) { + setResponseStatus(event, 404); + return { error }; +} + +function errorResponse(event: H3Event, error: unknown) { + const statusCode = + typeof (error as { statusCode?: unknown })?.statusCode === "number" + ? (error as { statusCode: number }).statusCode + : 500; + setResponseStatus(event, statusCode); + return { + error: error instanceof Error ? error.message : String(error), + }; +} + +async function readJsonBody(event: H3Event): Promise { + return ((await readBody(event).catch(() => ({}))) || {}) as T; +} + +export function mountBrowserSessionRoutes( + nitroApp: any, + options: MountBrowserSessionRoutesOptions = {}, +): void { + const routePrefix = options.routePrefix ?? "/_agent-native"; + const basePath = `${routePrefix}/browser-sessions`; + + getH3App(nitroApp).use( + basePath, + defineEventHandler(async (event: H3Event) => { + setResponseHeader(event, "Cache-Control", "no-store"); + + const method = getMethod(event); + const raw = (event.path || "/").split("?")[0]; + const segments = raw + .replace(/^\/+/, "") + .split("/") + .filter(Boolean) + .map(decodeSegment); + + try { + const ownerEmail = await ownerFromEvent(event, options); + + if (segments.length === 0) { + if (method === "GET") { + const sessions = await listBrowserSessions(ownerEmail); + return { ok: true, sessions }; + } + if (method === "POST") { + const body = + await readJsonBody(event); + const session = await registerBrowserSession(ownerEmail, body); + return { ok: true, session }; + } + return methodNotAllowed(event); + } + + const sessionId = segments[0]; + if (!sessionId) return badRequest(event, "sessionId is required"); + + if (segments.length === 1) { + if (method === "GET") { + const session = await getBrowserSession(ownerEmail, sessionId, { + includeExpired: true, + }); + return session + ? { ok: true, session } + : notFound(event, "Session not found"); + } + if (method === "DELETE") { + const deleted = await disconnectBrowserSession( + ownerEmail, + sessionId, + ); + return { ok: true, deleted }; + } + return methodNotAllowed(event); + } + + if (segments[1] === "heartbeat") { + if (method !== "POST") return methodNotAllowed(event); + const body = + await readJsonBody(event); + const session = await registerBrowserSession(ownerEmail, { + ...body, + session: { + ...(body.session ?? {}), + id: sessionId, + }, + sessionId, + }); + return { ok: true, session }; + } + + if (segments[1] !== "requests") { + return notFound(event, "Unknown browser-session route"); + } + + if (segments.length === 2) { + if (method !== "POST") return methodNotAllowed(event); + const body = await readJsonBody< + CreateAgentNativeBrowserSessionRequestInput & { wait?: boolean } + >(event); + const request = await createBrowserSessionRequest( + ownerEmail, + sessionId, + body, + ); + if (body.wait === true) { + const result = await waitForBrowserSessionRequest( + ownerEmail, + request.id, + { timeoutMs: body.timeoutMs }, + ); + return { ok: true, requestId: request.id, result }; + } + return { ok: true, request }; + } + + if (segments.length === 3 && segments[2] === "claim") { + if (method !== "POST") return methodNotAllowed(event); + const request = await claimBrowserSessionRequest( + ownerEmail, + sessionId, + ); + return { ok: true, request }; + } + + const requestId = segments[2]; + if (!requestId) return badRequest(event, "requestId is required"); + + if (segments.length === 3) { + if (method !== "GET") return methodNotAllowed(event); + const request = await getBrowserSessionRequest(ownerEmail, requestId); + return request + ? { ok: true, request } + : notFound(event, "Request not found"); + } + + if (segments.length === 4 && segments[3] === "complete") { + if (method !== "POST") return methodNotAllowed(event); + const body = await readJsonBody<{ + ok?: boolean; + result?: unknown; + error?: string; + }>(event); + const request = await completeBrowserSessionRequest( + ownerEmail, + sessionId, + requestId, + body.ok === false + ? { ok: false, error: body.error, result: body.result } + : { ok: true, result: body.result }, + ); + return { ok: true, request }; + } + + return notFound(event, "Unknown browser-session route"); + } catch (error) { + return errorResponse(event, error); + } + }), + ); +} diff --git a/packages/core/src/browser-sessions/store.spec.ts b/packages/core/src/browser-sessions/store.spec.ts new file mode 100644 index 000000000..834c0d682 --- /dev/null +++ b/packages/core/src/browser-sessions/store.spec.ts @@ -0,0 +1,202 @@ +import Database from "better-sqlite3"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +vi.mock("../db/client.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDbExec: () => sharedClient, + isPostgres: () => false, + intType: () => "INTEGER", + retryOnDdlRace: (fn: () => Promise) => fn(), + }; +}); + +interface FrameworkClient { + execute(arg: string | { sql: string; args?: any[] }): Promise<{ + rows: any[]; + rowsAffected: number; + }>; +} + +let sqlite: Database.Database; +let sharedClient: FrameworkClient = { + async execute() { + return { rows: [], rowsAffected: 0 }; + }, +}; + +beforeAll(() => { + sqlite = new Database(":memory:"); + sharedClient = { + async execute(arg) { + const sql = typeof arg === "string" ? arg : arg.sql; + const args = typeof arg === "string" ? [] : (arg.args ?? []); + const stmt = sqlite.prepare(sql); + if (/^\s*select/i.test(sql)) { + const rows = stmt.all(...args) as any[]; + return { rows, rowsAffected: 0 }; + } + const result = stmt.run(...args); + return { rows: [], rowsAffected: Number(result.changes ?? 0) }; + }, + }; +}); + +beforeEach(() => { + for (const table of [ + "agent_native_browser_session_requests", + "agent_native_browser_sessions", + ]) { + try { + sqlite.prepare(`DELETE FROM ${table}`).run(); + } catch { + // First test creates the tables through the store initializer. + } + } +}); + +afterAll(() => { + sqlite.close(); +}); + +describe("browser session store", () => { + it("registers sessions and scopes them to the owner", async () => { + const { listBrowserSessions, registerBrowserSession } = + await import("./store.js"); + + await registerBrowserSession("alice@example.com", { + session: { id: "tab-1", label: "Customer tab" }, + context: { + route: { name: "customer-detail" }, + resource: { type: "customer", id: "acme" }, + }, + actions: [ + { + name: "select-row", + description: "Select a visible row", + schema: { type: "object" }, + }, + ], + }); + + const aliceSessions = await listBrowserSessions("alice@example.com"); + expect(aliceSessions).toHaveLength(1); + expect(aliceSessions[0]).toMatchObject({ + sessionId: "tab-1", + label: "Customer tab", + active: true, + context: { route: { name: "customer-detail" } }, + }); + expect(aliceSessions[0].actions[0]).toMatchObject({ + name: "select-row", + }); + + await expect(listBrowserSessions("bob@example.com")).resolves.toEqual([]); + }); + + it("claims and completes pending requests once", async () => { + const { + claimBrowserSessionRequest, + completeBrowserSessionRequest, + createBrowserSessionRequest, + getBrowserSessionRequest, + registerBrowserSession, + } = await import("./store.js"); + + await registerBrowserSession("alice@example.com", { + session: { id: "tab-1" }, + }); + const request = await createBrowserSessionRequest( + "alice@example.com", + "tab-1", + { + type: "run-action", + name: "select-row", + args: { rowId: "row-1" }, + }, + ); + + const claimed = await claimBrowserSessionRequest( + "alice@example.com", + "tab-1", + ); + expect(claimed).toMatchObject({ + id: request.id, + status: "claimed", + type: "run-action", + name: "select-row", + args: { rowId: "row-1" }, + }); + await expect( + claimBrowserSessionRequest("alice@example.com", "tab-1"), + ).resolves.toBeNull(); + + const completed = await completeBrowserSessionRequest( + "alice@example.com", + "tab-1", + request.id, + { ok: true, result: { selected: "row-1" } }, + ); + expect(completed).toMatchObject({ + status: "completed", + result: { selected: "row-1" }, + }); + + await expect( + getBrowserSessionRequest("alice@example.com", request.id), + ).resolves.toMatchObject({ status: "completed" }); + }); + + it("waits for a live browser result", async () => { + const { + callBrowserSession, + claimBrowserSessionRequest, + completeBrowserSessionRequest, + registerBrowserSession, + } = await import("./store.js"); + + await registerBrowserSession("alice@example.com", { + session: { id: "tab-1" }, + }); + + const resultPromise = callBrowserSession( + "alice@example.com", + "tab-1", + { type: "command", command: "refreshData", payload: { scope: "rows" } }, + { timeoutMs: 1000, pollMs: 10 }, + ); + + const claimed = await vi.waitFor(async () => { + const request = await claimBrowserSessionRequest( + "alice@example.com", + "tab-1", + ); + expect(request).toBeTruthy(); + return request; + }); + + expect(claimed).toMatchObject({ + type: "command", + command: "refreshData", + payload: { scope: "rows" }, + }); + + await completeBrowserSessionRequest( + "alice@example.com", + "tab-1", + claimed!.id, + { ok: true, result: { refreshed: true } }, + ); + + await expect(resultPromise).resolves.toEqual({ refreshed: true }); + }); +}); diff --git a/packages/core/src/browser-sessions/store.ts b/packages/core/src/browser-sessions/store.ts new file mode 100644 index 000000000..08e8cacf1 --- /dev/null +++ b/packages/core/src/browser-sessions/store.ts @@ -0,0 +1,655 @@ +import { + getDbExec, + intType, + isPostgres, + retryOnDdlRace, + safeJsonParse, +} from "../db/client.js"; +import type { + AgentNativeBrowserSession, + AgentNativeBrowserSessionAction, + AgentNativeBrowserSessionRecord, + AgentNativeBrowserSessionRequest, + AgentNativeBrowserSessionRequestStatus, + AgentNativeBrowserSessionRequestType, + CreateAgentNativeBrowserSessionRequestInput, + RegisterAgentNativeBrowserSessionInput, +} from "./types.js"; + +export const DEFAULT_BROWSER_SESSION_TTL_MS = 45_000; +export const DEFAULT_BROWSER_SESSION_REQUEST_TIMEOUT_MS = 30_000; +export const DEFAULT_BROWSER_SESSION_REQUEST_POLL_MS = 250; + +const SESSION_TABLE = "agent_native_browser_sessions"; +const REQUEST_TABLE = "agent_native_browser_session_requests"; +const SAFE_ID_RE = /^[A-Za-z0-9._:-]{1,160}$/; + +let initPromise: Promise | undefined; + +function nowMs(): number { + return Date.now(); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function ensureTables(): Promise { + if (!initPromise) { + initPromise = (async () => { + const client = getDbExec(); + await retryOnDdlRace(() => + client.execute(` + CREATE TABLE IF NOT EXISTS ${SESSION_TABLE} ( + owner_email TEXT NOT NULL, + session_id TEXT NOT NULL, + label TEXT, + url TEXT, + session_json TEXT NOT NULL, + context_json TEXT, + actions_json TEXT, + connected_at ${intType()} NOT NULL, + last_seen_at ${intType()} NOT NULL, + expires_at ${intType()} NOT NULL, + PRIMARY KEY (owner_email, session_id) + ) + `), + ); + await retryOnDdlRace(() => + client.execute(` + CREATE INDEX IF NOT EXISTS agent_native_browser_sessions_owner_seen_idx + ON ${SESSION_TABLE} (owner_email, last_seen_at) + `), + ); + await retryOnDdlRace(() => + client.execute(` + CREATE TABLE IF NOT EXISTS ${REQUEST_TABLE} ( + owner_email TEXT NOT NULL, + session_id TEXT NOT NULL, + request_id TEXT NOT NULL, + type TEXT NOT NULL, + name TEXT, + command TEXT, + payload_json TEXT, + status TEXT NOT NULL, + created_at ${intType()} NOT NULL, + claimed_at ${intType()}, + completed_at ${intType()}, + expires_at ${intType()} NOT NULL, + result_json TEXT, + error TEXT, + PRIMARY KEY (owner_email, request_id) + ) + `), + ); + await retryOnDdlRace(() => + client.execute(` + CREATE INDEX IF NOT EXISTS agent_native_browser_session_requests_pending_idx + ON ${REQUEST_TABLE} (owner_email, session_id, status, created_at) + `), + ); + })(); + } + return initPromise; +} + +function assertOwnerEmail(ownerEmail: string): string { + const trimmed = ownerEmail.trim(); + if (!trimmed) throw new Error("ownerEmail is required"); + return trimmed; +} + +function assertSafeId(value: string, label: string): string { + const trimmed = value.trim(); + if (!SAFE_ID_RE.test(trimmed)) { + throw new Error( + `${label} must be 1-160 characters using letters, numbers, dot, underscore, colon, or dash`, + ); + } + return trimmed; +} + +function generateId(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} + +function json(value: unknown): string { + return JSON.stringify(value ?? null); +} + +function parseOptionalObject( + value: unknown, +): Record | undefined { + if (value == null) return undefined; + const parsed = safeJsonParse(value, undefined); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : undefined; +} + +function parseActions(value: unknown): AgentNativeBrowserSessionAction[] { + const parsed = safeJsonParse(value, []); + return Array.isArray(parsed) + ? (parsed.filter( + (action) => + action && + typeof action === "object" && + typeof (action as { name?: unknown }).name === "string", + ) as AgentNativeBrowserSessionAction[]) + : []; +} + +function parseSession( + value: unknown, + sessionId: string, +): AgentNativeBrowserSession { + const parsed = safeJsonParse(value, { id: sessionId }); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? ({ + id: sessionId, + ...(parsed as Record), + } as AgentNativeBrowserSession) + : { id: sessionId }; +} + +function coerceTime(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim()) { + const parsed = Date.parse(value); + if (Number.isFinite(parsed)) return parsed; + } + return undefined; +} + +function normalizeSessionInput(input: RegisterAgentNativeBrowserSessionInput): { + sessionId: string; + session: AgentNativeBrowserSession; + label?: string; + url?: string; + context?: Record; + actions: AgentNativeBrowserSessionAction[]; + connectedAt: number; + ttlMs: number; +} { + const sessionRecord: Partial = + input.session && typeof input.session === "object" ? input.session : {}; + const rawId = input.sessionId || sessionRecord.id; + if (typeof rawId !== "string") { + throw new Error("session.id is required"); + } + const sessionId = assertSafeId(rawId, "session.id"); + const now = nowMs(); + const connectedAt = coerceTime(sessionRecord.connectedAt) ?? now; + const label = + typeof input.label === "string" && input.label.trim() + ? input.label.trim() + : typeof sessionRecord.label === "string" && sessionRecord.label.trim() + ? sessionRecord.label.trim() + : undefined; + const url = + typeof input.url === "string" && input.url.trim() + ? input.url.trim() + : typeof sessionRecord.url === "string" && sessionRecord.url.trim() + ? sessionRecord.url.trim() + : undefined; + const session: AgentNativeBrowserSession = { + ...sessionRecord, + id: sessionId, + ...(label ? { label } : {}), + connectedAt: new Date(connectedAt).toISOString(), + ...(url ? { url } : {}), + }; + return { + sessionId, + session, + label, + url, + context: + input.context && + typeof input.context === "object" && + !Array.isArray(input.context) + ? input.context + : undefined, + actions: Array.isArray(input.actions) ? input.actions : [], + connectedAt, + ttlMs: + typeof input.ttlMs === "number" && input.ttlMs > 0 + ? Math.min(input.ttlMs, 5 * 60 * 1000) + : DEFAULT_BROWSER_SESSION_TTL_MS, + }; +} + +function rowToSession( + row: Record, + now = nowMs(), +): AgentNativeBrowserSessionRecord { + const sessionId = String(row.session_id ?? ""); + const expiresAt = Number(row.expires_at ?? 0); + return { + sessionId, + session: parseSession(row.session_json, sessionId), + label: typeof row.label === "string" ? row.label : undefined, + url: typeof row.url === "string" ? row.url : undefined, + context: parseOptionalObject(row.context_json), + actions: parseActions(row.actions_json), + connectedAt: Number(row.connected_at ?? 0), + lastSeenAt: Number(row.last_seen_at ?? 0), + expiresAt, + active: expiresAt > now, + }; +} + +function rowToRequest( + row: Record, +): AgentNativeBrowserSessionRequest { + const type = String( + row.type ?? "get-context", + ) as AgentNativeBrowserSessionRequestType; + const payload = safeJsonParse(row.payload_json, undefined); + const result = safeJsonParse(row.result_json, undefined); + const request: AgentNativeBrowserSessionRequest = { + id: String(row.request_id ?? ""), + sessionId: String(row.session_id ?? ""), + type, + status: String( + row.status ?? "pending", + ) as AgentNativeBrowserSessionRequestStatus, + createdAt: Number(row.created_at ?? 0), + expiresAt: Number(row.expires_at ?? 0), + ...(typeof row.name === "string" && row.name ? { name: row.name } : {}), + ...(typeof row.command === "string" && row.command + ? { command: row.command } + : {}), + ...(row.claimed_at != null ? { claimedAt: Number(row.claimed_at) } : {}), + ...(row.completed_at != null + ? { completedAt: Number(row.completed_at) } + : {}), + ...(row.error ? { error: String(row.error) } : {}), + ...(row.result_json != null ? { result } : {}), + }; + if (type === "run-action") request.args = payload; + else request.payload = payload; + return request; +} + +async function expireOldRequests( + ownerEmail: string, + sessionId?: string, +): Promise { + await ensureTables(); + const client = getDbExec(); + const now = nowMs(); + await client.execute({ + sql: sessionId + ? `UPDATE ${REQUEST_TABLE} + SET status = 'expired', completed_at = ?, error = 'Browser-session request expired' + WHERE owner_email = ? AND session_id = ? AND status IN ('pending', 'claimed') AND expires_at < ?` + : `UPDATE ${REQUEST_TABLE} + SET status = 'expired', completed_at = ?, error = 'Browser-session request expired' + WHERE owner_email = ? AND status IN ('pending', 'claimed') AND expires_at < ?`, + args: sessionId + ? [now, ownerEmail, sessionId, now] + : [now, ownerEmail, now], + }); +} + +export async function registerBrowserSession( + ownerEmailInput: string, + input: RegisterAgentNativeBrowserSessionInput, +): Promise { + const ownerEmail = assertOwnerEmail(ownerEmailInput); + const normalized = normalizeSessionInput(input); + await ensureTables(); + const client = getDbExec(); + const now = nowMs(); + const expiresAt = now + normalized.ttlMs; + + await client.execute({ + sql: isPostgres() + ? `INSERT INTO ${SESSION_TABLE} + (owner_email, session_id, label, url, session_json, context_json, actions_json, connected_at, last_seen_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (owner_email, session_id) DO UPDATE SET + label = EXCLUDED.label, + url = EXCLUDED.url, + session_json = EXCLUDED.session_json, + context_json = EXCLUDED.context_json, + actions_json = EXCLUDED.actions_json, + last_seen_at = EXCLUDED.last_seen_at, + expires_at = EXCLUDED.expires_at` + : `INSERT OR REPLACE INTO ${SESSION_TABLE} + (owner_email, session_id, label, url, session_json, context_json, actions_json, connected_at, last_seen_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + args: [ + ownerEmail, + normalized.sessionId, + normalized.label ?? null, + normalized.url ?? null, + json(normalized.session), + normalized.context ? json(normalized.context) : null, + json(normalized.actions), + normalized.connectedAt, + now, + expiresAt, + ], + }); + + const saved = await getBrowserSession(ownerEmail, normalized.sessionId, { + includeExpired: true, + }); + if (!saved) throw new Error("Failed to register browser session"); + return saved; +} + +export async function listBrowserSessions( + ownerEmailInput: string, + options: { includeExpired?: boolean; limit?: number } = {}, +): Promise { + const ownerEmail = assertOwnerEmail(ownerEmailInput); + await ensureTables(); + await expireOldRequests(ownerEmail); + const client = getDbExec(); + const limit = + typeof options.limit === "number" && options.limit > 0 + ? Math.min(Math.floor(options.limit), 100) + : 25; + const now = nowMs(); + const { rows } = await client.execute({ + sql: `SELECT * FROM ${SESSION_TABLE} + WHERE owner_email = ? + ORDER BY last_seen_at DESC + LIMIT ?`, + args: [ownerEmail, limit], + }); + const sessions = rows.map((row) => rowToSession(row, now)); + return options.includeExpired + ? sessions + : sessions.filter((session) => session.active); +} + +export async function getBrowserSession( + ownerEmailInput: string, + sessionIdInput: string, + options: { includeExpired?: boolean } = {}, +): Promise { + const ownerEmail = assertOwnerEmail(ownerEmailInput); + const sessionId = assertSafeId(sessionIdInput, "sessionId"); + await ensureTables(); + const client = getDbExec(); + const { rows } = await client.execute({ + sql: `SELECT * FROM ${SESSION_TABLE} WHERE owner_email = ? AND session_id = ?`, + args: [ownerEmail, sessionId], + }); + if (rows.length === 0) return null; + const session = rowToSession(rows[0]); + return options.includeExpired || session.active ? session : null; +} + +export async function disconnectBrowserSession( + ownerEmailInput: string, + sessionIdInput: string, +): Promise { + const ownerEmail = assertOwnerEmail(ownerEmailInput); + const sessionId = assertSafeId(sessionIdInput, "sessionId"); + await ensureTables(); + const client = getDbExec(); + await client.execute({ + sql: `UPDATE ${REQUEST_TABLE} + SET status = 'expired', completed_at = ?, error = 'Browser session disconnected' + WHERE owner_email = ? AND session_id = ? AND status IN ('pending', 'claimed')`, + args: [nowMs(), ownerEmail, sessionId], + }); + const result = await client.execute({ + sql: `DELETE FROM ${SESSION_TABLE} WHERE owner_email = ? AND session_id = ?`, + args: [ownerEmail, sessionId], + }); + return result.rowsAffected > 0; +} + +function normalizeRequestInput( + input: CreateAgentNativeBrowserSessionRequestInput, +): { + type: AgentNativeBrowserSessionRequestType; + name?: string; + command?: string; + payload?: unknown; + timeoutMs: number; +} { + if ( + input.type !== "get-context" && + input.type !== "list-actions" && + input.type !== "run-action" && + input.type !== "command" + ) { + throw new Error( + "request type must be get-context, list-actions, run-action, or command", + ); + } + const name = + typeof input.name === "string" && input.name.trim() + ? input.name.trim() + : undefined; + if (input.type === "run-action" && !name) { + throw new Error("name is required for run-action requests"); + } + const command = + typeof input.command === "string" && input.command.trim() + ? input.command.trim() + : input.type === "command" + ? "refreshData" + : undefined; + return { + type: input.type, + name, + command, + payload: input.type === "run-action" ? input.args : input.payload, + timeoutMs: + typeof input.timeoutMs === "number" && input.timeoutMs > 0 + ? Math.min(input.timeoutMs, 2 * 60 * 1000) + : DEFAULT_BROWSER_SESSION_REQUEST_TIMEOUT_MS, + }; +} + +export async function createBrowserSessionRequest( + ownerEmailInput: string, + sessionIdInput: string, + input: CreateAgentNativeBrowserSessionRequestInput, +): Promise { + const ownerEmail = assertOwnerEmail(ownerEmailInput); + const sessionId = assertSafeId(sessionIdInput, "sessionId"); + const session = await getBrowserSession(ownerEmail, sessionId); + if (!session) { + throw new Error(`No active browser session found for "${sessionId}"`); + } + const normalized = normalizeRequestInput(input); + await ensureTables(); + const client = getDbExec(); + const createdAt = nowMs(); + const requestId = generateId("browser-request"); + const expiresAt = createdAt + normalized.timeoutMs + 15_000; + await client.execute({ + sql: isPostgres() + ? `INSERT INTO ${REQUEST_TABLE} + (owner_email, session_id, request_id, type, name, command, payload_json, status, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?) + ON CONFLICT (owner_email, request_id) DO NOTHING` + : `INSERT OR IGNORE INTO ${REQUEST_TABLE} + (owner_email, session_id, request_id, type, name, command, payload_json, status, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`, + args: [ + ownerEmail, + sessionId, + requestId, + normalized.type, + normalized.name ?? null, + normalized.command ?? null, + json(normalized.payload), + createdAt, + expiresAt, + ], + }); + const request = await getBrowserSessionRequest(ownerEmail, requestId); + if (!request) throw new Error("Failed to create browser-session request"); + return request; +} + +export async function getBrowserSessionRequest( + ownerEmailInput: string, + requestIdInput: string, +): Promise { + const ownerEmail = assertOwnerEmail(ownerEmailInput); + const requestId = assertSafeId(requestIdInput, "requestId"); + await ensureTables(); + const client = getDbExec(); + const { rows } = await client.execute({ + sql: `SELECT * FROM ${REQUEST_TABLE} WHERE owner_email = ? AND request_id = ?`, + args: [ownerEmail, requestId], + }); + return rows.length ? rowToRequest(rows[0]) : null; +} + +export async function claimBrowserSessionRequest( + ownerEmailInput: string, + sessionIdInput: string, +): Promise { + const ownerEmail = assertOwnerEmail(ownerEmailInput); + const sessionId = assertSafeId(sessionIdInput, "sessionId"); + await ensureTables(); + await expireOldRequests(ownerEmail, sessionId); + const client = getDbExec(); + const now = nowMs(); + + for (let attempt = 0; attempt < 3; attempt++) { + const { rows } = await client.execute({ + sql: `SELECT * FROM ${REQUEST_TABLE} + WHERE owner_email = ? AND session_id = ? AND status = 'pending' AND expires_at >= ? + ORDER BY created_at ASC + LIMIT 1`, + args: [ownerEmail, sessionId, now], + }); + if (rows.length === 0) return null; + const candidate = rowToRequest(rows[0]); + const updated = await client.execute({ + sql: `UPDATE ${REQUEST_TABLE} + SET status = 'claimed', claimed_at = ? + WHERE owner_email = ? AND request_id = ? AND status = 'pending'`, + args: [now, ownerEmail, candidate.id], + }); + if (updated.rowsAffected > 0) { + return getBrowserSessionRequest(ownerEmail, candidate.id); + } + } + return null; +} + +export async function completeBrowserSessionRequest( + ownerEmailInput: string, + sessionIdInput: string, + requestIdInput: string, + result: + | { ok: true; result?: unknown } + | { ok: false; error?: string; result?: unknown }, +): Promise { + const ownerEmail = assertOwnerEmail(ownerEmailInput); + const sessionId = assertSafeId(sessionIdInput, "sessionId"); + const requestId = assertSafeId(requestIdInput, "requestId"); + await ensureTables(); + const client = getDbExec(); + const completedAt = nowMs(); + const status: AgentNativeBrowserSessionRequestStatus = + result.ok === true ? "completed" : "failed"; + const error = + result.ok === false + ? result.error || "Browser-session request failed" + : null; + const updated = await client.execute({ + sql: `UPDATE ${REQUEST_TABLE} + SET status = ?, completed_at = ?, result_json = ?, error = ? + WHERE owner_email = ? AND session_id = ? AND request_id = ? AND status IN ('pending', 'claimed')`, + args: [ + status, + completedAt, + "result" in result ? json(result.result) : null, + error, + ownerEmail, + sessionId, + requestId, + ], + }); + if (updated.rowsAffected === 0) { + const existing = await getBrowserSessionRequest(ownerEmail, requestId); + if (existing) return existing; + throw new Error(`No browser-session request found for "${requestId}"`); + } + const request = await getBrowserSessionRequest(ownerEmail, requestId); + if (!request) + throw new Error(`No browser-session request found for "${requestId}"`); + return request; +} + +export async function waitForBrowserSessionRequest( + ownerEmailInput: string, + requestIdInput: string, + options: { timeoutMs?: number; pollMs?: number } = {}, +): Promise { + const ownerEmail = assertOwnerEmail(ownerEmailInput); + const requestId = assertSafeId(requestIdInput, "requestId"); + const timeoutMs = + typeof options.timeoutMs === "number" && options.timeoutMs > 0 + ? options.timeoutMs + : DEFAULT_BROWSER_SESSION_REQUEST_TIMEOUT_MS; + const pollMs = + typeof options.pollMs === "number" && options.pollMs > 0 + ? options.pollMs + : DEFAULT_BROWSER_SESSION_REQUEST_POLL_MS; + const deadline = nowMs() + timeoutMs; + + while (nowMs() <= deadline) { + const request = await getBrowserSessionRequest(ownerEmail, requestId); + if (!request) + throw new Error(`No browser-session request found for "${requestId}"`); + if (request.status === "completed") return request.result; + if (request.status === "failed") { + throw new Error(request.error || "Browser-session request failed"); + } + if (request.status === "expired" || request.expiresAt < nowMs()) { + await expireOldRequests(ownerEmail, request.sessionId); + throw new Error(request.error || "Browser-session request expired"); + } + await sleep(Math.min(pollMs, Math.max(1, deadline - nowMs()))); + } + + const request = await getBrowserSessionRequest(ownerEmail, requestId); + if (request) { + await completeBrowserSessionRequest( + ownerEmail, + request.sessionId, + requestId, + { + ok: false, + error: "Timed out waiting for browser-session response", + }, + ); + } + throw new Error("Timed out waiting for browser-session response"); +} + +export async function callBrowserSession( + ownerEmailInput: string, + sessionIdInput: string, + input: CreateAgentNativeBrowserSessionRequestInput, + options: { timeoutMs?: number; pollMs?: number } = {}, +): Promise { + const request = await createBrowserSessionRequest( + ownerEmailInput, + sessionIdInput, + { + ...input, + timeoutMs: input.timeoutMs ?? options.timeoutMs, + }, + ); + return waitForBrowserSessionRequest(ownerEmailInput, request.id, { + timeoutMs: options.timeoutMs ?? input.timeoutMs, + pollMs: options.pollMs, + }); +} diff --git a/packages/core/src/browser-sessions/types.ts b/packages/core/src/browser-sessions/types.ts new file mode 100644 index 000000000..f3ca0cbad --- /dev/null +++ b/packages/core/src/browser-sessions/types.ts @@ -0,0 +1,84 @@ +export type AgentNativeBrowserSessionRequestType = + | "get-context" + | "list-actions" + | "run-action" + | "command"; + +export type AgentNativeBrowserSessionRequestStatus = + | "pending" + | "claimed" + | "completed" + | "failed" + | "expired"; + +export type AgentNativeBrowserSessionJsonObject = Record; + +export interface AgentNativeBrowserSessionAction { + name: string; + description: string; + schema?: AgentNativeBrowserSessionJsonObject; + parameters?: AgentNativeBrowserSessionJsonObject; + source?: string; + availability?: string; + destructive?: boolean; + requiresApproval?: boolean | AgentNativeBrowserSessionJsonObject; + approval?: AgentNativeBrowserSessionJsonObject; + [key: string]: unknown; +} + +export interface AgentNativeBrowserSession { + id: string; + label?: string; + connectedAt?: string; + url?: string; + [key: string]: unknown; +} + +export interface AgentNativeBrowserSessionRecord { + sessionId: string; + session: AgentNativeBrowserSession; + label?: string; + url?: string; + context?: AgentNativeBrowserSessionJsonObject; + actions: AgentNativeBrowserSessionAction[]; + connectedAt: number; + lastSeenAt: number; + expiresAt: number; + active: boolean; +} + +export interface AgentNativeBrowserSessionRequest { + id: string; + sessionId: string; + type: AgentNativeBrowserSessionRequestType; + name?: string; + command?: string; + args?: unknown; + payload?: unknown; + status: AgentNativeBrowserSessionRequestStatus; + createdAt: number; + claimedAt?: number; + completedAt?: number; + expiresAt: number; + result?: unknown; + error?: string; +} + +export interface RegisterAgentNativeBrowserSessionInput { + session?: AgentNativeBrowserSession; + sessionId?: string; + label?: string; + url?: string; + context?: AgentNativeBrowserSessionJsonObject; + actions?: AgentNativeBrowserSessionAction[]; + ttlMs?: number; +} + +export interface CreateAgentNativeBrowserSessionRequestInput { + type: AgentNativeBrowserSessionRequestType; + name?: string; + command?: string; + args?: unknown; + payload?: unknown; + timeoutMs?: number; +} diff --git a/packages/core/src/cli/code-agent-commands.spec.ts b/packages/core/src/cli/code-agent-commands.spec.ts new file mode 100644 index 000000000..0896a5981 --- /dev/null +++ b/packages/core/src/cli/code-agent-commands.spec.ts @@ -0,0 +1,134 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + findProjectSlashCommand, + isReservedProjectSlashCommandName, + listProjectSkills, + listProjectSlashCommands, + listVisibleProjectSlashCommands, + readProjectCodePack, +} from "./code-agent-commands.js"; + +const tmpRoots: string[] = []; + +afterEach(() => { + for (const root of tmpRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +describe("project code packs", () => { + it("reads commands and skills as structured metadata", () => { + const root = createTempProject(); + writeFile( + root, + ".agents/commands/release/check.md", + [ + "---", + 'description: "Run release checks"', + "argument-hint: ", + "---", + "Check release $ARGUMENTS.", + ].join("\n"), + ); + writeFile( + root, + ".agents/skills/release/SKILL.md", + [ + "---", + "name: release", + "description: >-", + " Guidance for preparing", + " release changes.", + "---", + "Use the release checklist.", + ].join("\n"), + ); + + expect(readProjectCodePack(root)).toMatchObject({ + schemaVersion: 1, + root, + commands: [ + { + kind: "command", + name: "release:check", + relativePath: path.join("release", "check.md"), + description: "Run release checks", + argumentHint: "", + reserved: false, + body: "Check release $ARGUMENTS.", + }, + ], + skills: [ + { + kind: "skill", + name: "release", + relativePath: path.join("release", "SKILL.md"), + description: "Guidance for preparing release changes.", + body: "Use the release checklist.", + }, + ], + }); + }); + + it("retains reserved command filtering for visible command lists", () => { + const root = createTempProject(); + writeFile(root, ".agents/commands/migrate.md", "Do not show."); + writeFile(root, ".agents/commands/review.md", "Review changes."); + + expect( + listProjectSlashCommands(root).map((command) => command.name), + ).toEqual(["migrate", "review"]); + expect( + listVisibleProjectSlashCommands(root).map((command) => command.name), + ).toEqual(["review"]); + expect(isReservedProjectSlashCommandName("/migrate")).toBe(true); + expect( + readProjectCodePack(root).commands.map((command) => command.name), + ).toEqual(["review"]); + expect( + readProjectCodePack(root, { includeReservedCommands: true }).commands.map( + (command) => command.name, + ), + ).toEqual(["migrate", "review"]); + }); + + it("finds reserved project command files for execution lookup", () => { + const root = createTempProject(); + writeFile(root, ".agents/commands/status.md", "Status shadow."); + + expect(findProjectSlashCommand("/status", root)).toMatchObject({ + name: "status", + reserved: true, + body: "Status shadow.", + }); + }); + + it("falls back to directory names for skills without frontmatter names", () => { + const root = createTempProject(); + writeFile(root, ".agents/skills/review-diff/SKILL.md", "Review diffs."); + + expect(listProjectSkills(root)).toMatchObject([ + { + name: "review-diff", + description: undefined, + body: "Review diffs.", + }, + ]); + }); +}); + +function createTempProject(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "an-code-pack-")); + tmpRoots.push(root); + return root; +} + +function writeFile(root: string, relativePath: string, contents: string): void { + const filePath = path.join(root, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, contents); +} diff --git a/packages/core/src/cli/code-agent-commands.ts b/packages/core/src/cli/code-agent-commands.ts new file mode 100644 index 000000000..3aaee620f --- /dev/null +++ b/packages/core/src/cli/code-agent-commands.ts @@ -0,0 +1,260 @@ +import fs from "fs"; +import path from "path"; + +export interface CodeAgentProjectCommand { + kind: "command"; + name: string; + path: string; + relativePath: string; + description?: string; + argumentHint?: string; + reserved: boolean; + body: string; +} + +export interface CodeAgentProjectSkill { + kind: "skill"; + name: string; + path: string; + relativePath: string; + description?: string; + body: string; +} + +export interface CodeAgentCodePack { + schemaVersion: 1; + root: string; + commands: CodeAgentProjectCommand[]; + skills: CodeAgentProjectSkill[]; +} + +export interface ReadProjectCodePackOptions { + includeReservedCommands?: boolean; +} + +interface ParsedFrontmatter { + data: Record; + body: string; +} + +const COMMANDS_DIR = path.join(".agents", "commands"); +const SKILLS_DIR = path.join(".agents", "skills"); +const RESERVED_PROJECT_COMMAND_NAMES = new Set([ + "approve", + "attach", + "audit", + "audit-agent-web", + "agent-web", + "e", + "exec", + "exit", + "goals", + "help", + "list", + "migrate", + "migration", + "ps", + "quit", + "resume", + "run", + "start", + "status", + "stop", + "task", + "todo", + "ui", +]); + +export function readProjectCodePack( + cwd = process.cwd(), + options: ReadProjectCodePackOptions = {}, +): CodeAgentCodePack { + return { + schemaVersion: 1, + root: cwd, + commands: options.includeReservedCommands + ? listProjectSlashCommands(cwd) + : listVisibleProjectSlashCommands(cwd), + skills: listProjectSkills(cwd), + }; +} + +export function listProjectSlashCommands( + cwd = process.cwd(), +): CodeAgentProjectCommand[] { + const root = path.join(cwd, COMMANDS_DIR); + if (!fs.existsSync(root)) return []; + return walkMarkdownFiles(root) + .map((filePath) => readProjectSlashCommand(root, filePath)) + .filter((command): command is CodeAgentProjectCommand => Boolean(command)) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +export function listVisibleProjectSlashCommands( + cwd = process.cwd(), +): CodeAgentProjectCommand[] { + return listProjectSlashCommands(cwd).filter((command) => !command.reserved); +} + +export function findProjectSlashCommand( + commandName: string, + cwd = process.cwd(), +): CodeAgentProjectCommand | null { + const normalized = normalizeProjectSlashCommandName(commandName); + return ( + listProjectSlashCommands(cwd).find( + (command) => command.name === normalized, + ) ?? null + ); +} + +export function listProjectSkills( + cwd = process.cwd(), +): CodeAgentProjectSkill[] { + const root = path.join(cwd, SKILLS_DIR); + if (!fs.existsSync(root)) return []; + return walkMarkdownFiles(root) + .map((filePath) => readProjectSkill(root, filePath)) + .filter((skill): skill is CodeAgentProjectSkill => Boolean(skill)) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +export function isReservedProjectSlashCommandName(value: string): boolean { + return RESERVED_PROJECT_COMMAND_NAMES.has( + normalizeProjectSlashCommandName(value), + ); +} + +export function renderProjectSlashCommandPrompt( + command: CodeAgentProjectCommand, + args: string[], +): string { + const argumentText = args.join(" ").trim(); + const positional = args + .map((arg, index) => [`$${index + 1}`, arg] as const) + .reduce( + (body, [token, value]) => body.replaceAll(token, value), + command.body, + ); + const withArguments = positional.replaceAll("$ARGUMENTS", argumentText); + return [ + `Run project slash command /${command.name}.`, + command.description ? `Description: ${command.description}` : "", + argumentText ? `Arguments: ${argumentText}` : "", + "", + withArguments.trim(), + ] + .filter((part) => part.length > 0) + .join("\n"); +} + +export function normalizeProjectSlashCommandName(value: string): string { + return value + .replace(/^\//, "") + .replaceAll("\\", "/") + .replaceAll("/", ":") + .toLowerCase(); +} + +function walkMarkdownFiles(root: string): string[] { + const files: string[] = []; + const visit = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + visit(entryPath); + continue; + } + if (entry.isFile() && entry.name.endsWith(".md")) { + files.push(entryPath); + } + } + }; + visit(root); + return files; +} + +function readProjectSlashCommand( + root: string, + filePath: string, +): CodeAgentProjectCommand | null { + try { + const raw = fs.readFileSync(filePath, "utf-8"); + const parsed = parseFrontmatter(raw); + const relative = path.relative(root, filePath).replace(/\.md$/i, ""); + if (relative.toLowerCase() === "readme") return null; + const name = normalizeProjectSlashCommandName(relative); + if (!name) return null; + return { + kind: "command", + name, + path: filePath, + relativePath: path.relative(root, filePath), + description: parsed.data.description, + argumentHint: parsed.data["argument-hint"], + reserved: isReservedProjectSlashCommandName(name), + body: parsed.body, + }; + } catch { + return null; + } +} + +function readProjectSkill( + root: string, + filePath: string, +): CodeAgentProjectSkill | null { + try { + if (path.basename(filePath).toLowerCase() !== "skill.md") return null; + const raw = fs.readFileSync(filePath, "utf-8"); + const parsed = parseFrontmatter(raw); + const relative = path.relative(root, filePath); + const skillDir = path.dirname(relative); + const fallbackName = skillDir === "." ? path.basename(root) : skillDir; + const name = parsed.data.name || normalizeSkillName(fallbackName); + if (!name) return null; + return { + kind: "skill", + name, + path: filePath, + relativePath: relative, + description: parsed.data.description, + body: parsed.body, + }; + } catch { + return null; + } +} + +function parseFrontmatter(raw: string): ParsedFrontmatter { + if (!raw.startsWith("---\n")) return { data: {}, body: raw }; + const end = raw.indexOf("\n---", 4); + if (end === -1) return { data: {}, body: raw }; + const frontmatter = raw.slice(4, end).trim(); + const body = raw.slice(end + 4).replace(/^\r?\n/, ""); + const data: Record = {}; + const lines = frontmatter.split(/\r?\n/); + for (let index = 0; index < lines.length; index++) { + const line = lines[index]; + const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/); + if (!match) continue; + const [, key, value] = match; + if (value === ">-" || value === ">" || value === "|" || value === "|-") { + const block: string[] = []; + while (index + 1 < lines.length && /^\s+/.test(lines[index + 1])) { + index += 1; + block.push(lines[index].trim()); + } + data[key] = value.startsWith("|") + ? block.join("\n").trim() + : block.join(" ").trim(); + continue; + } + data[key] = value.replace(/^["']|["']$/g, "").trim(); + } + return { data, body }; +} + +function normalizeSkillName(value: string): string { + return value.replaceAll("\\", "/").split("/").filter(Boolean).join(":"); +} diff --git a/packages/core/src/cli/code-agent-connector.ts b/packages/core/src/cli/code-agent-connector.ts new file mode 100644 index 000000000..689aa4ceb --- /dev/null +++ b/packages/core/src/cli/code-agent-connector.ts @@ -0,0 +1,857 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { + appendCodeAgentTranscriptEvent, + codeAgentRunTranscriptPath, + codeAgentStoreRoot, + createCodeAgentRunRecord, + getCodeAgentRunRecord, + listCodeAgentRunRecords, + normalizeCodeAgentPermissionMode, + queueCodeAgentFollowUp, + updateCodeAgentRunRecord, + type CodeAgentPermissionMode, + type CodeAgentRunRecord, + type CodeAgentTranscriptEvent, +} from "./code-agent-runs.js"; +import { executePendingCodeAgentApproval } from "./code-agent-executor.js"; + +export interface RemoteCodeAgentDeviceConfig { + token: string; + relayUrl?: string; + deviceId?: string; + deviceName?: string; + pollIntervalMs?: number; +} + +export interface RunCodeAgentConnectorOptions { + relayUrl?: string; + output?: NodeJS.WritableStream; + signal?: AbortSignal; + once?: boolean; +} + +interface RemoteCommand { + id: string; + kind: string; + params: Record; + raw: Record; +} + +interface TranscriptCursor { + offset: number; + seq: number; +} + +interface RunnerProcess { + child: ChildProcess; + runId: string; + cwd: string; + command: string; + startedAt: string; +} + +const DEVICE_PATH_ENV = "AGENT_NATIVE_REMOTE_DEVICE_PATH"; +const DEFAULT_POLL_INTERVAL_MS = 2_000; +const MAX_POLL_INTERVAL_MS = 30_000; +const MAX_TRANSCRIPT_EVENTS_PER_BATCH = 50; + +const activeRunners = new Map(); + +export function remoteDeviceConfigPath(): string { + return path.resolve( + process.env[DEVICE_PATH_ENV] ?? + path.join(os.homedir(), ".agent-native", "remote-device.json"), + ); +} + +export function loadRemoteCodeAgentDeviceConfig( + configPath = remoteDeviceConfigPath(), +): RemoteCodeAgentDeviceConfig | null { + try { + const raw = JSON.parse(fs.readFileSync(configPath, "utf-8")) as unknown; + if (!isObject(raw)) return null; + const token = firstStringValue( + raw.token, + raw.deviceToken, + raw.relayToken, + raw.accessToken, + raw.bearerToken, + ); + if (!token) return null; + const pollIntervalMs = Number(raw.pollIntervalMs); + return { + token, + relayUrl: firstStringValue(raw.relayUrl, raw.url, raw.baseUrl), + deviceId: firstStringValue(raw.deviceId, raw.id), + deviceName: firstStringValue(raw.deviceName, raw.name), + pollIntervalMs: + Number.isFinite(pollIntervalMs) && pollIntervalMs > 0 + ? Math.min(Math.max(pollIntervalMs, 500), MAX_POLL_INTERVAL_MS) + : undefined, + }; + } catch { + return null; + } +} + +export async function runCodeAgentConnector( + options: RunCodeAgentConnectorOptions = {}, +): Promise { + const output = options.output ?? process.stdout; + const configPath = remoteDeviceConfigPath(); + const config = loadRemoteCodeAgentDeviceConfig(configPath); + if (!config) { + output.write( + [ + "Agent-Native Code remote connector is not paired.", + "", + `Expected device config: ${configPath}`, + "Pair this device from Agent Native, or set AGENT_NATIVE_REMOTE_DEVICE_PATH to a JSON file containing a device token.", + "Then run: agent-native code serve --relay-url ", + "", + ].join("\n"), + ); + return 1; + } + + const relayUrl = normalizeRelayUrl(options.relayUrl ?? config.relayUrl); + if (!relayUrl) { + output.write( + [ + "Agent-Native Code remote connector needs a relay URL.", + "", + "Run: agent-native code serve --relay-url https://your-agent-native-app.example", + `Or add "relayUrl" to ${configPath}.`, + "", + ].join("\n"), + ); + return 1; + } + + const connector = new RemoteCodeAgentConnector(config, relayUrl, output); + await connector.run({ signal: options.signal, once: options.once }); + return 0; +} + +class RemoteCodeAgentConnector { + private readonly transcriptCursors = new Map(); + private readonly remoteRunIds = new Set(); + private stopped = false; + + constructor( + private readonly config: RemoteCodeAgentDeviceConfig, + private readonly relayUrl: string, + private readonly output: NodeJS.WritableStream, + ) { + for (const run of listCodeAgentRunRecords()) { + if (isRemoteStartedRun(run, relayUrl)) { + this.remoteRunIds.add(run.id); + this.transcriptCursors.set(run.id, { offset: 0, seq: 0 }); + } + } + } + + async run(options: { signal?: AbortSignal; once?: boolean } = {}) { + const onAbort = () => { + this.stopped = true; + }; + if (options.signal) { + if (options.signal.aborted) this.stopped = true; + else options.signal.addEventListener("abort", onAbort, { once: true }); + } + + this.output.write( + `Agent-Native Code remote connector serving ${this.relayUrl}\n`, + ); + + let backoffMs = this.pollIntervalMs(); + try { + while (!this.stopped) { + try { + await this.pollOnce(); + await this.flushRemoteRunEvents(); + backoffMs = this.pollIntervalMs(); + if (options.once) break; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.output.write(`Remote connector poll failed: ${message}\n`); + backoffMs = Math.min(backoffMs * 2, MAX_POLL_INTERVAL_MS); + } + await sleep(backoffMs, options.signal); + } + } finally { + options.signal?.removeEventListener("abort", onAbort); + } + } + + private async pollOnce() { + const response = await this.postJson( + "/_agent-native/integrations/remote/poll", + { + deviceId: this.config.deviceId, + deviceName: this.config.deviceName ?? os.hostname(), + capabilities: [ + "create-run", + "append-followup", + "approve", + "deny", + "stop", + "status", + "run-events", + ], + activeRunIds: Array.from(this.remoteRunIds), + }, + ); + const commands = normalizeCommands(response); + for (const command of commands) { + const result = await this.dispatchCommand(command).catch((err) => ({ + ok: false, + error: err instanceof Error ? err.message : String(err), + })); + await this.postCommandResult(command, result); + } + } + + private async dispatchCommand(command: RemoteCommand) { + switch (normalizeKind(command.kind)) { + case "create-run": + return this.createRun(command); + case "append-followup": + case "append-follow-up": + case "followup": + case "follow-up": + return this.appendFollowUp(command); + case "approve": + return this.approve(command); + case "deny": + return this.deny(command); + case "stop": + return this.stop(command); + case "status": + return this.status(command); + default: + return { + ok: false, + error: `Unsupported remote command kind: ${command.kind}`, + }; + } + } + + private createRun(command: RemoteCommand) { + const prompt = firstTextValue( + command.params.prompt, + command.params.message, + command.params.input, + ); + if (!prompt) { + return { ok: false, error: "Missing prompt." }; + } + const goalId = firstStringValue(command.params.goalId) ?? "task"; + const cwd = resolveCommandCwd(command.params.cwd); + const permissionMode = + normalizeCodeAgentPermissionMode(command.params.permissionMode) ?? + "full-auto"; + const engine = firstStringValue(command.params.engine); + const model = firstStringValue(command.params.model); + const effort = firstStringValue( + command.params.effort, + command.params.reasoningEffort, + ); + const metadata = isObject(command.params.metadata) + ? command.params.metadata + : {}; + const run = createCodeAgentRunRecord({ + goalId, + title: firstStringValue(command.params.title) ?? titleFromPrompt(prompt), + subtitle: "Remote coding task", + status: "queued", + phase: "queued", + permissionMode, + cwd, + progress: { + label: "Queued", + completed: 0, + total: 1, + percent: 0, + }, + details: [ + { label: "Prompt", value: truncateForDisplay(prompt, 160) }, + { label: "Agent", value: "Remote connector" }, + { label: "Mode", value: permissionMode }, + ], + metadata: { + ...metadata, + prompt, + source: "remote-connector", + engine, + model, + effort, + remote: { + commandId: command.id, + deviceId: this.config.deviceId, + relayUrl: this.relayUrl, + }, + }, + }); + + appendCodeAgentTranscriptEvent({ + runId: run.id, + kind: "user", + message: prompt, + metadata: { source: "remote-initial-prompt", commandId: command.id }, + }); + appendCodeAgentTranscriptEvent({ + runId: run.id, + kind: "status", + message: "Remote Agent-Native Code run queued.", + metadata: { status: "queued", phase: "queued", commandId: command.id }, + }); + this.remoteRunIds.add(run.id); + this.transcriptCursors.set(run.id, { offset: 0, seq: 0 }); + this.spawnRunner(run.id, cwd, permissionMode); + return { ok: true, runId: run.id, run }; + } + + private appendFollowUp(command: RemoteCommand) { + const runId = firstStringValue(command.params.runId); + const prompt = firstTextValue( + command.params.prompt, + command.params.message, + ); + if (!runId) return { ok: false, error: "Missing runId." }; + if (!prompt) return { ok: false, error: "Missing prompt." }; + const run = getCodeAgentRunRecord(runId); + if (!run) return { ok: false, error: `Run not found: ${runId}` }; + + const permissionMode = + normalizeCodeAgentPermissionMode(command.params.permissionMode) ?? + undefined; + const activeRun = permissionMode + ? (updateCodeAgentRunRecord(runId, { permissionMode }) ?? run) + : run; + const followUpMode = + firstStringValue(command.params.followUpMode) === "queued" + ? "queued" + : "immediate"; + const runnerActive = activeRunners.has(activeRun.id); + const shouldQueue = activeRun.status === "needs-approval" || runnerActive; + const event = appendCodeAgentTranscriptEvent({ + runId: activeRun.id, + kind: "user", + message: prompt, + metadata: { + source: "remote-follow-up", + commandId: command.id, + followUpMode, + delivery: shouldQueue ? followUpMode : "run-now", + }, + }); + + if (shouldQueue) { + queueCodeAgentFollowUp({ + runId: activeRun.id, + prompt, + mode: followUpMode, + eventId: event.id, + permissionMode, + source: "remote-follow-up", + createdAt: event.createdAt, + }); + } else { + this.spawnRunner(activeRun.id, activeRun.cwd, permissionMode); + } + this.remoteRunIds.add(activeRun.id); + return { ok: true, runId: activeRun.id, event, queued: shouldQueue }; + } + + private async approve(command: RemoteCommand) { + const runId = firstStringValue(command.params.runId); + if (!runId) return { ok: false, error: "Missing runId." }; + const run = getCodeAgentRunRecord(runId); + if (!run) return { ok: false, error: `Run not found: ${runId}` }; + const result = await executePendingCodeAgentApproval(runId); + this.spawnRunner(runId, run.cwd, run.permissionMode); + return { ok: true, runId, run: result ?? getCodeAgentRunRecord(runId) }; + } + + private deny(command: RemoteCommand) { + const runId = firstStringValue(command.params.runId); + if (!runId) return { ok: false, error: "Missing runId." }; + const run = getCodeAgentRunRecord(runId); + if (!run) return { ok: false, error: `Run not found: ${runId}` }; + appendCodeAgentTranscriptEvent({ + runId, + kind: "status", + message: "Remote approval denied.", + metadata: { source: "remote-connector", commandId: command.id }, + }); + const updated = updateCodeAgentRunRecord(runId, { + status: "paused", + phase: "approval-denied", + needsApproval: false, + metadata: { + pendingApproval: undefined, + approvalDeniedAt: new Date().toISOString(), + }, + }); + return { ok: true, runId, run: updated }; + } + + private stop(command: RemoteCommand) { + const runId = firstStringValue(command.params.runId); + if (!runId) return { ok: false, error: "Missing runId." }; + const run = getCodeAgentRunRecord(runId); + if (!run) return { ok: false, error: `Run not found: ${runId}` }; + const active = activeRunners.get(runId); + const pid = active?.child.pid ?? Number(run.metadata?.runnerPid); + let killed = false; + let killError: string | undefined; + if (Number.isFinite(pid) && pid > 0) { + try { + process.kill(pid, "SIGTERM"); + killed = true; + } catch (err) { + killError = err instanceof Error ? err.message : String(err); + } + } + activeRunners.delete(runId); + appendCodeAgentTranscriptEvent({ + runId, + kind: "status", + message: killed + ? "Remote stop requested for Agent-Native Code runner." + : "Remote stop requested; no active runner process was found.", + metadata: { + source: "remote-connector", + commandId: command.id, + pid: Number.isFinite(pid) ? pid : undefined, + killed, + killError, + }, + }); + const updated = updateCodeAgentRunRecord(runId, { + status: "paused", + phase: "stopped", + progress: { + label: "Stopped", + completed: 0, + total: 1, + percent: 0, + }, + metadata: { + runnerState: "stopped", + stoppedAt: new Date().toISOString(), + stoppedBy: "remote-connector", + stopSignalSent: killed, + stopError: killError, + }, + }); + return { ok: !killError, runId, run: updated, killed, error: killError }; + } + + private status(command: RemoteCommand) { + const runId = firstStringValue(command.params.runId); + if (runId) { + const run = getCodeAgentRunRecord(runId); + return run + ? { ok: true, runId, run, runner: runnerStatus(runId) } + : { ok: false, runId, error: `Run not found: ${runId}` }; + } + return { + ok: true, + runs: listCodeAgentRunRecords().slice(0, 20), + activeRunIds: Array.from(activeRunners.keys()), + }; + } + + private spawnRunner( + runId: string, + cwd: string, + permissionMode?: CodeAgentPermissionMode, + ) { + if (activeRunners.has(runId)) return; + const invocation = resolveCodeAgentCliInvocation(); + const child = spawn( + invocation.command, + [...invocation.args, "code", "run", runId], + { + cwd, + detached: true, + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + AGENT_NATIVE_CODE_AGENTS_HOME: codeAgentStoreRoot(), + ...(permissionMode + ? { AGENT_NATIVE_CODE_AGENT_PERMISSION_MODE: permissionMode } + : {}), + }, + }, + ); + const runnerCommand = `${invocation.command} ${[ + ...invocation.args, + "code", + "run", + runId, + ].join(" ")}`; + const startedAt = new Date().toISOString(); + activeRunners.set(runId, { + child, + runId, + cwd, + command: runnerCommand, + startedAt, + }); + updateCodeAgentRunRecord(runId, { + status: "running", + phase: "executing", + metadata: { + runnerState: "running", + runnerPid: child.pid, + runnerCommand, + runnerCwd: cwd, + runnerStartedAt: startedAt, + }, + }); + child.stdout?.on("data", (chunk) => appendRunnerOutput(runId, chunk)); + child.stderr?.on("data", (chunk) => + appendRunnerOutput(runId, chunk, "runner-stderr"), + ); + child.on("exit", (code, signal) => { + activeRunners.delete(runId); + updateCodeAgentRunRecord(runId, { + metadata: { + runnerState: "exited", + runnerExitedAt: new Date().toISOString(), + runnerExitCode: code, + runnerExitSignal: signal, + }, + }); + }); + child.unref(); + } + + private async flushRemoteRunEvents() { + for (const runId of this.remoteRunIds) { + const cursor = this.transcriptCursors.get(runId) ?? { offset: 0, seq: 0 }; + const batch = readTranscriptBatch(runId, cursor.offset); + if (batch.events.length === 0) { + this.transcriptCursors.set(runId, { + offset: batch.nextOffset, + seq: cursor.seq, + }); + continue; + } + await this.postJson("/_agent-native/integrations/remote/run-events", { + deviceId: this.config.deviceId, + remoteRunId: runId, + cursor: { offset: batch.nextOffset }, + events: batch.events.map((event, index) => ({ + seq: cursor.seq + index, + event, + })), + }); + this.transcriptCursors.set(runId, { + offset: batch.nextOffset, + seq: cursor.seq + batch.events.length, + }); + } + } + + private async postCommandResult( + command: RemoteCommand, + result: Record, + ) { + await this.postJson("/_agent-native/integrations/remote/result", { + deviceId: this.config.deviceId, + commandId: command.id, + kind: command.kind, + ok: result.ok !== false, + status: result.ok === false ? "failed" : "completed", + result, + errorMessage: typeof result.error === "string" ? result.error : undefined, + }); + } + + private async postJson(pathname: string, body: unknown): Promise { + const url = new URL(pathname, this.relayUrl); + const response = await fetch(url, { + method: "POST", + headers: { + authorization: `Bearer ${this.config.token}`, + "content-type": "application/json", + }, + body: JSON.stringify(body), + }); + if (!response.ok) { + throw new Error(`${url.pathname} returned ${response.status}`); + } + const text = await response.text(); + return text ? JSON.parse(text) : null; + } + + private pollIntervalMs() { + return this.config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + } +} + +function normalizeCommands(value: unknown): RemoteCommand[] { + const rawCommands = Array.isArray(value) + ? value + : isObject(value) && Array.isArray(value.commands) + ? value.commands + : isObject(value) && isObject(value.command) + ? [value.command] + : isObject(value) && firstStringValue(value.kind, value.type) + ? [value] + : []; + return rawCommands + .map((raw): RemoteCommand | null => { + if (!isObject(raw)) return null; + const kind = firstStringValue( + raw.kind, + raw.type, + raw.command, + raw.action, + ); + if (!kind) return null; + const params = isObject(raw.params) + ? raw.params + : isObject(raw.payload) + ? raw.payload + : isObject(raw.args) + ? raw.args + : raw; + return { + id: + firstStringValue(raw.id, raw.commandId, raw.requestId) ?? + `${normalizeKind(kind)}-${Date.now()}`, + kind, + params, + raw, + }; + }) + .filter((command): command is RemoteCommand => Boolean(command)); +} + +function normalizeKind(value: string): string { + return value.trim().toLowerCase().replaceAll("_", "-"); +} + +function readTranscriptBatch(runId: string, offset: number) { + const transcriptPath = codeAgentRunTranscriptPath(runId); + if (!fs.existsSync(transcriptPath)) { + return { events: [] as CodeAgentTranscriptEvent[], nextOffset: 0 }; + } + const stat = fs.statSync(transcriptPath); + const safeOffset = Math.max(0, Math.min(offset, stat.size)); + const fd = fs.openSync(transcriptPath, "r"); + try { + const length = Math.min(stat.size - safeOffset, 256_000); + if (length <= 0) { + return { + events: [] as CodeAgentTranscriptEvent[], + nextOffset: safeOffset, + }; + } + const buffer = Buffer.alloc(length); + fs.readSync(fd, buffer, 0, length, safeOffset); + const text = buffer.toString("utf-8"); + const lines = text.split(/\r?\n/); + const hasTrailingNewline = /\r?\n$/.test(text); + const completeLines = hasTrailingNewline ? lines : lines.slice(0, -1); + const selected = completeLines + .filter(Boolean) + .slice(0, MAX_TRANSCRIPT_EVENTS_PER_BATCH); + const consumedBytes = Buffer.byteLength( + selected.map((line) => `${line}\n`).join(""), + "utf-8", + ); + return { + events: selected + .map((line) => parseTranscriptEvent(line)) + .filter((event): event is CodeAgentTranscriptEvent => Boolean(event)), + nextOffset: safeOffset + consumedBytes, + }; + } finally { + fs.closeSync(fd); + } +} + +function parseTranscriptEvent(line: string): CodeAgentTranscriptEvent | null { + try { + const parsed = JSON.parse(line) as unknown; + if (!isObject(parsed)) return null; + if ( + parsed.schemaVersion !== 1 || + typeof parsed.id !== "string" || + typeof parsed.runId !== "string" || + typeof parsed.kind !== "string" || + typeof parsed.message !== "string" || + typeof parsed.createdAt !== "string" + ) { + return null; + } + if ( + parsed.kind !== "user" && + parsed.kind !== "system" && + parsed.kind !== "note" && + parsed.kind !== "artifact" && + parsed.kind !== "status" + ) { + return null; + } + return { + schemaVersion: 1, + id: parsed.id, + runId: parsed.runId, + kind: parsed.kind, + message: parsed.message, + createdAt: parsed.createdAt, + metadata: isObject(parsed.metadata) ? parsed.metadata : undefined, + }; + } catch { + return null; + } +} + +function resolveCodeAgentCliInvocation(): { command: string; args: string[] } { + const currentEntry = process.argv[1]; + if ( + currentEntry && + fs.existsSync(currentEntry) && + currentEntry.endsWith(".js") + ) { + return { command: process.execPath, args: [path.resolve(currentEntry)] }; + } + const localDist = path.resolve("packages/core/dist/cli/index.js"); + if (fs.existsSync(localDist)) { + return { command: process.execPath, args: [localDist] }; + } + return { + command: "pnpm", + args: [ + "--filter", + "@agent-native/core", + "exec", + "node", + "dist/cli/index.js", + ], + }; +} + +function appendRunnerOutput( + runId: string, + chunk: Buffer, + source = "runner-stdout", +) { + const text = chunk.toString().trim(); + if (!text) return; + appendCodeAgentTranscriptEvent({ + runId, + kind: "status", + message: text, + metadata: { source }, + }); +} + +function runnerStatus(runId: string) { + const runner = activeRunners.get(runId); + return runner + ? { + active: true, + pid: runner.child.pid, + command: runner.command, + cwd: runner.cwd, + startedAt: runner.startedAt, + } + : { active: false }; +} + +function isRemoteStartedRun( + run: CodeAgentRunRecord, + relayUrl: string, +): boolean { + const metadata = run.metadata ?? {}; + const remote = isObject(metadata.remote) ? metadata.remote : {}; + return ( + metadata.source === "remote-connector" || + firstStringValue(remote.relayUrl) === relayUrl + ); +} + +function resolveCommandCwd(value: unknown): string { + const cwd = firstStringValue(value); + return cwd ? path.resolve(cwd) : process.cwd(); +} + +function normalizeRelayUrl(value: string | undefined): string | null { + if (!value) return null; + try { + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + return `${url.origin}${url.pathname.replace(/\/+$/, "") || "/"}`; + } catch { + return null; + } +} + +function firstStringValue(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value !== "string") continue; + const trimmed = value.trim(); + if (trimmed) return trimmed; + } + return undefined; +} + +function firstTextValue(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value === "string" && value.trim()) return value.trim(); + if (!Array.isArray(value)) continue; + const parts = value + .map((item) => + typeof item === "string" + ? item + : isObject(item) + ? (firstStringValue(item.text, item.content, item.message) ?? "") + : "", + ) + .map((part) => part.trim()) + .filter(Boolean); + if (parts.length > 0) return parts.join("\n"); + } + return undefined; +} + +function titleFromPrompt(prompt: string): string { + return truncateForDisplay(prompt.replace(/\s+/g, " ").trim(), 80); +} + +function truncateForDisplay(value: string, max: number): string { + return value.length <= max + ? value + : `${value.slice(0, Math.max(0, max - 3))}...`; +} + +function isObject(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function sleep(ms: number, signal?: AbortSignal): Promise { + if (signal?.aborted) return Promise.resolve(); + return new Promise((resolve) => { + const timeout = setTimeout(resolve, ms); + signal?.addEventListener( + "abort", + () => { + clearTimeout(timeout); + resolve(); + }, + { once: true }, + ); + }); +} diff --git a/packages/core/src/cli/code-agent-executor.spec.ts b/packages/core/src/cli/code-agent-executor.spec.ts new file mode 100644 index 000000000..bd2929e74 --- /dev/null +++ b/packages/core/src/cli/code-agent-executor.spec.ts @@ -0,0 +1,309 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { Writable } from "node:stream"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + createCodeAgentRunRecord, + codeAgentRunTranscriptPath, + getCodeAgentRunRecord, + listCodeAgentTranscriptEvents, + queueCodeAgentFollowUp, + updateCodeAgentRunRecord, +} from "./code-agent-runs.js"; +import { + classifyCodeAgentCommandPermission, + executeCodeAgentRun, + executePendingCodeAgentApproval, +} from "./code-agent-executor.js"; + +const tmpRoots: string[] = []; +const providerEnvKeys = [ + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "GOOGLE_GENERATIVE_AI_API_KEY", + "OPENROUTER_API_KEY", + "GROQ_API_KEY", + "MISTRAL_API_KEY", + "COHERE_API_KEY", + "BUILDER_PRIVATE_KEY", +] as const; +const originalProviderEnv = new Map( + providerEnvKeys.map((key) => [key, process.env[key]]), +); + +afterEach(() => { + delete process.env.AGENT_NATIVE_CODE_AGENTS_HOME; + delete process.env.AGENT_NATIVE_CODE_AGENT_FAKE_RESPONSE; + for (const key of providerEnvKeys) { + const original = originalProviderEnv.get(key); + if (original === undefined) delete process.env[key]; + else process.env[key] = original; + } + for (const root of tmpRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +describe("executeCodeAgentRun", () => { + it("runs a file-backed Agent-Native Code session with a fake engine", async () => { + useTempCodeAgentsHome(); + process.env.AGENT_NATIVE_CODE_AGENT_FAKE_RESPONSE = + "I checked the workspace and found the issue."; + const output = createStringOutput(); + const run = createCodeAgentRunRecord({ + goalId: "task", + title: "Fix auth tests", + status: "queued", + cwd: process.cwd(), + }); + + await executeCodeAgentRun({ + runId: run.id, + prompt: "fix auth tests", + stdout: output.stream, + }); + + const updated = getCodeAgentRunRecord(run.id); + expect(updated).toMatchObject({ + status: "completed", + phase: "complete", + progress: { completed: 1, total: 1, percent: 100 }, + }); + expect(output.read()).toContain("I checked the workspace"); + expect( + listCodeAgentTranscriptEvents(run.id).map((event) => event.kind), + ).toEqual(["user", "status", "system", "status"]); + }); + + it("pauses with a credential hint when no provider key is available", async () => { + useTempCodeAgentsHome(); + for (const key of providerEnvKeys) delete process.env[key]; + const run = createCodeAgentRunRecord({ + goalId: "task", + title: "Fix auth tests", + status: "queued", + cwd: process.cwd(), + }); + + await executeCodeAgentRun({ runId: run.id, prompt: "fix auth tests" }); + + const updated = getCodeAgentRunRecord(run.id); + expect(updated).toMatchObject({ + status: "paused", + phase: "missing-credentials", + needsApproval: true, + }); + expect(listCodeAgentTranscriptEvents(run.id).at(-1)?.message).toContain( + "No LLM provider key was found", + ); + }); + + it("can execute a run whose initial prompt was written by Desktop", async () => { + useTempCodeAgentsHome(); + process.env.AGENT_NATIVE_CODE_AGENT_FAKE_RESPONSE = "Desktop run done."; + const run = createCodeAgentRunRecord({ + goalId: "task", + title: "Desktop task", + status: "queued", + cwd: process.cwd(), + }); + fs.mkdirSync(path.dirname(codeAgentRunTranscriptPath(run.id)), { + recursive: true, + }); + fs.appendFileSync( + codeAgentRunTranscriptPath(run.id), + `${JSON.stringify({ + schemaVersion: 1, + id: "desktop-event-1", + runId: run.id, + type: "user", + text: "fix desktop-started run", + createdAt: new Date().toISOString(), + })}\n`, + ); + const output = createStringOutput(); + + await executeCodeAgentRun({ + runId: run.id, + appendUserEvent: false, + stdout: output.stream, + }); + + expect(getCodeAgentRunRecord(run.id)).toMatchObject({ + status: "completed", + phase: "complete", + }); + expect(output.read()).toContain("Desktop run done."); + expect(listCodeAgentTranscriptEvents(run.id)[0]).toMatchObject({ + kind: "user", + message: "fix desktop-started run", + }); + }); + + it("records the run mode during execution", async () => { + useTempCodeAgentsHome(); + process.env.AGENT_NATIVE_CODE_AGENT_FAKE_RESPONSE = "Permission noted."; + const run = createCodeAgentRunRecord({ + goalId: "task", + title: "Explain repo", + permissionMode: "read-only", + status: "queued", + cwd: process.cwd(), + }); + + await executeCodeAgentRun({ + runId: run.id, + prompt: "explain repo", + }); + + expect(getCodeAgentRunRecord(run.id)).toMatchObject({ + status: "completed", + metadata: { + permissionMode: "read-only", + }, + }); + }); + + it("runs pending follow-ups after the current execution completes", async () => { + useTempCodeAgentsHome(); + process.env.AGENT_NATIVE_CODE_AGENT_FAKE_RESPONSE = "Turn done."; + const output = createStringOutput(); + const run = createCodeAgentRunRecord({ + goalId: "task", + title: "Active task", + status: "running", + phase: "executing", + cwd: process.cwd(), + }); + queueCodeAgentFollowUp({ + runId: run.id, + prompt: "follow up after completion", + mode: "queued", + source: "test", + }); + + await executeCodeAgentRun({ + runId: run.id, + prompt: "finish current work", + stdout: output.stream, + }); + + const updated = getCodeAgentRunRecord(run.id); + const events = listCodeAgentTranscriptEvents(run.id); + expect(updated).toMatchObject({ + status: "completed", + phase: "complete", + }); + expect(updated?.metadata?.pendingFollowUps).toBeUndefined(); + expect(output.read().match(/Turn done\./g)).toHaveLength(2); + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "status", + message: "Agent-Native Code run completed; running queued follow-up.", + }), + ]), + ); + }); + + it("executes an explicitly approved pending command and clears the approval", async () => { + const root = useTempCodeAgentsHome(); + const cwd = path.join(root, "repo"); + const target = path.join(cwd, "approval-target"); + fs.mkdirSync(target, { recursive: true }); + const run = createCodeAgentRunRecord({ + goalId: "task", + title: "Approved cleanup", + status: "needs-approval", + phase: "approval-required", + needsApproval: true, + cwd, + }); + updateCodeAgentRunRecord(run.id, { + metadata: { + pendingApproval: { + id: "approval-test", + tool: "run_command", + command: "rm -rf approval-target", + reason: "destructive recursive delete", + requestedAt: new Date().toISOString(), + permissionMode: "ask-before-edit", + }, + }, + }); + const output = createStringOutput(); + + await executePendingCodeAgentApproval(run.id, { stdout: output.stream }); + + const updated = getCodeAgentRunRecord(run.id); + expect(fs.existsSync(target)).toBe(false); + expect(updated).toMatchObject({ + status: "paused", + phase: "approval-complete", + needsApproval: false, + metadata: { + lastApproval: { + id: "approval-test", + exitCode: 0, + }, + }, + }); + expect(updated?.metadata?.pendingApproval).toBeUndefined(); + expect(output.read()).toContain("Approved command finished"); + }); +}); + +describe("classifyCodeAgentCommandPermission", () => { + it("allows read-only inspection commands", () => { + expect(classifyCodeAgentCommandPermission("git status --short")).toEqual({ + kind: "read", + }); + expect(classifyCodeAgentCommandPermission("rg button src")).toEqual({ + kind: "read", + }); + }); + + it("classifies file-writing commands as write operations", () => { + expect(classifyCodeAgentCommandPermission("echo hi > notes.txt")).toEqual({ + kind: "write", + }); + expect(classifyCodeAgentCommandPermission("pnpm add left-pad")).toEqual({ + kind: "write", + }); + }); + + it("blocks forbidden git commands and requests approval for destructive commands", () => { + expect( + classifyCodeAgentCommandPermission("git reset --hard"), + ).toMatchObject({ kind: "forbidden" }); + expect(classifyCodeAgentCommandPermission("rm -rf dist")).toMatchObject({ + kind: "approval-required", + }); + }); +}); + +function createStringOutput(): { + stream: Writable; + read: () => string; +} { + let text = ""; + const stream = new Writable({ + write(chunk, _encoding, callback) { + text += chunk.toString(); + callback(); + }, + }); + return { + stream, + read: () => text, + }; +} + +function useTempCodeAgentsHome(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "an-code-exec-")); + tmpRoots.push(root); + process.env.AGENT_NATIVE_CODE_AGENTS_HOME = path.join(root, "code-agents"); + return root; +} diff --git a/packages/core/src/cli/code-agent-executor.ts b/packages/core/src/cli/code-agent-executor.ts new file mode 100644 index 000000000..4f1444503 --- /dev/null +++ b/packages/core/src/cli/code-agent-executor.ts @@ -0,0 +1,1095 @@ +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { once } from "node:events"; + +import { + createToolSearchEntry, + TOOL_SEARCH_ACTION_NAME, +} from "../agent/tool-search.js"; +import { + buildMergedConfig, + McpClientManager, + mcpToolsToActionEntries, +} from "../mcp-client/index.js"; +import { runWithRequestContext } from "../server/request-context.js"; +import { + actionsToEngineTools, + runAgentLoop, + type ActionEntry, +} from "../agent/production-agent.js"; +import { + resolveEngine, + getStoredModelForEngine, + registerBuiltinEngines, +} from "../agent/engine/index.js"; +import type { + AgentEngine, + EngineContentPart, + EngineEvent, + EngineMessage, + EngineStreamOptions, +} from "../agent/engine/types.js"; +import type { AgentChatEvent } from "../agent/types.js"; +import { PROVIDER_ENV_VARS } from "../agent/engine/provider-env-vars.js"; +import { + isReasoningEffort, + type ReasoningEffort, +} from "../shared/reasoning-effort.js"; +import { + appendCodeAgentTranscriptEvent, + dequeueCodeAgentFollowUp, + getCodeAgentRunRecord, + listCodeAgentTranscriptEvents, + updateCodeAgentRunRecord, + type CodeAgentPermissionMode, + type CodeAgentRunRecord, +} from "./code-agent-runs.js"; + +export interface ExecuteCodeAgentRunOptions { + runId: string; + prompt?: string; + appendUserEvent?: boolean; + engine?: AgentEngine; + model?: string; + reasoningEffort?: ReasoningEffort; + stdout?: NodeJS.WritableStream; + signal?: AbortSignal; +} + +interface CommandResult { + code: number | null; + stdout: string; + stderr: string; + timedOut: boolean; +} + +interface PendingCodeAgentApproval { + id: string; + tool: "run_command"; + command: string; + reason: string; + requestedAt: string; + permissionMode: CodeAgentPermissionMode; +} + +const DEFAULT_COMMAND_TIMEOUT_MS = 120_000; +const MAX_TOOL_OUTPUT_CHARS = 50_000; +const MAX_FILE_READ_CHARS = 120_000; + +export async function executeCodeAgentRun( + options: ExecuteCodeAgentRunOptions, +): Promise { + const existing = getCodeAgentRunRecord(options.runId); + if (!existing) return null; + + const prompt = options.prompt ?? latestUserPrompt(existing.id); + if (!prompt) { + appendCodeAgentTranscriptEvent({ + runId: existing.id, + kind: "status", + message: "No prompt was found for this Agent-Native Code run.", + metadata: { status: "errored", phase: "missing-prompt" }, + }); + return updateCodeAgentRunRecord(existing.id, { + status: "errored", + phase: "missing-prompt", + progress: { + label: "Missing prompt", + completed: 0, + total: 1, + failed: 1, + percent: 0, + }, + }); + } + + if (options.appendUserEvent !== false) { + appendCodeAgentTranscriptEvent({ + runId: existing.id, + kind: "user", + message: prompt, + metadata: { source: "execution-prompt" }, + }); + } + + const running = updateCodeAgentRunRecord(existing.id, { + status: "running", + phase: "executing", + progress: { + label: "Running", + completed: 0, + total: 1, + percent: 10, + }, + metadata: { + executionStartedAt: new Date().toISOString(), + }, + }); + appendCodeAgentTranscriptEvent({ + runId: existing.id, + kind: "status", + message: "Agent-Native Code run started.", + metadata: { status: "running", phase: "executing" }, + }); + + const requestedEngine = metadataString(existing, "engine"); + const engine = + options.engine ?? (await resolveExecutorEngine(requestedEngine)); + if (!engine) { + const message = + "No LLM provider key was found. Set ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_GENERATIVE_AI_API_KEY, or another supported provider key and resume this run."; + options.stdout?.write(`${message}\n`); + appendCodeAgentTranscriptEvent({ + runId: existing.id, + kind: "status", + message, + metadata: { status: "paused", phase: "missing-credentials" }, + }); + return updateCodeAgentRunRecord(existing.id, { + status: "paused", + phase: "missing-credentials", + needsApproval: true, + progress: { + label: "Missing credentials", + completed: 0, + total: 1, + percent: 0, + }, + }); + } + + const model = + options.model ?? + metadataString(existing, "model") ?? + process.env.AGENT_MODEL ?? + (await getStoredModelForEngine(engine).catch(() => undefined)) ?? + engine.defaultModel; + const reasoningEffort = + options.reasoningEffort ?? metadataReasoningEffort(existing); + const cwd = existing.cwd || process.cwd(); + const permissionMode = existing.permissionMode ?? "full-auto"; + const actions = createLocalCodeAgentActions(cwd, permissionMode, existing.id); + const mcpManager = await startCodeAgentMcpManager(existing.id); + if (mcpManager) { + Object.assign(actions, mcpToolsToActionEntries(mcpManager)); + } + actions[TOOL_SEARCH_ACTION_NAME] = createToolSearchEntry(() => actions); + const tools = actionsToEngineTools(actions); + const messages = buildCodeAgentMessages(existing, prompt); + const controller = new AbortController(); + const abortFromParent = () => controller.abort(); + if (options.signal) { + if (options.signal.aborted) controller.abort(); + else + options.signal.addEventListener("abort", abortFromParent, { once: true }); + } + + let assistantText = ""; + const send = (event: AgentChatEvent) => { + if (event.type === "text") { + assistantText += event.text; + options.stdout?.write(event.text); + return; + } + if (event.type === "activity") { + appendCodeAgentTranscriptEvent({ + runId: existing.id, + kind: "status", + message: event.label, + metadata: { type: "activity", tool: event.tool }, + }); + return; + } + if (event.type === "tool_start") { + appendCodeAgentTranscriptEvent({ + runId: existing.id, + kind: "status", + message: `Running ${event.tool}.`, + metadata: { type: "tool_start", tool: event.tool, input: event.input }, + }); + return; + } + if (event.type === "tool_done") { + appendCodeAgentTranscriptEvent({ + runId: existing.id, + kind: "status", + message: `Finished ${event.tool}.`, + metadata: { + type: "tool_done", + tool: event.tool, + result: truncate(event.result, 4000), + }, + }); + return; + } + if (event.type === "error") { + appendCodeAgentTranscriptEvent({ + runId: existing.id, + kind: "status", + message: event.error, + metadata: { type: "error", errorCode: event.errorCode }, + }); + } + }; + + try { + await runWithOptionalCodeAgentRequestContext(existing, () => + runAgentLoop({ + engine, + model, + systemPrompt: codeAgentSystemPrompt(cwd, permissionMode), + tools, + actions, + messages, + send, + signal: controller.signal, + maxIterations: 12, + reasoningEffort, + }), + ); + if (assistantText.trim()) { + options.stdout?.write("\n"); + appendCodeAgentTranscriptEvent({ + runId: existing.id, + kind: "system", + message: assistantText.trim(), + metadata: { + role: "assistant", + model, + engine: engine.name, + reasoningEffort, + }, + }); + } + const approvalPending = getPendingApproval(existing.id); + if (approvalPending) { + const message = `Agent-Native Code run paused for approval: ${approvalPending.reason}`; + options.stdout?.write(`\n${message}\n`); + appendCodeAgentTranscriptEvent({ + runId: existing.id, + kind: "status", + message, + metadata: { + status: "needs-approval", + phase: "approval-required", + pendingApprovalId: approvalPending.id, + }, + }); + return updateCodeAgentRunRecord(existing.id, { + status: "needs-approval", + phase: "approval-required", + needsApproval: true, + progress: { + label: "Approval required", + completed: 0, + total: 1, + percent: 50, + }, + }); + } + + const pendingFollowUp = dequeueCodeAgentFollowUp(existing.id); + if (pendingFollowUp) { + const message = + pendingFollowUp.mode === "queued" + ? "Agent-Native Code run completed; running queued follow-up." + : "Agent-Native Code run completed; applying steering follow-up."; + appendCodeAgentTranscriptEvent({ + runId: existing.id, + kind: "status", + message, + metadata: { + status: "running", + phase: "follow-up", + followUpId: pendingFollowUp.id, + followUpMode: pendingFollowUp.mode, + }, + }); + if (pendingFollowUp.permissionMode) { + updateCodeAgentRunRecord(existing.id, { + permissionMode: pendingFollowUp.permissionMode, + }); + } + return executeCodeAgentRun({ + ...options, + runId: existing.id, + prompt: pendingFollowUp.prompt, + appendUserEvent: false, + }); + } + + appendCodeAgentTranscriptEvent({ + runId: existing.id, + kind: "status", + message: "Agent-Native Code run completed.", + metadata: { status: "completed", phase: "complete" }, + }); + return updateCodeAgentRunRecord(existing.id, { + status: "completed", + phase: "complete", + needsApproval: false, + progress: { + label: "Complete", + completed: 1, + total: 1, + percent: 100, + }, + metadata: { + executionCompletedAt: new Date().toISOString(), + engine: engine.name, + model, + reasoningEffort, + permissionMode, + }, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + options.stdout?.write(`\nAgent-Native Code run failed: ${message}\n`); + appendCodeAgentTranscriptEvent({ + runId: existing.id, + kind: "status", + message: `Agent-Native Code run failed: ${message}`, + metadata: { status: "errored", phase: "error" }, + }); + return updateCodeAgentRunRecord(existing.id, { + status: controller.signal.aborted ? "paused" : "errored", + phase: controller.signal.aborted ? "paused" : "error", + progress: { + label: controller.signal.aborted ? "Paused" : "Error", + completed: 0, + total: 1, + failed: controller.signal.aborted ? 0 : 1, + percent: 0, + }, + metadata: { + executionError: message, + executionErroredAt: new Date().toISOString(), + }, + }); + } finally { + options.signal?.removeEventListener("abort", abortFromParent); + await mcpManager?.stop().catch(() => undefined); + void running; + } +} + +export async function executeExistingCodeAgentRun( + runId: string, + options: Omit = {}, +): Promise { + return executeCodeAgentRun({ ...options, runId, appendUserEvent: false }); +} + +export async function executePendingCodeAgentApproval( + runId: string, + options: { stdout?: NodeJS.WritableStream } = {}, +): Promise { + const record = getCodeAgentRunRecord(runId); + if (!record) return null; + const approval = getPendingApproval(runId); + if (!approval) { + options.stdout?.write("No pending approval was found for this run.\n"); + return record; + } + + const permission = classifyCodeAgentCommandPermission(approval.command); + if (permission.kind === "forbidden") { + const message = `Approval cannot run forbidden command: ${permission.reason}`; + options.stdout?.write(`${message}\n`); + appendCodeAgentTranscriptEvent({ + runId, + kind: "status", + message, + metadata: { + status: "needs-approval", + phase: "approval-forbidden", + approvalId: approval.id, + }, + }); + return updateCodeAgentRunRecord(runId, { + status: "needs-approval", + phase: "approval-forbidden", + needsApproval: true, + }); + } + + appendCodeAgentTranscriptEvent({ + runId, + kind: "status", + message: `Approved command ${approval.id}; running now.`, + metadata: { + status: "running", + phase: "approval-running", + approvalId: approval.id, + command: approval.command, + }, + }); + const result = await runCommand( + approval.command, + record.cwd || process.cwd(), + DEFAULT_COMMAND_TIMEOUT_MS, + ); + const summary = truncate( + [ + `Approved command finished with exit code ${result.code}.`, + result.timedOut ? "Timed out: true" : "", + result.stdout ? `stdout:\n${result.stdout}` : "", + result.stderr ? `stderr:\n${result.stderr}` : "", + ] + .filter(Boolean) + .join("\n\n"), + MAX_TOOL_OUTPUT_CHARS, + ); + options.stdout?.write(`${summary}\n`); + appendCodeAgentTranscriptEvent({ + runId, + kind: "status", + message: summary, + metadata: { + status: result.code === 0 ? "paused" : "errored", + phase: "approval-complete", + approvalId: approval.id, + exitCode: result.code, + timedOut: result.timedOut, + }, + }); + return updateCodeAgentRunRecord(runId, { + status: result.code === 0 ? "paused" : "errored", + phase: result.code === 0 ? "approval-complete" : "approval-command-error", + needsApproval: false, + progress: { + label: result.code === 0 ? "Approval complete" : "Approval failed", + completed: result.code === 0 ? 1 : 0, + total: 1, + failed: result.code === 0 ? 0 : 1, + percent: result.code === 0 ? 100 : 0, + }, + metadata: { + pendingApproval: undefined, + lastApproval: { + ...approval, + completedAt: new Date().toISOString(), + exitCode: result.code, + }, + }, + }); +} + +function latestUserPrompt(runId: string): string { + const events = listCodeAgentTranscriptEvents(runId); + for (let i = events.length - 1; i >= 0; i--) { + const event = events[i]; + if (event.kind === "user" && event.message.trim()) return event.message; + } + return ""; +} + +function metadataString( + run: CodeAgentRunRecord, + key: string, +): string | undefined { + const value = run.metadata?.[key]; + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +async function startCodeAgentMcpManager( + runId: string, +): Promise { + const config = await buildMergedConfig().catch((err) => { + const message = err instanceof Error ? err.message : String(err); + appendCodeAgentTranscriptEvent({ + runId, + kind: "status", + message: `MCP tools unavailable: ${message}`, + metadata: { type: "mcp-config-error" }, + }); + return null; + }); + if (!config || Object.keys(config.servers ?? {}).length === 0) return null; + + const manager = new McpClientManager(config); + await manager.start().catch((err) => { + const message = err instanceof Error ? err.message : String(err); + appendCodeAgentTranscriptEvent({ + runId, + kind: "status", + message: `MCP tools failed to start: ${message}`, + metadata: { type: "mcp-start-error" }, + }); + }); + const status = manager.getStatus(); + if (status.totalTools === 0) { + await manager.stop().catch(() => undefined); + return null; + } + appendCodeAgentTranscriptEvent({ + runId, + kind: "status", + message: `Connected ${status.totalTools} MCP tool${status.totalTools === 1 ? "" : "s"} for this run.`, + metadata: { + type: "mcp-tools-connected", + servers: status.connectedServers, + toolCount: status.totalTools, + }, + }); + return manager; +} + +function runWithOptionalCodeAgentRequestContext( + run: CodeAgentRunRecord, + fn: () => T | Promise, +): T | Promise { + const userEmail = + metadataString(run, "ownerEmail") ?? + metadataString(run, "userEmail") ?? + process.env.AGENT_USER_EMAIL; + const orgId = metadataString(run, "orgId") ?? process.env.AGENT_ORG_ID; + if (!userEmail && !orgId) return fn(); + return runWithRequestContext({ userEmail, orgId }, fn); +} + +function metadataReasoningEffort( + run: CodeAgentRunRecord, +): ReasoningEffort | undefined { + const value = run.metadata?.reasoningEffort ?? run.metadata?.effort; + return isReasoningEffort(value) && value !== "auto" ? value : undefined; +} + +async function resolveExecutorEngine( + requestedEngine?: string, +): Promise { + const fakeText = process.env.AGENT_NATIVE_CODE_AGENT_FAKE_RESPONSE; + if (fakeText !== undefined) { + return createFakeCodeAgentEngine(fakeText || "Done."); + } + registerBuiltinEngines(); + if (!hasAnyProviderCredential()) return null; + return resolveEngine({ + engineOption: requestedEngine ?? process.env.AGENT_ENGINE, + }); +} + +function hasAnyProviderCredential(): boolean { + if (process.env.AGENT_ENGINE) return true; + if (PROVIDER_ENV_VARS.some((key) => Boolean(process.env[key]))) return true; + return Boolean( + process.env.BUILDER_PRIVATE_KEY && process.env.BUILDER_PUBLIC_KEY, + ); +} + +function createFakeCodeAgentEngine(text: string): AgentEngine { + return { + name: "fake-code-agent", + label: "Fake Agent-Native Code", + defaultModel: "fake-code-agent", + supportedModels: ["fake-code-agent"], + capabilities: { + thinking: false, + promptCaching: false, + vision: false, + computerUse: false, + parallelToolCalls: false, + }, + async *stream(_opts: EngineStreamOptions): AsyncIterable { + yield { type: "text-delta", text }; + yield { + type: "assistant-content", + parts: [{ type: "text", text }], + }; + yield { + type: "usage", + inputTokens: 1, + outputTokens: 1, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }; + yield { type: "stop", reason: "end_turn" }; + }, + }; +} + +function buildCodeAgentMessages( + run: CodeAgentRunRecord, + prompt: string, +): EngineMessage[] { + const transcript = listCodeAgentTranscriptEvents(run.id) + .slice(-40) + .map((event) => { + const label = + event.kind === "user" + ? "User" + : event.metadata?.role === "assistant" + ? "Assistant" + : event.kind; + return `${label}: ${event.message}`; + }) + .join("\n"); + const context = transcript + ? `\n\nPrevious session transcript:\n${transcript}` + : ""; + return [ + { + role: "user", + content: [ + { + type: "text", + text: `${prompt}${context}`, + }, + ], + }, + ]; +} + +function codeAgentSystemPrompt( + cwd: string, + permissionMode: CodeAgentPermissionMode, +): string { + return `You are Agent-Native Code, a local coding agent running in ${cwd}. + +Work like a careful senior engineer: +- Read relevant files before editing. +- Prefer small, focused changes. +- Current run mode: ${permissionMode === "read-only" ? "Plan mode" : "Auto mode"} (${permissionMode}). +- In Plan mode, inspect and explain only. +- In Auto mode, edit files and run ordinary project commands without pausing. Pause only for genuinely destructive operations such as recursive deletes, package publishing, privileged commands, destructive database operations, or forbidden git branch/reset/stash/rebase operations. +- Do not create, switch, delete, reset, rebase, or stash git branches. +- Do not run destructive git commands. +- Use apply_patch or write_file for edits, then run focused verification. +- Use tool-search when you need a capability that may come from MCP, including browser automation or computer control. +- Prefer Playwright MCP for deterministic browser testing; prefer Chrome DevTools MCP when the user needs their live logged-in Chrome session. +- Only use computer-control MCP tools when they are explicitly available and the user request warrants controlling the local computer. +- Keep the final answer concise and include files changed plus tests run. +- Respect any AGENTS.md instructions in the repository.`; +} + +function createLocalCodeAgentActions( + cwd: string, + permissionMode: CodeAgentPermissionMode, + runId: string, +): Record { + const actions: Record = { + list_files: { + readOnly: true, + tool: { + description: "List files under the current repository/workspace.", + parameters: { + type: "object", + properties: { + pattern: { + type: "string", + description: + "Optional substring or glob-like fragment to filter.", + }, + }, + required: [], + }, + }, + run: async (args) => { + const result = await runCommand("rg --files", cwd, 30_000); + const output = + result.code === 0 + ? result.stdout + : (await runCommand("find . -type f | sed 's#^./##'", cwd, 30_000)) + .stdout; + const pattern = stringArg(args.pattern).toLowerCase(); + const files = output + .split(/\r?\n/) + .filter(Boolean) + .filter((file) => !pattern || file.toLowerCase().includes(pattern)) + .slice(0, 500); + return files.join("\n") || "(no files found)"; + }, + }, + search_files: { + readOnly: true, + tool: { + description: "Search files with ripgrep.", + parameters: { + type: "object", + properties: { + query: { type: "string", description: "Search query or regex." }, + glob: { + type: "string", + description: "Optional glob, for example src/**/*.ts.", + }, + }, + required: ["query"], + }, + }, + run: async (args) => { + const query = stringArg(args.query); + if (!query) return "Error: query is required."; + const glob = stringArg(args.glob); + const command = glob + ? `rg --line-number --no-heading ${shellQuote(query)} -g ${shellQuote(glob)}` + : `rg --line-number --no-heading ${shellQuote(query)}`; + const result = await runCommand(command, cwd, 30_000); + return truncate( + result.stdout || result.stderr || "(no matches)", + MAX_TOOL_OUTPUT_CHARS, + ); + }, + }, + read_file: { + readOnly: true, + tool: { + description: "Read a UTF-8 text file inside the workspace.", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "Relative file path." }, + }, + required: ["path"], + }, + }, + run: async (args) => { + const filePath = resolveInsideCwd(cwd, stringArg(args.path)); + if (!filePath) return "Error: path must stay inside the workspace."; + if (!fs.existsSync(filePath)) + return `Error: file not found: ${args.path}`; + const stat = fs.statSync(filePath); + if (!stat.isFile()) return `Error: not a file: ${args.path}`; + return truncate(fs.readFileSync(filePath, "utf8"), MAX_FILE_READ_CHARS); + }, + }, + write_file: { + tool: { + description: "Write a complete UTF-8 text file inside the workspace.", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "Relative file path." }, + content: { type: "string", description: "Full file content." }, + }, + required: ["path", "content"], + }, + }, + run: async (args) => { + const permissionError = permissionErrorForWrite( + permissionMode, + "write_file", + ); + if (permissionError) return permissionError; + const filePath = resolveInsideCwd(cwd, stringArg(args.path)); + if (!filePath) return "Error: path must stay inside the workspace."; + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, stringArg(args.content)); + return `Wrote ${path.relative(cwd, filePath)}`; + }, + }, + apply_patch: { + tool: { + description: + "Apply a unified git patch from the workspace root. Prefer this for precise edits.", + parameters: { + type: "object", + properties: { + patch: { type: "string", description: "Unified diff patch text." }, + }, + required: ["patch"], + }, + }, + run: async (args) => { + const permissionError = permissionErrorForWrite( + permissionMode, + "apply_patch", + ); + if (permissionError) return permissionError; + const patch = stringArg(args.patch); + if (!patch.trim()) return "Error: patch is required."; + const result = await runCommand( + "git apply --whitespace=nowarn -", + cwd, + 30_000, + patch, + ); + if (result.code !== 0) { + return `Error applying patch:\n${result.stderr || result.stdout}`; + } + return "Patch applied."; + }, + }, + run_command: { + tool: { + description: + "Run a shell command from the workspace root. Use for tests, typechecks, and safe project commands.", + parameters: { + type: "object", + properties: { + command: { type: "string", description: "Shell command to run." }, + timeoutMs: { + type: "string", + description: "Optional timeout in milliseconds.", + }, + }, + required: ["command"], + }, + }, + run: async (args) => { + const command = stringArg(args.command); + if (!command) return "Error: command is required."; + const permission = classifyCodeAgentCommandPermission(command); + if (permission.kind === "forbidden") { + return `Error: command is blocked by Agent-Native Code policy: ${permission.reason}`; + } + if (permission.kind === "approval-required") { + const approval = requestCodeAgentApproval(runId, { + tool: "run_command", + command, + reason: permission.reason, + permissionMode, + }); + return [ + `Approval required before running this command: ${permission.reason}.`, + `Approval id: ${approval.id}`, + `Command: ${command}`, + "The run is paused; approve from the Agent-Native Code UI/CLI if this command is intentional.", + ].join("\n"); + } + const timeoutMs = Number(args.timeoutMs); + const result = await runCommand( + command, + cwd, + Number.isFinite(timeoutMs) && timeoutMs > 0 + ? Math.min(timeoutMs, 10 * 60_000) + : DEFAULT_COMMAND_TIMEOUT_MS, + ); + return truncate( + [ + `exitCode: ${result.code}`, + result.timedOut ? "timedOut: true" : "", + result.stdout ? `stdout:\n${result.stdout}` : "", + result.stderr ? `stderr:\n${result.stderr}` : "", + ] + .filter(Boolean) + .join("\n\n"), + MAX_TOOL_OUTPUT_CHARS, + ); + }, + }, + }; + if (permissionMode === "read-only") { + return Object.fromEntries( + Object.entries(actions).filter(([, action]) => action.readOnly), + ); + } + return actions; +} + +export type CodeAgentCommandPermission = + | { kind: "read" } + | { kind: "write" } + | { kind: "approval-required"; reason: string } + | { kind: "forbidden"; reason: string }; + +export function classifyCodeAgentCommandPermission( + command: string, +): CodeAgentCommandPermission { + const normalized = command.trim().toLowerCase(); + if (!normalized) return { kind: "read" }; + + const blockedPatterns: Array<[RegExp, string]> = [ + [ + /\bgit\s+(checkout|switch|reset|rebase|stash|clean|worktree)\b/, + "forbidden git branch/reset/stash/rebase operation", + ], + [ + /\bgit\s+branch\b(?!\s+--show-current\b)/, + "forbidden git branch operation", + ], + [/\bdrizzle-kit\s+push\b/, "drizzle-kit push is not allowed"], + ]; + for (const [pattern, reason] of blockedPatterns) { + if (pattern.test(normalized)) return { kind: "forbidden", reason }; + } + + const approvalPatterns: Array<[RegExp, string]> = [ + [/\brm\s+-rf\b/, "destructive recursive delete"], + [/\bsudo\b/, "privileged command"], + [/\bkill\s+-9\b/, "force-kill command"], + [/\bcurl\b.*\|\s*(sh|bash|zsh)\b/, "remote script execution"], + [/\b(wget|fetch)\b.*\|\s*(sh|bash|zsh)\b/, "remote script execution"], + [/\bnpm\s+publish\b/, "package publish"], + [/\bpnpm\s+publish\b/, "package publish"], + [/\btruncate\b/, "destructive data command"], + [/\bdrop\s+(table|column|database)\b/, "destructive database command"], + [/\bdelete\s+from\b(?![\s\S]*\bwhere\b)/, "unscoped delete command"], + ]; + for (const [pattern, reason] of approvalPatterns) { + if (pattern.test(normalized)) { + return { kind: "approval-required", reason }; + } + } + + const readPatterns = [ + /^pwd\b/, + /^ls\b/, + /^find\b/, + /^rg\b/, + /^grep\b/, + /^cat\b/, + /^sed\s+-n\b/, + /^head\b/, + /^tail\b/, + /^wc\b/, + /^git\s+(status|diff|show|log)\b/, + /^git\s+branch\s+--show-current\b/, + /^pnpm\b.*\b(test|typecheck|lint|check)\b/, + /^npm\b.*\b(test|run\s+(test|typecheck|lint|check))\b/, + ]; + if (readPatterns.some((pattern) => pattern.test(normalized))) { + return { kind: "read" }; + } + + const writePatterns = [ + /(^|[^>])>(?!>)/, + />>/, + /\btee\b/, + /\bapply_patch\b/, + /\b(write|touch|mkdir|cp|mv|rm|chmod|chown)\b/, + /\bpnpm\s+(add|install|remove|dlx)\b/, + /\bnpm\s+(install|i|add|remove|uninstall)\b/, + ]; + if (writePatterns.some((pattern) => pattern.test(normalized))) { + return { kind: "write" }; + } + + return { kind: "write" }; +} + +function permissionErrorForWrite( + permissionMode: CodeAgentPermissionMode, + toolName: string, +): string | null { + if ( + permissionMode === "ask-before-edit" || + permissionMode === "auto-edit" || + permissionMode === "full-auto" + ) { + return null; + } + if (permissionMode === "read-only") { + return `Error: ${toolName} is unavailable in read-only mode.`; + } + return `Error: ${toolName} is blocked by the current run mode.`; +} + +function requestCodeAgentApproval( + runId: string, + input: Omit, +): PendingCodeAgentApproval { + const requestedAt = new Date().toISOString(); + const approval: PendingCodeAgentApproval = { + id: `approval-${requestedAt.replace(/\D/g, "").slice(0, 14)}`, + requestedAt, + ...input, + }; + appendCodeAgentTranscriptEvent({ + runId, + kind: "status", + message: `Approval required: ${approval.reason}`, + metadata: { + status: "needs-approval", + phase: "approval-required", + pendingApproval: approval, + }, + }); + updateCodeAgentRunRecord(runId, { + status: "needs-approval", + phase: "approval-required", + needsApproval: true, + progress: { + label: "Approval required", + completed: 0, + total: 1, + percent: 50, + }, + metadata: { + pendingApproval: approval, + }, + }); + return approval; +} + +function getPendingApproval(runId: string): PendingCodeAgentApproval | null { + const record = getCodeAgentRunRecord(runId); + const approval = record?.metadata?.pendingApproval; + if (!approval || typeof approval !== "object") return null; + const candidate = approval as Record; + if ( + candidate.tool !== "run_command" || + typeof candidate.command !== "string" || + typeof candidate.reason !== "string" || + typeof candidate.id !== "string" || + typeof candidate.requestedAt !== "string" + ) { + return null; + } + return { + id: candidate.id, + tool: "run_command", + command: candidate.command, + reason: candidate.reason, + requestedAt: candidate.requestedAt, + permissionMode: + candidate.permissionMode === "read-only" || + candidate.permissionMode === "ask-before-edit" || + candidate.permissionMode === "auto-edit" || + candidate.permissionMode === "full-auto" + ? candidate.permissionMode + : "full-auto", + }; +} + +function resolveInsideCwd(cwd: string, value: string): string | null { + if (!value.trim()) return null; + const resolved = path.resolve(cwd, value); + const relative = path.relative(cwd, resolved); + if (relative.startsWith("..") || path.isAbsolute(relative)) return null; + return resolved; +} + +async function runCommand( + command: string, + cwd: string, + timeoutMs: number, + stdin?: string, +): Promise { + const child = spawn(command, { + cwd, + shell: true, + stdio: ["pipe", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + }, timeoutMs); + child.stdout?.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr?.on("data", (chunk) => { + stderr += chunk.toString(); + }); + if (stdin) child.stdin?.end(stdin); + else child.stdin?.end(); + const [code] = (await once(child, "exit")) as [number | null]; + clearTimeout(timer); + return { code, stdout, stderr, timedOut }; +} + +function stringArg(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function truncate(value: string, max: number): string { + if (value.length <= max) return value; + return `${value.slice(0, max)}\n\n...[truncated ${value.length - max} chars]`; +} diff --git a/packages/core/src/cli/code-agent-runs.ts b/packages/core/src/cli/code-agent-runs.ts new file mode 100644 index 000000000..f2b9dc59f --- /dev/null +++ b/packages/core/src/cli/code-agent-runs.ts @@ -0,0 +1,449 @@ +import crypto from "crypto"; +import fs from "fs"; +import os from "os"; +import path from "path"; + +export type CodeAgentRunStatus = + | "queued" + | "running" + | "paused" + | "needs-approval" + | "completed" + | "errored" + | "unknown"; + +export const CODE_AGENT_PERMISSION_MODES = [ + "read-only", + "ask-before-edit", + "auto-edit", + "full-auto", +] as const; + +export type CodeAgentPermissionMode = + (typeof CODE_AGENT_PERMISSION_MODES)[number]; + +export interface CodeAgentRunProgress { + label?: string; + completed: number; + total: number; + failed?: number; + percent: number; +} + +export interface CodeAgentRunDetail { + label: string; + value: string; +} + +export type CodeAgentFollowUpMode = "immediate" | "queued"; + +export interface CodeAgentPendingFollowUp { + id: string; + prompt: string; + mode: CodeAgentFollowUpMode; + createdAt: string; + eventId?: string; + permissionMode?: CodeAgentPermissionMode; + source?: string; +} + +export interface CodeAgentRunRecord { + schemaVersion: 1; + id: string; + goalId: string; + title: string; + subtitle?: string; + status: CodeAgentRunStatus; + phase?: string; + needsApproval?: boolean; + progress?: CodeAgentRunProgress; + permissionMode?: CodeAgentPermissionMode; + details?: CodeAgentRunDetail[]; + artifactRoot?: string; + surfaceUrl?: string; + cwd: string; + createdAt: string; + updatedAt: string; + metadata?: Record; +} + +export type CodeAgentTranscriptEventKind = + | "user" + | "system" + | "note" + | "artifact" + | "status"; + +export interface CodeAgentTranscriptEvent { + schemaVersion: 1; + id: string; + runId: string; + kind: CodeAgentTranscriptEventKind; + message: string; + createdAt: string; + metadata?: Record; +} + +export interface CreateCodeAgentRunInput { + goalId: string; + title: string; + subtitle?: string; + status?: CodeAgentRunStatus; + phase?: string; + needsApproval?: boolean; + progress?: CodeAgentRunProgress; + permissionMode?: CodeAgentPermissionMode; + details?: CodeAgentRunDetail[]; + artifactRoot?: string; + surfaceUrl?: string; + cwd?: string; + metadata?: Record; +} + +export interface AppendCodeAgentTranscriptEventInput { + runId: string; + kind: CodeAgentTranscriptEventKind; + message: string; + createdAt?: string; + metadata?: Record; +} + +export interface QueueCodeAgentFollowUpInput { + runId: string; + prompt: string; + mode: CodeAgentFollowUpMode; + eventId?: string; + permissionMode?: CodeAgentPermissionMode; + source?: string; + createdAt?: string; +} + +const STORE_ENV = "AGENT_NATIVE_CODE_AGENTS_HOME"; + +export function codeAgentStoreRoot(): string { + return path.resolve( + process.env[STORE_ENV] ?? + path.join(os.homedir(), ".agent-native", "code-agents"), + ); +} + +export function codeAgentRunsDir(): string { + return path.join(codeAgentStoreRoot(), "runs"); +} + +export function codeAgentRunArtifactsDir(runId: string): string { + return path.join(codeAgentStoreRoot(), "artifacts", runId); +} + +export function codeAgentTranscriptsDir(): string { + return path.join(codeAgentStoreRoot(), "transcripts"); +} + +export function codeAgentRunTranscriptPath(runId: string): string { + return path.join(codeAgentTranscriptsDir(), `${runId}.jsonl`); +} + +export function createCodeAgentRunRecord( + input: CreateCodeAgentRunInput, +): CodeAgentRunRecord { + const now = new Date().toISOString(); + const id = `${input.goalId}-${timestampSlug(now)}-${crypto.randomUUID().slice(0, 8)}`; + const record: CodeAgentRunRecord = { + schemaVersion: 1, + id, + goalId: input.goalId, + title: input.title, + subtitle: input.subtitle, + status: input.status ?? "queued", + phase: input.phase, + needsApproval: input.needsApproval, + progress: input.progress, + permissionMode: input.permissionMode, + details: input.details, + artifactRoot: input.artifactRoot, + surfaceUrl: input.surfaceUrl, + cwd: input.cwd ?? process.cwd(), + createdAt: now, + updatedAt: now, + metadata: input.metadata, + }; + writeCodeAgentRunRecord(record); + return record; +} + +export function normalizeCodeAgentPermissionMode( + value: unknown, +): CodeAgentPermissionMode | null { + if (typeof value !== "string") return null; + return CODE_AGENT_PERMISSION_MODES.includes(value as CodeAgentPermissionMode) + ? (value as CodeAgentPermissionMode) + : null; +} + +export function writeCodeAgentRunRecord(record: CodeAgentRunRecord): void { + fs.mkdirSync(codeAgentRunsDir(), { recursive: true }); + fs.writeFileSync( + codeAgentRunRecordPath(record.id), + `${JSON.stringify(record, null, 2)}\n`, + ); +} + +export function getCodeAgentRunRecord( + runId: string, +): CodeAgentRunRecord | null { + return readRunFile(codeAgentRunRecordPath(runId)); +} + +export function updateCodeAgentRunRecord( + runId: string, + updates: + | Partial + | ((record: CodeAgentRunRecord) => Partial), +): CodeAgentRunRecord | null { + const record = getCodeAgentRunRecord(runId); + if (!record) return null; + const patch = typeof updates === "function" ? updates(record) : updates; + const next: CodeAgentRunRecord = { + ...record, + ...patch, + metadata: { + ...(record.metadata ?? {}), + ...(patch.metadata ?? {}), + }, + updatedAt: patch.updatedAt ?? new Date().toISOString(), + }; + writeCodeAgentRunRecord(next); + return next; +} + +export function listCodeAgentRunRecords(goalId?: string): CodeAgentRunRecord[] { + const dir = codeAgentRunsDir(); + if (!fs.existsSync(dir)) return []; + return fs + .readdirSync(dir) + .filter((file) => file.endsWith(".json")) + .map((file) => readRunFile(path.join(dir, file))) + .filter((run): run is CodeAgentRunRecord => Boolean(run)) + .filter((run) => !goalId || run.goalId === goalId) + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); +} + +export function getLastCodeAgentRunRecord( + goalId?: string, +): CodeAgentRunRecord | null { + return listCodeAgentRunRecords(goalId)[0] ?? null; +} + +export function appendCodeAgentTranscriptEvent( + input: AppendCodeAgentTranscriptEventInput, +): CodeAgentTranscriptEvent { + const createdAt = input.createdAt ?? new Date().toISOString(); + const event: CodeAgentTranscriptEvent = { + schemaVersion: 1, + id: `evt-${timestampSlug(createdAt)}-${crypto.randomUUID().slice(0, 8)}`, + runId: input.runId, + kind: input.kind, + message: input.message, + createdAt, + metadata: input.metadata, + }; + + fs.mkdirSync(codeAgentTranscriptsDir(), { recursive: true }); + fs.appendFileSync( + codeAgentRunTranscriptPath(input.runId), + `${JSON.stringify(event)}\n`, + ); + touchCodeAgentRunRecord(input.runId, createdAt); + return event; +} + +export function listCodeAgentTranscriptEvents( + runId: string, +): CodeAgentTranscriptEvent[] { + const transcriptPath = codeAgentRunTranscriptPath(runId); + if (!fs.existsSync(transcriptPath)) return []; + return fs + .readFileSync(transcriptPath, "utf-8") + .split(/\r?\n/) + .filter(Boolean) + .map(readTranscriptLine) + .filter((event): event is CodeAgentTranscriptEvent => Boolean(event)); +} + +export function isActiveCodeAgentRun(run: CodeAgentRunRecord): boolean { + return run.status === "running" || run.status === "needs-approval"; +} + +export function queueCodeAgentFollowUp( + input: QueueCodeAgentFollowUpInput, +): CodeAgentPendingFollowUp | null { + const createdAt = input.createdAt ?? new Date().toISOString(); + const followUp: CodeAgentPendingFollowUp = { + id: `followup-${timestampSlug(createdAt)}-${crypto.randomUUID().slice(0, 8)}`, + prompt: input.prompt, + mode: input.mode, + createdAt, + eventId: input.eventId, + permissionMode: input.permissionMode, + source: input.source, + }; + const updated = updateCodeAgentRunRecord(input.runId, (record) => ({ + metadata: { + pendingFollowUps: [ + ...readPendingFollowUps(record.metadata?.pendingFollowUps), + followUp, + ], + }, + })); + return updated ? followUp : null; +} + +export function dequeueCodeAgentFollowUp( + runId: string, +): CodeAgentPendingFollowUp | null { + let selected: CodeAgentPendingFollowUp | null = null; + updateCodeAgentRunRecord(runId, (record) => { + const [first, ...rest] = readPendingFollowUps( + record.metadata?.pendingFollowUps, + ); + selected = first ?? null; + return { + metadata: { + pendingFollowUps: rest.length > 0 ? rest : undefined, + }, + }; + }); + return selected; +} + +function codeAgentRunRecordPath(runId: string): string { + return path.join(codeAgentRunsDir(), `${runId}.json`); +} + +function readPendingFollowUps(value: unknown): CodeAgentPendingFollowUp[] { + if (!Array.isArray(value)) return []; + return value + .map((item): CodeAgentPendingFollowUp | null => { + if (!item || typeof item !== "object") return null; + const candidate = item as Record; + if ( + typeof candidate.id !== "string" || + typeof candidate.prompt !== "string" || + typeof candidate.createdAt !== "string" || + (candidate.mode !== "immediate" && candidate.mode !== "queued") + ) { + return null; + } + return { + id: candidate.id, + prompt: candidate.prompt, + mode: candidate.mode, + createdAt: candidate.createdAt, + eventId: + typeof candidate.eventId === "string" ? candidate.eventId : undefined, + permissionMode: + normalizeCodeAgentPermissionMode(candidate.permissionMode) ?? + undefined, + source: + typeof candidate.source === "string" ? candidate.source : undefined, + } satisfies CodeAgentPendingFollowUp; + }) + .filter((item): item is CodeAgentPendingFollowUp => Boolean(item)); +} + +function touchCodeAgentRunRecord(runId: string, updatedAt: string): void { + const record = readRunFile(codeAgentRunRecordPath(runId)); + if (!record) return; + writeCodeAgentRunRecord({ ...record, updatedAt }); +} + +function readRunFile(filePath: string): CodeAgentRunRecord | null { + try { + const raw = JSON.parse(fs.readFileSync(filePath, "utf-8")) as unknown; + if (!raw || typeof raw !== "object") return null; + const record = raw as Partial; + if ( + record.schemaVersion !== 1 || + typeof record.id !== "string" || + typeof record.goalId !== "string" || + typeof record.title !== "string" || + typeof record.status !== "string" || + typeof record.cwd !== "string" || + typeof record.createdAt !== "string" || + typeof record.updatedAt !== "string" + ) { + return null; + } + return record as CodeAgentRunRecord; + } catch { + return null; + } +} + +function readTranscriptLine(line: string): CodeAgentTranscriptEvent | null { + try { + const raw = JSON.parse(line) as unknown; + if (!raw || typeof raw !== "object") return null; + const event = raw as Partial & { + type?: unknown; + role?: unknown; + text?: unknown; + content?: unknown; + }; + const kind = isTranscriptEventKind(event.kind) + ? event.kind + : normalizeTranscriptKind(event.type ?? event.role); + const message = + typeof event.message === "string" + ? event.message + : typeof event.text === "string" + ? event.text + : typeof event.content === "string" + ? event.content + : undefined; + if ( + event.schemaVersion !== 1 || + typeof event.id !== "string" || + typeof event.runId !== "string" || + !kind || + typeof message !== "string" || + typeof event.createdAt !== "string" + ) { + return null; + } + return { + ...(event as Partial), + kind, + message, + } as CodeAgentTranscriptEvent; + } catch { + return null; + } +} + +function normalizeTranscriptKind( + value: unknown, +): CodeAgentTranscriptEventKind | null { + if (typeof value !== "string") return null; + const normalized = value.toLowerCase(); + if (normalized === "human" || normalized === "prompt") return "user"; + if (normalized === "assistant") return "system"; + if (isTranscriptEventKind(normalized)) return normalized; + return null; +} + +function isTranscriptEventKind( + value: unknown, +): value is CodeAgentTranscriptEventKind { + return ( + value === "user" || + value === "system" || + value === "note" || + value === "artifact" || + value === "status" + ); +} + +function timestampSlug(value: string): string { + return value.replace(/\D/g, "").slice(0, 14); +} diff --git a/packages/core/src/cli/code.spec.ts b/packages/core/src/cli/code.spec.ts new file mode 100644 index 000000000..0677f4cca --- /dev/null +++ b/packages/core/src/cli/code.spec.ts @@ -0,0 +1,876 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { Readable, Writable } from "node:stream"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createCodeAgentRunRecord, + listCodeAgentRunRecords, + listCodeAgentTranscriptEvents, +} from "./code-agent-runs.js"; +import { + CODE_AGENT_CLI_GOALS, + codeUsage, + handleCodeShellLine, + parseCodeShellArgs, + resolveCodeCommand, + runCode, + type CodeAgentGoalId, +} from "./code.js"; + +const tmpRoots: string[] = []; +const originalCwd = process.cwd(); + +afterEach(() => { + process.chdir(originalCwd); + delete process.env.AGENT_NATIVE_CODE_AGENTS_HOME; + delete process.env.AGENT_NATIVE_CODE_AGENT_FAKE_RESPONSE; + vi.restoreAllMocks(); + for (const root of tmpRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +describe("resolveCodeCommand", () => { + it("opens the shell when no goal is provided", () => { + expect(resolveCodeCommand([])).toEqual({ kind: "shell" }); + }); + + it("shows help when requested", () => { + expect(resolveCodeCommand(["--help"])).toEqual({ kind: "help" }); + }); + + it("lists available goals", () => { + expect(resolveCodeCommand(["goals"])).toEqual({ kind: "list-goals" }); + }); + + it("serves the remote connector", () => { + expect( + resolveCodeCommand(["serve", "--relay-url", "https://app.test"]), + ).toEqual({ + kind: "serve", + relayUrl: "https://app.test", + }); + }); + + it("lists sessions through Codex-style session commands", () => { + expect(resolveCodeCommand(["list"])).toEqual({ + kind: "control", + subcommand: "list", + args: ["list"], + }); + expect(resolveCodeCommand(["ps"])).toEqual({ + kind: "control", + subcommand: "ps", + args: ["ps"], + }); + }); + + it("forwards slash goals to their backing command", () => { + expect( + resolveCodeCommand(["/migrate", "./source", "--out", "../migrated"]), + ).toEqual({ + kind: "run-goal", + goalId: "migrate", + forwardedArgs: ["./source", "--out", "../migrated"], + }); + }); + + it("forwards task goals", () => { + expect(resolveCodeCommand(["/task", "fix", "the", "tests"])).toEqual({ + kind: "run-goal", + goalId: "task", + forwardedArgs: ["fix", "the", "tests"], + }); + }); + + it("accepts exec and print aliases for generic tasks", () => { + expect(resolveCodeCommand(["exec", "fix", "the", "tests"])).toEqual({ + kind: "run-goal", + goalId: "task", + forwardedArgs: ["fix", "the", "tests"], + }); + expect(resolveCodeCommand(["-p", "fix", "the", "tests"])).toEqual({ + kind: "run-goal", + goalId: "task", + forwardedArgs: ["fix", "the", "tests"], + }); + }); + + it("forwards non-migration slash goals", () => { + expect( + resolveCodeCommand(["/audit", "--url", "https://example.com"]), + ).toEqual({ + kind: "run-goal", + goalId: "audit", + forwardedArgs: ["--url", "https://example.com"], + }); + }); + + it("routes project slash commands from .agents/commands", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "an-code-command-")); + tmpRoots.push(root); + fs.mkdirSync(path.join(root, ".agents", "commands"), { + recursive: true, + }); + fs.writeFileSync( + path.join(root, ".agents", "commands", "review-diff.md"), + "Review the current diff.", + ); + process.chdir(root); + + expect(resolveCodeCommand(["/review-diff", "--cached"])).toEqual({ + kind: "run-project-command", + commandName: "review-diff", + forwardedArgs: ["--cached"], + }); + }); + + it("accepts bare goal aliases", () => { + expect(resolveCodeCommand(["migration", "--describe", "old app"])).toEqual({ + kind: "run-goal", + goalId: "migrate", + forwardedArgs: ["--describe", "old app"], + }); + }); + + it("handles resume/status/ui/stop at the generic Agent-Native Code layer", () => { + expect(resolveCodeCommand(["resume", "--last"])).toEqual({ + kind: "control", + subcommand: "resume", + args: ["resume", "--last"], + }); + expect(resolveCodeCommand(["approve", "--last"])).toEqual({ + kind: "control", + subcommand: "approve", + args: ["approve", "--last"], + }); + }); + + it("supports continue and resume flag aliases", () => { + expect(resolveCodeCommand(["--continue"])).toEqual({ + kind: "control", + subcommand: "resume", + args: ["resume", "--last"], + }); + expect(resolveCodeCommand(["-c", "please continue"])).toEqual({ + kind: "record-follow-up", + prompt: "please continue", + }); + expect(resolveCodeCommand(["--resume", "task-123"])).toEqual({ + kind: "control", + subcommand: "resume", + args: ["resume", "task-123"], + }); + }); + + it("can execute an existing run by id", () => { + expect(resolveCodeCommand(["run", "task-123"])).toEqual({ + kind: "execute-existing-run", + runId: "task-123", + }); + }); + + it("records resume follow-up prompts against the last run", () => { + expect(resolveCodeCommand(["resume", "--last", "please continue"])).toEqual( + { + kind: "record-follow-up", + prompt: "please continue", + }, + ); + expect(resolveCodeCommand(["resume", "--last", "--", "--fix-it"])).toEqual({ + kind: "record-follow-up", + prompt: "--fix-it", + }); + expect( + resolveCodeCommand([ + "resume", + "task-20260515t120000z-deadbeef", + "please continue", + ]), + ).toEqual({ + kind: "record-follow-up", + runId: "task-20260515t120000z-deadbeef", + prompt: "please continue", + }); + }); + + it("lets built-in slash goals win over project command files", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "an-code-shadow-")); + tmpRoots.push(root); + fs.mkdirSync(path.join(root, ".agents", "commands"), { + recursive: true, + }); + fs.writeFileSync( + path.join(root, ".agents", "commands", "task.md"), + "Shadow the task command.", + ); + process.chdir(root); + + expect(resolveCodeCommand(["/task", "fix", "the", "tests"])).toEqual({ + kind: "run-goal", + goalId: "task", + forwardedArgs: ["fix", "the", "tests"], + }); + }); + + it("treats freeform input as a generic task", () => { + expect(resolveCodeCommand(["please", "refactor", "the", "app"])).toEqual({ + kind: "run-goal", + goalId: "task", + forwardedArgs: ["please", "refactor", "the", "app"], + }); + }); +}); + +describe("codeUsage", () => { + it("documents migrate as a slash goal", () => { + expect(codeUsage()).toContain("agent-native code\n"); + expect(codeUsage()).toContain('agent-native code "fix the failing'); + expect(codeUsage()).toContain("agent-native code exec"); + expect(codeUsage()).toContain("agent-native code -p"); + expect(codeUsage()).toContain('agent-native code "fix'); + expect(codeUsage()).toContain("agent-native code --plan"); + expect(codeUsage()).toContain("agent-native code /audit --url"); + expect(codeUsage()).toContain("agent-native code /migrate "); + expect(codeUsage()).toContain("agent-native code attach --last"); + expect(codeUsage()).toContain("/migrate"); + }); + + it("lists visible project slash commands without reserved names", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "an-code-help-")); + tmpRoots.push(root); + fs.mkdirSync(path.join(root, ".agents", "commands"), { + recursive: true, + }); + fs.writeFileSync( + path.join(root, ".agents", "commands", "release-check.md"), + [ + "---", + 'description: "Run release checks"', + "argument-hint: ", + "---", + "Check release $ARGUMENTS.", + ].join("\n"), + ); + fs.writeFileSync( + path.join(root, ".agents", "commands", "migrate.md"), + ["---", 'description: "Shadow migrate"', "---", "Do not show this."].join( + "\n", + ), + ); + process.chdir(root); + const output = createStringOutput(); + + await runCode(["goals"], { output: output.stream }); + + const text = output.read(); + expect(text).toContain("Project commands:"); + expect(text).toContain("/release-check "); + expect(text).toContain("Run release checks"); + expect(text).not.toContain("Shadow migrate"); + }); +}); + +describe("parseCodeShellArgs", () => { + it("splits shell input while preserving quoted text", () => { + expect(parseCodeShellArgs('/migrate --describe "old app"')).toEqual({ + ok: true, + args: ["/migrate", "--describe", "old app"], + }); + }); + + it("reports unclosed quotes without throwing", () => { + expect(parseCodeShellArgs('/migrate --describe "old app')).toEqual({ + ok: false, + error: 'Unclosed " quote.', + }); + }); +}); + +describe("handleCodeShellLine", () => { + it("routes slash goals to the injected runner", async () => { + const output = createStringOutput(); + const calls: Array<{ goalId: CodeAgentGoalId; forwardedArgs: string[] }> = + []; + + await handleCodeShellLine('/migrate --describe "old app"', { + output: output.stream, + runGoal: async (goalId, forwardedArgs) => { + calls.push({ goalId, forwardedArgs }); + }, + }); + + expect(calls).toEqual([ + { goalId: "migrate", forwardedArgs: ["--describe", "old app"] }, + ]); + expect(output.read()).toBe(""); + }); + + it("handles status shortcuts without running a goal", async () => { + useTempCodeAgentsHome(); + createCodeAgentRunRecord({ + goalId: "task", + title: "Existing task", + }); + const output = createStringOutput(); + const calls: Array<{ goalId: CodeAgentGoalId; forwardedArgs: string[] }> = + []; + + await handleCodeShellLine("status --last", { + output: output.stream, + runGoal: async (goalId, forwardedArgs) => { + calls.push({ goalId, forwardedArgs }); + }, + }); + + expect(calls).toEqual([]); + expect(output.read()).toContain("Agent-Native Code status"); + expect(output.read()).toContain("Existing task"); + }); + + it("prints transcript logs without running a goal", async () => { + useTempCodeAgentsHome(); + const run = createCodeAgentRunRecord({ + goalId: "task", + title: "Existing task", + status: "completed", + phase: "complete", + }); + const output = createStringOutput(); + const calls: Array<{ goalId: CodeAgentGoalId; forwardedArgs: string[] }> = + []; + + await handleCodeShellLine("logs --last", { + output: output.stream, + runGoal: async (goalId, forwardedArgs) => { + calls.push({ goalId, forwardedArgs }); + }, + }); + + expect(calls).toEqual([]); + expect(output.read()).toContain(`Agent-Native Code logs: ${run.id}`); + expect(output.read()).toContain("Existing task"); + expect(output.read()).toContain("Events: 0"); + }); + + it("answers shell-only slash commands without running a goal", async () => { + const output = createStringOutput(); + const calls: Array<{ goalId: CodeAgentGoalId; forwardedArgs: string[] }> = + []; + + await expect( + handleCodeShellLine("/goals", { + output: output.stream, + runGoal: async (goalId, forwardedArgs) => { + calls.push({ goalId, forwardedArgs }); + }, + }), + ).resolves.toBe("continue"); + + expect(calls).toEqual([]); + expect(output.read()).toContain("Available Agent-Native Code goals:"); + }); + + it("exits for /exit and /quit", async () => { + const output = createStringOutput(); + + await expect( + handleCodeShellLine("/exit", { + output: output.stream, + runGoal: async () => {}, + }), + ).resolves.toBe("exit"); + + await expect( + handleCodeShellLine("/quit", { + output: output.stream, + runGoal: async () => {}, + }), + ).resolves.toBe("exit"); + }); + + it("records bare shell prompts as generic tasks", async () => { + const output = createStringOutput(); + const calls: Array<{ goalId: CodeAgentGoalId; forwardedArgs: string[] }> = + []; + + await handleCodeShellLine("please refactor the app", { + output: output.stream, + runGoal: async (goalId, forwardedArgs) => { + calls.push({ goalId, forwardedArgs }); + }, + }); + + expect(calls).toEqual([ + { + goalId: "task", + forwardedArgs: ["please", "refactor", "the", "app"], + }, + ]); + expect(output.read()).toBe(""); + }); + + it("records shell resume follow-up prompts without running a goal", async () => { + useTempCodeAgentsHome(); + process.env.AGENT_NATIVE_CODE_AGENT_FAKE_RESPONSE = "Follow-up done."; + const run = createCodeAgentRunRecord({ + goalId: "task", + title: "Existing task", + }); + const output = createStringOutput(); + const calls: Array<{ goalId: CodeAgentGoalId; forwardedArgs: string[] }> = + []; + + await handleCodeShellLine('resume --last "add regression tests"', { + output: output.stream, + runGoal: async (goalId, forwardedArgs) => { + calls.push({ goalId, forwardedArgs }); + }, + }); + + const events = listCodeAgentTranscriptEvents(run.id); + expect(calls).toEqual([]); + expect(output.read()).toContain("Running follow-up prompt"); + expect(output.read()).toContain("Follow-up done."); + expect(events[0]).toMatchObject({ + kind: "user", + message: "add regression tests", + metadata: { source: "resume-follow-up" }, + }); + expect(events.map((event) => event.kind)).toContain("system"); + }); +}); + +describe("generic task sessions", () => { + it("runs a task session with transcript events", async () => { + useTempCodeAgentsHome(); + process.env.AGENT_NATIVE_CODE_AGENT_FAKE_RESPONSE = "Task complete."; + const output = createStringOutput(); + + await runCode(["/task", "fix", "the", "tests"], { + output: output.stream, + }); + + const runs = listCodeAgentRunRecords("task"); + expect(runs).toHaveLength(1); + expect(runs[0]).toMatchObject({ + goalId: "task", + title: "fix the tests", + status: "completed", + phase: "complete", + metadata: { + prompt: "fix the tests", + source: "agent-native code", + }, + }); + expect( + listCodeAgentTranscriptEvents(runs[0].id).map((event) => event.kind), + ).toEqual(["user", "status", "status", "system", "status"]); + expect(output.read()).toContain("Agent-Native Code session started."); + expect(output.read()).toContain("Task complete."); + }); + + it("stores run mode on generic task sessions", async () => { + useTempCodeAgentsHome(); + process.env.AGENT_NATIVE_CODE_AGENT_FAKE_RESPONSE = "Read-only pass."; + const output = createStringOutput(); + + await runCode(["--permission-mode", "read-only", "explain", "repo"], { + output: output.stream, + }); + + const runs = listCodeAgentRunRecords("task"); + expect(runs).toHaveLength(1); + expect(runs[0]).toMatchObject({ + permissionMode: "read-only", + metadata: { + prompt: "explain repo", + permissionMode: "read-only", + }, + }); + expect(output.read()).toContain("Mode: Plan mode"); + }); + + it("supports plan and auto mode shortcuts", async () => { + useTempCodeAgentsHome(); + process.env.AGENT_NATIVE_CODE_AGENT_FAKE_RESPONSE = "Mode noted."; + const output = createStringOutput(); + + await runCode(["--plan", "explain", "repo"], { + output: output.stream, + }); + await runCode(["--auto", "fix", "repo"], { + output: output.stream, + }); + + const runs = listCodeAgentRunRecords("task"); + expect(runs).toHaveLength(2); + expect(runs.find((run) => run.title === "explain repo")).toMatchObject({ + permissionMode: "read-only", + metadata: { permissionMode: "read-only" }, + }); + expect(runs.find((run) => run.title === "fix repo")).toMatchObject({ + permissionMode: "full-auto", + metadata: { permissionMode: "full-auto" }, + }); + const text = output.read(); + expect(text).toContain("Mode: Plan mode"); + expect(text).toContain("Mode: Auto mode"); + }); + + it("runs project slash commands as generic task sessions", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "an-code-project-")); + tmpRoots.push(root); + process.chdir(root); + fs.mkdirSync(path.join(root, ".agents", "commands"), { + recursive: true, + }); + fs.writeFileSync( + path.join(root, ".agents", "commands", "review-diff.md"), + [ + "---", + 'description: "Review repository changes"', + "argument-hint: [--cached]", + "---", + "Review the diff for $ARGUMENTS.", + ].join("\n"), + ); + useTempCodeAgentsHome(); + process.env.AGENT_NATIVE_CODE_AGENT_FAKE_RESPONSE = "Project command done."; + const output = createStringOutput(); + + await runCode(["/review-diff", "--read-only", "--cached"], { + output: output.stream, + }); + + const runs = listCodeAgentRunRecords("task"); + expect(runs).toHaveLength(1); + expect(runs[0]).toMatchObject({ + subtitle: "Project command /review-diff", + permissionMode: "read-only", + metadata: { + commandName: "review-diff", + source: "agent-native code project-command", + permissionMode: "read-only", + }, + }); + expect(listCodeAgentTranscriptEvents(runs[0].id)[0]?.message).toContain( + "Review the diff for --cached.", + ); + expect(output.read()).toContain("Project command done."); + }); + + it("runs direct CLI follow-up prompts on the last run", async () => { + useTempCodeAgentsHome(); + process.env.AGENT_NATIVE_CODE_AGENT_FAKE_RESPONSE = "Follow-up done."; + const run = createCodeAgentRunRecord({ + goalId: "task", + title: "Existing task", + }); + const output = createStringOutput(); + + await runCode(["resume", "--last", "please continue"], { + output: output.stream, + }); + + const events = listCodeAgentTranscriptEvents(run.id); + expect(output.read()).toContain("Running follow-up prompt"); + expect(output.read()).toContain("Follow-up done."); + expect(events[0]).toMatchObject({ + kind: "user", + message: "please continue", + }); + expect(events.map((event) => event.kind)).toContain("system"); + }); + + it("runs direct CLI follow-up prompts on an explicit run", async () => { + useTempCodeAgentsHome(); + process.env.AGENT_NATIVE_CODE_AGENT_FAKE_RESPONSE = "Explicit done."; + const selectedRun = createCodeAgentRunRecord({ + goalId: "task", + title: "Selected task", + }); + const latestRun = createCodeAgentRunRecord({ + goalId: "task", + title: "Latest task", + }); + const output = createStringOutput(); + + await runCode(["resume", selectedRun.id, "continue selected"], { + output: output.stream, + }); + + expect(output.read()).toContain("Explicit done."); + expect(listCodeAgentTranscriptEvents(selectedRun.id)[0]).toMatchObject({ + kind: "user", + message: "continue selected", + }); + expect(listCodeAgentTranscriptEvents(latestRun.id)).toEqual([]); + }); + + it("records steering prompts on active runs without starting another executor", async () => { + useTempCodeAgentsHome(); + process.env.AGENT_NATIVE_CODE_AGENT_FAKE_RESPONSE = "Should not stream."; + const run = createCodeAgentRunRecord({ + goalId: "task", + title: "Active task", + status: "running", + phase: "executing", + }); + const output = createStringOutput(); + + await runCode(["resume", "--last", "tighten the tests"], { + output: output.stream, + }); + + const [updated] = listCodeAgentRunRecords("task"); + const events = listCodeAgentTranscriptEvents(run.id); + expect(output.read()).toContain("Recorded steering prompt"); + expect(output.read()).not.toContain("Should not stream."); + expect(updated).toMatchObject({ + id: run.id, + status: "running", + metadata: { + pendingFollowUps: [ + { + prompt: "tighten the tests", + mode: "immediate", + }, + ], + }, + }); + expect(events[0]).toMatchObject({ + kind: "user", + message: "tighten the tests", + metadata: { + source: "resume-follow-up", + followUpMode: "immediate", + delivery: "immediate", + }, + }); + }); + + it("can queue active-run follow-ups for after the current execution", async () => { + useTempCodeAgentsHome(); + const run = createCodeAgentRunRecord({ + goalId: "task", + title: "Active task", + status: "running", + phase: "executing", + }); + const output = createStringOutput(); + + await runCode(["resume", "--last", "--queue", "run the slow suite"], { + output: output.stream, + }); + + const [updated] = listCodeAgentRunRecords("task"); + expect(output.read()).toContain("Queued follow-up prompt"); + expect(updated).toMatchObject({ + id: run.id, + metadata: { + pendingFollowUps: [ + { + prompt: "run the slow suite", + mode: "queued", + }, + ], + }, + }); + }); + + it("shows generic Agent-Native Code status for the last run", async () => { + useTempCodeAgentsHome(); + createCodeAgentRunRecord({ + goalId: "task", + title: "Existing task", + status: "paused", + phase: "review", + }); + const output = createStringOutput(); + + await runCode(["status", "--last"], { output: output.stream }); + + const text = output.read(); + expect(text).toContain("Agent-Native Code status"); + expect(text).toContain("Existing task"); + expect(text).toContain("paused (review)"); + }); + + it("does not rewrite completed runs when stop is requested", async () => { + useTempCodeAgentsHome(); + const run = createCodeAgentRunRecord({ + goalId: "task", + title: "Finished task", + status: "completed", + phase: "complete", + progress: { completed: 1, total: 1, percent: 100 }, + }); + const output = createStringOutput(); + + await runCode(["stop", "--last"], { output: output.stream }); + + const [updated] = listCodeAgentRunRecords("task"); + expect(output.read()).toContain("already finished"); + expect(updated).toMatchObject({ + id: run.id, + status: "completed", + phase: "complete", + progress: { completed: 1, total: 1, percent: 100 }, + }); + }); + + it("approves a pending command and points back to resume", async () => { + const root = useTempCodeAgentsHome(); + const cwd = path.join(root, "workspace"); + fs.mkdirSync(cwd, { recursive: true }); + const run = createCodeAgentRunRecord({ + goalId: "task", + title: "Approval task", + status: "needs-approval", + phase: "approval-required", + needsApproval: true, + cwd, + permissionMode: "ask-before-edit", + metadata: { + pendingApproval: { + id: "approval-test", + tool: "run_command", + command: + "node -e \"require('fs').writeFileSync('approved.txt', 'ok')\"", + reason: "destructive recursive delete", + requestedAt: new Date().toISOString(), + permissionMode: "ask-before-edit", + }, + }, + }); + const output = createStringOutput(); + + await runCode(["approve", "--last"], { output: output.stream }); + + const [updated] = listCodeAgentRunRecords("task"); + expect(fs.readFileSync(path.join(cwd, "approved.txt"), "utf-8")).toBe("ok"); + expect(updated).toMatchObject({ + id: run.id, + status: "paused", + phase: "approval-complete", + needsApproval: false, + }); + expect(output.read()).toContain("Agent-Native Code approve"); + expect(output.read()).toContain( + "Approved command finished with exit code 0", + ); + expect(output.read()).toContain(`agent-native code run ${run.id}`); + }); + + it("lists sessions with inspect commands", async () => { + useTempCodeAgentsHome(); + const run = createCodeAgentRunRecord({ + goalId: "task", + title: "Existing task", + permissionMode: "auto-edit", + }); + const output = createStringOutput(); + + await runCode(["list"], { output: output.stream }); + + const text = output.read(); + expect(text).toContain("Agent-Native Code sessions"); + expect(text).toContain(run.id); + expect(text).toContain("Auto mode"); + expect(text).toContain("agent-native code status "); + expect(text).toContain( + 'agent-native code resume "follow-up prompt"', + ); + }); + + it("shows resume commands for an explicit session", async () => { + useTempCodeAgentsHome(); + const run = createCodeAgentRunRecord({ + goalId: "task", + title: "Existing task", + permissionMode: "read-only", + }); + const output = createStringOutput(); + + await runCode(["resume", run.id], { output: output.stream }); + + const text = output.read(); + expect(text).toContain("Agent-Native Code resume"); + expect(text).toContain("Title: Existing task"); + expect(text).toContain("Mode: Plan mode"); + expect(text).toContain(`agent-native code run ${run.id}`); + expect(text).toContain( + `agent-native code resume ${run.id} "next instruction"`, + ); + }); +}); + +describe("runCode shell", () => { + it("can run with scripted stdin for tests", async () => { + const output = createStringOutput(); + + await runCode([], { + input: Readable.from(["/goals\n", "/exit\n"]), + output: output.stream, + runGoal: async () => { + throw new Error("No goal should run"); + }, + }); + + expect(output.read()).toContain("Agent-Native Code"); + expect(output.read()).toContain("Available Agent-Native Code goals:"); + expect(output.read()).toContain("code> "); + }); +}); + +describe("CODE_AGENT_CLI_GOALS", () => { + it("keeps slash goals mapped through an explicit backing command", () => { + expect(CODE_AGENT_CLI_GOALS).toContainEqual( + expect.objectContaining({ + id: "task", + slashCommand: "/task", + backingCommand: "task", + }), + ); + expect(CODE_AGENT_CLI_GOALS).toContainEqual( + expect.objectContaining({ + id: "migrate", + slashCommand: "/migrate", + backingCommand: "migrate", + }), + ); + expect(CODE_AGENT_CLI_GOALS).toContainEqual( + expect.objectContaining({ + id: "audit", + slashCommand: "/audit", + backingCommand: "audit-agent-web", + }), + ); + }); +}); + +function createStringOutput(): { + stream: Writable; + read: () => string; +} { + let text = ""; + const stream = new Writable({ + write(chunk, _encoding, callback) { + text += chunk.toString(); + callback(); + }, + }); + return { + stream, + read: () => text, + }; +} + +function useTempCodeAgentsHome(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "an-code-")); + tmpRoots.push(root); + process.env.AGENT_NATIVE_CODE_AGENTS_HOME = path.join(root, "code-agents"); + return root; +} diff --git a/packages/core/src/cli/code.ts b/packages/core/src/cli/code.ts new file mode 100644 index 000000000..cc2175fdc --- /dev/null +++ b/packages/core/src/cli/code.ts @@ -0,0 +1,1697 @@ +import { createInterface } from "node:readline"; + +import { + CODE_AGENT_PERMISSION_MODES, + appendCodeAgentTranscriptEvent, + createCodeAgentRunRecord, + getCodeAgentRunRecord, + getLastCodeAgentRunRecord, + isActiveCodeAgentRun, + listCodeAgentRunRecords, + listCodeAgentTranscriptEvents, + normalizeCodeAgentPermissionMode, + queueCodeAgentFollowUp, + updateCodeAgentRunRecord, + type CodeAgentFollowUpMode, + type CodeAgentPermissionMode, + type CodeAgentRunRecord, + type CodeAgentTranscriptEvent, +} from "./code-agent-runs.js"; +import { + findProjectSlashCommand, + listProjectSlashCommands, + renderProjectSlashCommandPrompt, +} from "./code-agent-commands.js"; +import { + executeCodeAgentRun, + executeExistingCodeAgentRun, + executePendingCodeAgentApproval, +} from "./code-agent-executor.js"; +import { runAuditAgentWeb } from "./audit-agent-web.js"; +import { runMigrate } from "./migrate.js"; + +export type CodeAgentGoalId = "task" | "migrate" | "audit"; + +export interface CodeAgentCliGoal { + id: CodeAgentGoalId; + slashCommand: string; + aliases: string[]; + summary: string; + backingCommand: "task" | "migrate" | "audit-agent-web"; +} + +export type CodeCliCommand = + | { kind: "shell" } + | { kind: "help" } + | { kind: "list-goals" } + | { kind: "serve"; relayUrl?: string } + | { kind: "execute-existing-run"; runId: string } + | { kind: "control"; subcommand: CodeAgentControlSubcommand; args: string[] } + | { + kind: "record-follow-up"; + prompt: string; + runId?: string; + permissionMode?: CodeAgentPermissionMode; + followUpMode?: CodeAgentFollowUpMode; + } + | { + kind: "run-project-command"; + commandName: string; + forwardedArgs: string[]; + } + | { + kind: "run-goal"; + goalId: CodeAgentGoalId; + forwardedArgs: string[]; + }; + +export const CODE_AGENT_CLI_GOALS: CodeAgentCliGoal[] = [ + { + id: "task", + slashCommand: "/task", + aliases: ["task", "todo"], + summary: + "Run a generic coding task as a resumable Agent-Native Code session.", + backingCommand: "task", + }, + { + id: "migrate", + slashCommand: "/migrate", + aliases: ["migrate", "migration"], + summary: + "Move a path, URL, or described product into agent-native with verification.", + backingCommand: "migrate", + }, + { + id: "audit", + slashCommand: "/audit", + aliases: ["audit", "audit-agent-web", "agent-web"], + summary: + "Audit a public URL for agent-readable surfaces such as llms.txt and markdown mirrors.", + backingCommand: "audit-agent-web", + }, +]; + +type CodeAgentControlSubcommand = + | "approve" + | "attach" + | "list" + | "logs" + | "ps" + | "resume" + | "status" + | "stop" + | "ui"; + +const CODE_AGENT_CONTROL_SUBCOMMANDS = new Set([ + "approve", + "attach", + "list", + "logs", + "ps", + "resume", + "status", + "stop", + "ui", +] as CodeAgentControlSubcommand[]); +const SHELL_PROMPT = "code> "; + +export interface CodeShellOptions { + input?: NodeJS.ReadableStream; + output?: NodeJS.WritableStream; + runGoal?: CodeGoalRunner; +} + +type CodeGoalRunner = ( + goalId: CodeAgentGoalId, + forwardedArgs: string[], + output?: NodeJS.WritableStream, +) => Promise; + +type CodeShellLineResult = "continue" | "exit"; + +interface ParsedTaskArgs { + prompt: string; + promptArgs: string[]; + permissionMode: CodeAgentPermissionMode; + permissionModeExplicit?: boolean; + error?: string; +} + +interface RunTaskOptions { + subtitle?: string; + source?: string; + commandName?: string; + commandPath?: string; + permissionMode?: CodeAgentPermissionMode; +} + +export function resolveCodeCommand(argv: string[]): CodeCliCommand { + const [rawFirst, ...rest] = argv; + if (!rawFirst) { + return { kind: "shell" }; + } + + if (rawFirst === "--help" || rawFirst === "-h") { + return { kind: "help" }; + } + + const first = normalizeGoalToken(rawFirst); + if (first === "goals") { + return { kind: "list-goals" }; + } + + if (first === "serve") { + return { kind: "serve", relayUrl: parseRelayUrlOption(rest) }; + } + + if (first === "exec" || first === "e") { + return { + kind: "run-goal", + goalId: "task", + forwardedArgs: rest, + }; + } + + if (first === "--print" || first === "-p") { + return { + kind: "run-goal", + goalId: "task", + forwardedArgs: rest, + }; + } + + if (first === "--continue" || first === "-c") { + const parsed = parseFollowUpArgs(rest); + return parsed.prompt + ? { + kind: "record-follow-up", + prompt: parsed.prompt, + ...(parsed.followUpMode === "queued" + ? { followUpMode: parsed.followUpMode } + : {}), + ...(parsed.permissionModeExplicit + ? { permissionMode: parsed.permissionMode } + : {}), + } + : { + kind: "control", + subcommand: "resume", + args: ["resume", "--last"], + }; + } + + if (first === "--resume" || first === "-r") { + return { + kind: "control", + subcommand: "resume", + args: ["resume", ...rest], + }; + } + + if ((first === "run" || first === "start") && rest[0]) { + return { kind: "execute-existing-run", runId: rest[0] }; + } + + const followUp = parseResumeFollowUpPrompt([rawFirst, ...rest]); + if (followUp) { + return { + kind: "record-follow-up", + prompt: followUp.prompt, + runId: followUp.runId, + ...(followUp.followUpMode ? { followUpMode: followUp.followUpMode } : {}), + ...(followUp.permissionMode + ? { permissionMode: followUp.permissionMode } + : {}), + }; + } + + const goal = findGoal(first); + if (goal) { + return { + kind: "run-goal", + goalId: goal.id, + forwardedArgs: rest, + }; + } + + if (rawFirst.startsWith("/")) { + const projectCommand = findProjectSlashCommand(first); + if (projectCommand) { + return { + kind: "run-project-command", + commandName: projectCommand.name, + forwardedArgs: rest, + }; + } + } + + if (isCodeAgentControlSubcommand(first)) { + return { + kind: "control", + subcommand: first, + args: [first, ...rest], + }; + } + + return { + kind: "run-goal", + goalId: "task", + forwardedArgs: argv, + }; +} + +export async function runCode( + argv: string[], + options: CodeShellOptions = {}, +): Promise { + const command = resolveCodeCommand(argv); + const output = options.output ?? process.stdout; + const runGoal = options.runGoal ?? runCodeGoal; + + if (command.kind === "shell") { + await runCodeShell({ ...options, output, runGoal }); + return; + } + + if (command.kind === "help") { + writeLine(output, codeUsage()); + return; + } + + if (command.kind === "list-goals") { + writeLine(output, renderGoalList()); + return; + } + + if (command.kind === "serve") { + const { runCodeAgentConnector } = await import("./code-agent-connector.js"); + const exitCode = await runCodeAgentConnector({ + relayUrl: command.relayUrl, + output, + }); + if (exitCode !== 0) process.exitCode = exitCode; + return; + } + + if (command.kind === "execute-existing-run") { + await executeExistingCodeAgentRun(command.runId, { stdout: output }); + return; + } + + if (command.kind === "control") { + await runCodeAgentControl(command.subcommand, command.args, output); + return; + } + + if (command.kind === "record-follow-up") { + await recordCodeAgentFollowUpPrompt( + command.prompt, + output, + command.permissionMode, + command.runId, + command.followUpMode, + ); + return; + } + + if (command.kind === "run-project-command") { + await runProjectSlashCommand( + command.commandName, + command.forwardedArgs, + output, + ); + return; + } + + await runGoal(command.goalId, command.forwardedArgs, output); +} + +export async function runCodeShell( + options: CodeShellOptions = {}, +): Promise { + const input = options.input ?? process.stdin; + const output = options.output ?? process.stdout; + const runGoal = options.runGoal ?? runCodeGoal; + const rl = createInterface({ + input, + output, + terminal: isInteractiveTerminal(input, output), + }); + + writeLine(output, codeShellIntro()); + writePrompt(output); + + try { + for await (const line of rl) { + const result = await handleCodeShellLine(line, { output, runGoal }); + if (result === "exit") { + break; + } + writePrompt(output); + } + } finally { + rl.close(); + } +} + +export async function handleCodeShellLine( + line: string, + options: Required>, +): Promise { + const trimmed = line.trim(); + if (!trimmed) { + return "continue"; + } + + const parsed = parseCodeShellArgs(trimmed); + if ("error" in parsed) { + writeLine(options.output, parsed.error); + return "continue"; + } + + const [rawFirst, ...rest] = parsed.args; + if (!rawFirst) { + return "continue"; + } + + const followUp = parseResumeFollowUpPrompt(parsed.args); + if (followUp) { + await recordCodeAgentFollowUpPrompt( + followUp.prompt, + options.output, + followUp.permissionMode, + followUp.runId, + followUp.followUpMode, + ); + return "continue"; + } + + if (rawFirst.startsWith("/")) { + const first = normalizeGoalToken(rawFirst); + if (first === "help") { + writeLine(options.output, codeShellHelp()); + return "continue"; + } + + if (first === "goals") { + writeLine(options.output, renderGoalList()); + return "continue"; + } + + if (first === "exit" || first === "quit") { + writeLine(options.output, "Leaving Agent-Native Code."); + return "exit"; + } + + const goal = findGoal(first); + if (goal) { + await options.runGoal(goal.id, rest, options.output); + return "continue"; + } + + const projectCommand = findProjectSlashCommand(first); + if (projectCommand) { + await runProjectSlashCommand(projectCommand.name, rest, options.output); + return "continue"; + } + + writeLine( + options.output, + `Unknown slash command: ${rawFirst}\nTry /help to see available commands.`, + ); + return "continue"; + } + + const first = normalizeGoalToken(rawFirst); + if (first === "exec" || first === "e") { + await options.runGoal("task", rest, options.output); + return "continue"; + } + + if (first === "--print" || first === "-p") { + await options.runGoal("task", rest, options.output); + return "continue"; + } + + if (first === "--continue" || first === "-c") { + const parsedTask = parseFollowUpArgs(rest); + if (parsedTask.prompt) { + await recordCodeAgentFollowUpPrompt( + parsedTask.prompt, + options.output, + parsedTask.permissionModeExplicit + ? parsedTask.permissionMode + : undefined, + undefined, + parsedTask.followUpMode, + ); + } else { + await runCodeAgentControl("resume", ["resume", "--last"], options.output); + } + return "continue"; + } + + if (first === "--resume" || first === "-r") { + await runCodeAgentControl("resume", ["resume", ...rest], options.output); + return "continue"; + } + + if ((first === "run" || first === "start") && rest[0]) { + await executeExistingCodeAgentRun(rest[0], { stdout: options.output }); + return "continue"; + } + + if (isCodeAgentControlSubcommand(first)) { + await runCodeAgentControl(first, parsed.args, options.output); + return "continue"; + } + + await options.runGoal("task", parsed.args, options.output); + return "continue"; +} + +export function codeUsage(): string { + return `agent-native code + +Open the Agent-Native Code shell or run a coding-agent goal directly. + +Usage: + agent-native code + agent-native code "fix the failing auth tests" + agent-native code exec "fix the failing auth tests" + agent-native code -p "fix the failing auth tests" + agent-native code --plan "explain this repo" + agent-native code --auto "fix the failing auth tests" + agent-native code /review-diff + agent-native code /audit --url https://example.com + agent-native code /migrate [--out ../migrated-app] + agent-native code /migrate --describe "what to build or migrate" + agent-native code attach --last + agent-native code logs --last + agent-native code approve --last + agent-native code list + agent-native code resume --last "follow-up prompt" + agent-native code --continue "follow-up prompt" + agent-native code resume --last + agent-native code status --last + agent-native code serve --relay-url + agent-native code ui --last + agent-native code run + agent-native code goals + +Interactive shell: + /help Show shell commands + /goals List available coding-agent goals + /migrate ... Run the migration goal + /audit ... Run the web audit goal + / Run .agents/commands/.md + /exit Leave the shell + +Session commands: + list List recent sessions + attach ... Attach to a run transcript, following active work + logs ... Print a run transcript once + approve ... Run one pending approved command, then resume the session + resume ... Continue the latest or selected run + status ... Show run status + stop ... Stop a tracked Desktop/CLI runner + serve ... Run the remote relay connector + +Modes: + --plan, --auto + --permission-mode read-only|ask-before-edit|auto-edit|full-auto + --read-only, --ask-before-edit, --auto-edit, --full-auto + +Available goals: +${renderGoalRows()} +${renderProjectCommandRows()} + +The existing shortcut still works: + agent-native migrate [options]`; +} + +export function codeShellIntro(): string { + return `Agent-Native Code +Type a coding task to start a session, /help for commands, /goals for goals, or /exit to leave.`; +} + +export function codeShellHelp(): string { + return `Agent-Native Code shell commands: + /help Show this help + /goals List available coding-agent goals + /migrate ... Move a source into agent-native + /audit ... Audit a public URL for agent-readable surfaces + / Run a project command from .agents/commands/*.md + /exit Leave the shell + /quit Leave the shell + +Compatibility shortcuts: + exec "prompt" + -p "prompt" + list + ps + attach --last + logs --last + approve --last + resume --last "follow-up prompt" + --continue "follow-up prompt" + resume --last + status --last + ui --last + stop --last`; +} + +export function codeShellFreeTextMessage(): string { + return `Bare prompts run as generic Agent-Native Code sessions. +Use /migrate and /audit for specialized goals.`; +} + +export function parseCodeShellArgs( + line: string, +): { ok: true; args: string[] } | { ok: false; error: string } { + const args: string[] = []; + let current = ""; + let quote: "'" | '"' | undefined; + let escaping = false; + let hasValue = false; + + const pushCurrent = () => { + if (hasValue) { + args.push(current); + current = ""; + hasValue = false; + } + }; + + for (const char of line) { + if (escaping) { + current += char; + hasValue = true; + escaping = false; + continue; + } + + if (char === "\\" && quote !== "'") { + escaping = true; + hasValue = true; + continue; + } + + if (quote) { + if (char === quote) { + quote = undefined; + } else { + current += char; + hasValue = true; + } + continue; + } + + if (char === "'" || char === '"') { + quote = char; + hasValue = true; + continue; + } + + if (/\s/.test(char)) { + pushCurrent(); + continue; + } + + current += char; + hasValue = true; + } + + if (escaping) { + current += "\\"; + } + + if (quote) { + return { ok: false, error: `Unclosed ${quote} quote.` }; + } + + pushCurrent(); + return { ok: true, args }; +} + +function renderGoalList(): string { + return `Available Agent-Native Code goals: +${renderGoalRows()} +${renderProjectCommandRows()}`; +} + +function renderGoalRows(): string { + return CODE_AGENT_CLI_GOALS.map( + (goal) => ` ${goal.slashCommand.padEnd(12)} ${goal.summary}`, + ).join("\n"); +} + +function renderProjectCommandRows(): string { + const commands = listVisibleProjectSlashCommands(); + if (commands.length === 0) return ""; + return [ + "", + "Project commands:", + ...commands.map((command) => { + const args = command.argumentHint ? ` ${command.argumentHint}` : ""; + const description = command.description ?? "Project slash command."; + return ` /${command.name}${args}`.padEnd(24) + description; + }), + ].join("\n"); +} + +function listVisibleProjectSlashCommands() { + return listProjectSlashCommands().filter( + (command) => !isReservedSlashName(command.name), + ); +} + +function isReservedSlashName(name: string): boolean { + const normalized = normalizeGoalToken(name); + return ( + Boolean(findGoal(normalized)) || + isCodeAgentControlSubcommand(normalized) || + normalized === "help" || + normalized === "exit" || + normalized === "quit" || + normalized === "goals" + ); +} + +function normalizeGoalToken(value: string): string { + return value.replace(/^\//, "").toLowerCase(); +} + +function findGoal(value: string): CodeAgentCliGoal | undefined { + const normalized = normalizeGoalToken(value); + return CODE_AGENT_CLI_GOALS.find( + (goal) => + goal.id === normalized || + normalizeGoalToken(goal.slashCommand) === normalized || + goal.aliases.includes(normalized), + ); +} + +function isCodeAgentControlSubcommand( + value: string, +): value is CodeAgentControlSubcommand { + return CODE_AGENT_CONTROL_SUBCOMMANDS.has( + value as CodeAgentControlSubcommand, + ); +} + +function parseResumeFollowUpPrompt(args: string[]): { + prompt: string; + runId?: string; + permissionMode?: CodeAgentPermissionMode; + followUpMode?: CodeAgentFollowUpMode; +} | null { + const [rawFirst, ...rest] = args; + if (normalizeGoalToken(rawFirst ?? "") !== "resume") return null; + const selector = parseResumeSelectorAndPrompt(rest); + if (!selector) return null; + if ( + !selector.hasSeparator && + !selector.promptArgs.some((arg) => !arg.startsWith("-")) + ) { + return null; + } + + const parsed = parseFollowUpArgs(selector.promptArgs); + return parsed.prompt + ? { + prompt: parsed.prompt, + runId: selector.runId, + ...(parsed.followUpMode === "queued" + ? { followUpMode: parsed.followUpMode } + : {}), + permissionMode: parsed.permissionModeExplicit + ? parsed.permissionMode + : undefined, + } + : null; +} + +function parseResumeSelectorAndPrompt( + args: string[], +): { runId?: string; promptArgs: string[]; hasSeparator: boolean } | null { + const lastIndex = args.indexOf("--last"); + if (lastIndex !== -1) { + const promptArgs = args.filter((_, index) => index !== lastIndex); + const separatorIndex = promptArgs.indexOf("--"); + return { + promptArgs: + separatorIndex === -1 + ? promptArgs + : promptArgs.slice(separatorIndex + 1), + hasSeparator: separatorIndex !== -1, + }; + } + + const [maybeRunId, ...rest] = args; + if ( + !maybeRunId || + maybeRunId.startsWith("-") || + !looksLikeRunId(maybeRunId) + ) { + return null; + } + const separatorIndex = rest.indexOf("--"); + return { + runId: maybeRunId, + promptArgs: separatorIndex === -1 ? rest : rest.slice(separatorIndex + 1), + hasSeparator: separatorIndex !== -1, + }; +} + +function looksLikeRunId(value: string): boolean { + return /^[a-z][a-z0-9-]*-(?:\d{14}|\d{8}t\d{6}z)-[a-f0-9]{8}$/i.test(value); +} + +function parseRelayUrlOption(args: string[]): string | undefined { + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--relay-url" && args[i + 1]) return args[i + 1]; + if (arg.startsWith("--relay-url=")) { + const value = arg.slice("--relay-url=".length).trim(); + if (value) return value; + } + } + return undefined; +} + +async function runCodeAgentControl( + subcommand: CodeAgentControlSubcommand, + args: string[], + output: NodeJS.WritableStream, + allowPicker = true, +): Promise { + const runs = listCodeAgentRunRecords(); + const effectiveArgs = await maybePickRunArgs( + subcommand, + runs, + args, + output, + allowPicker, + ); + if (!effectiveArgs) { + writeLine(output, "No Agent-Native Code session selected."); + return; + } + switch (subcommand) { + case "approve": + await approveCodeAgentRun(runs, effectiveArgs, output); + return; + case "attach": + await attachCodeAgentRun(runs, effectiveArgs, output); + return; + case "logs": + writeLine(output, renderCodeAgentLogs(runs, effectiveArgs)); + return; + case "list": + case "ps": + writeLine(output, renderCodeAgentSessionList(runs)); + return; + case "status": + writeLine(output, renderCodeAgentStatus(runs, effectiveArgs)); + return; + case "resume": + writeLine(output, renderCodeAgentResume(runs, effectiveArgs)); + return; + case "ui": + writeLine(output, renderCodeAgentUi(runs, effectiveArgs)); + return; + case "stop": + writeLine(output, stopCodeAgentRun(runs, effectiveArgs)); + return; + } +} + +async function maybePickRunArgs( + subcommand: CodeAgentControlSubcommand, + runs: CodeAgentRunRecord[], + args: string[], + output: NodeJS.WritableStream, + allowPicker: boolean, +): Promise { + if ( + !allowPicker || + !["approve", "attach", "logs", "resume"].includes(subcommand) || + args.includes("--last") || + hasExplicitRunId(args) || + runs.length === 0 || + !isInteractiveTerminal(process.stdin, output) + ) { + return args; + } + + const selected = await promptForRunSelection(runs, output); + return selected ? [subcommand, selected.id] : null; +} + +async function promptForRunSelection( + runs: CodeAgentRunRecord[], + output: NodeJS.WritableStream, +): Promise { + const choices = runs.slice(0, 10); + writeLine(output, ""); + writeLine(output, "Select an Agent-Native Code session:"); + choices.forEach((run, index) => { + writeLine(output, ` ${index + 1}. ${run.id}`); + writeLine( + output, + ` /${run.goalId} ${run.status}${run.phase ? ` (${run.phase})` : ""} updated ${run.updatedAt}`, + ); + writeLine(output, ` ${truncateForDisplay(run.title, 90)}`); + }); + writeLine(output, ""); + + const rl = createInterface({ + input: process.stdin, + output, + terminal: true, + }); + const answer = await new Promise((resolve) => { + rl.question("Run number or id (blank cancels): ", resolve); + }); + rl.close(); + + const trimmed = answer.trim(); + if (!trimmed) { + writeLine(output, "No run selected."); + return null; + } + const index = Number(trimmed); + if (Number.isInteger(index) && index >= 1 && index <= choices.length) { + return choices[index - 1] ?? null; + } + const matchingRun = + choices.find((run) => run.id === trimmed) ?? + choices.find((run) => run.id.startsWith(trimmed)); + if (matchingRun) return matchingRun; + + writeLine(output, "No matching run selected."); + return null; +} + +function renderCodeAgentSessionList(runs: CodeAgentRunRecord[]): string { + return [ + "", + "Agent-Native Code sessions", + "", + runs.length === 0 + ? " No Agent-Native Code sessions found." + : ` ${runs.length} session${runs.length === 1 ? "" : "s"} found. Most recent first.`, + ...runs.slice(0, 10).map(renderCodeAgentRunListItem), + runs.length > 10 ? ` - ${runs.length - 10} more...` : "", + "", + runs.length > 0 ? "Inspect a session:" : "", + runs.length > 0 ? " agent-native code status " : "", + runs.length > 0 ? " agent-native code logs " : "", + runs.length > 0 ? " agent-native code resume " : "", + runs.length > 0 + ? ' agent-native code resume "follow-up prompt"' + : 'Start one with: agent-native code "what to change"', + ] + .filter(Boolean) + .join("\n"); +} + +function renderCodeAgentStatus( + runs: CodeAgentRunRecord[], + args: string[], +): string { + const selected = selectCodeAgentRun(runs, args, { + defaultToLast: args.includes("--last") || hasExplicitRunId(args), + }); + if (selected) { + return renderCodeAgentRunDetail("Agent-Native Code status", selected); + } + + return [ + "", + "Agent-Native Code status", + "", + runs.length === 0 + ? " No Agent-Native Code sessions found." + : ` ${runs.length} session${runs.length === 1 ? "" : "s"} found.`, + ...runs.slice(0, 10).map(renderCodeAgentRunListItem), + runs.length > 10 ? ` - ${runs.length - 10} more...` : "", + "", + 'Start one with: agent-native code "what to change"', + 'Add a follow-up with: agent-native code resume --last "what next"', + ] + .filter(Boolean) + .join("\n"); +} + +function renderCodeAgentResume( + runs: CodeAgentRunRecord[], + args: string[], +): string { + const run = selectCodeAgentRun(runs, args, { defaultToLast: true }); + if (!run) { + return [ + "", + "Agent-Native Code resume", + "", + " No Agent-Native Code sessions found.", + "", + 'Start one with: agent-native code "what to change"', + ].join("\n"); + } + + const transcriptEvents = listCodeAgentTranscriptEvents(run.id); + const latestEvent = transcriptEvents.at(-1); + const followUpTarget = args.includes("--last") ? "--last" : run.id; + return [ + "", + "Agent-Native Code resume", + "", + ` Run: ${run.id}`, + ` Goal: /${run.goalId}`, + ` Title: ${run.title}`, + ` Status: ${run.status}${run.phase ? ` (${run.phase})` : ""}`, + run.permissionMode + ? ` Mode: ${formatCodeAgentRunMode(run.permissionMode)}` + : "", + ` Updated: ${run.updatedAt}`, + latestEvent + ? ` Last: ${truncateForDisplay(latestEvent.message, 140)}` + : "", + "", + "Resume execution:", + ` agent-native code run ${run.id}`, + "", + "Attach to the live transcript:", + ` agent-native code attach ${run.id}`, + "", + "Append and run a follow-up:", + ` agent-native code resume ${followUpTarget} "next instruction"`, + ] + .filter(Boolean) + .join("\n"); +} + +function renderCodeAgentUi(runs: CodeAgentRunRecord[], args: string[]): string { + const run = selectCodeAgentRun(runs, args, { defaultToLast: true }); + return [ + "", + "Agent-Native Code UI", + "", + "Open Agent-Native Desktop and choose Agent-Native Code from the left sidebar.", + run ? `Run: ${run.id}` : "No run selected yet.", + run ? `Deep link: agentnative://open?app=code-agents&run=${run.id}` : "", + ] + .filter(Boolean) + .join("\n"); +} + +function stopCodeAgentRun(runs: CodeAgentRunRecord[], args: string[]): string { + const run = selectCodeAgentRun(runs, args, { defaultToLast: true }); + if ( + run && + (run.status === "completed" || + run.status === "errored" || + run.phase === "complete" || + run.phase === "error") + ) { + return [ + "", + "Agent-Native Code stop", + "", + ` Run: ${run.id}`, + ` Status: ${run.status}${run.phase ? ` (${run.phase})` : ""}`, + "", + " This run is already finished; no stop signal was sent.", + ].join("\n"); + } + if (run) { + const pid = Number(run.metadata?.runnerPid); + let killed = false; + let killError = ""; + if (Number.isFinite(pid) && pid > 0) { + try { + process.kill(pid, "SIGTERM"); + killed = true; + } catch (err) { + killError = err instanceof Error ? err.message : String(err); + } + } + appendCodeAgentTranscriptEvent({ + runId: run.id, + kind: "status", + message: killed + ? "Stop requested for Agent-Native Code runner." + : "Stop requested; no active runner process was found from the CLI.", + metadata: { + source: "cli-stop", + pid: Number.isFinite(pid) ? pid : undefined, + killed, + killError: killError || undefined, + }, + }); + updateCodeAgentRunRecord(run.id, { + status: "paused", + phase: "stopped", + progress: { + label: "Stopped", + completed: 0, + total: 1, + percent: 0, + }, + metadata: { + stoppedAt: new Date().toISOString(), + stoppedBy: "cli", + stopSignalSent: killed, + stopError: killError || undefined, + }, + }); + } + return [ + "", + "Agent-Native Code stop", + "", + run ? ` Run: ${run.id}` : " No Agent-Native Code session selected.", + "", + run + ? " Stop requested. If a tracked runner process is active, it received SIGTERM." + : ' Start one with: agent-native code "what to change"', + ].join("\n"); +} + +async function approveCodeAgentRun( + runs: CodeAgentRunRecord[], + args: string[], + output: NodeJS.WritableStream, +): Promise { + const run = selectCodeAgentRun(runs, args, { + defaultToLast: args.includes("--last"), + }); + if (!run) { + writeLine( + output, + [ + "", + "Agent-Native Code approve", + "", + " No Agent-Native Code session selected.", + "", + "Try: agent-native code approve --last", + ].join("\n"), + ); + return; + } + + writeLine( + output, + [ + "", + "Agent-Native Code approve", + "", + ` Run: ${run.id}`, + "", + "Running the pending approved command.", + ].join("\n"), + ); + await executePendingCodeAgentApproval(run.id, { stdout: output }); + writeLine( + output, + [ + "", + "Approval step finished.", + "", + "Resume the Agent-Native Code session:", + ` agent-native code run ${run.id}`, + ].join("\n"), + ); +} + +function renderCodeAgentLogs( + runs: CodeAgentRunRecord[], + args: string[], +): string { + const run = selectCodeAgentRun(runs, args, { defaultToLast: true }); + if (!run) { + return [ + "", + "Agent-Native Code logs", + "", + " No Agent-Native Code session selected.", + "", + "Try: agent-native code logs --last", + ].join("\n"); + } + const events = listCodeAgentTranscriptEvents(run.id); + return [ + "", + `Agent-Native Code logs: ${run.id}`, + `/${run.goalId} ${run.status}${run.phase ? ` (${run.phase})` : ""}`, + run.title, + `Updated: ${run.updatedAt}`, + `Events: ${events.length}`, + "", + events.length === 0 + ? " No transcript events recorded yet." + : events.map(renderTranscriptEventForCli).join("\n"), + ].join("\n"); +} + +async function attachCodeAgentRun( + runs: CodeAgentRunRecord[], + args: string[], + output: NodeJS.WritableStream, +): Promise { + const run = selectCodeAgentRun(runs, args, { defaultToLast: true }); + if (!run) { + writeLine( + output, + [ + "", + "Agent-Native Code attach", + "", + " No Agent-Native Code session selected.", + "", + "Try: agent-native code attach --last", + ].join("\n"), + ); + return; + } + + const follow = !args.includes("--no-follow"); + const printed = new Set(); + writeLine(output, ""); + writeLine(output, `Attaching to Agent-Native Code run ${run.id}`); + writeLine( + output, + "Press Ctrl+C to detach. The session keeps its transcript.", + ); + writeLine(output, ""); + + const printNewEvents = () => { + const events = listCodeAgentTranscriptEvents(run.id); + for (const event of events) { + const key = `${event.id}:${event.createdAt}`; + if (printed.has(key)) continue; + printed.add(key); + writeLine(output, renderTranscriptEventForCli(event)); + } + }; + + printNewEvents(); + if (!follow) return; + + while (true) { + const latest = getCodeAgentRunRecord(run.id); + if (!latest || isTerminalRun(latest)) { + printNewEvents(); + if (latest) { + writeLine( + output, + `\nRun ${latest.status}${latest.phase ? ` (${latest.phase})` : ""}.`, + ); + } + return; + } + await delay(1_000); + printNewEvents(); + } +} + +function renderTranscriptEventForCli(event: CodeAgentTranscriptEvent): string { + const timestamp = event.createdAt.replace("T", " ").replace(/\.\d+Z$/, "Z"); + const label = + event.kind === "user" + ? "user" + : event.metadata?.role === "assistant" + ? "assistant" + : event.kind; + const tool = + typeof event.metadata?.tool === "string" ? ` ${event.metadata.tool}` : ""; + return `[${timestamp}] ${label}${tool}: ${event.message}`; +} + +function isTerminalRun(run: CodeAgentRunRecord): boolean { + return ( + run.status === "completed" || + run.status === "errored" || + run.status === "paused" || + run.phase === "complete" || + run.phase === "error" || + run.phase === "paused" || + run.phase === "missing-credentials" || + run.phase === "stopped" + ); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function selectCodeAgentRun( + runs: CodeAgentRunRecord[], + args: string[], + options: { defaultToLast: boolean }, +): CodeAgentRunRecord | null { + const explicitRunId = getExplicitRunId(args); + if (explicitRunId) { + return runs.find((run) => run.id === explicitRunId) ?? null; + } + return options.defaultToLast ? (runs[0] ?? null) : null; +} + +function hasExplicitRunId(args: string[]): boolean { + return Boolean(getExplicitRunId(args)); +} + +function getExplicitRunId(args: string[]): string | null { + const subcommand = args[0]; + for (const arg of args.slice(1)) { + if (arg === "--last" || arg === "--") continue; + if (arg.startsWith("-")) continue; + if (arg === subcommand) continue; + return arg; + } + return null; +} + +function renderCodeAgentRunDetail( + heading: string, + run: CodeAgentRunRecord, +): string { + const transcriptEvents = listCodeAgentTranscriptEvents(run.id); + return [ + "", + heading, + "", + ` Run: ${run.id}`, + ` Goal: /${run.goalId}`, + ` Title: ${run.title}`, + run.subtitle ? ` Subtitle: ${run.subtitle}` : "", + run.permissionMode + ? ` Mode: ${formatCodeAgentRunMode(run.permissionMode)}` + : "", + ` Status: ${run.status}${run.phase ? ` (${run.phase})` : ""}`, + run.progress + ? ` Progress: ${run.progress.completed}/${run.progress.total} (${run.progress.percent}%)` + : "", + run.artifactRoot ? ` Artifacts: ${run.artifactRoot}` : "", + ` Transcript: ${transcriptEvents.length} event${transcriptEvents.length === 1 ? "" : "s"}`, + ` Updated: ${run.updatedAt}`, + ] + .filter(Boolean) + .join("\n"); +} + +function renderCodeAgentRunListItem(run: CodeAgentRunRecord): string { + const progress = run.progress + ? `, ${run.progress.completed}/${run.progress.total}` + : ""; + const permission = run.permissionMode + ? `, ${formatCodeAgentRunMode(run.permissionMode)}` + : ""; + return [ + ` - ${run.id}`, + ` /${run.goalId} ${run.status}${run.phase ? ` (${run.phase})` : ""}${progress}${permission}`, + ` ${truncateForDisplay(run.title, 100)}`, + ` updated ${run.updatedAt}`, + ].join("\n"); +} + +function isInteractiveTerminal( + input: NodeJS.ReadableStream, + output: NodeJS.WritableStream, +): boolean { + return Boolean( + (input as NodeJS.ReadStream).isTTY && (output as NodeJS.WriteStream).isTTY, + ); +} + +function writeLine(output: NodeJS.WritableStream, text = ""): void { + output.write(`${text}\n`); +} + +function writePrompt(output: NodeJS.WritableStream): void { + output.write(SHELL_PROMPT); +} + +async function runCodeGoal( + goalId: CodeAgentGoalId, + forwardedArgs: string[], + output: NodeJS.WritableStream = process.stdout, +): Promise { + const goal = CODE_AGENT_CLI_GOALS.find( + (candidate) => candidate.id === goalId, + ); + if (!goal) { + throw new Error(`Unknown Agent-Native Code goal: ${goalId}`); + } + + switch (goal.backingCommand) { + case "task": + await runTask(forwardedArgs, output); + return; + case "audit-agent-web": + await runAuditAgentWeb(forwardedArgs); + return; + case "migrate": + await runMigrate(forwardedArgs); + return; + } +} + +async function runTask( + forwardedArgs: string[], + output: NodeJS.WritableStream, + options: RunTaskOptions = {}, +): Promise { + const parsed = parseTaskArgs(forwardedArgs, options.permissionMode); + if (parsed.error) { + writeLine(output, parsed.error); + writeLine(output, taskUsage()); + return; + } + if (!parsed.prompt) { + writeLine(output, taskUsage()); + return; + } + + const prompt = parsed.prompt; + const run = createCodeAgentRunRecord({ + goalId: "task", + title: titleFromPrompt(prompt), + subtitle: options.subtitle ?? "Generic coding task", + status: "running", + phase: "starting", + permissionMode: parsed.permissionMode, + progress: { + label: "Starting", + completed: 0, + total: 1, + percent: 5, + }, + details: [ + { label: "Prompt", value: truncateForDisplay(prompt, 160) }, + { label: "Agent", value: "Running locally" }, + { label: "Mode", value: formatCodeAgentRunMode(parsed.permissionMode) }, + ], + cwd: process.cwd(), + metadata: { + prompt, + source: options.source ?? "agent-native code", + commandName: options.commandName, + commandPath: options.commandPath, + permissionMode: parsed.permissionMode, + }, + }); + + appendCodeAgentTranscriptEvent({ + runId: run.id, + kind: "user", + message: prompt, + metadata: { source: "initial-prompt" }, + }); + appendCodeAgentTranscriptEvent({ + runId: run.id, + kind: "status", + message: "Starting local Agent-Native Code execution.", + metadata: { + status: "running", + phase: "starting", + }, + }); + + writeLine(output, renderTaskStarted(run, prompt)); + await executeCodeAgentRun({ + runId: run.id, + prompt, + appendUserEvent: false, + stdout: output, + }); +} + +async function runProjectSlashCommand( + commandName: string, + forwardedArgs: string[], + output: NodeJS.WritableStream, +): Promise { + const command = findProjectSlashCommand(commandName); + if (!command) { + writeLine( + output, + `Project slash command not found: /${normalizeGoalToken(commandName)}`, + ); + return; + } + const parsed = parseTaskArgs(forwardedArgs); + if (parsed.error) { + writeLine(output, parsed.error); + return; + } + const prompt = renderProjectSlashCommandPrompt(command, parsed.promptArgs); + await runTask([prompt], output, { + subtitle: `Project command /${command.name}`, + source: "agent-native code project-command", + commandName: command.name, + commandPath: command.path, + permissionMode: parsed.permissionMode, + }); +} + +async function recordCodeAgentFollowUpPrompt( + prompt: string, + output: NodeJS.WritableStream, + permissionMode?: CodeAgentPermissionMode, + runId?: string, + followUpMode: CodeAgentFollowUpMode = "immediate", +): Promise { + const run = runId + ? getCodeAgentRunRecord(runId) + : getLastCodeAgentRunRecord(); + if (!run) { + writeLine( + output, + [ + "", + runId + ? `Agent-Native Code run not found: ${runId}` + : "No Agent-Native Code runs found.", + "", + 'Start one with: agent-native code "what to change"', + ].join("\n"), + ); + return; + } + + const activeRun = permissionMode + ? (updateCodeAgentRunRecord(run.id, { permissionMode }) ?? run) + : run; + const shouldQueue = isActiveCodeAgentRun(activeRun); + const event = appendCodeAgentTranscriptEvent({ + runId: activeRun.id, + kind: "user", + message: prompt, + metadata: { + source: "resume-follow-up", + permissionMode, + followUpMode, + delivery: shouldQueue ? followUpMode : "run-now", + }, + }); + if (shouldQueue) { + queueCodeAgentFollowUp({ + runId: activeRun.id, + prompt, + mode: followUpMode, + eventId: event.id, + permissionMode, + source: "resume-follow-up", + createdAt: event.createdAt, + }); + writeLine(output, renderFollowUpRecorded(activeRun, event, followUpMode)); + return; + } + + writeLine(output, renderFollowUpRecorded(activeRun, event, "immediate")); + await executeCodeAgentRun({ + runId: activeRun.id, + prompt, + appendUserEvent: false, + stdout: output, + }); +} + +function parseTaskArgs( + forwardedArgs: string[], + defaultPermissionMode: CodeAgentPermissionMode = "full-auto", +): ParsedTaskArgs { + const promptArgs: string[] = []; + let permissionMode: CodeAgentPermissionMode = defaultPermissionMode; + let permissionModeExplicit = false; + for (let index = 0; index < forwardedArgs.length; index += 1) { + const arg = forwardedArgs[index]; + if (arg === "--") { + promptArgs.push(...forwardedArgs.slice(index + 1)); + break; + } + if (arg === "--permission-mode") { + const value = forwardedArgs[index + 1]; + const normalized = normalizeCodeAgentPermissionMode(value); + if (!normalized) { + return { + prompt: "", + promptArgs, + permissionMode, + error: `Invalid run mode: ${value ?? "(missing)"}`, + }; + } + permissionMode = normalized; + permissionModeExplicit = true; + index += 1; + continue; + } + if (arg.startsWith("--permission-mode=")) { + const normalized = normalizeCodeAgentPermissionMode( + arg.slice("--permission-mode=".length), + ); + if (!normalized) { + return { + prompt: "", + promptArgs, + permissionMode, + error: `Invalid run mode: ${arg.slice("--permission-mode=".length)}`, + }; + } + permissionMode = normalized; + permissionModeExplicit = true; + continue; + } + const shorthand = parsePermissionModeFlag(arg); + if (shorthand) { + permissionMode = shorthand; + permissionModeExplicit = true; + continue; + } + promptArgs.push(arg); + } + return { + prompt: promptArgs.join(" ").trim(), + promptArgs, + permissionMode, + permissionModeExplicit, + }; +} + +function parseFollowUpArgs(forwardedArgs: string[]): ParsedTaskArgs & { + followUpMode: CodeAgentFollowUpMode; +} { + const promptArgs: string[] = []; + let followUpMode: CodeAgentFollowUpMode = "immediate"; + for (let index = 0; index < forwardedArgs.length; index += 1) { + const arg = forwardedArgs[index]; + if (arg === "--") { + promptArgs.push(...forwardedArgs.slice(index)); + break; + } + if (arg === "--queue" || arg === "--after-completion") { + followUpMode = "queued"; + continue; + } + if (arg === "--immediate" || arg === "--steer") { + followUpMode = "immediate"; + continue; + } + promptArgs.push(arg); + } + return { + ...parseTaskArgs(promptArgs), + followUpMode, + }; +} + +function parsePermissionModeFlag(arg: string): CodeAgentPermissionMode | null { + switch (arg) { + case "--plan": + case "--read-only": + return "read-only"; + case "--auto": + return "full-auto"; + case "--ask-before-edit": + return "ask-before-edit"; + case "--auto-edit": + return "auto-edit"; + case "--full-auto": + return "full-auto"; + default: + return null; + } +} + +function titleFromPrompt(prompt: string): string { + return truncateForDisplay(prompt.replace(/\s+/g, " "), 80); +} + +function truncateForDisplay(value: string, maxLength: number): string { + if (value.length <= maxLength) return value; + return `${value.slice(0, Math.max(0, maxLength - 3))}...`; +} + +function renderTaskStarted(run: CodeAgentRunRecord, prompt: string): string { + return [ + "", + "Agent-Native Code session started.", + "", + ` Run: ${run.id}`, + ` Prompt: ${truncateForDisplay(prompt, 160)}`, + ` Mode: ${formatCodeAgentRunMode(run.permissionMode)}`, + "", + "Streaming output below. The transcript is saved with this run.", + ].join("\n"); +} + +function renderFollowUpRecorded( + run: CodeAgentRunRecord, + event: ReturnType, + mode: CodeAgentFollowUpMode, +): string { + const active = isActiveCodeAgentRun(run); + const heading = active + ? mode === "queued" + ? "Queued follow-up prompt for Agent-Native Code run." + : "Recorded steering prompt for active Agent-Native Code run." + : "Running follow-up prompt for Agent-Native Code run."; + return [ + "", + heading, + "", + ` Run: ${run.id}`, + ` Goal: /${run.goalId}`, + ` Event: ${event.id}`, + "", + active + ? mode === "queued" + ? "It will run after the current execution finishes." + : "It will be applied by the active runner as soon as it can steer." + : "Streaming output below. The transcript is saved with this run.", + ].join("\n"); +} + +function taskUsage(): string { + return [ + "", + "Usage:", + ' agent-native code "what to change"', + ' agent-native code --plan "explain this repo"', + ' agent-native code --auto "fix this and verify it"', + ` agent-native code --permission-mode ${CODE_AGENT_PERMISSION_MODES.join("|")} "what to change"`, + "", + "The task goal starts a local Agent-Native Code session, saves transcript events, and can be resumed with follow-up prompts.", + ].join("\n"); +} + +function formatCodeAgentRunMode( + permissionMode: CodeAgentPermissionMode | undefined, +): string { + return permissionMode === "read-only" ? "Plan mode" : "Auto mode"; +} diff --git a/packages/core/src/cli/create-e2e.spec.ts b/packages/core/src/cli/create-e2e.spec.ts index 5269814ed..262e1e13b 100644 --- a/packages/core/src/cli/create-e2e.spec.ts +++ b/packages/core/src/cli/create-e2e.spec.ts @@ -25,6 +25,7 @@ import { _loadCatalog, _rewriteNetlifyToml, _getCoreDependencyVersion, + _getDispatchDependencyVersion, _getGitHubTemplateRef, _getGitHubTemplateRefCandidates, _shouldSkipScaffoldEntry, @@ -154,6 +155,7 @@ describe("workspace scaffold — required packages", { timeout: 60000 }, () => { workspaceRoot: targetDir, workspaceCoreName, coreDependencyVersion: _getCoreDependencyVersion(), + dispatchDependencyVersion: _getDispatchDependencyVersion(), }); _fixPackageJsonName(appDir, t); _renameGitignore(appDir); @@ -234,6 +236,24 @@ describe("workspace scaffold — required packages", { timeout: 60000 }, () => { expect(dispatchPkg.dependencies["@agent-native/dispatch"]).toBe("latest"); }); + it("can opt into local dispatch linking for framework development", async () => { + const previous = process.env.AGENT_NATIVE_CREATE_USE_LOCAL_CORE; + process.env.AGENT_NATIVE_CREATE_USE_LOCAL_CORE = "1"; + try { + const wsDir = await scaffoldWorkspace("my-ws", ["dispatch"]); + const dispatchPkg = readPkg(path.join(wsDir, "apps", "dispatch")); + expect(dispatchPkg.dependencies["@agent-native/dispatch"]).toMatch( + /^file:\/\//, + ); + } finally { + if (previous === undefined) { + delete process.env.AGENT_NATIVE_CREATE_USE_LOCAL_CORE; + } else { + process.env.AGENT_NATIVE_CREATE_USE_LOCAL_CORE = previous; + } + } + }); + it("adds postinstall script for required packages", async () => { const wsDir = await scaffoldWorkspace("my-ws", ["calendar"]); const rootPkg = readPkg(wsDir); diff --git a/packages/core/src/cli/create.ts b/packages/core/src/cli/create.ts index 911b0fd66..52f45abd2 100644 --- a/packages/core/src/cli/create.ts +++ b/packages/core/src/cli/create.ts @@ -204,6 +204,7 @@ async function createWorkspaceInteractive( workspaceRoot: targetDir, workspaceCoreName, coreDependencyVersion: getCoreDependencyVersion(), + dispatchDependencyVersion: getDispatchDependencyVersion(), }); fixPackageJsonName(appDir, t); rewriteNetlifyToml(appDir, t, "workspace"); @@ -479,6 +480,7 @@ async function scaffoldOneAppIntoWorkspace( workspaceRoot: workspace.workspaceRoot, workspaceCoreName: workspace.workspaceCoreName, coreDependencyVersion: getCoreDependencyVersion(), + dispatchDependencyVersion: getDispatchDependencyVersion(), }); fixPackageJsonName(appDir, appName, templateName); rewriteNetlifyToml(appDir, appName, "workspace"); @@ -1000,6 +1002,7 @@ export { renameGitignore as _renameGitignore, rewriteNetlifyToml as _rewriteNetlifyToml, getCoreDependencyVersion as _getCoreDependencyVersion, + getDispatchDependencyVersion as _getDispatchDependencyVersion, getGitHubTemplateRef as _getGitHubTemplateRef, getGitHubTemplateRefCandidates as _getGitHubTemplateRefCandidates, shouldSkipScaffoldEntry as _shouldSkipScaffoldEntry, @@ -1186,6 +1189,15 @@ function getCoreDependencyVersion(): string { return "latest"; } +function getDispatchDependencyVersion(): string { + if (process.env.AGENT_NATIVE_CREATE_USE_LOCAL_CORE === "1") { + const localDispatch = findLocalPackage("dispatch"); + if (localDispatch) return pathToFileURL(localDispatch).href; + } + + return "latest"; +} + function getCorePackageVersion(): string | undefined { try { const packageRoot = path.resolve(__dirname, "../.."); diff --git a/packages/core/src/cli/index.ts b/packages/core/src/cli/index.ts index ce3edcabe..541070586 100644 --- a/packages/core/src/cli/index.ts +++ b/packages/core/src/cli/index.ts @@ -580,6 +580,13 @@ switch (command) { break; } + case "code": { + import("./code.js") + .then((m) => m.runCode(args)) + .catch(handleScaffoldImportError); + break; + } + case "create-workspace": { // Deprecated alias for `create` (since workspace is now the default). const parsed = parseScaffoldArgs(args); @@ -642,12 +649,19 @@ switch (command) { break; } + case undefined: + import("./code.js") + .then((m) => m.runCode([])) + .catch(handleScaffoldImportError); + break; + case "--help": case "-h": - case undefined: console.log(`agent-native v${_version} Usage: + agent-native Launch Agent-Native Code workspace + agent-native "fix tests" Start an Agent-Native Code coding session agent-native dev Start development server (or the workspace gateway at a workspace root) agent-native build Build for production (client + server) @@ -658,6 +672,11 @@ Usage: agent-native create [name] Scaffold a new agent-native workspace with a multi-select template picker. Use --standalone for a single-app scaffold. + agent-native code Launch Agent-Native Code workspace. Type a task or + use goals like /migrate and /audit. + agent-native code serve Run the Agent-Native Code remote connector. + agent-native migrate Create an Agent-Native Code /migrate session, or use + --emit for a portable own-agent dossier. agent-native add-app [name] Add one or more apps to the current workspace agent-native workspace-dev Start the multi-app workspace gateway agent-native deploy Build & deploy every app in the workspace to @@ -674,6 +693,8 @@ Options: (mail,calendar,analytics,...) — or github:user/repo for community templates --standalone Scaffold a single standalone app (no workspace) + --emit [dir] With migrate, emit an own-agent dossier + --describe With migrate, describe URL/prose-only sources --preset Workspace deploy preset: cloudflare_pages (default), netlify, or vercel --build-only Build workspace deploy artifacts without publishing @@ -685,6 +706,12 @@ Bugs: ${BUGS_URL}`); break; default: + if (command && !command.startsWith("-")) { + import("./code.js") + .then((m) => m.runCode([command, ...args])) + .catch(handleScaffoldImportError); + break; + } console.error(`Unknown command: ${command}`); console.error('Run "agent-native --help" for usage.'); console.error(`Bugs: ${BUGS_URL}`); diff --git a/packages/core/src/cli/migrate.spec.ts b/packages/core/src/cli/migrate.spec.ts index 439ff62fe..ec9e380c4 100644 --- a/packages/core/src/cli/migrate.spec.ts +++ b/packages/core/src/cli/migrate.spec.ts @@ -1,5 +1,29 @@ -import { describe, expect, it } from "vitest"; -import { parseMigrateArgs } from "./migrate.js"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { fileURLToPath } from "url"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + listCodeAgentRunRecords, + listCodeAgentTranscriptEvents, +} from "./code-agent-runs.js"; +import { + emitOwnAgentDossier, + isExpectedMigrationCliError, + parseMigrateArgs, + runMigrate, +} from "./migrate.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const tmpRoots: string[] = []; + +afterEach(() => { + delete process.env.AGENT_NATIVE_CODE_AGENTS_HOME; + vi.restoreAllMocks(); + for (const root of tmpRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); describe("parseMigrateArgs", () => { it("parses source and output defaults", () => { @@ -27,4 +51,245 @@ describe("parseMigrateArgs", () => { planOnly: true, }); }); + + it("parses subcommands and any-input source options", () => { + expect(parseMigrateArgs(["status", "./migration"])).toEqual({ + subcommand: "status", + workbench: "./migration", + }); + expect(parseMigrateArgs(["resume", "--last"])).toEqual({ + subcommand: "resume", + last: true, + }); + + expect( + parseMigrateArgs([ + "--url", + "https://example.com", + "--describe", + "marketing site", + "--emit", + "../dossier", + ]), + ).toEqual({ + sourceUrl: "https://example.com", + sourceDescription: "marketing site", + emit: true, + emitDir: "../dossier", + }); + expect(parseMigrateArgs(["./next-app", "--app-surface"])).toEqual({ + source: "./next-app", + appSurface: true, + }); + }); + + it("creates a generic Agent-Native Code session by default", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "an-migrate-")); + tmpRoots.push(root); + process.env.AGENT_NATIVE_CODE_AGENTS_HOME = path.join(root, "code-agents"); + const sourceRoot = path.join(root, "source"); + fs.mkdirSync(path.join(sourceRoot, "pages"), { recursive: true }); + fs.writeFileSync( + path.join(sourceRoot, "package.json"), + JSON.stringify({ dependencies: { next: "^16.0.0" } }), + ); + fs.writeFileSync( + path.join(sourceRoot, "pages", "index.tsx"), + "export default function Home() { return
; }\n", + ); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + + await runMigrate([sourceRoot, "--out", path.join(root, "migrated")]); + + const runs = listCodeAgentRunRecords("migrate"); + expect(runs).toHaveLength(1); + expect(runs[0]).toMatchObject({ + goalId: "migrate", + status: "needs-approval", + phase: "intake", + progress: { + label: "Dossier ready; waiting for approval", + completed: 1, + total: 2, + percent: 50, + }, + }); + const dossierRoot = runs[0].metadata?.dossierRoot; + expect(typeof dossierRoot).toBe("string"); + expect(runs[0].metadata).toMatchObject({ + preferredCommand: "agent-native code /migrate", + resumeCommand: "agent-native code resume --last", + attachCommand: "agent-native code attach --last", + statusCommand: "agent-native code status --last", + }); + expect(fs.existsSync(path.join(String(dossierRoot), "AGENTS.md"))).toBe( + true, + ); + expect( + fs.existsSync(path.join(String(dossierRoot), "MIGRATION_PLAYBOOK.md")), + ).toBe(true); + expect( + fs.existsSync(path.join(String(dossierRoot), "01-assessment.md")), + ).toBe(true); + expect(fs.existsSync(path.join(String(dossierRoot), "ir.json"))).toBe(true); + expect(fs.existsSync(path.join(root, "migration"))).toBe(false); + expect( + listCodeAgentTranscriptEvents(runs[0].id).map((event) => event.kind), + ).toEqual(["user", "status", "artifact", "note", "status"]); + expect(log.mock.calls.join("\n")).toContain( + "Agent-Native Code /migrate session created.", + ); + expect(log.mock.calls.join("\n")).toContain("Artifacts:"); + expect(log.mock.calls.join("\n")).toContain( + "agent-native code attach --last", + ); + expect(log.mock.calls.join("\n")).toContain( + "Migration stays in Agent-Native Code. No hidden app/template was scaffolded.", + ); + }); + + it("dogfoods a real Next.js fixture into a slash-command migration dossier", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "an-migrate-fixture-")); + tmpRoots.push(root); + process.env.AGENT_NATIVE_CODE_AGENTS_HOME = path.join(root, "code-agents"); + const sourceRoot = path.resolve( + __dirname, + "../../../migrate/src/__fixtures__/next-pages", + ); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + + await runMigrate([sourceRoot, "--out", path.join(root, "migrated")]); + + const [run] = listCodeAgentRunRecords("migrate"); + expect(typeof run?.metadata?.usedMigrateHelpers).toBe("boolean"); + const dossierRoot = String(run?.metadata?.dossierRoot); + const assessment = fs.readFileSync( + path.join(dossierRoot, "01-assessment.md"), + "utf-8", + ); + const ir = JSON.parse( + fs.readFileSync(path.join(dossierRoot, "ir.json"), "utf-8"), + ); + + expect(assessment).toContain("Framework: nextjs"); + expect(ir.site.routes.map((route: { path: string }) => route.path)).toEqual( + expect.arrayContaining(["/", "/dashboard"]), + ); + expect(ir.behavior.apiEndpoints[0]).toMatchObject({ + path: "/api/hello", + recommendedRecipe: "api-routes-to-actions", + }); + expect(fs.existsSync(path.join(root, "migration"))).toBe(false); + expect(log.mock.calls.join("\n")).toContain("Goal: /migrate"); + }); + + it("emits a dossier outside sourceRoot", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "an-migrate-")); + tmpRoots.push(root); + const sourceRoot = path.join(root, "source"); + const dossierRoot = path.join(root, "dossier"); + fs.mkdirSync(path.join(sourceRoot, "pages"), { recursive: true }); + fs.writeFileSync( + path.join(sourceRoot, "package.json"), + JSON.stringify({ dependencies: { next: "^16.0.0" } }), + ); + fs.writeFileSync( + path.join(sourceRoot, "pages", "index.tsx"), + "export default function Home() { return
; }\n", + ); + + const result = await emitOwnAgentDossier( + { source: sourceRoot, emit: true, emitDir: dossierRoot }, + root, + ); + + expect(result.dossierRoot).toBe(dossierRoot); + expect(result.files).toEqual( + expect.arrayContaining([ + ".agents/skills/migration/SKILL.md", + ".agents/skills/migration-source-nextjs/SKILL.md", + ".agents/skills/migration-source-aem/SKILL.md", + ".agents/skills/migration-target-builder/SKILL.md", + "AGENTS.md", + "MIGRATION_PLAYBOOK.md", + "01-assessment.md", + "ir.json", + "source.json", + ]), + ); + expect(fs.existsSync(path.join(dossierRoot, "AGENTS.md"))).toBe(true); + expect(fs.existsSync(path.join(dossierRoot, "MIGRATION_PLAYBOOK.md"))).toBe( + true, + ); + expect(fs.existsSync(path.join(dossierRoot, "01-assessment.md"))).toBe( + true, + ); + const agentsMd = fs.readFileSync( + path.join(dossierRoot, "AGENTS.md"), + "utf-8", + ); + const playbook = fs.readFileSync( + path.join(dossierRoot, "MIGRATION_PLAYBOOK.md"), + "utf-8", + ); + const ir = JSON.parse( + fs.readFileSync(path.join(dossierRoot, "ir.json"), "utf-8"), + ); + + expect(agentsMd).toContain("Treat source as read-only"); + expect(playbook).toContain("Use With Agent-Native Code Or Desktop"); + expect(ir).toMatchObject({ + site: { framework: "nextjs" }, + }); + expect(fs.existsSync(path.join(sourceRoot, "AGENTS.md"))).toBe(false); + }); + + it("refuses explicit emit paths inside sourceRoot", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "an-migrate-")); + tmpRoots.push(root); + const sourceRoot = path.join(root, "source"); + fs.mkdirSync(sourceRoot, { recursive: true }); + + await expect( + emitOwnAgentDossier( + { + source: sourceRoot, + emit: true, + emitDir: path.join(sourceRoot, "dossier"), + }, + root, + ), + ).rejects.toThrow(/Refusing to emit dossier inside sourceRoot/); + }); + + it("classifies expected emit validation failures as user-facing CLI errors", () => { + expect( + isExpectedMigrationCliError( + new Error( + "Refusing to emit dossier inside sourceRoot (/tmp/source). Choose an --emit path outside the source project.", + ), + ), + ).toBe(true); + expect( + isExpectedMigrationCliError( + new Error("Usage: agent-native migrate --emit"), + ), + ).toBe(true); + expect(isExpectedMigrationCliError(new Error("disk exploded"))).toBe(false); + }); + + it("status output points empty users at the Agent-Native Code slash command", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "an-migrate-status-")); + tmpRoots.push(root); + process.env.AGENT_NATIVE_CODE_AGENTS_HOME = path.join(root, "code-agents"); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + + await runMigrate(["status", "--last"]); + + const output = log.mock.calls.join("\n"); + expect(output).toContain("No /migrate sessions found."); + expect(output).toContain("agent-native code /migrate "); + expect(output).toContain("agent-native migrate status --last"); + expect(output).toContain("Add --app-surface only"); + }); }); diff --git a/packages/core/src/cli/migrate.ts b/packages/core/src/cli/migrate.ts index bb5026dda..d47962c86 100644 --- a/packages/core/src/cli/migrate.ts +++ b/packages/core/src/cli/migrate.ts @@ -1,20 +1,143 @@ +import crypto from "crypto"; import fs from "fs"; import path from "path"; +import { fileURLToPath } from "url"; +import { + appendCodeAgentTranscriptEvent, + codeAgentRunArtifactsDir, + createCodeAgentRunRecord, + getLastCodeAgentRunRecord, + listCodeAgentRunRecords, + writeCodeAgentRunRecord, + type CodeAgentRunRecord, +} from "./code-agent-runs.js"; import { createApp } from "./create.js"; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const DEFAULT_APP_NAME = "migration"; +const DEFAULT_OUTPUT = "../migrated-app"; +const DEFAULT_TARGET = "agent-native"; +const DEFAULT_DOSSIER_DIR = "agent-native-migration-dossier"; +const MIGRATION_DEV_PORT = 8101; +const MIGRATE_SUBCOMMANDS = new Set(["resume", "status", "stop", "ui"]); +const MIGRATION_SESSION_COMMAND = "agent-native code /migrate"; +const MODEL_CREDENTIAL_ENV_NAMES = [ + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "GOOGLE_GENERATIVE_AI_API_KEY", + "GROQ_API_KEY", + "MISTRAL_API_KEY", + "COHERE_API_KEY", + "BUILDER_PRIVATE_KEY", +]; + +type MigrateSubcommand = "resume" | "status" | "stop" | "ui"; +type SourceKind = "path" | "url" | "description"; + export interface MigrateCliOptions { + subcommand?: MigrateSubcommand; source?: string; + sourcePath?: string; + sourceUrl?: string; + sourceDescription?: string; + workbench?: string; output?: string; appName?: string; target?: string; planOnly?: boolean; + emit?: boolean; + emitDir?: string; + appSurface?: boolean; + last?: boolean; + help?: boolean; +} + +export interface SourceSpec { + kind: SourceKind; + value: string; + sourceRoot?: string; + description?: string; +} + +export interface EmitDossierResult { + dossierRoot: string; + files: string[]; + source: SourceSpec; + usedMigrateHelpers: boolean; +} + +interface ProjectIRLike { + site: { + framework: "nextjs" | "react" | "aem" | "unknown"; + sourceRoot: string; + routes: Array<{ + id: string; + path: string; + filePath: string; + router: "next-pages" | "next-app" | "unknown"; + kind: "marketing" | "docs" | "landing" | "app" | "api" | "unknown"; + dynamic: boolean; + public: boolean; + notes?: string[]; + }>; + redirects: Array>; + metadata: Record; + }; + components: { + components: Array<{ + id: string; + name: string; + filePath: string; + usedByRoutes: string[]; + notes?: string[]; + }>; + designTokens: Record; + }; + content: { + models: Array>; + assets: Array<{ + id: string; + path: string; + type: string; + metadata?: Record; + }>; + }; + behavior: { + apiEndpoints: Array<{ + id: string; + path: string; + method: string; + filePath: string; + recommendedRecipe: string; + }>; + dataStores: Array<{ + id: string; + name: string; + filePath: string; + kind: "database" | "api" | "local-state" | "unknown"; + }>; + llmCalls: Array<{ id: string; filePath: string; provider: string }>; + clientState: Array<{ id: string; filePath: string; reason: string }>; + auth: Array<{ id: string; filePath: string; provider: string }>; + jobs: Array<{ id: string; filePath: string; kind: string }>; + }; } export function parseMigrateArgs(argv: string[]): MigrateCliOptions { const opts: MigrateCliOptions = {}; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; - if (arg === "--out" && argv[i + 1]) { + + if (i === 0 && MIGRATE_SUBCOMMANDS.has(arg)) { + opts.subcommand = arg as MigrateSubcommand; + continue; + } + + if (arg === "--help" || arg === "-h") { + opts.help = true; + } else if (arg === "--out" && argv[i + 1]) { opts.output = argv[++i]; } else if (arg.startsWith("--out=")) { opts.output = arg.slice("--out=".length); @@ -26,10 +149,59 @@ export function parseMigrateArgs(argv: string[]): MigrateCliOptions { opts.target = argv[++i]; } else if (arg.startsWith("--target=")) { opts.target = arg.slice("--target=".length); + } else if (arg === "--source" && argv[i + 1]) { + setSourceOption(opts, argv[++i]); + } else if (arg.startsWith("--source=")) { + setSourceOption(opts, arg.slice("--source=".length)); + } else if (arg === "--path" && argv[i + 1]) { + opts.sourcePath = argv[++i]; + } else if (arg.startsWith("--path=")) { + opts.sourcePath = arg.slice("--path=".length); + } else if (arg === "--url" && argv[i + 1]) { + opts.sourceUrl = argv[++i]; + } else if (arg.startsWith("--url=")) { + opts.sourceUrl = arg.slice("--url=".length); + } else if ( + (arg === "--description" || arg === "--describe") && + argv[i + 1] + ) { + opts.sourceDescription = argv[++i]; + } else if (arg.startsWith("--description=")) { + opts.sourceDescription = arg.slice("--description=".length); + } else if (arg.startsWith("--describe=")) { + opts.sourceDescription = arg.slice("--describe=".length); + } else if (arg === "--emit") { + opts.emit = true; + if (argv[i + 1] && !argv[i + 1].startsWith("-")) { + opts.emitDir = argv[++i]; + } + } else if (arg.startsWith("--emit=")) { + opts.emit = true; + opts.emitDir = arg.slice("--emit=".length); + } else if (arg === "--emit-dir" && argv[i + 1]) { + opts.emit = true; + opts.emitDir = argv[++i]; + } else if (arg.startsWith("--emit-dir=")) { + opts.emit = true; + opts.emitDir = arg.slice("--emit-dir=".length); + } else if (arg === "--app-surface" || arg === "--workbench") { + opts.appSurface = true; } else if (arg === "--plan-only") { opts.planOnly = true; - } else if (!arg.startsWith("-") && !opts.source) { - opts.source = arg; + } else if (arg === "--last") { + opts.last = true; + } else if (!arg.startsWith("-")) { + if ( + opts.subcommand && + ["status", "stop", "ui"].includes(opts.subcommand) && + !opts.workbench + ) { + opts.workbench = arg; + } else if (!opts.source) { + opts.source = arg; + } else if (!opts.workbench) { + opts.workbench = arg; + } } } return opts; @@ -37,31 +209,189 @@ export function parseMigrateArgs(argv: string[]): MigrateCliOptions { export async function runMigrate(argv: string[]): Promise { const opts = parseMigrateArgs(argv); - if (!opts.source) { - console.error( - "Usage: agent-native migrate [--out ../migrated-app] [--name migration]", + if (opts.help) { + console.log(migrateUsage()); + return; + } + + if (opts.subcommand === "status") { + printMigrationStatus(opts); + return; + } + if (opts.subcommand === "stop") { + printMigrationStop(opts); + return; + } + if (opts.subcommand === "ui") { + printMigrationUi(opts); + return; + } + if (opts.emit) { + try { + const result = await emitOwnAgentDossier(opts); + console.log(renderEmitResult(result)); + } catch (error) { + if (isExpectedMigrationCliError(error)) { + console.error(`\n${migrationCliErrorMessage(error)}\n`); + process.exit(1); + } + throw error; + } + return; + } + if (opts.subcommand === "resume" && !hasAnySource(opts)) { + printMigrationResume(opts); + return; + } + + if (opts.appSurface) { + await scaffoldOrResumeWorkbench(opts); + return; + } + + try { + await createMigrationCodeAgentSession(opts); + } catch (error) { + if (isExpectedMigrationCliError(error)) { + console.error(`\n${migrationCliErrorMessage(error)}\n`); + process.exit(1); + } + throw error; + } +} + +export async function emitOwnAgentDossier( + opts: MigrateCliOptions, + cwd = process.cwd(), +): Promise { + const source = resolveSourceSpec(opts, cwd); + if (!source) { + throw new Error( + "Usage: agent-native migrate --emit [dossier-dir]", ); - process.exit(1); } - const appName = opts.appName ?? "migration"; - const target = opts.target ?? "agent-native"; - const sourceRoot = path.resolve(process.cwd(), opts.source); - const outputRoot = path.resolve( - process.cwd(), - opts.output ?? "../migrated-app", + let dossierRoot = resolveDossierRoot(opts, source, cwd); + const explicitEmitDir = Boolean(opts.emitDir); + if (source.sourceRoot && isInsideOrSame(source.sourceRoot, dossierRoot)) { + if (explicitEmitDir) { + throw new Error( + `Refusing to emit dossier inside sourceRoot (${source.sourceRoot}). Choose an --emit path outside the source project.`, + ); + } + dossierRoot = defaultDossierRoot(source, cwd); + } + if (source.sourceRoot) { + assertOutsideSourceRoot(source.sourceRoot, dossierRoot, "dossier"); + } + + fs.mkdirSync(dossierRoot, { recursive: true }); + const written = new Set(); + const write = (relativePath: string, content: string) => { + const filePath = path.join(dossierRoot, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync( + filePath, + content.endsWith("\n") ? content : `${content}\n`, + ); + written.add(relativePath); + }; + + const templateDir = findMigrationTemplateDir(); + const templateAgents = readTextIfExists( + templateDir ? path.join(templateDir, "AGENTS.md") : undefined, ); + write("AGENTS.md", renderDossierAgentsMd(source, templateAgents)); - await createApp(appName, { template: "migration" }); + for (const copied of copyMigrationSkills(templateDir, dossierRoot)) { + written.add(copied); + } - const appDir = resolveScaffoldedAppDir(process.cwd(), appName); + const helperResult = await writeAssessmentWithMigrateHelpers({ + source, + dossierRoot, + target: opts.target ?? DEFAULT_TARGET, + write, + }); + const usedMigrateHelpers = helperResult; + if (!helperResult) { + const fallback = buildFallbackAssessment(source); + write("01-assessment.md", fallback.assessment); + if (fallback.ir) { + write("ir.json", `${JSON.stringify(fallback.ir, null, 2)}\n`); + } + } + + write("MIGRATION_PLAYBOOK.md", renderMigrationPlaybook(source)); + write( + "source.json", + `${JSON.stringify( + { + source, + target: opts.target ?? DEFAULT_TARGET, + createdAt: new Date().toISOString(), + usedMigrateHelpers, + }, + null, + 2, + )}\n`, + ); + + return { + dossierRoot, + files: [...written].sort(), + source, + usedMigrateHelpers, + }; +} + +export function isExpectedMigrationCliError(error: unknown): boolean { + const message = migrationCliErrorMessage(error); + return ( + message.startsWith("Usage: agent-native migrate") || + message.startsWith("Refusing to emit dossier inside sourceRoot") || + message.startsWith("Refusing to write ") + ); +} + +function migrationCliErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + return String(error); +} + +async function scaffoldOrResumeWorkbench( + opts: MigrateCliOptions, +): Promise { + const cwd = process.cwd(); + const source = resolveSourceSpec(opts, cwd); + if (!source) { + console.error(migrateUsage()); + process.exit(1); + } + + const appName = opts.appName ?? DEFAULT_APP_NAME; + const target = opts.target ?? DEFAULT_TARGET; + const outputRoot = path.resolve(cwd, opts.output ?? DEFAULT_OUTPUT); + const appDirBefore = resolveScaffoldedAppDir(cwd, appName); + const existing = fs.existsSync(appDirBefore); + + if (!existing) { + await createApp(appName, { template: "migration" }); + } + + const appDir = resolveScaffoldedAppDir(cwd, appName); fs.mkdirSync(path.join(appDir, "data"), { recursive: true }); fs.writeFileSync( path.join(appDir, "data", "migration-source.json"), `${JSON.stringify( { - name: `Migration from ${path.basename(sourceRoot)}`, - sourceRoot, + schemaVersion: 2, + name: defaultRunName(source), + source: sourceSeedPayload(source), + sourceKind: source.kind, + sourceRoot: source.sourceRoot ?? "", + sourceUrl: source.kind === "url" ? source.value : undefined, + sourceDescription: source.description, outputRoot, target, planOnly: Boolean(opts.planOnly), @@ -72,27 +402,1283 @@ export async function runMigrate(argv: string[]): Promise { )}\n`, ); + console.log(renderWorkbenchReady({ appDir, existing, outputRoot, source })); +} + +async function createMigrationCodeAgentSession( + opts: MigrateCliOptions, +): Promise { + const cwd = process.cwd(); + const source = resolveSourceSpec(opts, cwd); + if (!source) { + console.error(migrateUsage()); + process.exit(1); + } + + const outputRoot = path.resolve(cwd, opts.output ?? DEFAULT_OUTPUT); + if (source.sourceRoot) { + assertOutsideSourceRoot(source.sourceRoot, outputRoot, "outputRoot"); + } + + const run = createCodeAgentRunRecord({ + goalId: "migrate", + title: defaultRunName(source), + subtitle: formatSourceForDisplay(source), + status: "needs-approval", + phase: "intake", + needsApproval: true, + progress: { + label: "Intake", + completed: 0, + total: 1, + percent: 0, + }, + cwd, + metadata: { + source, + sourceRoot: source.sourceRoot, + outputRoot, + target: opts.target ?? DEFAULT_TARGET, + }, + }); + appendCodeAgentTranscriptEvent({ + runId: run.id, + kind: "user", + message: `Migrate ${formatSourceForDisplay(source)} to ${opts.target ?? DEFAULT_TARGET}.`, + metadata: { + source, + outputRoot, + target: opts.target ?? DEFAULT_TARGET, + }, + }); + appendCodeAgentTranscriptEvent({ + runId: run.id, + kind: "status", + message: "Preparing migration dossier.", + metadata: { status: "needs-approval", phase: "intake" }, + }); + const artifactRoot = codeAgentRunArtifactsDir(run.id); + const dossierRoot = path.join(artifactRoot, "migration-dossier"); + const dossier = await emitOwnAgentDossier( + { + ...opts, + emit: true, + emitDir: dossierRoot, + target: opts.target ?? DEFAULT_TARGET, + }, + cwd, + ); + + const updated: CodeAgentRunRecord = { + ...run, + progress: { + label: "Dossier ready; waiting for approval", + completed: 1, + total: 2, + percent: 50, + }, + artifactRoot, + details: [ + { label: "Source", value: formatSourceForDisplay(source) }, + { label: "Output", value: outputRoot }, + { label: "Dossier", value: dossierRoot }, + { label: "Resume", value: "agent-native code resume --last" }, + { label: "Attach", value: "agent-native code attach --last" }, + ], + metadata: { + ...(run.metadata ?? {}), + dossierRoot, + dossierFiles: dossier.files, + artifactFiles: dossier.files.map((file) => path.join(dossierRoot, file)), + usedMigrateHelpers: dossier.usedMigrateHelpers, + resumeCommand: "agent-native code resume --last", + attachCommand: "agent-native code attach --last", + statusCommand: "agent-native code status --last", + preferredCommand: MIGRATION_SESSION_COMMAND, + }, + updatedAt: new Date().toISOString(), + }; + writeCodeAgentRunRecord(updated); + appendCodeAgentTranscriptEvent({ + runId: run.id, + kind: "artifact", + message: "Migration dossier created.", + metadata: { + path: dossierRoot, + files: dossier.files, + usedMigrateHelpers: dossier.usedMigrateHelpers, + }, + }); + appendCodeAgentTranscriptEvent({ + runId: run.id, + kind: "note", + message: + "Use the dossier with Codex, Claude Code, Cursor, or another coding agent; no migration agent process has been started by the CLI.", + metadata: { source: "migration-dossier" }, + }); + appendCodeAgentTranscriptEvent({ + runId: run.id, + kind: "status", + message: + "Migration dossier is ready. Resume the /migrate session from Agent-Native Code when you are ready to approve or continue.", + metadata: { status: "needs-approval", phase: "intake" }, + }); + + console.log(renderCodeAgentMigrationSession(updated, dossier)); +} + +function printMigrationStatus(opts: MigrateCliOptions): void { + const last = getLastCodeAgentRunRecord("migrate"); + const runs = listCodeAgentRunRecords("migrate"); + if (last || !opts.appSurface) { + console.log(renderCodeAgentMigrationStatus(runs)); + return; + } + + const appDir = resolveWorkbenchDir(opts); + const seedPath = path.join(appDir, "data", "migration-source.json"); + const seed = readJsonIfExists(seedPath) as { + sourceKind?: string; + sourceRoot?: string; + sourceUrl?: string; + sourceDescription?: string; + outputRoot?: string; + target?: string; + } | null; + const artifactRuns = readArtifactRuns(appDir); + + if (!fs.existsSync(appDir)) { + console.error(`No Migration Workbench found at ${appDir}.`); + console.error( + `Create one with: npx @agent-native/core@latest code /migrate `, + ); + console.error( + `The direct migrate command is a shortcut into that same Agent-Native Code slash command.`, + ); + process.exit(1); + } + + console.log( + [ + "", + "Migration Workbench status", + "", + ` App: ${appDir}`, + ` Seed: ${fs.existsSync(seedPath) ? seedPath : "not found"}`, + ` Source: ${formatSeedSource(seed)}`, + ` Output: ${seed?.outputRoot ?? "not set"}`, + ` Target: ${seed?.target ?? DEFAULT_TARGET}`, + ` Artifacts: ${artifactRuns.length} run${artifactRuns.length === 1 ? "" : "s"}`, + ...artifactRuns + .slice(0, 5) + .map((run) => ` - ${run.id} (${run.phase})`), + artifactRuns.length > 5 ? ` - ${artifactRuns.length - 5} more...` : "", + "", + ...credentialStatusLines(), + ] + .filter(Boolean) + .join("\n"), + ); +} + +function printMigrationStop(_opts: MigrateCliOptions): void { + console.log( + [ + "", + "Agent-Native Code /migrate stop", + "", + "The migrate CLI creates resumable session records and artifacts; it does not daemonize a background process yet.", + "Stop the terminal, Desktop run, or external coding agent that is actively working on the session.", + ].join("\n"), + ); +} + +function printMigrationUi(opts: MigrateCliOptions): void { + const last = getLastCodeAgentRunRecord("migrate"); + if (last && !opts.appSurface) { + console.log(renderCodeAgentMigrationUi(last)); + return; + } + + const appDir = resolveWorkbenchDir(opts); console.log( [ "", - "Migration Workbench is ready.", + "Migration Workbench UI", "", - ` Source: ${sourceRoot}`, - ` Output: ${outputRoot}`, - ` App: ${appDir}`, + ` App: ${appDir}`, + ` URL: http://localhost:${MIGRATION_DEV_PORT}/`, "", - "Next:", - ` cd ${path.relative(process.cwd(), appDir) || "."}`, + "Start it with:", + ` cd ${shellQuote(path.relative(process.cwd(), appDir) || ".")}`, " pnpm install", " pnpm dev", "", - "The Workbench will prefill the source and output paths. Create the run, assess, plan, approve, run a task, then verify.", + "Deep-link shape:", + ` http://localhost:${MIGRATION_DEV_PORT}/`, + " Pick a run in the Workbench, or ask the app agent to navigate by run ID.", + ].join("\n"), + ); +} + +function printMigrationResume(opts: MigrateCliOptions): void { + const last = getLastCodeAgentRunRecord("migrate"); + if (last && !opts.appSurface) { + console.log(renderCodeAgentMigrationResume(last)); + return; + } + + const appDir = resolveWorkbenchDir(opts); + const seedPath = path.join(appDir, "data", "migration-source.json"); + const seed = readJsonIfExists(seedPath); + if (!fs.existsSync(appDir) || !seed) { + console.error( + "No resumable Migration Workbench seed found. Run `npx @agent-native/core@latest code /migrate ` first.", + ); + process.exit(1); + } + console.log( + [ + "", + "Migration Workbench resume", + "", + ` App: ${appDir}`, + ` Seed: ${seedPath}`, + "", + "Continue with:", + ` cd ${shellQuote(path.relative(process.cwd(), appDir) || ".")}`, + " pnpm dev", + "", + `Workbench URL: http://localhost:${MIGRATION_DEV_PORT}/`, ].join("\n"), ); } +function renderWorkbenchReady(args: { + appDir: string; + existing: boolean; + outputRoot: string; + source: SourceSpec; +}): string { + const rel = path.relative(process.cwd(), args.appDir) || "."; + const sourceCommand = formatSourceForCommand(args.source); + return [ + "", + args.existing + ? "Migration Workbench is ready (reused existing app)." + : "Migration Workbench is ready.", + "", + ` Source: ${formatSourceForDisplay(args.source)}`, + ` Output: ${args.outputRoot}`, + ` App: ${args.appDir}`, + "", + "Run it:", + ` cd ${shellQuote(rel)}`, + " pnpm install", + " pnpm dev", + "", + "npx-friendly commands:", + ` npx @agent-native/core@latest code /migrate ${sourceCommand} --out ${shellQuote(path.relative(process.cwd(), args.outputRoot) || ".")}`, + ` npx @agent-native/core@latest code /migrate ${sourceCommand} --emit ${shellQuote(defaultDossierDirForDisplay(args.source))}`, + "", + "Workbench URL:", + ` http://localhost:${MIGRATION_DEV_PORT}/`, + " If Vite chooses another port, use the URL printed by `pnpm dev`.", + "", + "Deep-link shape:", + ` http://localhost:${MIGRATION_DEV_PORT}/`, + " Select the run inside the Workbench, or ask the app agent to open a run by ID.", + "", + ...credentialStatusLines(), + "", + "The Workbench seed is written to data/migration-source.json. It stores paths and source metadata only, never secret values.", + ].join("\n"); +} + +function renderCodeAgentMigrationSession( + run: CodeAgentRunRecord, + dossier: EmitDossierResult, +): string { + return [ + "", + "Agent-Native Code /migrate session created.", + "", + ` Run: ${run.id}`, + " Goal: /migrate", + ` Source: ${run.subtitle ?? "not set"}`, + ` Output: ${stringMetadata(run, "outputRoot") ?? "not set"}`, + ` Dossier: ${stringMetadata(run, "dossierRoot") ?? dossier.dossierRoot}`, + ` Engine: ${dossier.usedMigrateHelpers ? "@agent-native/migrate helpers" : "safe local fallback"}`, + "", + "Artifacts:", + ...dossier.files + .filter((file) => + [ + "AGENTS.md", + "MIGRATION_PLAYBOOK.md", + "01-assessment.md", + "ir.json", + "source.json", + ].includes(file), + ) + .map((file) => ` - ${path.join(dossier.dossierRoot, file)}`), + "", + "Continue:", + " agent-native code attach --last", + " agent-native code resume --last", + " agent-native code logs --last", + " agent-native code status --last", + "", + "Desktop:", + " Open Agent-Native Code in the left sidebar. This run appears as a /migrate session.", + "", + "Use another agent:", + ` Point Codex, Claude Code, Cursor, or another coding agent at ${shellQuote(dossier.dossierRoot)} and ask it to follow AGENTS.md plus MIGRATION_PLAYBOOK.md.`, + "", + "Default surface:", + " Migration stays in Agent-Native Code. No hidden app/template was scaffolded.", + " Use --app-surface only when you explicitly want the legacy migration detail app.", + ].join("\n"); +} + +function renderCodeAgentMigrationStatus(runs: CodeAgentRunRecord[]): string { + return [ + "", + "Agent-Native Code /migrate status", + "", + runs.length === 0 + ? " No /migrate sessions found. Start one with `agent-native code /migrate `." + : ` ${runs.length} session${runs.length === 1 ? "" : "s"} found.`, + ...runs.slice(0, 8).map((run) => { + const output = stringMetadata(run, "outputRoot"); + const dossier = stringMetadata(run, "dossierRoot"); + const progress = run.progress?.label + ? ` Progress: ${run.progress.label} (${run.progress.completed}/${run.progress.total})` + : ""; + return [ + ` - ${run.id}`, + ` Status: ${run.status}${run.phase ? ` (${run.phase})` : ""}`, + progress, + ` Source: ${run.subtitle ?? "not set"}`, + output ? ` Output: ${output}` : "", + dossier ? ` Dossier: ${dossier}` : "", + ` Resume: agent-native code resume ${run.id}`, + ] + .filter(Boolean) + .join("\n"); + }), + runs.length > 8 ? ` - ${runs.length - 8} more...` : "", + "", + "Shortcuts:", + " agent-native migrate status --last shows the same Agent-Native Code sessions.", + " Add --app-surface only to inspect the legacy hidden migration app.", + ] + .filter(Boolean) + .join("\n"); +} + +function renderCodeAgentMigrationResume(run: CodeAgentRunRecord): string { + const dossier = stringMetadata(run, "dossierRoot"); + return [ + "", + "Agent-Native Code /migrate resume", + "", + ` Run: ${run.id}`, + ` Status: ${run.status}${run.phase ? ` (${run.phase})` : ""}`, + ` Source: ${run.subtitle ?? "not set"}`, + dossier ? ` Dossier: ${dossier}` : "", + "", + "Continue in the interactive shell:", + " agent-native code", + "", + "Resume this run directly:", + ` agent-native code resume ${run.id}`, + " agent-native code attach --last", + "", + dossier + ? `Or hand off to another agent by pointing it at ${shellQuote(dossier)}.` + : "No dossier path is recorded on this run.", + ] + .filter(Boolean) + .join("\n"); +} + +function renderCodeAgentMigrationUi(run: CodeAgentRunRecord): string { + return [ + "", + "Agent-Native Code /migrate UI", + "", + ` Run: ${run.id}`, + "", + "Open Agent-Native Desktop and choose Agent-Native Code from the left sidebar.", + "The migration detail app is no longer scaffolded by default; it is only an optional legacy surface.", + ].join("\n"); +} + +function renderEmitResult(result: EmitDossierResult): string { + return [ + "", + "Migration agent dossier emitted.", + "", + ` Source: ${formatSourceForDisplay(result.source)}`, + ` Dossier: ${result.dossierRoot}`, + ` IR: ${result.files.includes("ir.json") ? "included" : "not available from this input"}`, + ` Engine: ${result.usedMigrateHelpers ? "@agent-native/migrate helpers" : "safe local fallback"}`, + "", + "Files:", + ...result.files.map((file) => ` - ${file}`), + "", + "Use with Agent-Native Code/Desktop:", + ` Point the agent at ${shellQuote(result.dossierRoot)} and ask it to follow AGENTS.md plus MIGRATION_PLAYBOOK.md.`, + "", + "Safety:", + " The dossier was written outside sourceRoot and contains no secret values.", + ].join("\n"); +} + +function migrateUsage(): string { + return [ + "Usage:", + " agent-native code /migrate [--out ../migrated-app] (preferred)", + " agent-native migrate [--out ../migrated-app] (shortcut)", + " agent-native migrate --emit [dossier-dir]", + ' agent-native migrate --describe "legacy app described in prose" --emit', + " agent-native migrate resume --last", + " agent-native migrate status --last", + " agent-native migrate ui --last", + " agent-native migrate stop --last", + "", + "Examples:", + " npx @agent-native/core@latest code /migrate ./my-next-app --out ../migrated-app", + " npx @agent-native/core@latest migrate ./my-next-app --out ../migrated-app", + ' npx @agent-native/core@latest code /migrate https://example.com --describe "marketing site" --emit ../migration-dossier', + ' npx @agent-native/core@latest code /migrate --describe "A Rails admin app with reporting dashboards" --emit', + "", + "Default:", + " Migration is an Agent-Native Code slash command. The hidden migration app is not scaffolded unless --app-surface is passed.", + "", + "Options:", + " --source, --path Local source path", + " --url Source URL", + " --description, --describe Source description for any-input migrations", + " --emit [dir] Emit an own-agent dossier without recording a session", + " --out Generated output path for the migration session", + " --app-surface, --workbench Scaffold the legacy hidden migration detail app", + " --name Legacy app-surface name (default: migration)", + " --target Migration target (default: agent-native)", + " --plan-only Legacy app-surface plan-only seed", + ].join("\n"); +} + +function setSourceOption(opts: MigrateCliOptions, value: string): void { + opts.source = value; + if (isProbablyUrl(value)) { + opts.sourceUrl = value; + } else { + opts.sourcePath = value; + } +} + +function hasAnySource(opts: MigrateCliOptions): boolean { + return Boolean( + opts.source || opts.sourcePath || opts.sourceUrl || opts.sourceDescription, + ); +} + +function resolveSourceSpec( + opts: MigrateCliOptions, + cwd = process.cwd(), +): SourceSpec | null { + if (opts.sourceUrl) { + return { + kind: "url", + value: opts.sourceUrl, + description: opts.sourceDescription, + }; + } + if (opts.sourcePath) { + return { + kind: "path", + value: opts.sourcePath, + sourceRoot: path.resolve(cwd, opts.sourcePath), + description: opts.sourceDescription, + }; + } + if (opts.source) { + if (isProbablyUrl(opts.source)) { + return { + kind: "url", + value: opts.source, + description: opts.sourceDescription, + }; + } + return { + kind: "path", + value: opts.source, + sourceRoot: path.resolve(cwd, opts.source), + description: opts.sourceDescription, + }; + } + if (opts.sourceDescription) { + return { + kind: "description", + value: opts.sourceDescription, + description: opts.sourceDescription, + }; + } + return null; +} + +function resolveDossierRoot( + opts: MigrateCliOptions, + source: SourceSpec, + cwd: string, +): string { + if (opts.emitDir) return path.resolve(cwd, opts.emitDir); + return defaultDossierRoot(source, cwd); +} + +function defaultDossierRoot(source: SourceSpec, cwd: string): string { + if (source.sourceRoot) { + return path.resolve( + path.dirname(source.sourceRoot), + `${path.basename(source.sourceRoot)}-migration-dossier`, + ); + } + return path.resolve(cwd, DEFAULT_DOSSIER_DIR); +} + +function defaultDossierDirForDisplay(source: SourceSpec): string { + if (source.sourceRoot) { + return `../${path.basename(source.sourceRoot)}-migration-dossier`; + } + return DEFAULT_DOSSIER_DIR; +} + +function assertOutsideSourceRoot( + sourceRoot: string, + targetPath: string, + label: string, +): void { + if (isInsideOrSame(sourceRoot, targetPath)) { + throw new Error( + `Refusing to write ${label} inside sourceRoot (${sourceRoot}). Choose a path outside the source project.`, + ); + } +} + +function isInsideOrSame(root: string, candidate: string): boolean { + const relative = path.relative(path.resolve(root), path.resolve(candidate)); + return ( + relative === "" || + (!relative.startsWith("..") && !path.isAbsolute(relative)) + ); +} + +async function writeAssessmentWithMigrateHelpers(args: { + source: SourceSpec; + dossierRoot: string; + target: string; + write(relativePath: string, content: string): void; +}): Promise { + try { + const migratePackage = "@agent-native/migrate"; + const migrate = (await import(migratePackage)) as any; + const adapter = migrate.nextjsSourceAdapter; + + if ( + args.source.sourceRoot && + fs.existsSync(args.source.sourceRoot) && + adapter?.introspect && + migrate.createMigrationRun && + migrate.discoverMigration && + migrate.artifactPaths + ) { + if (adapter.detect) { + const detected = await adapter.detect(args.source.sourceRoot); + if (!detected) return false; + } + + const artifactRoot = path.join(args.dossierRoot, ".migrate-artifacts"); + const outputRoot = path.join(args.dossierRoot, "generated-output"); + assertOutsideSourceRoot( + args.source.sourceRoot, + artifactRoot, + "artifacts", + ); + assertOutsideSourceRoot(args.source.sourceRoot, outputRoot, "outputRoot"); + + const run = await migrate.createMigrationRun({ + sourceRoot: args.source.sourceRoot, + outputRoot, + artifactRoot, + target: args.target, + id: "dossier", + }); + const result = await migrate.discoverMigration(run, adapter); + const artifacts = migrate.artifactPaths(result.run); + args.write( + "01-assessment.md", + fs.readFileSync(result.assessmentPath, "utf-8"), + ); + if (fs.existsSync(artifacts.irPath)) { + args.write("ir.json", fs.readFileSync(artifacts.irPath, "utf-8")); + } + return true; + } + + if (!args.source.sourceRoot && migrate.createSkeletonProjectIR) { + const ir = migrate.createSkeletonProjectIR({ + sourceRoot: args.source.value, + inputKind: args.source.kind, + inputDescription: args.source.description ?? args.source.value, + }) as ProjectIRLike; + args.write("01-assessment.md", renderLocalAssessment(args.source, ir)); + args.write("ir.json", `${JSON.stringify(ir, null, 2)}\n`); + return true; + } + } catch { + return false; + } + return false; +} + +function buildFallbackAssessment(source: SourceSpec): { + assessment: string; + ir?: ProjectIRLike; +} { + if (source.sourceRoot && fs.existsSync(source.sourceRoot)) { + const ir = createLocalProjectIr(source.sourceRoot); + return { assessment: renderLocalAssessment(source, ir), ir }; + } + return { assessment: renderNonPathAssessment(source) }; +} + +function createLocalProjectIr(sourceRoot: string): ProjectIRLike { + const files = walkSourceFiles(sourceRoot); + const packageJson = readJsonIfExists( + path.join(sourceRoot, "package.json"), + ) as { + dependencies?: Record; + devDependencies?: Record; + } | null; + const deps = { + ...(packageJson?.dependencies ?? {}), + ...(packageJson?.devDependencies ?? {}), + }; + const framework = + deps.next || + hasAnyFile(sourceRoot, [ + "next.config.js", + "next.config.mjs", + "next.config.ts", + ]) + ? "nextjs" + : deps.react + ? "react" + : "unknown"; + const routes = files + .map((file) => routeFromFile(file)) + .filter((route): route is ProjectIRLike["site"]["routes"][number] => + Boolean(route), + ) + .sort((a, b) => a.path.localeCompare(b.path)); + const codeFiles = files.filter((file) => /\.(ts|tsx|js|jsx)$/.test(file)); + const behavior: ProjectIRLike["behavior"] = { + apiEndpoints: [], + dataStores: [], + llmCalls: [], + clientState: [], + auth: [], + jobs: [], + }; + + for (const file of codeFiles) { + const text = readSmallText(path.join(sourceRoot, file)); + if (!text) continue; + if ( + file.startsWith("pages/api/") || + /(^|\/)api\/.*\/route\.[tj]sx?$/.test(file) + ) { + behavior.apiEndpoints.push({ + id: stableId(file), + path: apiPathFromFile(file), + method: inferHttpMethod(text), + filePath: file, + recommendedRecipe: "api-routes-to-actions", + }); + } + if (/\b(useState|useReducer|localStorage|sessionStorage)\b/.test(text)) { + behavior.clientState.push({ + id: stableId(`${file}:state`), + filePath: file, + reason: + "Review for important UI state that should move into application_state.", + }); + } + if ( + /\b(openai|anthropic|generateText|streamText|chat\.completions|messages\.create)\b/i.test( + text, + ) + ) { + behavior.llmCalls.push({ + id: stableId(`${file}:llm`), + filePath: file, + provider: inferLlmProvider(text), + }); + } + if ( + /\b(drizzle|prisma|postgres|supabase|mysql|sqlite|mongoose)\b/i.test(text) + ) { + behavior.dataStores.push({ + id: stableId(`${file}:data`), + name: path.basename(file), + filePath: file, + kind: "database", + }); + } + if (/\b(next-auth|better-auth|auth0|clerk|supabase\.auth)\b/i.test(text)) { + behavior.auth.push({ + id: stableId(`${file}:auth`), + filePath: file, + provider: inferAuthProvider(text), + }); + } + if ( + /\b(cron|schedule|queue|inngest|trigger\.dev|setInterval)\b/i.test(text) + ) { + behavior.jobs.push({ + id: stableId(`${file}:job`), + filePath: file, + kind: "scheduled-or-queued", + }); + } + } + + return { + site: { + framework, + sourceRoot, + routes, + redirects: [], + metadata: { + routeCount: routes.length, + fileCount: files.length, + packageManager: detectPackageManager(sourceRoot), + }, + }, + components: { + components: files + .filter( + (file) => + /(^|\/)(components|ui)\//.test(file) && + /\.(ts|tsx|js|jsx)$/.test(file), + ) + .sort() + .map((file) => ({ + id: stableId(file), + name: componentName(file), + filePath: file, + usedByRoutes: [], + })), + designTokens: {}, + }, + content: { + models: [], + assets: files + .filter((file) => + /\.(png|jpe?g|webp|gif|svg|avif|pdf|mp4|webm)$/i.test(file), + ) + .sort() + .map((file) => ({ + id: stableId(file), + path: file, + type: path.extname(file).slice(1).toLowerCase() || "unknown", + })), + }, + behavior, + }; +} + +function renderLocalAssessment(source: SourceSpec, ir: ProjectIRLike): string { + return `# Migration Assessment + +Source type: \`${source.kind}\` +Source: \`${formatSourceForDisplay(source)}\` +${source.description ? `Description: ${source.description}\n` : ""} +Target: \`${DEFAULT_TARGET}\` + +## Inventory + +- Framework: ${ir.site.framework} +- Routes: ${ir.site.routes.length} +- Components: ${ir.components.components.length} +- API endpoints: ${ir.behavior.apiEndpoints.length} +- Data stores: ${ir.behavior.dataStores.length} +- LLM calls: ${ir.behavior.llmCalls.length} +- Client state hotspots: ${ir.behavior.clientState.length} +- Auth hotspots: ${ir.behavior.auth.length} +- Jobs: ${ir.behavior.jobs.length} +- Assets: ${ir.content.assets.length} + +## Routes + +${ir.site.routes.map((route) => `- \`${route.path}\` (${route.kind}) from \`${route.filePath}\``).join("\n") || "- No routes detected."} + +## Agent-Native Focus Areas + +- Convert API routes and server mutations into actions unless they are uploads, webhooks, OAuth callbacks, or streams. +- Move app-owned state into SQL with Drizzle and expose reads/writes through actions. +- Delegate all AI work to the agent chat instead of calling model APIs directly from UI code. +- Expose important navigation and selection state through application_state. +- Keep public pages server-rendered and logged-in workflows inside the persistent app shell. +`; +} + +function renderNonPathAssessment(source: SourceSpec): string { + return `# Migration Assessment + +Source type: \`${source.kind}\` +Source: ${source.kind === "url" ? source.value : "provided description"} +${source.description && source.kind !== "description" ? `Description: ${source.description}\n` : ""} +${source.kind === "description" ? `Description: ${source.value}\n` : ""} +Target: \`${DEFAULT_TARGET}\` + +## Inventory + +No local source path was provided, so this dossier does not include file-level IR. Use the URL or description as the intake brief, then let an Agent-Native Code or Desktop session inspect the real source before writing output. + +## Agent-Native Focus Areas + +- Identify public pages, logged-in workflows, API endpoints, data ownership, auth, jobs, and direct LLM calls. +- Convert operations into actions and keep application data in SQL. +- Generate output outside the original source tree. +- Verify claims with deterministic checks or explicit human review notes. +`; +} + +function renderDossierAgentsMd( + source: SourceSpec, + templateAgents: string | null, +): string { + return `# Migration Dossier Agent Instructions + +You are migrating an existing application to agent-native. + +## Source + +- Type: ${source.kind} +- Value: ${formatSourceForDisplay(source)} +${source.description ? `- Description: ${source.description}\n` : ""} +## Hard Rules + +- Never write generated files inside the sourceRoot. +- Treat source as read-only unless the user explicitly asks for source edits. +- Put generated output in a separate directory. +- Keep app-owned data in SQL, expose operations as actions, and route AI work through the agent chat. +- Use 01-assessment.md and ir.json when present. If they are incomplete, update the assessment before implementation. +- Record manual gaps and verification evidence. Do not present a migration as complete without checks. + +## Files In This Dossier + +- \`MIGRATION_PLAYBOOK.md\` - ordered workflow for Agent-Native Code/Desktop. +- \`01-assessment.md\` - initial source assessment. +- \`ir.json\` - source inventory when available. +- \`.agents/skills/migration*/SKILL.md\` - extra instruction packs when available from the migration goal surface. + +${templateAgents ? `## Migration Goal Surface Instructions\n\n${templateAgents.trim()}\n` : ""} +`; +} + +function renderMigrationPlaybook(source: SourceSpec): string { + return `# Migration Playbook + +## 1. Intake Any Input + +Start from the available input: a local source path, a URL, or a prose description. If this dossier has \`ir.json\`, use it as a first inventory. If not, inspect the real source before planning implementation. + +Source: ${formatSourceForDisplay(source)} + +## 2. Build The Migration Map + +Classify routes, public pages, logged-in app surfaces, API endpoints, data stores, auth, jobs, client state, assets, and direct LLM calls. Update \`01-assessment.md\` when you learn more. + +## 3. Apply Agent-Native Rules + +- Actions are the single source of truth for operations. +- Data lives in SQL through Drizzle. +- AI work goes through the agent chat. +- Important UI state is mirrored through \`application_state\`. +- Public/SEO pages server-render; logged-in workflows use the persistent app shell. +- Ownable resources use sharing and access helpers. + +## 4. Work In Samples + +Migrate one representative route or workflow first. Verify it, tune the pattern, then sweep similar surfaces. Keep generated output outside the original source tree. + +## 5. Verify + +Run typecheck/build plus route, action, and data checks that fit the migrated app. Capture unresolved gaps in a report before handing off. + +## 6. Use With Agent-Native Code Or Desktop + +Point Codex, Claude Code, another code agent, or Agent-Native Desktop at this dossier directory. Ask it to follow \`AGENTS.md\`, then implement the plan in a separate output project. +`; +} + +function copyMigrationSkills( + templateDir: string | undefined, + dossierRoot: string, +): string[] { + if (!templateDir) return []; + const skillsRoot = path.join(templateDir, ".agents", "skills"); + if (!fs.existsSync(skillsRoot)) return []; + const copied: string[] = []; + for (const entry of fs.readdirSync(skillsRoot, { withFileTypes: true })) { + if (!entry.isDirectory() || !entry.name.startsWith("migration")) continue; + const src = path.join(skillsRoot, entry.name, "SKILL.md"); + if (!fs.existsSync(src)) continue; + const relative = path.join(".agents", "skills", entry.name, "SKILL.md"); + const dest = path.join(dossierRoot, relative); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.copyFileSync(src, dest); + copied.push(relative); + } + return copied; +} + +function findMigrationTemplateDir(): string | undefined { + const starts = [__dirname, process.cwd()]; + for (const start of starts) { + let dir = path.resolve(start); + for (let i = 0; i < 12; i++) { + for (const candidate of [ + path.join(dir, "templates", "migration"), + path.join(dir, "src", "templates", "migration"), + ]) { + if (fs.existsSync(path.join(candidate, "AGENTS.md"))) { + return candidate; + } + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + } + return undefined; +} + +function resolveWorkbenchDir(opts: MigrateCliOptions): string { + if (opts.workbench) return path.resolve(process.cwd(), opts.workbench); + return resolveScaffoldedAppDir( + process.cwd(), + opts.appName ?? DEFAULT_APP_NAME, + ); +} + function resolveScaffoldedAppDir(cwd: string, appName: string): string { const workspaceAppDir = path.join(cwd, "apps", appName); if (fs.existsSync(workspaceAppDir)) return workspaceAppDir; return path.join(cwd, appName); } + +function sourceSeedPayload(source: SourceSpec): Record { + return { + kind: source.kind, + value: source.value, + ...(source.sourceRoot ? { sourceRoot: source.sourceRoot } : {}), + ...(source.description ? { description: source.description } : {}), + }; +} + +function defaultRunName(source: SourceSpec): string { + if (source.sourceRoot) { + return `Migration from ${path.basename(source.sourceRoot)}`; + } + if (source.kind === "url") { + return `Migration from ${source.value}`; + } + return "Migration from described source"; +} + +function stringMetadata( + run: CodeAgentRunRecord, + key: string, +): string | undefined { + const value = run.metadata?.[key]; + return typeof value === "string" ? value : undefined; +} + +function formatSeedSource( + seed: { + sourceKind?: string; + sourceRoot?: string; + sourceUrl?: string; + sourceDescription?: string; + } | null, +): string { + if (!seed) return "not set"; + if (seed.sourceRoot) return seed.sourceRoot; + if (seed.sourceUrl) return seed.sourceUrl; + if (seed.sourceDescription) return seed.sourceDescription; + return seed.sourceKind ?? "not set"; +} + +function readArtifactRuns( + appDir: string, +): Array<{ id: string; phase: string }> { + const runsRoot = path.join(appDir, "data", "migration-runs"); + if (!fs.existsSync(runsRoot)) return []; + return fs + .readdirSync(runsRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => { + const run = readJsonIfExists( + path.join(runsRoot, entry.name, "run.json"), + ) as { id?: string; phase?: string; updatedAt?: string } | null; + return { + id: run?.id ?? entry.name, + phase: run?.phase ?? "unknown", + updatedAt: run?.updatedAt ?? "", + }; + }) + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); +} + +function credentialStatusLines(): string[] { + const configured = MODEL_CREDENTIAL_ENV_NAMES.filter((key) => + Boolean(process.env[key]), + ); + if (configured.length > 0) { + return [ + `Headless credentials: detected ${configured.join(", ")} in this shell.`, + "Secret values were not read or stored.", + ]; + } + return [ + `Headless credentials: none of ${MODEL_CREDENTIAL_ENV_NAMES.join(", ")} are set in this shell.`, + "Set credentials in the Workbench app env or use Desktop/Agent-Native Code credentials; the migrate CLI will not store them.", + ]; +} + +function walkSourceFiles(root: string): string[] { + const out: string[] = []; + const ignored = new Set([ + ".git", + ".next", + ".turbo", + "node_modules", + "dist", + "build", + ".output", + "coverage", + ]); + const visit = (dir: string) => { + if (out.length > 5000) return; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (ignored.has(entry.name)) continue; + const absolute = path.join(dir, entry.name); + if (entry.isDirectory()) { + visit(absolute); + } else if (entry.isFile()) { + out.push(toPosix(path.relative(root, absolute))); + } + } + }; + visit(root); + return out.sort(); +} + +function routeFromFile( + relativePath: string, +): ProjectIRLike["site"]["routes"][number] | null { + const normalized = toPosix(relativePath); + const router = normalized.startsWith("app/") ? "next-app" : "next-pages"; + const isPageRoute = + normalized.startsWith("pages/") && + /\.(ts|tsx|js|jsx|md|mdx)$/.test(normalized) && + !normalized.startsWith("pages/_app.") && + !normalized.startsWith("pages/_document."); + const isAppRoute = + normalized.startsWith("app/") && + /\/(page|route)\.(ts|tsx|js|jsx|md|mdx)$/.test(normalized); + if (!isPageRoute && !isAppRoute) return null; + + const isApi = + normalized.startsWith("pages/api/") || + /(^|\/)api\/.*\/route\.[tj]sx?$/.test(normalized) || + normalized.endsWith("/route.ts") || + normalized.endsWith("/route.tsx") || + normalized.endsWith("/route.js") || + normalized.endsWith("/route.jsx"); + + let routePath = normalized; + if (router === "next-app") { + routePath = routePath + .replace(/^app\//, "") + .replace(/(^|\/)(page|route)\.(ts|tsx|js|jsx|md|mdx)$/, ""); + } else { + routePath = routePath + .replace(/^pages\//, "") + .replace(/\.(ts|tsx|js|jsx|md|mdx)$/, ""); + } + + routePath = routePath + .replace(/\/index$/, "") + .replace(/^index$/, "") + .replace(/^\(.*?\)\//, "") + .replace(/\[(\.\.\.)?([^\]]+)\]/g, (_, dots: string, name: string) => + dots ? `*${name}` : `:${name}`, + ); + const publicPath = routePath ? `/${routePath}` : "/"; + const pathValue = publicPath === "/api" && isApi ? "/api/*" : publicPath; + const kind = isApi + ? "api" + : pathValue === "/" + ? "landing" + : pathValue.includes("docs") + ? "docs" + : pathValue.includes("pricing") || pathValue.includes("blog") + ? "marketing" + : "app"; + return { + id: stableId(normalized), + path: pathValue, + filePath: normalized, + router, + kind, + dynamic: pathValue.includes(":") || pathValue.includes("*"), + public: kind !== "app" && kind !== "api", + notes: isApi + ? [ + "Convert to an action unless it uploads, streams, handles OAuth, or receives webhooks.", + ] + : [], + }; +} + +function apiPathFromFile(file: string): string { + let value = file + .replace(/^pages\/api\//, "/api/") + .replace(/^app\//, "/") + .replace(/\/route\.[tj]sx?$/, "") + .replace(/\.[tj]sx?$/, ""); + value = value + .replace(/\/index$/, "") + .replace(/\[(\.\.\.)?([^\]]+)\]/g, (_, dots: string, name: string) => + dots ? `*${name}` : `:${name}`, + ); + return value || "/api"; +} + +function inferHttpMethod(text: string): string { + const exported = text.match( + /export\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE)/, + ); + if (exported) return exported[1]; + const method = text.match( + /method\s*[:=]\s*["'](GET|POST|PUT|PATCH|DELETE)["']/i, + ); + return method?.[1]?.toUpperCase() ?? "GET"; +} + +function inferLlmProvider(text: string): string { + if (/anthropic/i.test(text)) return "anthropic"; + if (/openai/i.test(text)) return "openai"; + return "unknown"; +} + +function inferAuthProvider(text: string): string { + if (/better-auth/i.test(text)) return "better-auth"; + if (/next-auth/i.test(text)) return "next-auth"; + if (/clerk/i.test(text)) return "clerk"; + if (/auth0/i.test(text)) return "auth0"; + if (/supabase\.auth/i.test(text)) return "supabase"; + return "unknown"; +} + +function componentName(file: string): string { + const base = path.basename(file).replace(/\.(ts|tsx|js|jsx)$/, ""); + return base + .split(/[-_]/g) + .filter(Boolean) + .map((part) => part[0]?.toUpperCase() + part.slice(1)) + .join(""); +} + +function detectPackageManager(sourceRoot: string): string { + if (fs.existsSync(path.join(sourceRoot, "pnpm-lock.yaml"))) return "pnpm"; + if (fs.existsSync(path.join(sourceRoot, "yarn.lock"))) return "yarn"; + if (fs.existsSync(path.join(sourceRoot, "package-lock.json"))) return "npm"; + return "unknown"; +} + +function readSmallText(filePath: string): string | null { + try { + const stat = fs.statSync(filePath); + if (stat.size > 256_000) return null; + return fs.readFileSync(filePath, "utf-8"); + } catch { + return null; + } +} + +function readJsonIfExists(filePath: string): unknown | null { + try { + return JSON.parse(fs.readFileSync(filePath, "utf-8")); + } catch { + return null; + } +} + +function readTextIfExists(filePath: string | undefined): string | null { + if (!filePath) return null; + try { + return fs.readFileSync(filePath, "utf-8"); + } catch { + return null; + } +} + +function hasAnyFile(root: string, files: string[]): boolean { + return files.some((file) => fs.existsSync(path.join(root, file))); +} + +function stableId(value: string): string { + return crypto.createHash("sha1").update(value).digest("hex").slice(0, 12); +} + +function isProbablyUrl(value: string): boolean { + return /^[a-z][a-z0-9+.-]*:\/\//i.test(value); +} + +function formatSourceForDisplay(source: SourceSpec): string { + if (source.sourceRoot) return source.sourceRoot; + return source.value; +} + +function formatSourceForCommand(source: SourceSpec): string { + if (source.kind === "description") { + return `--describe ${shellQuote(source.value)}`; + } + const base = shellQuote(source.value); + if (source.description) { + return `${base} --describe ${shellQuote(source.description)}`; + } + return base; +} + +function shellQuote(value: string): string { + if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value)) return value; + return JSON.stringify(value); +} + +function toPosix(value: string): string { + return value.split(path.sep).join("/"); +} diff --git a/packages/core/src/cli/templates-meta.ts b/packages/core/src/cli/templates-meta.ts index e48d22945..f5856afca 100644 --- a/packages/core/src/cli/templates-meta.ts +++ b/packages/core/src/cli/templates-meta.ts @@ -149,10 +149,21 @@ export const TEMPLATES: TemplateMeta[] = [ defaultMode: "prod", core: true, }, + { + name: "code", + label: "Agent-Native Code", + hint: "Hidden customizable Agent-Native Code UI for local coding sessions and slash commands", + icon: "Code", + color: "#71717A", + colorRgb: "113 113 122", + devPort: 8103, + defaultMode: "dev", + hidden: true, + }, { name: "migration", - label: "Migration", - hint: "Migration Workbench — move existing apps to agent-native with verification", + label: "Migration Goal Surface", + hint: "Internal Agent-Native Code detail surface for /migrate assessment, approval, and verification", icon: "Route", color: "#0F766E", colorRgb: "15 118 110", @@ -210,6 +221,19 @@ export const TEMPLATES: TemplateMeta[] = [ defaultMode: "prod", core: true, }, + { + name: "brain", + label: "Brain", + hint: "Cited company memory from Slack, meetings, transcripts, and decisions", + icon: "Brain", + color: "#8B5CF6", + colorRgb: "139 92 246", + devPort: 8102, + prodUrl: "https://brain.agent-native.com", + defaultMode: "prod", + defaultAgent: true, + core: true, + }, { name: "design", label: "Design", diff --git a/packages/core/src/cli/workspacify.ts b/packages/core/src/cli/workspacify.ts index 9bfd0eca9..8da8003a0 100644 --- a/packages/core/src/cli/workspacify.ts +++ b/packages/core/src/cli/workspacify.ts @@ -38,11 +38,14 @@ export interface WorkspacifyOptions { workspaceCoreName: string; /** Version range to use for the published @agent-native/core package */ coreDependencyVersion?: string; + /** Version range to use for the package-backed Dispatch app */ + dispatchDependencyVersion?: string; } export function workspacifyApp(opts: WorkspacifyOptions): void { const { appDir, workspaceCoreName } = opts; const coreDependencyVersion = opts.coreDependencyVersion ?? "latest"; + const dispatchDependencyVersion = opts.dispatchDependencyVersion ?? "latest"; // 1) Rewrite package.json to add the workspace core dep and resolve // @agent-native/core / @agent-native/dispatch workspace:* refs to @@ -66,7 +69,7 @@ export function workspacifyApp(opts: WorkspacifyOptions): void { deps[key] = coreDependencyVersion; } if (key === "@agent-native/dispatch") { - deps[key] = "latest"; + deps[key] = dispatchDependencyVersion; } } } diff --git a/packages/core/src/client/AgentNative.tsx b/packages/core/src/client/AgentNative.tsx new file mode 100644 index 000000000..c9a68daa4 --- /dev/null +++ b/packages/core/src/client/AgentNative.tsx @@ -0,0 +1,160 @@ +import React, { useCallback, useMemo } from "react"; +import { + AgentNativeFrame, + type AgentNativeFrameProps, +} from "./AgentNativeFrame.js"; +import { + readAgentNativeScreenContext, + type AgentNativeClientActions, + type AgentNativeHostCommandHandler, + type AgentNativeHostCommandHandlers, + type AgentNativeHostCommandRequest, + type AgentNativeHostContext, + type AgentNativeHostContextGetter, + type AgentNativeScreenSnapshotOptions, +} from "./host-bridge.js"; + +export interface AgentNativeCommandCallbackInfo { + command: string; + requestId?: string; + origin: string; +} + +export type AgentNativeCommandCallback = ( + payload: unknown, + info: AgentNativeCommandCallbackInfo, +) => unknown | Promise; + +export interface AgentNativeProps extends Omit< + AgentNativeFrameProps, + "actions" | "commands" | "getContext" +> { + /** + * Live browser-session tools. These can change as page state changes and are + * only callable while this tab is connected. + */ + actions?: AgentNativeClientActions; + /** Semantic app/page context layered over the built-in screen snapshot. */ + getContext?: AgentNativeHostContextGetter; + /** + * Built-in screen context. Defaults to visible text + route + selection. + * Pass false to disable, or { includeDomHtml: true } for a DOM fallback. + */ + screen?: boolean | AgentNativeScreenSnapshotOptions; + /** Extra/advanced host commands. */ + commands?: AgentNativeHostCommandHandlers; + onRefresh?: AgentNativeCommandCallback; + onNavigate?: AgentNativeCommandCallback; + onRemount?: AgentNativeCommandCallback; + onOpenResource?: AgentNativeCommandCallback; + onRequestApproval?: AgentNativeCommandCallback; +} + +function mergeObject( + base: T | undefined, + override: T | undefined, +): T | undefined { + if (!base && !override) return undefined; + return { ...(base ?? {}), ...(override ?? {}) } as T; +} + +function mergeHostContext( + base: AgentNativeHostContext, + override: AgentNativeHostContext | undefined, +): AgentNativeHostContext { + if (!override) return base; + return { + ...base, + ...override, + route: mergeObject(base.route, override.route), + selection: mergeObject(base.selection, override.selection), + screen: mergeObject(base.screen, override.screen), + }; +} + +function toCommandHandler( + callback: AgentNativeCommandCallback | undefined, +): AgentNativeHostCommandHandler | undefined { + if (!callback) return undefined; + return (request: AgentNativeHostCommandRequest) => + callback(request.payload, { + command: request.command, + requestId: request.requestId, + origin: request.origin, + }); +} + +export function useAgentNativeScreenContext( + options?: AgentNativeScreenSnapshotOptions, +): AgentNativeHostContextGetter { + return useCallback(() => readAgentNativeScreenContext(options), [options]); +} + +export function AgentNative({ + actions, + getContext, + screen = true, + commands, + onRefresh, + onNavigate, + onRemount, + onOpenResource, + onRequestApproval, + ...frameProps +}: AgentNativeProps) { + const getMergedContext = useCallback(async () => { + const screenContext = + screen === false + ? {} + : readAgentNativeScreenContext(screen === true ? {} : screen); + const customContext = getContext ? await getContext() : undefined; + return mergeHostContext(screenContext, customContext); + }, [getContext, screen]); + + const mergedCommands = useMemo(() => { + const refreshHandler = toCommandHandler(onRefresh); + const navigateHandler = toCommandHandler(onNavigate); + const remountHandler = toCommandHandler(onRemount); + const openResourceHandler = toCommandHandler(onOpenResource); + const requestApprovalHandler = toCommandHandler(onRequestApproval); + + return { + ...commands, + ...(refreshHandler + ? { refreshData: refreshHandler, "refresh-data": refreshHandler } + : {}), + ...(navigateHandler ? { navigate: navigateHandler } : {}), + ...(remountHandler + ? { remountView: remountHandler, "remount-view": remountHandler } + : {}), + ...(openResourceHandler + ? { + openResource: openResourceHandler, + "open-resource": openResourceHandler, + } + : {}), + ...(requestApprovalHandler + ? { + requestApproval: requestApprovalHandler, + "request-approval": requestApprovalHandler, + } + : {}), + }; + }, [ + commands, + onNavigate, + onOpenResource, + onRefresh, + onRemount, + onRequestApproval, + ]); + + return ( + + ); +} diff --git a/packages/core/src/client/AgentNativeEmbedded.tsx b/packages/core/src/client/AgentNativeEmbedded.tsx new file mode 100644 index 000000000..d17e2e33f --- /dev/null +++ b/packages/core/src/client/AgentNativeEmbedded.tsx @@ -0,0 +1,287 @@ +import React, { useCallback, useEffect, useMemo } from "react"; +import { + AgentChatSurface, + AgentSidebar, + type AgentChatSurfaceProps, + type AgentSidebarProps, +} from "./AgentPanel.js"; +import { + createAgentNativeBrowserSessionBridge, + type AgentNativeBrowserSessionBridge, +} from "./browser-session-bridge.js"; +import { + readAgentNativeScreenContext, + type AgentNativeClientActions, + type AgentNativeHostCommandHandler, + type AgentNativeHostCommandHandlers, + type AgentNativeHostCommandRequest, + type AgentNativeHostContext, + type AgentNativeHostContextGetter, + type AgentNativeHostSession, + type AgentNativeScreenSnapshotOptions, +} from "./host-bridge.js"; + +export interface AgentNativeEmbeddedCommandCallbackInfo { + command: string; + requestId?: string; + origin: string; +} + +export type AgentNativeEmbeddedCommandCallback = ( + payload: unknown, + info: AgentNativeEmbeddedCommandCallbackInfo, +) => unknown | Promise; + +export interface AgentNativeEmbeddedBrowserSessionOptions { + endpoint?: string; + sessionId?: string; + label?: string; + heartbeatMs?: number; + pollMs?: number; + ttlMs?: number; + fetch?: typeof fetch; + onReady?: (bridge: AgentNativeBrowserSessionBridge) => void; +} + +export interface UseAgentNativeEmbeddedBrowserSessionOptions { + enabled?: boolean; + actions?: AgentNativeClientActions; + getContext?: AgentNativeHostContextGetter; + screen?: boolean | AgentNativeScreenSnapshotOptions; + commands?: AgentNativeHostCommandHandlers; + session?: string | Partial; + browserSession?: AgentNativeEmbeddedBrowserSessionOptions; + onRefresh?: AgentNativeEmbeddedCommandCallback; + onNavigate?: AgentNativeEmbeddedCommandCallback; + onRemount?: AgentNativeEmbeddedCommandCallback; + onOpenResource?: AgentNativeEmbeddedCommandCallback; + onRequestApproval?: AgentNativeEmbeddedCommandCallback; +} + +export interface AgentNativeEmbeddedProps + extends + Omit, + UseAgentNativeEmbeddedBrowserSessionOptions { + children?: React.ReactNode; + /** + * Render only the agent chat surface when no host children are supplied. + * Defaults to "sidebar" when `children` exist and "panel" otherwise. + */ + surface?: "sidebar" | "panel"; + /** Props forwarded to AgentChatSurface in panel mode. */ + panel?: AgentChatSurfaceProps; +} + +function mergeObject( + base: T | undefined, + override: T | undefined, +): T | undefined { + if (!base && !override) return undefined; + return { ...(base ?? {}), ...(override ?? {}) } as T; +} + +function mergeHostContext( + base: AgentNativeHostContext, + override: AgentNativeHostContext | undefined, +): AgentNativeHostContext { + if (!override) return base; + return { + ...base, + ...override, + route: mergeObject(base.route, override.route), + selection: mergeObject(base.selection, override.selection), + screen: mergeObject(base.screen, override.screen), + }; +} + +function toCommandHandler( + callback: AgentNativeEmbeddedCommandCallback | undefined, +): AgentNativeHostCommandHandler | undefined { + if (!callback) return undefined; + return (request: AgentNativeHostCommandRequest) => + callback(request.payload, { + command: request.command, + requestId: request.requestId, + origin: request.origin, + }); +} + +function sessionBrowserTabId( + session: UseAgentNativeEmbeddedBrowserSessionOptions["session"], +): string | undefined { + if (typeof session === "string") return session; + return typeof session?.id === "string" ? session.id : undefined; +} + +function useMergedEmbeddedCommands({ + commands, + onNavigate, + onOpenResource, + onRefresh, + onRemount, + onRequestApproval, +}: Pick< + UseAgentNativeEmbeddedBrowserSessionOptions, + | "commands" + | "onNavigate" + | "onOpenResource" + | "onRefresh" + | "onRemount" + | "onRequestApproval" +>) { + return useMemo(() => { + const refreshHandler = toCommandHandler(onRefresh); + const navigateHandler = toCommandHandler(onNavigate); + const remountHandler = toCommandHandler(onRemount); + const openResourceHandler = toCommandHandler(onOpenResource); + const requestApprovalHandler = toCommandHandler(onRequestApproval); + + return { + ...commands, + ...(refreshHandler + ? { refreshData: refreshHandler, "refresh-data": refreshHandler } + : {}), + ...(navigateHandler ? { navigate: navigateHandler } : {}), + ...(remountHandler + ? { remountView: remountHandler, "remount-view": remountHandler } + : {}), + ...(openResourceHandler + ? { + openResource: openResourceHandler, + "open-resource": openResourceHandler, + } + : {}), + ...(requestApprovalHandler + ? { + requestApproval: requestApprovalHandler, + "request-approval": requestApprovalHandler, + } + : {}), + }; + }, [ + commands, + onNavigate, + onOpenResource, + onRefresh, + onRemount, + onRequestApproval, + ]); +} + +export function useAgentNativeEmbeddedBrowserSession({ + enabled = true, + actions, + getContext, + screen = true, + commands, + session, + browserSession, + onNavigate, + onOpenResource, + onRefresh, + onRemount, + onRequestApproval, +}: UseAgentNativeEmbeddedBrowserSessionOptions) { + const mergedCommands = useMergedEmbeddedCommands({ + commands, + onNavigate, + onOpenResource, + onRefresh, + onRemount, + onRequestApproval, + }); + + const getMergedContext = useCallback(async () => { + const screenContext = + screen === false + ? {} + : readAgentNativeScreenContext(screen === true ? {} : screen); + const customContext = getContext ? await getContext() : undefined; + return mergeHostContext(screenContext, customContext); + }, [getContext, screen]); + + useEffect(() => { + if (!enabled) return; + + const bridge = createAgentNativeBrowserSessionBridge({ + endpoint: browserSession?.endpoint, + sessionId: browserSession?.sessionId, + label: browserSession?.label, + heartbeatMs: browserSession?.heartbeatMs, + pollMs: browserSession?.pollMs, + ttlMs: browserSession?.ttlMs, + fetch: browserSession?.fetch, + session, + getContext: getMergedContext, + actions, + commands: mergedCommands, + }).start(); + + browserSession?.onReady?.(bridge); + return () => bridge.stop(); + }, [ + actions, + browserSession?.endpoint, + browserSession?.fetch, + browserSession?.heartbeatMs, + browserSession?.label, + browserSession?.onReady, + browserSession?.pollMs, + browserSession?.sessionId, + browserSession?.ttlMs, + enabled, + getMergedContext, + mergedCommands, + session, + ]); +} + +export function AgentNativeEmbedded({ + children, + surface, + actions, + getContext, + enabled, + screen, + commands, + session, + browserSession, + onNavigate, + onOpenResource, + onRefresh, + onRemount, + onRequestApproval, + panel, + ...sidebarProps +}: AgentNativeEmbeddedProps) { + useAgentNativeEmbeddedBrowserSession({ + enabled, + actions, + getContext, + screen, + commands, + session, + browserSession, + onNavigate, + onOpenResource, + onRefresh, + onRemount, + onRequestApproval, + }); + + const mode = surface ?? (children ? "sidebar" : "panel"); + const browserTabId = + sidebarProps.browserTabId ?? + panel?.browserTabId ?? + sessionBrowserTabId(session); + + if (mode === "panel" || !children) { + return ; + } + + return ( + + {children} + + ); +} diff --git a/packages/core/src/client/AgentNativeFrame.tsx b/packages/core/src/client/AgentNativeFrame.tsx new file mode 100644 index 000000000..17e20e3f5 --- /dev/null +++ b/packages/core/src/client/AgentNativeFrame.tsx @@ -0,0 +1,150 @@ +import React, { + forwardRef, + useEffect, + useMemo, + useRef, + type CSSProperties, + type IframeHTMLAttributes, +} from "react"; +import { + createAgentNativeHostBridge, + type AgentNativeClientActions, + type AgentNativeHostAuth, + type AgentNativeHostBridge, + type AgentNativeHostBridgeEvent, + type AgentNativeHostCommandHandlers, + type AgentNativeHostContextGetter, + type AgentNativeHostSession, +} from "./host-bridge.js"; + +export interface AgentNativeFrameProps extends Omit< + IframeHTMLAttributes, + "src" +> { + /** URL of the Agent-Native sidecar/frame app. */ + agentUrl: string; + /** + * Exact trusted sidecar origin. Defaults to `new URL(agentUrl).origin`. + * Pass "*" only for local prototypes. + */ + agentOrigin?: string; + /** Stable browser-session identity for multi-tab sidecars. */ + session?: string | Partial; + /** Return page, selection, resource, user/org, and host-specific context. */ + getContext?: AgentNativeHostContextGetter; + /** Commands the iframe sidecar can ask the host app to run. */ + commands?: AgentNativeHostCommandHandlers; + /** Live browser-session actions the iframe sidecar can discover and call. */ + actions?: AgentNativeClientActions; + /** Optional auth payload sent to the trusted iframe sidecar. */ + auth?: AgentNativeHostAuth; + onBridgeEvent?: (event: AgentNativeHostBridgeEvent) => void; + onBridgeReady?: (bridge: AgentNativeHostBridge) => void; +} + +function originFromUrl(value: string): string | undefined { + try { + const base = + typeof window !== "undefined" + ? window.location.href + : "http://agent-native.local"; + return new URL(value, base).origin; + } catch { + return undefined; + } +} + +function setForwardedRef(ref: React.ForwardedRef, value: T | null) { + if (typeof ref === "function") ref(value); + else if (ref) ref.current = value; +} + +const defaultStyle: CSSProperties = { + border: 0, + width: "100%", + height: "100%", +}; + +export const AgentNativeFrame = forwardRef< + HTMLIFrameElement, + AgentNativeFrameProps +>(function AgentNativeFrame( + { + agentUrl, + agentOrigin, + session, + getContext, + commands, + actions, + auth, + onBridgeEvent, + onBridgeReady, + title = "Agent Native assistant", + sandbox = "allow-scripts allow-same-origin allow-forms allow-popups allow-downloads", + allow = "clipboard-read; clipboard-write; microphone; fullscreen", + referrerPolicy = "strict-origin-when-cross-origin", + style, + onLoad, + ...iframeProps + }, + forwardedRef, +) { + const iframeRef = useRef(null); + const bridgeRef = useRef(null); + const resolvedOrigin = useMemo( + () => agentOrigin ?? originFromUrl(agentUrl), + [agentOrigin, agentUrl], + ); + + useEffect(() => { + const bridge = createAgentNativeHostBridge({ + agentOrigin: resolvedOrigin, + session, + getContext, + commands, + actions, + auth, + onEvent: onBridgeEvent, + targetWindow: iframeRef.current?.contentWindow ?? null, + }).start(); + bridgeRef.current = bridge; + onBridgeReady?.(bridge); + return () => { + bridge.stop(); + if (bridgeRef.current === bridge) bridgeRef.current = null; + }; + }, [ + auth, + actions, + commands, + getContext, + onBridgeEvent, + onBridgeReady, + resolvedOrigin, + session, + ]); + + return ( +