diff --git a/.gitignore b/.gitignore
index e84593b5..b2784d9b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -98,3 +98,6 @@ scripts/_icc_row.css
apps/seeker-mobile/.expo/
apps/seeker-mobile/dapp-store/build/
apps/seeker-mobile/dapp-store/publisher-keypair.json
+
+# stray project copy (2.8GB) - lives in Projects/cabal-visualizer
+cabal-visualizer/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 65a9261f..503f338f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,125 @@ All notable changes to DAEMON are documented here. The format is based on
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
follows semantic-ish desktop release versioning.
+## [4.6.2] - 2026-07-02
+
+### Fixed
+- **Swap high-impact gate no longer double-scales price impact.** Jupiter's `priceImpactPct` is a
+ 0..1 fraction; it is now normalized to a percentage exactly once, so an ordinary sub-5% swap is
+ never spuriously hard-blocked (a regression that could block all swaps on one quote path).
+- **ARIA session isolation now covers approval and patch cards.** Streamed `tool-call`,
+ `approval-request`, and `patch-proposal` events are tagged with the session id by the transport,
+ so a card can never render in — or leak an approval into — the wrong conversation, and patch
+ proposals no longer hang the turn.
+- **ARIA terminal backend no longer deadlocks on approvals.** The stdin frame loop dispatches
+ turns without blocking, so an approval/patch-decision frame is delivered while a turn awaits it;
+ piped `aria` invocations now exit cleanly after answering.
+- **`claude:install-cli` works on Windows** — the npm.cmd spawn no longer throws EINVAL.
+
+### Security & money-safety
+- **Autopilot cluster re-validation per tick.** An armed mandate is auto-held if the live cluster
+ is not mainnet or no longer matches the mandate's cluster, so a devnet switch can never
+ green-light a real mainnet spend.
+- **Autopilot crash-safe ledger.** Each tick claims its ledger row (`executing`) and advances the
+ next-tick time before it swaps; a boot reconciler holds any interrupted tick for review instead
+ of replaying it, closing a double-buy window.
+- **Autopilot exit rules validated** (finite, positive, in-range) so a malformed stop-loss can
+ never be silently dead; unattended slippage is capped tighter than a manual swap and a
+ high-impact clip is skipped rather than executed.
+- **Autopilot exits liquidate only the mandate's own position**, tracked separately from the
+ wallet's pre-existing balance of the same mint. Fee lines that were never actually charged are
+ no longer recorded on swap actions.
+- **`hl_update_leverage` reclassified sensitive** (was write) so it always requires a typed
+ confirmation and can't auto-run under a plan approval.
+- **ARIA approval-resolution channels reject untrusted senders**; the approval card now shows all
+ material fields (amount, side, size), not just the first input value.
+- **Bridge/ARIA file reads deny secret-bearing paths** (`.env`, keypairs, key material) and
+ enforce real-path containment against symlink escapes.
+- **Windows `claude:auth-login` path quoting** hardened (strip stray quotes rather than leaving an
+ incomplete escape) and the `claude:install-cli` npm.cmd spawn no longer throws EINVAL.
+- **Swarm lanes run with a minimal allowlisted environment** (no blanket env passthrough) and have
+ push disabled at the git layer, not just by a tool-name prefix.
+- **ARIA no longer hijacks ordinary messages**: the read-only fast path only fires on short,
+ unambiguous commands, never on a keyword buried in a question.
+
+### Docs
+- Backfilled CHANGELOG entries for 4.3.0-4.6.1; refreshed the README feature list, `Whatsnew.md`,
+ and the `CLAUDE.md`/`AGENTS.md` agent-context files (runtime versions, schema, model-id rule) to
+ the current release. Documented the execution fee meter.
+
+## [4.6.1] - 2026-06-25
+
+### Fixed
+- Stabilized embedded UI surfaces: Recovery log, RicoMaps, Zauth, and Remotion panels no longer
+ break when re-mounted, and hidden terminal instances stay inert.
+
+## [4.6.0] - 2026-06-22
+
+### Added
+- **Agent economy control tower**: a dedicated panel plus `AgentEconomyService`, IPC domain, and
+ ARIA operator tools for tracking agent-routed execution, fees, and venue activity in one place.
+ Schema advanced to V55.
+
+## [4.5.0] - 2026-06-21
+
+### Added
+- **Hyperliquid via HypurrClaw**: ARIA reads Hyperliquid markets and trades perps/spot by driving
+ the agent-first `hyperliquid` CLI through a single execFile gate (no raw shell, one known binary,
+ fixed argv). Read tools auto-run; `hl_place_order`, `hl_cancel_order`, `hl_modify_order`, and
+ `hl_transfer` are sensitive and typed-confirm gated. Network defaults to testnet and DAEMON never
+ holds a Hyperliquid key: the CLI's encrypted wallet signs. Sensitive summaries carry an
+ `[HL-MAINNET]`/`[HL-TESTNET]` marker.
+- **Autopilot trading mandates**: unattended scheduled mandates with exit rules. Armed mandates
+ resume after restart, and the action ledger is idempotent so a tick interrupted mid-swap cannot
+ double-fire on resume. Schema V54.
+- **Execution fee meter**: agent-routed mainnet execution carries a transparent fee line surfaced
+ on the approval card before anything runs, never charged silently.
+- **Agent bridge (MCP)**: loopback MCP server with bearer-token auth exposing DAEMON's gated
+ wallet, launch, and memory tools to external agents. Every call re-filters against enabled packs;
+ sensitive actions route through the same approval gate as ARIA.
+
+### Fixed
+- Explorer file tree auto-refreshes on external filesystem changes.
+- Hidden terminal instances are inert, so scroll and click-to-focus work.
+
+## [4.4.0] - 2026-06-11
+
+### Added
+- **Venum integration**: `VenumService` with secure-key auth, live/batch prices, and ranked swap
+ quotes; read-tier ARIA tools (`venum_get_price`, `venum_get_prices`, `venum_get_quote`);
+ Integration Command Center card with key check and live price-feed test.
+- **Compounding console memory**: `remember_fact` / `recall_memories` / `forget_memory` /
+ `update_memory` operator tools, post-turn fact extraction with inline Keep/Dismiss cards, cited
+ recall per assistant turn, and a "What DAEMON Knows" view in the Memory panel. Memories
+ strengthen with use and prompt pruning when stale.
+- **Editor settings**: font family/size, tab size, word wrap, minimap, and theme configurable in
+ Settings > Display, applied to Monaco live. Adds a `daemon-light` theme.
+- **BrainBlast pre-flight (opt-in)**: swarm lanes can run a research gate that compiles external
+ components into a structured report before code is written; critical risks gate the lane and the
+ gate fails open. Schema V51.
+
+### Fixed
+- Seeker relay records the OS-assigned port when binding to port 0, fixing the flaky relay test.
+- Dropped the deprecated `--space-2xs` token alias.
+
+## [4.3.0] - 2026-06-07
+
+### Added
+- **VS Code-style shell**: explorer, editor, bottom-panel terminal, and the DAEMON Console on the
+ right rail (chat-first with `>` and `/` command accelerators; can swap to the bottom panel).
+- **Capability packs**: Solana, Wallet, Launch, Agents, Memory, Sites, Markets, and Create are now
+ toggleable packs, each owning its tools, integrations, sidebar icon, console commands, and
+ background work. Disabling a pack quiesces its IPC handlers. Capability Manager UI plus a live
+ status-bar pack indicator. Schema V50.
+- **Create pack**: image editor and email tools for launch and marketing assets.
+
+### Changed
+- Per-pack integrations replace the single giant Integrations tab; the Activity Bar is dynamic.
+
+### Fixed
+- Pack host panels no longer collapse to zero height in narrow windows.
+- macOS packaging unblocked (switched to a `.icns` icon).
+
## [4.2.0] - 2026-06-05
### Added
diff --git a/README.md b/README.md
index 48eef6f8..ad18c3c4 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@
-
+
@@ -99,9 +99,23 @@ Requires **Node.js 22+** and **pnpm 9+**.
**Agent Launcher** — Spawn Claude Code agents with custom system prompts, model selection, and per-project MCP configurations. Agents run as real CLI sessions in dedicated terminal tabs.
-**Operator Console** — The right-panel AI operator drives the whole IDE from natural language. Per-project chat sessions (new / switch / rename / archive / delete) with memory that survives restarts. It can run DAEMON itself — create an agent wallet for the codebase, spin up a Clawpump agent, preflight and launch a token, configure the Flywheel, commit changes — through a tool catalog with typed confirmation for sensitive on-chain actions (and a `[MAINNET]` guard). It never pushes to git autonomously.
+**VS Code-style shell + capability packs** — Explorer, editor, a bottom-panel terminal, and the DAEMON Console on the right rail. Domain features ship as toggleable capability packs (Solana, Wallet, Launch, Agents, Memory, Sites, Markets, Create); disabling a pack quiesces its tools, integrations, sidebar icon, console commands, and background work — IPC handlers included. The Capability Manager shows how many packs are active and how much backend work is idle.
-**Agent Swarms** — From the operator, launch several tasks to run in parallel, each in its own isolated git worktree and branch, driven by a separate Claude agent. The Swarms tab shows live run/lane status; each lane writes a `RESULTS.md`. Concurrency is capped and worktrees are cleaned up automatically — merging stays a manual, reviewed step.
+**DAEMON Console (ARIA operator)** — The right-rail AI operator drives the whole IDE from natural language, chat-first with `>` and `/` command accelerators. Per-project chat sessions (new / switch / rename / archive / delete) with memory that survives restarts and compounds: the console proposes durable facts after real work (Keep/Dismiss), cites which taught facts a turn drew on, and strengthens proven facts over time. It runs DAEMON itself — agent wallets, token preflight/launch, Flywheel config, git — through a registry of typed tools with typed confirmation for sensitive on-chain actions (and a `[MAINNET]` guard). It never pushes to git autonomously.
+
+**ARIA Autopilot** — Standing, structured trading mandates parsed from natural language and executed unattended on mainnet on a fixed cadence, with exit rules (take-profit / stop-loss / liquidity floor), a hard exposure cap, arm/disarm/kill-switch, and a "The Desk" panel showing live unrealized P&L and the action tape. Every tick claims its ledger row before it swaps, so a crash mid-tick is held for review, never replayed into a double-buy; a cluster switch auto-holds armed mandates; unattended slippage and price impact are capped tighter than a human-confirmed trade.
+
+**Hyperliquid (via HypurrClaw)** — ARIA reads Hyperliquid markets and trades perps/spot by driving the agent-first `hyperliquid` CLI through a single execFile gate (no raw shell). Network defaults to testnet, DAEMON never holds a Hyperliquid key (the CLI's encrypted wallet signs), and every signing action stops for typed confirmation with an `[HL-MAINNET]`/`[HL-TESTNET]` marker.
+
+**Execution fee meter** — Agent-routed SOL transfers on mainnet carry a transparent fee line surfaced on the approval card before anything runs, never charged silently. The rate is config-driven (default 0.25%, hard ceiling 0.75%), devnet is always free, and it stays disabled until a treasury is configured. Jupiter swaps sign a prebuilt transaction and are not fee-metered.
+
+**Agent bridge (MCP)** — A loopback MCP server with bearer-token auth exposes DAEMON's gated wallet, launch, and memory tools to external agents (Cursor, Claude Code). Every call re-filters against enabled packs, file reads deny secret-bearing paths, and sensitive actions route through the same approval gate as ARIA.
+
+**Agent economy control tower** — Track agent-routed execution, fees, and paid-resource (x402/IDLE) activity in one panel, with policy-and-budget-gated paid calls and redacted receipts.
+
+**Agent Swarms** — Launch several tasks in parallel, each in its own isolated git worktree and branch, driven by a separate headless Claude agent with a minimal allowlisted env and push disabled at the git layer. The Swarms tab shows live run/lane status; each lane writes a `RESULTS.md`. Concurrency is capped, worktrees are cleaned up automatically, and merging stays a manual, reviewed step.
+
+**Venum** — First-class Solana execution provider in the Markets pack: live/batch prices and ranked swap quotes, with read-tier ARIA tools and an Integration Command Center card.
**MCP Management** — Toggle project-level and global MCP servers from the sidebar. Changes write directly to `.claude/settings.json` and `.mcp.json` with a restart indicator when configs change.
@@ -121,9 +135,9 @@ Requires **Node.js 22+** and **pnpm 9+**.
**PumpFun Integration** — Token launches and bonding curve interactions directly from the IDE.
-**Multi-Project Tabs** — Tabbed project switching with per-project terminal sessions, MCP configs, and file trees. Context switching without losing state.
+**Editor settings** — Font family/size, tab size, word wrap, minimap, and theme configurable in Settings › Display and applied to Monaco live, including a `daemon-light` theme.
-**Plugin System** — Extensible architecture for loading additional panels and integrations.
+**Multi-project switching** — Per-project terminal sessions, MCP configs, wallets, and file trees. Context switching without losing state.
## DAEMON AI and Access
diff --git a/Whatsnew.md b/Whatsnew.md
index c7827026..fc1edfe5 100644
--- a/Whatsnew.md
+++ b/Whatsnew.md
@@ -1,27 +1,27 @@
-# DAEMON v4.0.0
+# DAEMON v4.6
-DAEMON v4 ships the hosted DAEMON AI cloud path, broader release smoke coverage, and the low-power desktop work needed for slower machines.
+DAEMON v4.6 turns the operator into a full trading and execution surface: unattended mandates, a second venue, a transparent fee line, and a bridge that lets external agents drive DAEMON's gated tools, all on top of the VS Code-style shell and capability packs introduced in v4.3.
## Highlights
-- Added the DAEMON AI cloud API for hosted Pro, Operator, Ultra, and holder-backed access.
-- Added hosted model routing across OpenAI, Anthropic, and compatible provider lanes.
-- Wired the desktop DAEMON AI and Pro subscription clients to the production cloud service.
-- Preserved BYOK and local development paths for users who do not want hosted model traffic.
-- Added local and live cloud smoke gates for Pro, Operator, and Ultra entitlement behavior.
-- Expanded release coverage for Electron startup, MCP stress, Pro entitlement flow, workflow journeys, responsive layout, visual regression, packaging, and packaged app smoke.
-- Added low-power performance mode with deferred startup work, reduced background refresh pressure, and lower motion.
+- **DAEMON Console + capability packs** — a VS Code-style shell (explorer, editor, bottom terminal, right-rail console) with toggleable packs. Turn a pack off and its tools, integrations, and background work go quiet.
+- **ARIA Autopilot** — standing trading mandates parsed from natural language and run unattended on mainnet with exit rules, a hard exposure cap, and arm/disarm/kill switches. "The Desk" shows live unrealized P&L and the action tape.
+- **Hyperliquid via HypurrClaw** — ARIA reads Hyperliquid markets and trades perps/spot through the agent-first CLI. Testnet by default; DAEMON never holds a Hyperliquid key.
+- **Execution fee meter** — agent-routed SOL transfers on mainnet carry a fee line shown on the approval card before anything runs. Default 0.25%, devnet always free, disabled until a treasury is configured.
+- **Agent bridge (MCP)** — a loopback, token-authenticated server exposing DAEMON's gated wallet, launch, and memory tools to external agents like Cursor and Claude Code, behind the same approval gate as ARIA.
+- **Compounding console memory** — the console proposes durable facts after real work, cites which facts it drew on, and strengthens proven facts over time.
+- **Agent economy control tower** — track agent-routed execution, fees, and paid-resource activity in one panel.
+- **Venum** — a first-class Solana execution provider in the Markets pack (live/batch prices, ranked swap quotes).
## Hardening
-- Required hosted model lane entitlements at the cloud API boundary before credit checks or provider calls.
-- Added SQLite-backed usage metering for hosted DAEMON AI requests.
-- Added production readiness checks for provider configuration, JWT secrets, admin grant support, Solana RPC, and cloud storage.
-- Stabilized wallet workspace visual regression coverage after the loaded wallet header became the expected release surface.
-- Updated Integration Command Center tests for the enabled-integration workflow.
+- Autopilot ticks claim their ledger row before swapping, so a crash mid-tick is held for review rather than replayed into a double-buy; a cluster switch auto-holds armed mandates; unattended slippage and price impact are capped tighter than a human-confirmed trade.
+- Swap price impact is normalized to a single unit end to end, so ordinary low-impact swaps are never spuriously blocked.
+- ARIA streamed events are tagged per session so approval cards can never attach to the wrong conversation; the approval-resolution channels reject untrusted senders.
+- Bridge and ARIA file reads deny secret-bearing paths (.env, keypairs, key material) and enforce real-path containment.
+- Swarm lanes run with a minimal allowlisted environment and have push disabled at the git layer.
## Verification
-- `pnpm run release:check:v4:local`
-- `pnpm run release:check:v4:live`
-- `DAEMON_AI_LIVE_SMOKE_CHAT=1 pnpm run release:check:v4:live`
+- `pnpm run typecheck && pnpm run test && pnpm run build`
+- `pnpm run lint:styles`
diff --git a/cli-design/RESEARCH_NOTES.md b/cli-design/RESEARCH_NOTES.md
new file mode 100644
index 00000000..1fcef4d1
--- /dev/null
+++ b/cli-design/RESEARCH_NOTES.md
@@ -0,0 +1,46 @@
+# ARIA CLI - Design Research Notes
+
+Accumulated rationale behind the standalone CLI design. Append findings; don't rewrite history.
+
+## 2026-06-26 - Consolidation pass
+
+**Problem.** The CLI's command set, ANSI theme, and banner art each existed three times: inline in
+`scripts/aria.mjs`, inline in `AriaTerminalBackendService.ts`, and inline again in a dead
+`AriaTerminalService.ts` (`--aria-cli`, unreachable - `aria.mjs` only ever spawned `--aria-server`).
+Adding a command meant editing multiple files, and the three copies had drifted.
+
+**Hard constraint discovered.** `dist-electron/main/index.js` is a single Rollup bundle (~1.3 MB);
+there is no per-file emit. The launcher (`scripts/aria.mjs`) is plain `.mjs` run by Node before any
+build step, so it **cannot import a compiled backend module by path**. This rules out a single shared
+TS module consumed by both runtimes.
+
+**Decision - two sharing channels:**
+- *Static data* (theme tokens): canonical in `cli/ansi-theme.ts`, mirrored in
+ `scripts/aria-shared/ansi-theme.mjs`, parity asserted by `AriaCliThemeSync.test.ts`. The duplication
+ is real but contained - the test fails the gate if the two diverge.
+- *Dynamic data* (commands, banner): flow over the existing frame protocol as `manifest` and `banner`
+ frames emitted before `ready`. The backend stays the single behavioral source; the launcher renders
+ what it's told. This is what makes "add a command = one file edit" true.
+
+**Why not codegen the mirror.** Adds a build step + a stale-artifact failure mode. A parity test is
+cheaper and the theme rarely changes.
+
+**Deleted.** `electron/services/AriaTerminalService.ts` and the `--aria-cli` branch in
+`electron/main/index.ts`. One front-end, one backend.
+
+**Distribution fix.** `react` was in `devDependencies` but `aria.mjs` imports it at runtime via Ink - moved to `dependencies` so `npx aria` / a standalone install resolves it. `react-dom` stays dev-only
+(renderer-bundled).
+
+**New commands.** `/tools` (catalog grouped by risk with colored dots), `/status`
+(`read_project_status` tool), `/memory` (`recall_memories` tool). All reuse existing tool handlers via
+`getTool()` - no logic re-implemented.
+
+**TUI polish.** Manifest-driven slash autocomplete (Tab), scrollback as a pure React offset over the
+transcript (PageUp/PageDown - not terminal-native, to avoid fighting Ink's `alternateScreen`),
+theme-token approval/patch coloring, real `--help`/`--version`, defined exit codes, `NO_COLOR`.
+
+## Open follow-ups
+- Protocol versioning: launcher tolerates a missing `manifest`/`banner` with a static fallback. If the
+ frame schema changes incompatibly, add a version field to the `manifest` frame.
+- Banner wordmark uses CP437/box-drawing block glyphs (`█ ╗ ╔ ═ ╚`); verified to render in Windows
+ Terminal. Legacy `conhost` with a raster font may misalign - revisit if a user reports it.
diff --git a/cli-design/SKILL.md b/cli-design/SKILL.md
new file mode 100644
index 00000000..1134af62
--- /dev/null
+++ b/cli-design/SKILL.md
@@ -0,0 +1,66 @@
+# SKILL: ARIA CLI Workflow Design
+
+The authoritative spec for how the DAEMON standalone ARIA CLI (`aria`) is built.
+The `daemon-cli-workflow-designer` agent reads this before designing or reviewing
+CLI command workflows, ANSI theming, or boot banners.
+
+## Architecture (the one true path)
+
+```
+scripts/aria.mjs Ink (React) TUI launcher - the `aria` bin. Plain JS.
+ │ spawns electron ... --aria-server, newline-delimited JSON frames over stdio
+ ▼
+electron/services/AriaTerminalBackendService.ts --aria-server backend (bundled in main)
+ │ dispatches slash commands, calls AriaAgentService + the ARIA tool catalog
+ ▼
+electron/services/AriaAgentService.ts the tool-calling agent loop
+```
+
+There is exactly one front-end (`aria.mjs`) and one backend (`AriaTerminalBackendService`).
+The former `--aria-cli` readline path was removed - do not reintroduce a second CLI.
+
+## Single sources of truth
+
+| Concern | Owner | Notes |
+|---|---|---|
+| Commands | `electron/services/aria/cli/commandRegistry.ts` | Shipped to the launcher as a `manifest` frame → drives `/help`, autocomplete, footer. Add a command = one entry here. |
+| ANSI theme | `electron/services/aria/cli/ansi-theme.ts` | Canonical tokens. Mirrored in `scripts/aria-shared/ansi-theme.mjs` (launcher can't import the bundle); `test/services/AriaCliThemeSync.test.ts` asserts parity. |
+| Boot banner | `electron/services/aria/cli/aria-boot-banner.ts` | `buildBanner(state)` from real version/cluster/session; emitted as a `banner` frame. |
+| Frame protocol | `electron/services/aria/cli/frames.ts` | Typed `AriaServerFrame` / `AriaClientFrame` unions. |
+
+## The runtime boundary (why sharing works the way it does)
+
+`dist-electron/main/index.js` is a **single bundle** - the launcher (plain `.mjs`,
+runs before any build) cannot import compiled backend modules by path. So:
+- **Static data** (theme tokens) is duplicated into a `.mjs` mirror, drift-guarded by a test.
+- **Dynamic data** (commands, banner) flows over the frame protocol - the backend stays
+ the single behavioral source; the launcher renders what it's told.
+
+Never try to `import` a `dist-electron/**` file from `scripts/aria.mjs`.
+
+## Hard rules (DAEMON, non-negotiable)
+
+1. No inline `\x1b[` escapes or color literals - add a token to `ansi-theme.ts`.
+2. No emoji in chrome; status via colored dots (`ARIA_DOT`) only.
+3. Honor `NO_COLOR` and non-TTY (`isColorDisabled()`) - degrade to raw text.
+4. Windows-terminal-safe glyphs only; deterministic banner width.
+5. Full versioned model strings; never bare names.
+6. Commands are operator-local (`read` tier). On-chain effects are **tools**, gated by ARIA's
+ risk tiers (read=auto, write=approve, sensitive=typed-confirm; `[MAINNET]` re-validates cluster).
+7. Reuse existing tools (`getTool`) for data - never re-implement tool logic in a command.
+
+## Adding a command (the workflow)
+
+1. Write the contract from `command-contract-template.md`.
+2. Add one entry to `COMMAND_REGISTRY` (+ an `AriaCommandActions` method if it changes state).
+3. If it emits structured data, add a frame to `frames.ts` and a `format*` + handler in `aria.mjs`.
+4. All rendering via theme tokens; plain-mode formatter must handle the frame.
+5. Gate loop green: `pnpm run typecheck && pnpm run test && pnpm run build`.
+6. Verify TTY (`node scripts/aria.mjs --cwd .`) and piped (`echo "/cmd" | node scripts/aria.mjs`).
+
+## Verification
+
+- **Gate loop:** `pnpm run typecheck && pnpm run test && pnpm run build`
+- **TTY:** banner renders from frame, `/help` lists registry commands, Tab completes, PgUp scrolls.
+- **Piped:** structured frames render as plain text, exit code 0.
+- **Distribution:** `--version`, `--help`, `NO_COLOR=1` all behave.
diff --git a/cli-design/command-contract-template.md b/cli-design/command-contract-template.md
new file mode 100644
index 00000000..29a635c9
--- /dev/null
+++ b/cli-design/command-contract-template.md
@@ -0,0 +1,69 @@
+# Command Contract - `/`
+
+> Copy this template for every new ARIA CLI command. A command is not "done"
+> until every section below is filled. Commands are operator-local (they drive
+> the CLI session); on-chain side effects belong to ARIA **tools**, not commands.
+
+## Name & synopsis
+- **Name:** `/` (no leading slash in the registry entry)
+- **Synopsis:** one sentence, imperative. Shown in `/help` and autocomplete.
+- **Args:** argument hint, e.g. `` or `auto|fast|standard|reasoning|premium`. Omit if none.
+- **Keybinding:** optional, e.g. `shift+tab`. Shown in the footer.
+
+## Risk tier
+One of `read` | `write` | `sensitive`, matching ARIA's central gating:
+- `read` - auto-run, no prompt. **All session commands are `read`.**
+- `write` - requires inline approval.
+- `sensitive` - typed-confirm; mark `[MAINNET]` and re-validate cluster if on-chain.
+
+The tier here is used for color coding only (read=blue, write=amber, sensitive=red).
+
+## Data source / channel
+- **Registry entry:** `electron/services/aria/cli/commandRegistry.ts` (the single source).
+- **Backend action:** which `AriaCommandActions` method it calls.
+- **Tool reused (if any):** e.g. `read_project_status`, `recall_memories` from
+ `electron/services/aria/tools/*` via `getTool(name)`. Do not re-implement tool logic.
+
+## Output schema (frame)
+Define the frame the backend emits and its payload, e.g.:
+```
+{ type: 'status', status: { project, cluster, rpcProvider, defaultWallet, ... } }
+```
+Add the frame to the `AriaServerFrame` union in `electron/services/aria/cli/frames.ts`.
+
+## ANSI rendering
+- Every visual element references a named token from
+ `electron/services/aria/cli/ansi-theme.ts` (and its launcher mirror
+ `scripts/aria-shared/ansi-theme.mjs`). **No inline `\x1b[` escapes.**
+- Status uses colored dots (`ARIA_DOT`), never emoji.
+- Provide graceful degradation: `isColorDisabled()` (NO_COLOR / non-TTY) returns raw text.
+- Launcher formatter lives in `scripts/aria.mjs` (`format*`) and is shared by the
+ TUI transcript and plain (piped) mode.
+
+## Error cases
+Enumerate each failure with the exact user-facing message, e.g.:
+- Missing arg → `Usage: /`
+- Not found → `Session not found: `
+- Tool failure → the tool's `summary` surfaced as a `log` warn frame.
+
+## Example
+Show the invocation and sample rendered output (both TTY and piped).
+```
+> /status
+Project status:
+ project C:/Users/offic/Projects/DAEMON
+ cluster devnet (helius)
+ wallet main · 2 wallet(s)
+ packs core, solana
+```
+
+## Checklist (before delivering)
+- [ ] One entry in `COMMAND_REGISTRY`; manifest auto-ships to the launcher.
+- [ ] Risk tier assigned; on-chain commands flag `[MAINNET]` + re-validate cluster.
+- [ ] All styling via theme tokens; no inline escapes; dots not emoji.
+- [ ] Output frame added to `frames.ts` union and handled in `aria.mjs`.
+- [ ] Plain-mode formatter handles the frame (piped output works).
+- [ ] Errors enumerated with exact messages.
+- [ ] Example with rendered output.
+- [ ] Windows-terminal glyph/width safe.
+- [ ] Gate loop green: `pnpm run typecheck && pnpm run test && pnpm run build`.
diff --git a/electron/db/migrations.ts b/electron/db/migrations.ts
index de8286ba..aadaf01c 100644
--- a/electron/db/migrations.ts
+++ b/electron/db/migrations.ts
@@ -1,5 +1,5 @@
import type Database from 'better-sqlite3'
-import { SCHEMA_V1, SCHEMA_V2, SCHEMA_V3, SCHEMA_V4, SCHEMA_V5, SCHEMA_V6, SCHEMA_V7, SCHEMA_V8, SCHEMA_V9, SCHEMA_V10, SCHEMA_V11, SCHEMA_V12, SCHEMA_V13, SCHEMA_V14, SCHEMA_V15, SCHEMA_V16, SCHEMA_V17, SCHEMA_V18, SCHEMA_V19, SCHEMA_V20, SCHEMA_V21, SCHEMA_V22, SCHEMA_V23, SCHEMA_V24, SCHEMA_V25, SCHEMA_V26, SCHEMA_V27, SCHEMA_V28, SCHEMA_V29, SCHEMA_V30, SCHEMA_V31, SCHEMA_V32, SCHEMA_V33, SCHEMA_V34, SCHEMA_V35, SCHEMA_V36, SCHEMA_V37, SCHEMA_V38, SCHEMA_V39, SCHEMA_V40, SCHEMA_V41, SCHEMA_V42, SCHEMA_V43, SCHEMA_V44, SCHEMA_V45, SCHEMA_V46, SCHEMA_V47, SCHEMA_V48, SCHEMA_V49, SCHEMA_V50, SCHEMA_V51, SCHEMA_V52, SCHEMA_V53, SCHEMA_V54 } from './schema'
+import { SCHEMA_V1, SCHEMA_V2, SCHEMA_V3, SCHEMA_V4, SCHEMA_V5, SCHEMA_V6, SCHEMA_V7, SCHEMA_V8, SCHEMA_V9, SCHEMA_V10, SCHEMA_V11, SCHEMA_V12, SCHEMA_V13, SCHEMA_V14, SCHEMA_V15, SCHEMA_V16, SCHEMA_V17, SCHEMA_V18, SCHEMA_V19, SCHEMA_V20, SCHEMA_V21, SCHEMA_V22, SCHEMA_V23, SCHEMA_V24, SCHEMA_V25, SCHEMA_V26, SCHEMA_V27, SCHEMA_V28, SCHEMA_V29, SCHEMA_V30, SCHEMA_V31, SCHEMA_V32, SCHEMA_V33, SCHEMA_V34, SCHEMA_V35, SCHEMA_V36, SCHEMA_V37, SCHEMA_V38, SCHEMA_V39, SCHEMA_V40, SCHEMA_V41, SCHEMA_V42, SCHEMA_V43, SCHEMA_V44, SCHEMA_V45, SCHEMA_V46, SCHEMA_V47, SCHEMA_V48, SCHEMA_V49, SCHEMA_V50, SCHEMA_V51, SCHEMA_V52, SCHEMA_V53, SCHEMA_V54, SCHEMA_V55, SCHEMA_V56, SCHEMA_V57 } from './schema'
export function runMigrations(db: Database.Database) {
db.exec(`
@@ -656,6 +656,45 @@ export function runMigrations(db: Database.Database) {
})()
}
+ if (currentVersion < 55) {
+ db.transaction(() => {
+ const stmts = SCHEMA_V55.split(';').map((s) => s.trim()).filter(Boolean)
+ for (const stmt of stmts) {
+ try { db.exec(stmt) } catch (err) {
+ const msg = (err instanceof Error ? err.message : String(err)).toLowerCase()
+ if (!msg.includes('duplicate column') && !msg.includes('already exists')) throw err
+ }
+ }
+ db.prepare('INSERT INTO _migrations (version) VALUES (?)').run(55)
+ })()
+ }
+
+ if (currentVersion < 56) {
+ db.transaction(() => {
+ const stmts = SCHEMA_V56.split(';').map((s) => s.trim()).filter(Boolean)
+ for (const stmt of stmts) {
+ try { db.exec(stmt) } catch (err) {
+ const msg = (err instanceof Error ? err.message : String(err)).toLowerCase()
+ if (!msg.includes('duplicate column') && !msg.includes('already exists')) throw err
+ }
+ }
+ db.prepare('INSERT INTO _migrations (version) VALUES (?)').run(56)
+ })()
+ }
+
+ if (currentVersion < 57) {
+ db.transaction(() => {
+ const stmts = SCHEMA_V57.split(';').map((s) => s.trim()).filter(Boolean)
+ for (const stmt of stmts) {
+ try { db.exec(stmt) } catch (err) {
+ const msg = (err instanceof Error ? err.message : String(err)).toLowerCase()
+ if (!msg.includes('duplicate column') && !msg.includes('already exists')) throw err
+ }
+ }
+ db.prepare('INSERT INTO _migrations (version) VALUES (?)').run(57)
+ })()
+ }
+
// Ensure Solana agent exists (idempotent — handles existing DBs before it was seeded)
try {
const hasSolanaAgent = db.prepare("SELECT id FROM agents WHERE id = 'solana-agent'").get()
@@ -689,7 +728,7 @@ Output format:
- For debugging: show the failing instruction index and decoded error
Proceed immediately with the task. Ask for clarification only when the target program/network (devnet vs mainnet) is ambiguous.`,
- 'claude-opus-4-20250514',
+ 'claude-opus-4-8',
'["filesystem"]',
'cmd+shift+s',
'daemon',
@@ -726,7 +765,7 @@ You help builders:
- Find market gaps ("What's missing in the ecosystem?")
Output: bullet points with inline citations. Be direct. No fluff.`,
- 'claude-sonnet-4-20250514',
+ 'claude-sonnet-4-6',
'["filesystem"]',
null,
'daemon',
@@ -873,7 +912,7 @@ Output format:
- If a fix touches IPC, show both the handler (electron/ipc/) and the renderer call site
Proceed with diagnosis immediately when given an error or symptom. Ask for clarification only when the symptom is ambiguous and multiple subsystems could be responsible.`,
- model: 'claude-sonnet-4-20250514',
+ model: 'claude-sonnet-4-6',
mcps: '["filesystem"]',
shortcut: 'cmd+shift+d',
},
@@ -895,7 +934,7 @@ Output format:
- End with a summary table: total findings by severity
Ask for clarification only when the audit scope is unclear (e.g., "audit everything" with 50+ files). Otherwise, proceed with the files available.`,
- model: 'claude-sonnet-4-20250514',
+ model: 'claude-sonnet-4-6',
mcps: '["filesystem"]',
shortcut: 'cmd+2',
},
@@ -920,7 +959,7 @@ Output format:
- Conservative changes only — do not refactor architecture or change public APIs unless asked
Proceed immediately with the files or diff provided. Ask for clarification only if no files or diff are given.`,
- model: 'claude-sonnet-4-20250514',
+ model: 'claude-sonnet-4-6',
mcps: '["filesystem"]',
shortcut: 'cmd+3',
},
@@ -1026,7 +1065,7 @@ Rules:
- Invoke relevant /skill when working with a specific protocol
Proceed immediately. Ask for clarification only when target network is ambiguous.`,
- model: 'claude-opus-4-20250514',
+ model: 'claude-opus-4-8',
mcps: '["filesystem","helius","solana-mcp-server"]',
shortcut: 'cmd+shift+s',
},
diff --git a/electron/db/schema.ts b/electron/db/schema.ts
index 803e946a..38e7f6f8 100644
--- a/electron/db/schema.ts
+++ b/electron/db/schema.ts
@@ -55,7 +55,7 @@ CREATE TABLE IF NOT EXISTS agents (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
system_prompt TEXT,
- model TEXT DEFAULT 'claude-opus-4-20250514',
+ model TEXT DEFAULT 'claude-opus-4-8',
mcps TEXT DEFAULT '[]',
project_id TEXT,
shortcut TEXT,
@@ -1421,3 +1421,141 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_autopilot_actions_tick
CREATE INDEX IF NOT EXISTS idx_autopilot_actions_mandate
ON autopilot_actions(mandate_id, created_at DESC);
`
+
+export const SCHEMA_V55 = `
+CREATE TABLE IF NOT EXISTS agent_economy_profiles (
+ id TEXT PRIMARY KEY,
+ project_id TEXT,
+ agent_id TEXT,
+ name TEXT NOT NULL,
+ wallet_id TEXT,
+ wallet_address TEXT,
+ registry_asset TEXT,
+ agent_identity_pda TEXT,
+ service_url TEXT,
+ capabilities_json TEXT NOT NULL DEFAULT '[]',
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_agent_economy_profiles_project
+ ON agent_economy_profiles(project_id, updated_at DESC);
+CREATE INDEX IF NOT EXISTS idx_agent_economy_profiles_agent
+ ON agent_economy_profiles(agent_id);
+
+CREATE TABLE IF NOT EXISTS agent_spend_policies (
+ id TEXT PRIMARY KEY,
+ profile_id TEXT NOT NULL,
+ asset TEXT NOT NULL,
+ network TEXT NOT NULL,
+ allowed_domains_json TEXT NOT NULL DEFAULT '[]',
+ allowed_payees_json TEXT NOT NULL DEFAULT '[]',
+ max_per_call_usdc REAL NOT NULL,
+ max_per_day_usdc REAL NOT NULL,
+ expires_at INTEGER,
+ enabled INTEGER NOT NULL DEFAULT 1,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL,
+ FOREIGN KEY(profile_id) REFERENCES agent_economy_profiles(id) ON DELETE CASCADE
+);
+CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_spend_policies_profile
+ ON agent_spend_policies(profile_id);
+CREATE INDEX IF NOT EXISTS idx_agent_spend_policies_enabled
+ ON agent_spend_policies(enabled, expires_at);
+`
+
+// Garrison token-holder program — subsystem A (staking + referral commission).
+// v0 ships the full schema but only subsystem-A tables are used. All money math
+// (caps, fee-floor) lives in the services, never the DB. See GARRISON_BACKEND_SPEC.md.
+export const SCHEMA_V56 = `
+CREATE TABLE IF NOT EXISTS garrison_multiplier_tiers (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ min_stake_raw INTEGER NOT NULL,
+ multiplier_bps INTEGER NOT NULL,
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ active INTEGER NOT NULL DEFAULT 1,
+ created_at INTEGER DEFAULT (CAST(unixepoch('now') * 1000 AS INTEGER))
+);
+CREATE INDEX IF NOT EXISTS idx_garrison_multiplier_tiers_active
+ ON garrison_multiplier_tiers(active, min_stake_raw DESC);
+
+CREATE TABLE IF NOT EXISTS garrison_stakes (
+ id TEXT PRIMARY KEY,
+ holder_wallet TEXT NOT NULL UNIQUE,
+ staked_raw INTEGER NOT NULL DEFAULT 0,
+ escrow_wallet TEXT NOT NULL,
+ tier_id TEXT,
+ effective_from INTEGER,
+ status TEXT NOT NULL DEFAULT 'active',
+ created_at INTEGER DEFAULT (CAST(unixepoch('now') * 1000 AS INTEGER)),
+ updated_at INTEGER DEFAULT (CAST(unixepoch('now') * 1000 AS INTEGER))
+);
+CREATE INDEX IF NOT EXISTS idx_garrison_stakes_holder
+ ON garrison_stakes(holder_wallet);
+
+CREATE TABLE IF NOT EXISTS garrison_stake_movements (
+ id TEXT PRIMARY KEY,
+ stake_id TEXT NOT NULL,
+ kind TEXT NOT NULL,
+ amount_raw INTEGER NOT NULL,
+ signature TEXT UNIQUE,
+ status TEXT NOT NULL DEFAULT 'pending',
+ created_at INTEGER DEFAULT (CAST(unixepoch('now') * 1000 AS INTEGER)),
+ FOREIGN KEY(stake_id) REFERENCES garrison_stakes(id) ON DELETE CASCADE
+);
+CREATE INDEX IF NOT EXISTS idx_garrison_stake_movements_stake
+ ON garrison_stake_movements(stake_id, created_at DESC);
+
+CREATE TABLE IF NOT EXISTS garrison_referral_attributions (
+ id TEXT PRIMARY KEY,
+ referred_wallet TEXT NOT NULL UNIQUE,
+ referrer_wallet TEXT NOT NULL,
+ ref_code TEXT,
+ bound_via TEXT NOT NULL DEFAULT 'link',
+ verified INTEGER NOT NULL DEFAULT 0,
+ status TEXT NOT NULL DEFAULT 'active',
+ bound_at INTEGER DEFAULT (CAST(unixepoch('now') * 1000 AS INTEGER))
+);
+CREATE INDEX IF NOT EXISTS idx_garrison_referral_attributions_referrer
+ ON garrison_referral_attributions(referrer_wallet, status);
+
+CREATE TABLE IF NOT EXISTS garrison_referral_commissions (
+ id TEXT PRIMARY KEY,
+ referrer_wallet TEXT NOT NULL,
+ epoch INTEGER NOT NULL,
+ attributed_notional_lamports INTEGER NOT NULL DEFAULT 0,
+ attributed_fee_lamports INTEGER NOT NULL DEFAULT 0,
+ base_commission_lamports INTEGER NOT NULL DEFAULT 0,
+ bonus_commission_lamports INTEGER NOT NULL DEFAULT 0,
+ gross_commission_lamports INTEGER NOT NULL DEFAULT 0,
+ tier_id_snapshot TEXT,
+ high_water_fee_event_at INTEGER NOT NULL DEFAULT 0,
+ status TEXT NOT NULL DEFAULT 'accrued',
+ created_at INTEGER DEFAULT (CAST(unixepoch('now') * 1000 AS INTEGER)),
+ updated_at INTEGER DEFAULT (CAST(unixepoch('now') * 1000 AS INTEGER))
+);
+CREATE UNIQUE INDEX IF NOT EXISTS idx_garrison_referral_commissions_epoch
+ ON garrison_referral_commissions(referrer_wallet, epoch);
+CREATE INDEX IF NOT EXISTS idx_garrison_referral_commissions_status
+ ON garrison_referral_commissions(referrer_wallet, status);
+
+CREATE TABLE IF NOT EXISTS garrison_commission_payouts (
+ id TEXT PRIMARY KEY,
+ referrer_wallet TEXT NOT NULL,
+ amount_lamports INTEGER NOT NULL,
+ commission_ids_json TEXT NOT NULL DEFAULT '[]',
+ treasury_wallet TEXT NOT NULL,
+ payout_signature TEXT,
+ status TEXT NOT NULL DEFAULT 'pending',
+ created_at INTEGER DEFAULT (CAST(unixepoch('now') * 1000 AS INTEGER))
+);
+CREATE INDEX IF NOT EXISTS idx_garrison_commission_payouts_referrer
+ ON garrison_commission_payouts(referrer_wallet, created_at DESC);
+`
+
+// V57: track the raw token quantity a mandate has actually accumulated, so exit
+// sells liquidate ONLY the mandate's position, never the wallet's pre-existing
+// holdings of the same mint. Applied as a tolerant ALTER (duplicate-column ok).
+export const SCHEMA_V57 = `
+ALTER TABLE autopilot_mandates ADD COLUMN bought_raw_tokens TEXT NOT NULL DEFAULT '0';
+`
diff --git a/electron/ipc/agentEconomy.ts b/electron/ipc/agentEconomy.ts
new file mode 100644
index 00000000..b5c48fc4
--- /dev/null
+++ b/electron/ipc/agentEconomy.ts
@@ -0,0 +1,50 @@
+import { ipcMain } from 'electron'
+import { ipcHandler } from '../services/IpcHandlerFactory'
+import * as AgentEconomyService from '../services/AgentEconomyService'
+import type {
+ AgentEconomyExecutePaidCallInput,
+ AgentEconomyListReceiptsInput,
+ AgentEconomyPolicyCheckInput,
+ AgentEconomyReadAgentIdentityInput,
+ AgentEconomyRegisterDevnetAgentInput,
+ AgentEconomySetPolicyInput,
+ AgentEconomyUpsertProfileInput,
+} from '../shared/types'
+
+export function registerAgentEconomyHandlers() {
+ ipcMain.handle('agent-economy:list-profiles', ipcHandler(async (_event, projectId?: string | null) => {
+ return AgentEconomyService.listProfiles(projectId ?? null)
+ }))
+
+ ipcMain.handle('agent-economy:upsert-profile', ipcHandler(async (_event, input: AgentEconomyUpsertProfileInput) => {
+ return AgentEconomyService.upsertProfile(input)
+ }))
+
+ ipcMain.handle('agent-economy:get-profile', ipcHandler(async (_event, profileId: string) => {
+ return AgentEconomyService.getProfile(profileId)
+ }))
+
+ ipcMain.handle('agent-economy:set-policy', ipcHandler(async (_event, input: AgentEconomySetPolicyInput) => {
+ return AgentEconomyService.setPolicy(input)
+ }))
+
+ ipcMain.handle('agent-economy:check-policy', ipcHandler(async (_event, input: AgentEconomyPolicyCheckInput) => {
+ return AgentEconomyService.checkPolicy(input)
+ }))
+
+ ipcMain.handle('agent-economy:execute-paid-call', ipcHandler(async (_event, input: AgentEconomyExecutePaidCallInput) => {
+ return AgentEconomyService.executePaidCall(input)
+ }))
+
+ ipcMain.handle('agent-economy:list-receipts', ipcHandler(async (_event, input?: AgentEconomyListReceiptsInput) => {
+ return AgentEconomyService.listReceipts(input ?? {})
+ }))
+
+ ipcMain.handle('agent-economy:register-devnet-agent', ipcHandler(async (_event, input: AgentEconomyRegisterDevnetAgentInput) => {
+ return AgentEconomyService.registerDevnetAgent(input)
+ }))
+
+ ipcMain.handle('agent-economy:read-agent-identity', ipcHandler(async (_event, input: AgentEconomyReadAgentIdentityInput) => {
+ return AgentEconomyService.readAgentIdentity(input)
+ }))
+}
diff --git a/electron/ipc/aria.ts b/electron/ipc/aria.ts
index 2c23cfe9..4b846017 100644
--- a/electron/ipc/aria.ts
+++ b/electron/ipc/aria.ts
@@ -1,9 +1,10 @@
import { ipcMain } from 'electron'
import type { WebContents } from 'electron'
import { ipcHandler } from '../services/IpcHandlerFactory'
+import { isTrustedSender } from '../security/ipcSender'
import * as AriaAgentService from '../services/AriaAgentService'
import * as DaemonAIService from '../services/DaemonAIService'
-import type { AriaTransport } from '../services/AriaAgentService'
+import type { AriaTransport, AriaEmitEvent } from '../services/AriaAgentService'
import type { AriaContextSnapshot, AriaUiEffect } from '../services/aria/AriaTool'
import type { AriaToolEvent, AriaPatchAction, DaemonAiModelLane } from '../shared/types'
@@ -14,21 +15,30 @@ const pendingEffects = new Map void>()
let effectSeq = 0
-function makeTransport(sender: WebContents): AriaTransport {
- const emit = (event: AriaToolEvent) => {
- if (!sender.isDestroyed()) sender.send('aria:tool-event', event)
+function makeTransport(sender: WebContents, sessionId: string): AriaTransport {
+ // Stamp every streamed event with messageId === sessionId so the renderer's
+ // session-isolation guard can route uniformly. tool-call / approval-request /
+ // patch-proposal are emitted from deep call sites without an explicit
+ // messageId, so we inject the session id here rather than trusting each site
+ // (a missing tag would silently drop the event and hang the turn).
+ const emit = (event: AriaEmitEvent) => {
+ const tagged: AriaToolEvent =
+ 'messageId' in event && event.messageId
+ ? (event as AriaToolEvent)
+ : ({ ...event, messageId: sessionId } as AriaToolEvent)
+ if (!sender.isDestroyed()) sender.send('aria:tool-event', tagged)
}
return {
emit,
requestApproval: (req) =>
new Promise((resolve) => {
pendingApprovals.set(req.callId, resolve)
- emit({ kind: 'approval-request', callId: req.callId, name: req.name, risk: req.risk, summary: req.summary, input: req.input, fee: req.fee })
+ emit({ kind: 'approval-request', messageId: sessionId, callId: req.callId, name: req.name, risk: req.risk, summary: req.summary, input: req.input, fee: req.fee })
}),
requestPatchDecision: (proposal) =>
new Promise((resolve) => {
pendingDecisions.set(proposal.id, resolve)
- emit({ kind: 'patch-proposal', messageId: proposal.id, proposal })
+ emit({ kind: 'patch-proposal', messageId: sessionId, proposal })
}),
runUiEffect: (effect: AriaUiEffect, awaitData: boolean) =>
new Promise((resolve) => {
@@ -53,7 +63,7 @@ export function registerAriaHandlers() {
modelLane?: DaemonAiModelLane,
) => {
if (!message?.trim()) throw new Error('Message cannot be empty')
- const transport = makeTransport(event.sender)
+ const transport = makeTransport(event.sender, sessionId)
return await AriaAgentService.sendMessage(sessionId, message.trim(), snapshot, transport, modelLane)
}))
@@ -89,8 +99,11 @@ export function registerAriaHandlers() {
AriaAgentService.deleteSession(sessionId)
}))
- // Renderer resolves a pending approval.
- ipcMain.on('aria:approve', (_event, callId: string, approved: boolean) => {
+ // Renderer resolves a pending approval. These raw channels resolve write/sensitive actions,
+ // so — like bridge:approve and terminal's raw channels — they must reject an untrusted sender:
+ // a spoofed frame here would convert directly into an approved mainnet action.
+ ipcMain.on('aria:approve', (event, callId: string, approved: boolean) => {
+ if (!isTrustedSender(event)) return
const resolve = pendingApprovals.get(callId)
if (resolve) {
pendingApprovals.delete(callId)
@@ -99,7 +112,8 @@ export function registerAriaHandlers() {
})
// Renderer resolves a pending patch decision (keep / run-tests / discard).
- ipcMain.on('aria:patch-decision', (_event, proposalId: string, action: AriaPatchAction) => {
+ ipcMain.on('aria:patch-decision', (event, proposalId: string, action: AriaPatchAction) => {
+ if (!isTrustedSender(event)) return
const resolve = pendingDecisions.get(proposalId)
if (resolve) {
pendingDecisions.delete(proposalId)
@@ -108,7 +122,8 @@ export function registerAriaHandlers() {
})
// Renderer posts back data for a two-phase ui-effect (e.g. integration check).
- ipcMain.on('aria:tool-effect-result', (_event, callId: string, data: unknown) => {
+ ipcMain.on('aria:tool-effect-result', (event, callId: string, data: unknown) => {
+ if (!isTrustedSender(event)) return
const resolve = pendingEffects.get(callId)
if (resolve) {
pendingEffects.delete(callId)
diff --git a/electron/ipc/claude.ts b/electron/ipc/claude.ts
index 4137b7b7..75e9d3b3 100644
--- a/electron/ipc/claude.ts
+++ b/electron/ipc/claude.ts
@@ -1,17 +1,25 @@
import { ipcMain } from 'electron'
import fs from 'node:fs'
import path from 'node:path'
+import os from 'node:os'
import { execSync } from 'node:child_process'
+import { execFile, spawn } from 'node:child_process'
+import { promisify } from 'node:util'
import * as SecureKey from '../services/SecureKeyService'
import * as McpConfig from '../services/McpConfig'
import * as Anthropic from '../services/AnthropicService'
import * as Skills from '../services/SkillsConfig'
import * as ClaudeRouter from '../services/ClaudeRouter'
+import { ClaudeProvider } from '../services/providers/ClaudeProvider'
+import { broadcast } from '../services/EventBus'
+import { getDb } from '../db/db'
import { isPathSafe } from '../shared/pathValidation'
import { ipcHandler, withValidation } from '../services/IpcHandlerFactory'
import { restartProviderInPty, restartAllProviderSessions } from '../shared/providerRestart'
import type { McpAddInput } from '../shared/types'
+const execFileAsync = promisify(execFile)
+
/**
* Gracefully exit Claude in a PTY and resume with `claude -c`.
* Uses fixed delays — more reliable than prompt detection which can false-positive.
@@ -215,4 +223,80 @@ ${content}`,
ipcMain.handle('claude:get-connection', ipcHandler(async () => {
return ClaudeRouter.getConnection()
}))
+
+ ipcMain.handle('claude:install-cli', ipcHandler(async () => {
+ const isWin = process.platform === 'win32'
+ let npmPath = isWin ? 'npm.cmd' : 'npm'
+
+ try {
+ const npmPrefix = execSync('npm prefix -g', { encoding: 'utf8', timeout: 10000 }).trim()
+ const candidate = isWin ? path.join(npmPrefix, 'npm.cmd') : path.join(npmPrefix, 'bin', 'npm')
+ if (fs.existsSync(candidate)) npmPath = candidate
+ } catch {
+ // PATH fallback
+ }
+
+ // On Windows npm resolves to npm.cmd. Since the CVE-2024-27980 hardening
+ // (Node >= 20.12.2, shipped by current Electron) execFile/spawn of a .cmd
+ // without shell:true throws EINVAL, which broke the install button entirely.
+ // Route the .cmd through the shell so it launches; POSIX runs the bare binary.
+ const { stdout, stderr } = await execFileAsync(npmPath, ['install', '-g', '@anthropic-ai/claude-code'], {
+ timeout: 120000,
+ env: { ...process.env },
+ shell: isWin,
+ })
+
+ ClaudeRouter.clearCachedPath()
+ ClaudeRouter.clearCachedConnection()
+ ClaudeProvider.clearCache()
+ broadcast('auth:changed', { providerId: 'claude' })
+
+ return { stdout: stdout.trim(), stderr: stderr.trim() }
+ }))
+
+ ipcMain.handle('claude:auth-login', ipcHandler(async () => {
+ const claudePath = ClaudeRouter.getClaudePath()
+ if (process.platform === 'win32') {
+ // Wrap the path in double quotes for the nested `cmd /k` token. Inside cmd double-quotes a
+ // backslash is literal (so we must NOT escape it — doubling it would corrupt C:\... paths),
+ // and a double-quote is the only char that could break out. A Windows path can't legally
+ // contain a double-quote, so strip any that appear rather than trying to escape them; this
+ // closes the CodeQL "incomplete string escaping" finding without mangling valid paths.
+ const command = `"${claudePath.replace(/"/g, '')}"`
+ spawn('cmd.exe', ['/c', 'start', '""', 'cmd.exe', '/k', command], {
+ detached: true,
+ stdio: 'ignore',
+ windowsHide: false,
+ }).unref()
+ } else {
+ spawn(claudePath, [], {
+ detached: true,
+ stdio: 'ignore',
+ }).unref()
+ }
+
+ ClaudeRouter.clearCachedConnection()
+ ClaudeProvider.clearCache()
+ broadcast('auth:changed', { providerId: 'claude' })
+ return { success: true }
+ }))
+
+ ipcMain.handle('claude:disconnect', ipcHandler(async () => {
+ const credPath = path.join(os.homedir(), '.claude', '.credentials.json')
+ let disconnected = false
+ if (fs.existsSync(credPath)) {
+ fs.renameSync(credPath, `${credPath}.bak-${Date.now()}`)
+ disconnected = true
+ }
+ try { SecureKey.deleteKey('ANTHROPIC_API_KEY') } catch { /* non-fatal */ }
+
+ ClaudeRouter.clearCachedConnection()
+ ClaudeProvider.clearCache()
+ try {
+ const db = getDb()
+ db.prepare('DELETE FROM app_settings WHERE key IN (?, ?, ?)').run('claude_path', 'claude_auth_mode', 'claude_verified_at')
+ } catch { /* non-fatal */ }
+ broadcast('auth:changed', { providerId: 'claude' })
+ return { disconnected }
+ }))
}
diff --git a/electron/ipc/filesystem.ts b/electron/ipc/filesystem.ts
index 7cc7c8fc..429e9b45 100644
--- a/electron/ipc/filesystem.ts
+++ b/electron/ipc/filesystem.ts
@@ -1,4 +1,4 @@
-import { ipcMain, shell, clipboard } from 'electron'
+import { ipcMain, shell, clipboard, dialog } from 'electron'
import fs from 'node:fs/promises'
import fsSync from 'node:fs'
import path from 'node:path'
@@ -155,7 +155,6 @@ export function registerFilesystemHandlers() {
}))
ipcMain.handle('fs:pickImage', ipcHandler(async () => {
- const { dialog } = await import('electron')
const result = await dialog.showOpenDialog({
title: 'Open Image',
filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp', 'avif'] }],
diff --git a/electron/ipc/terminal.ts b/electron/ipc/terminal.ts
index 429a5334..9fa21a51 100644
--- a/electron/ipc/terminal.ts
+++ b/electron/ipc/terminal.ts
@@ -280,7 +280,7 @@ export function registerTerminalHandlers() {
cwd?: string
initialPrompt?: string
}) => {
- if (opts.providerId !== 'claude' && opts.providerId !== 'codex' && opts.providerId !== 'spettro') {
+ if (opts.providerId !== 'claude' && opts.providerId !== 'codex' && opts.providerId !== 'spettro' && opts.providerId !== 'aria') {
throw new Error('Unsupported provider')
}
@@ -309,7 +309,7 @@ export function registerTerminalHandlers() {
id,
pid: session.pty.pid,
agentId: null,
- agentName: opts.providerId === 'claude' ? 'Claude' : opts.providerId === 'codex' ? 'Codex' : 'Spettro',
+ agentName: opts.providerId === 'claude' ? 'Claude' : opts.providerId === 'codex' ? 'Codex' : opts.providerId === 'aria' ? 'Aria' : 'Spettro',
}
Voight.emitEventSafe({
agentId: opts.providerId,
diff --git a/electron/ipc/wallet.ts b/electron/ipc/wallet.ts
index d5951352..db6d7422 100644
--- a/electron/ipc/wallet.ts
+++ b/electron/ipc/wallet.ts
@@ -156,13 +156,19 @@ export function registerWalletHandlers() {
throw new Error('Swap confirmation expired — please review the quote again')
}
- // H1: if the quote has high price impact, acknowledgedImpact must be true
- if (input.rawQuoteResponse != null) {
- const quote = input.rawQuoteResponse as Record
- const impactPct = typeof quote.priceImpact === 'number'
- ? Math.abs(quote.priceImpact) * 100
- : parseFloat(String(quote.priceImpactPct ?? '0'))
- if (impactPct >= 5 && input.acknowledgedImpact !== true) {
+ // H1: if the quote has high price impact, acknowledgedImpact must be true.
+ // The impact is read from the SERVER-stored draft (keyed by quoteId), never
+ // from the renderer's rawQuoteResponse — otherwise a caller could send a
+ // low/zero priceImpact to skip the acknowledgement. acknowledgedImpact stays a
+ // renderer signal (it records the human's click), but it is only consulted
+ // when the server itself determines the swap is high-impact.
+ const rawQuote = input.rawQuoteResponse
+ const quoteId = rawQuote && typeof rawQuote === 'object' && typeof (rawQuote as Record).quoteId === 'string'
+ ? (rawQuote as Record).quoteId as string
+ : null
+ if (quoteId) {
+ const serverImpactPct = WalletService.getServerSwapImpactPct(quoteId)
+ if (serverImpactPct >= 5 && input.acknowledgedImpact !== true) {
throw new Error('High price impact must be explicitly acknowledged before executing')
}
}
diff --git a/electron/main/index.ts b/electron/main/index.ts
index 12b5488f..b6f4e0d8 100644
--- a/electron/main/index.ts
+++ b/electron/main/index.ts
@@ -67,6 +67,7 @@ import { registerSeekerHandlers } from '../ipc/seeker'
import { registerPnlHandlers } from '../ipc/pnl'
import { registerFeedbackHandlers } from '../ipc/feedback'
import { registerAgentStationHandlers } from '../ipc/agentStation'
+import { registerAgentEconomyHandlers } from '../ipc/agentEconomy'
import { registerReplayHandlers } from '../ipc/replay'
import { registerLspHandlers } from '../ipc/lsp'
import { registerTelemetryHandlers, initTelemetry } from '../ipc/telemetry'
@@ -75,14 +76,13 @@ import { registerPackHandlers, setPackDomainRegistrar } from '../ipc/packs'
import { enabledIpcDomains, type IpcDomainId } from '../shared/packManifest'
import { flushRemoteTelemetry } from '../services/RemoteTelemetryService'
import { flushQueue as flushVoightQueue } from '../services/VoightService'
+import { runAriaServer } from '../services/AriaTerminalBackendService'
import { clearLoadedWallets } from '../services/RecoveryService'
import { maybeRecoverUnstableUiState, getEnabledPacks, type UiRecoveryResult } from '../services/SettingsService'
import { getKeyEncryptionWarning, getStorageBackend } from '../services/SecureKeyService'
import { shutdownAllLspSessions } from '../services/LspService'
import { isAllowedWebviewUrl, isSafeExternalUrl, openSafeExternalUrl } from '../security/externalNavigation'
import { isTrustedSender, setTrustedIpcOrigin } from '../security/ipcSender'
-import pkg from 'electron-updater'
-const { autoUpdater } = pkg
const __dirname = path.dirname(fileURLToPath(import.meta.url))
@@ -91,6 +91,7 @@ export const MAIN_DIST = path.join(process.env.APP_ROOT, 'dist-electron')
export const RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist')
export const VITE_DEV_SERVER_URL = app.isPackaged ? undefined : process.env.VITE_DEV_SERVER_URL
const SMOKE_TEST_MODE = process.env.DAEMON_SMOKE_TEST === '1'
+const ARIA_CLI_MODE = process.argv.includes('--aria-server')
const WINDOWS_COMPOSITOR_DISABLED_FEATURES = ['EnableTransparentHwndEnlargement'] as const
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL
@@ -120,7 +121,7 @@ if (process.platform === 'win32') {
if (SMOKE_TEST_MODE) {
app.commandLine.appendSwitch('remote-debugging-port', process.env.DAEMON_SMOKE_CDP_PORT ?? '9333')
-} else if (!app.isPackaged) {
+} else if (!ARIA_CLI_MODE && !app.isPackaged) {
app.commandLine.appendSwitch('remote-debugging-port', '9222')
}
@@ -179,7 +180,7 @@ app.on('child-process-gone', (_event, details) => {
recordNativeDiagnostic('child-process-gone', details)
})
-if (!SMOKE_TEST_MODE && !app.requestSingleInstanceLock()) {
+if (!ARIA_CLI_MODE && !SMOKE_TEST_MODE && !app.requestSingleInstanceLock()) {
app.quit()
process.exit(0)
}
@@ -391,6 +392,7 @@ function registerAllIpc() {
registerFeedbackHandlers()
registerLspHandlers()
registerVoightHandlers()
+ registerAgentEconomyHandlers()
// --- Pack-owned domains: registered only when their capability pack is on ---
const enabled = getEnabledPacks()
@@ -690,8 +692,23 @@ async function createWindow() {
}, 1000)
})
}
-app.whenReady().then(() => {
+app.whenReady().then(async () => {
if (SMOKE_TEST_MODE) console.log('[smoke] app:ready')
+ if (ARIA_CLI_MODE) {
+ ProviderRegistry.register(ClaudeProvider)
+ ProviderRegistry.register(CodexProvider)
+ if (process.platform === 'darwin') app.dock?.hide()
+ runAriaServer(process.argv.slice(process.argv.indexOf('--aria-server')))
+ .catch((err) => {
+ console.error(err instanceof Error ? err.message : String(err))
+ process.exitCode = 1
+ })
+ .finally(() => {
+ cleanupRuntimeState()
+ app.exit(typeof process.exitCode === 'number' ? process.exitCode : 0)
+ })
+ return
+ }
registerDaemonProtocolClient()
if (process.platform === 'darwin' && app.dock && !app.isPackaged) {
try {
@@ -731,6 +748,8 @@ app.whenReady().then(() => {
}, 5000)
if (app.isPackaged && process.env.DAEMON_DISABLE_AUTO_UPDATE !== '1') {
+ const pkg = await import('electron-updater')
+ const { autoUpdater } = pkg.default
autoUpdater.on('error', (err: Error) => {
console.error('[AutoUpdater] error:', err.message)
})
diff --git a/electron/preload/index.ts b/electron/preload/index.ts
index b6f6b130..15cbbfd3 100644
--- a/electron/preload/index.ts
+++ b/electron/preload/index.ts
@@ -44,7 +44,7 @@ contextBridge.exposeInMainWorld('daemon', {
terminal: {
create: (opts?: { cwd?: string; startupCommand?: string; userInitiated?: boolean; isAgent?: boolean }) => ipcRenderer.invoke('terminal:create', opts ?? {}),
spawnAgent: (opts: { agentId: string; projectId: string; initialPrompt?: string }) => ipcRenderer.invoke('terminal:spawnAgent', opts),
- spawnProvider: (opts: { providerId: 'claude' | 'codex' | 'spettro'; projectId?: string; cwd?: string; initialPrompt?: string }) => ipcRenderer.invoke('terminal:spawnProvider', opts),
+ spawnProvider: (opts: { providerId: 'claude' | 'codex' | 'spettro' | 'aria'; projectId?: string; cwd?: string; initialPrompt?: string }) => ipcRenderer.invoke('terminal:spawnProvider', opts),
ready: (id: string, cols?: number, rows?: number) => ipcRenderer.send('terminal:ready', id, cols, rows),
write: (id: string, data: string) => ipcRenderer.send('terminal:write', id, data),
resize: (id: string, cols: number, rows: number) => ipcRenderer.send('terminal:resize', id, cols, rows),
@@ -926,6 +926,18 @@ contextBridge.exposeInMainWorld('daemon', {
listReceipts: (limit?: number) => ipcRenderer.invoke('idle:list-receipts', limit),
},
+ agentEconomy: {
+ listProfiles: (projectId?: string | null) => ipcRenderer.invoke('agent-economy:list-profiles', projectId ?? null),
+ upsertProfile: (input: unknown) => ipcRenderer.invoke('agent-economy:upsert-profile', input),
+ getProfile: (profileId: string) => ipcRenderer.invoke('agent-economy:get-profile', profileId),
+ setPolicy: (input: unknown) => ipcRenderer.invoke('agent-economy:set-policy', input),
+ checkPolicy: (input: unknown) => ipcRenderer.invoke('agent-economy:check-policy', input),
+ executePaidCall: (input: unknown) => ipcRenderer.invoke('agent-economy:execute-paid-call', input),
+ listReceipts: (input?: unknown) => ipcRenderer.invoke('agent-economy:list-receipts', input ?? {}),
+ registerDevnetAgent: (input: unknown) => ipcRenderer.invoke('agent-economy:register-devnet-agent', input),
+ readAgentIdentity: (input: unknown) => ipcRenderer.invoke('agent-economy:read-agent-identity', input),
+ },
+
meterflow: {
status: () => ipcRenderer.invoke('meterflow:status'),
storeApiKey: (apiKey: string) => ipcRenderer.invoke('meterflow:store-api-key', apiKey),
diff --git a/electron/services/AgentEconomyService.ts b/electron/services/AgentEconomyService.ts
new file mode 100644
index 00000000..71cb0a21
--- /dev/null
+++ b/electron/services/AgentEconomyService.ts
@@ -0,0 +1,518 @@
+import crypto from 'node:crypto'
+import type Database from 'better-sqlite3'
+import { getDb } from '../db/db'
+import type {
+ AgentEconomyExecutePaidCallInput,
+ AgentEconomyExecutePaidCallResult,
+ AgentEconomyListReceiptsInput,
+ AgentEconomyPolicyCheckInput,
+ AgentEconomyPolicyCheckResult,
+ AgentEconomyProfile,
+ AgentEconomyReadAgentIdentityInput,
+ AgentEconomyReadAgentIdentityResult,
+ AgentEconomyRegisterDevnetAgentInput,
+ AgentEconomyRegisterDevnetAgentResult,
+ AgentEconomySetPolicyInput,
+ AgentEconomySpendPolicy,
+ AgentEconomyUpsertProfileInput,
+ IdleBudgetPolicy,
+ IdlePaidCallReceipt,
+ IdleResource,
+} from '../shared/types'
+import * as IdlePaidCallService from './IdlePaidCallService'
+import * as MetaplexOperatorService from './MetaplexOperatorService'
+
+type ResourceRow = {
+ id: string
+ provider: string
+ type: IdleResource['type']
+ name: string
+ endpoint: string
+ method: 'GET' | 'POST'
+ price_usdc: number
+ asset: string
+ network: string
+ payee: string
+ score: number
+ status: IdleResource['status']
+ schema_json: string
+ registry_url: string | null
+ last_seen_at: number
+}
+
+type ProfileRow = {
+ id: string
+ project_id: string | null
+ agent_id: string | null
+ name: string
+ wallet_id: string | null
+ wallet_address: string | null
+ registry_asset: string | null
+ agent_identity_pda: string | null
+ service_url: string | null
+ capabilities_json: string
+ created_at: number
+ updated_at: number
+}
+
+type PolicyRow = {
+ id: string
+ profile_id: string
+ asset: string
+ network: string
+ allowed_domains_json: string
+ allowed_payees_json: string
+ max_per_call_usdc: number
+ max_per_day_usdc: number
+ expires_at: number | null
+ enabled: number
+ created_at: number
+ updated_at: number
+}
+
+const DEFAULT_LIMIT = 50
+const MAX_LIMIT = 200
+const MINT_REGISTERED_AGENT_ACKNOWLEDGEMENT = 'MINT REGISTERED AGENT'
+
+function dbFrom(db?: Database.Database) {
+ return db ?? getDb()
+}
+
+function parseJson(value: string, fallback: T): T {
+ try {
+ return JSON.parse(value) as T
+ } catch {
+ return fallback
+ }
+}
+
+function cleanString(value: unknown): string | null {
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
+}
+
+function cleanArray(value: unknown): string[] {
+ if (!Array.isArray(value)) return []
+ return [...new Set(value.map(cleanString).filter(Boolean) as string[])]
+}
+
+function normalizeLimit(limit: unknown): number {
+ const parsed = typeof limit === 'number' ? Math.floor(limit) : Number(limit)
+ if (!Number.isFinite(parsed)) return DEFAULT_LIMIT
+ return Math.min(Math.max(1, parsed), MAX_LIMIT)
+}
+
+function policyFromRow(row: PolicyRow): AgentEconomySpendPolicy {
+ return {
+ id: row.id,
+ profileId: row.profile_id,
+ asset: row.asset,
+ network: row.network,
+ allowedDomains: parseJson(row.allowed_domains_json, []),
+ allowedPayees: parseJson(row.allowed_payees_json, []),
+ maxPerCallUsdc: Number(row.max_per_call_usdc),
+ maxPerDayUsdc: Number(row.max_per_day_usdc),
+ expiresAt: row.expires_at,
+ enabled: row.enabled === 1,
+ createdAt: Number(row.created_at),
+ updatedAt: Number(row.updated_at),
+ }
+}
+
+function policyForProfile(profileId: string, db?: Database.Database): AgentEconomySpendPolicy | null {
+ const row = dbFrom(db).prepare('SELECT * FROM agent_spend_policies WHERE profile_id = ? LIMIT 1').get(profileId) as PolicyRow | undefined
+ return row ? policyFromRow(row) : null
+}
+
+function profileFromRow(row: ProfileRow, db?: Database.Database): AgentEconomyProfile {
+ return {
+ id: row.id,
+ projectId: row.project_id,
+ agentId: row.agent_id,
+ name: row.name,
+ walletId: row.wallet_id,
+ walletAddress: row.wallet_address,
+ registryAsset: row.registry_asset,
+ agentIdentityPda: row.agent_identity_pda,
+ serviceUrl: row.service_url,
+ capabilities: parseJson(row.capabilities_json, []),
+ policy: policyForProfile(row.id, db),
+ createdAt: Number(row.created_at),
+ updatedAt: Number(row.updated_at),
+ }
+}
+
+function resourceFromRow(row: ResourceRow): IdleResource {
+ return {
+ id: row.id,
+ provider: row.provider,
+ type: row.type,
+ name: row.name,
+ endpoint: row.endpoint,
+ method: row.method,
+ priceUsdc: Number(row.price_usdc),
+ asset: row.asset,
+ network: row.network,
+ payee: row.payee,
+ score: Number(row.score),
+ status: row.status,
+ schema: parseJson>(row.schema_json, {}),
+ registryUrl: row.registry_url,
+ lastSeenAt: Number(row.last_seen_at),
+ }
+}
+
+function requireProfile(profileId: string, db?: Database.Database): AgentEconomyProfile {
+ const id = cleanString(profileId)
+ if (!id) throw new Error('Profile id is required.')
+ const row = dbFrom(db).prepare('SELECT * FROM agent_economy_profiles WHERE id = ?').get(id) as ProfileRow | undefined
+ if (!row) throw new Error('Agent economy profile was not found.')
+ return profileFromRow(row, db)
+}
+
+function resourceById(resourceId: string, db?: Database.Database): IdleResource | null {
+ const id = cleanString(resourceId)
+ if (!id) return null
+ const row = dbFrom(db).prepare('SELECT * FROM idle_resource_cache WHERE id = ?').get(id) as ResourceRow | undefined
+ return row ? resourceFromRow(row) : null
+}
+
+function walletAddressFor(walletId: string | null, db?: Database.Database): string | null {
+ if (!walletId) return null
+ const row = dbFrom(db).prepare('SELECT address FROM wallets WHERE id = ?').get(walletId) as { address: string } | undefined
+ return row?.address ?? null
+}
+
+function startOfToday(): number {
+ const date = new Date()
+ date.setHours(0, 0, 0, 0)
+ return date.getTime()
+}
+
+function containsValue(values: string[], value: string): boolean {
+ return values.some((item) => item.toLowerCase() === value.toLowerCase())
+}
+
+function endpointHost(endpoint: string): string | null {
+ try {
+ return new URL(endpoint).hostname.toLowerCase()
+ } catch {
+ return null
+ }
+}
+
+function spentToday(profile: AgentEconomyProfile, policy: AgentEconomySpendPolicy, db?: Database.Database): number {
+ const since = startOfToday()
+ const idle = dbFrom(db).prepare(`
+ SELECT COALESCE(SUM(amount_usdc), 0) AS total
+ FROM idle_paid_call_receipts
+ WHERE (? IS NULL OR project_id = ?)
+ AND (? IS NULL OR agent_id = ?)
+ AND lower(asset) = lower(?)
+ AND lower(network) = lower(?)
+ AND status = 'settled'
+ AND created_at >= ?
+ `).get(profile.projectId, profile.projectId, profile.agentId, profile.agentId, policy.asset, policy.network, since) as { total: number }
+
+ const meterflowIds = [profile.registryAsset, profile.agentId].filter(Boolean)
+ if (meterflowIds.length === 0) return Number(idle.total)
+ const placeholders = meterflowIds.map(() => '?').join(',')
+ const meterflow = dbFrom(db).prepare(`
+ SELECT COALESCE(SUM(amount_usd), 0) AS total
+ FROM meterflow_receipts
+ WHERE agent_id IN (${placeholders})
+ AND lower(asset) = lower(?)
+ AND status IN ('settled', 'recorded', 'verified', 'success')
+ AND created_at >= ?
+ `).get(...meterflowIds, policy.asset, since) as { total: number }
+
+ return Number(idle.total) + Number(meterflow.total)
+}
+
+function idlePolicy(policy: AgentEconomySpendPolicy): IdleBudgetPolicy {
+ return {
+ maxPerCallUsdc: policy.maxPerCallUsdc,
+ maxPerTaskUsdc: policy.maxPerDayUsdc,
+ allowedDomains: policy.allowedDomains,
+ allowedNetworks: [policy.network],
+ allowedAssets: [policy.asset],
+ allowedPayees: policy.allowedPayees,
+ receiptRequired: true,
+ humanApproved: true,
+ }
+}
+
+function receiptFromRow(row: Record): IdlePaidCallReceipt {
+ return {
+ id: String(row.id),
+ resourceId: String(row.resource_id),
+ projectId: row.project_id as string | null,
+ taskId: row.task_id as string | null,
+ agentId: row.agent_id as string | null,
+ endpoint: String(row.endpoint),
+ method: String(row.method),
+ amountUsdc: Number(row.amount_usdc),
+ asset: String(row.asset),
+ network: String(row.network),
+ payee: String(row.payee),
+ status: row.status as IdlePaidCallReceipt['status'],
+ paymentId: row.payment_id as string | null,
+ facilitator: row.facilitator as string | null,
+ responseStatus: row.response_status as number | null,
+ responseContentType: row.response_content_type as string | null,
+ responseBytes: row.response_bytes as number | null,
+ errorMessage: row.error_message as string | null,
+ metadata: parseJson>(String(row.metadata_json ?? '{}'), {}),
+ createdAt: Number(row.created_at),
+ updatedAt: Number(row.updated_at),
+ }
+}
+
+export function listProfiles(projectId?: string | null, db?: Database.Database): AgentEconomyProfile[] {
+ const id = cleanString(projectId)
+ const rows = id
+ ? dbFrom(db).prepare('SELECT * FROM agent_economy_profiles WHERE project_id = ? ORDER BY updated_at DESC').all(id)
+ : dbFrom(db).prepare('SELECT * FROM agent_economy_profiles ORDER BY updated_at DESC').all()
+ return (rows as ProfileRow[]).map((row) => profileFromRow(row, db))
+}
+
+export function getProfile(profileId: string, db?: Database.Database): AgentEconomyProfile {
+ return requireProfile(profileId, db)
+}
+
+export function upsertProfile(input: AgentEconomyUpsertProfileInput, db?: Database.Database): AgentEconomyProfile {
+ const name = cleanString(input.name)
+ if (!name) throw new Error('Profile name is required.')
+
+ const id = cleanString(input.id) ?? crypto.randomUUID()
+ const walletId = cleanString(input.walletId)
+ const now = Date.now()
+ dbFrom(db).prepare(`
+ INSERT INTO agent_economy_profiles (
+ id, project_id, agent_id, name, wallet_id, wallet_address, registry_asset,
+ agent_identity_pda, service_url, capabilities_json, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(id) DO UPDATE SET
+ project_id = excluded.project_id,
+ agent_id = excluded.agent_id,
+ name = excluded.name,
+ wallet_id = excluded.wallet_id,
+ wallet_address = excluded.wallet_address,
+ registry_asset = excluded.registry_asset,
+ agent_identity_pda = excluded.agent_identity_pda,
+ service_url = excluded.service_url,
+ capabilities_json = excluded.capabilities_json,
+ updated_at = excluded.updated_at
+ `).run(
+ id,
+ cleanString(input.projectId),
+ cleanString(input.agentId),
+ name,
+ walletId,
+ cleanString(input.walletAddress) ?? walletAddressFor(walletId, db),
+ cleanString(input.registryAsset),
+ cleanString(input.agentIdentityPda),
+ cleanString(input.serviceUrl),
+ JSON.stringify(cleanArray(input.capabilities)),
+ now,
+ now,
+ )
+ return requireProfile(id, db)
+}
+
+export function setPolicy(input: AgentEconomySetPolicyInput, db?: Database.Database): AgentEconomySpendPolicy {
+ requireProfile(input.profileId, db)
+ const asset = cleanString(input.asset)
+ const network = cleanString(input.network)
+ if (!asset) throw new Error('Policy asset is required.')
+ if (!network) throw new Error('Policy network is required.')
+ if (!Number.isFinite(input.maxPerCallUsdc) || input.maxPerCallUsdc <= 0) throw new Error('Per-call budget must be greater than zero.')
+ if (!Number.isFinite(input.maxPerDayUsdc) || input.maxPerDayUsdc <= 0) throw new Error('Daily budget must be greater than zero.')
+ if (input.maxPerCallUsdc > input.maxPerDayUsdc) throw new Error('Per-call budget cannot exceed daily budget.')
+
+ const now = Date.now()
+ dbFrom(db).prepare(`
+ INSERT INTO agent_spend_policies (
+ id, profile_id, asset, network, allowed_domains_json, allowed_payees_json,
+ max_per_call_usdc, max_per_day_usdc, expires_at, enabled, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(profile_id) DO UPDATE SET
+ id = excluded.id,
+ asset = excluded.asset,
+ network = excluded.network,
+ allowed_domains_json = excluded.allowed_domains_json,
+ allowed_payees_json = excluded.allowed_payees_json,
+ max_per_call_usdc = excluded.max_per_call_usdc,
+ max_per_day_usdc = excluded.max_per_day_usdc,
+ expires_at = excluded.expires_at,
+ enabled = excluded.enabled,
+ updated_at = excluded.updated_at
+ `).run(
+ crypto.randomUUID(),
+ input.profileId,
+ asset,
+ network,
+ JSON.stringify(cleanArray(input.allowedDomains).map((item) => item.toLowerCase())),
+ JSON.stringify(cleanArray(input.allowedPayees)),
+ input.maxPerCallUsdc,
+ input.maxPerDayUsdc,
+ input.expiresAt ?? null,
+ input.enabled ? 1 : 0,
+ now,
+ now,
+ )
+
+ const row = dbFrom(db).prepare('SELECT * FROM agent_spend_policies WHERE profile_id = ?').get(input.profileId) as PolicyRow
+ return policyFromRow(row)
+}
+
+export function checkPolicy(input: AgentEconomyPolicyCheckInput, db?: Database.Database): AgentEconomyPolicyCheckResult {
+ const profile = requireProfile(input.profileId, db)
+ const resource = resourceById(input.resourceId, db)
+ const policy = policyForProfile(profile.id, db)
+ const reasons: string[] = []
+
+ if (!resource) reasons.push('Paid resource was not found in the local IDLE registry cache.')
+ if (!policy) reasons.push('No spend policy is configured for this profile.')
+ if (policy) {
+ if (!policy.enabled) reasons.push('Spend policy is disabled.')
+ if (policy.expiresAt !== null && policy.expiresAt <= Date.now()) reasons.push('Spend policy is expired.')
+ }
+ if (resource && policy) {
+ const host = endpointHost(resource.endpoint)
+ if (resource.status !== 'available') reasons.push(`Resource status is ${resource.status}.`)
+ if (resource.asset.toLowerCase() !== policy.asset.toLowerCase()) reasons.push('Payment asset does not match policy.')
+ if (resource.network.toLowerCase() !== policy.network.toLowerCase()) reasons.push('Payment network does not match policy.')
+ if (!host || !containsValue(policy.allowedDomains, host)) reasons.push('Endpoint host is not allowed by policy.')
+ if (!containsValue(policy.allowedPayees, resource.payee)) reasons.push('Payee is not allowed by policy.')
+ if (resource.priceUsdc > policy.maxPerCallUsdc) reasons.push('Resource price exceeds per-call budget.')
+ }
+
+ const spentTodayUsdc = policy ? spentToday(profile, policy, db) : 0
+ const remainingDayBudgetUsdc = policy ? Math.max(0, policy.maxPerDayUsdc - spentTodayUsdc) : 0
+ if (resource && policy && resource.priceUsdc > remainingDayBudgetUsdc) {
+ reasons.push('Resource price exceeds remaining daily budget.')
+ }
+
+ return {
+ allowed: reasons.length === 0,
+ reasons,
+ resource,
+ spentThisTaskUsdc: spentTodayUsdc,
+ remainingTaskBudgetUsdc: remainingDayBudgetUsdc,
+ profile,
+ policy,
+ spentTodayUsdc,
+ remainingDayBudgetUsdc,
+ }
+}
+
+export async function executePaidCall(input: AgentEconomyExecutePaidCallInput, db?: Database.Database): Promise {
+ const check = checkPolicy(input, db)
+ if (!check.allowed || !check.policy || !check.resource) {
+ return { status: 'blocked', allowed: false, reasons: check.reasons, requiresSignature: false, check, receipt: null }
+ }
+
+ const paymentSignature = cleanString(input.paymentSignature)
+ if (!paymentSignature) {
+ return {
+ status: 'ready',
+ allowed: true,
+ reasons: ['Payment signature is required before executing this paid call.'],
+ requiresSignature: true,
+ check,
+ receipt: null,
+ }
+ }
+
+ const receipt = await IdlePaidCallService.executePaidCall({
+ resourceId: input.resourceId,
+ projectId: check.profile.projectId,
+ taskId: input.taskId ?? null,
+ agentId: check.profile.agentId,
+ requestBody: input.requestBody,
+ paymentSignature,
+ approvedBy: cleanString(input.approvedBy),
+ policy: idlePolicy(check.policy),
+ })
+ return {
+ status: 'executed',
+ allowed: receipt.status === 'settled',
+ reasons: receipt.errorMessage ? [receipt.errorMessage] : [],
+ requiresSignature: false,
+ check,
+ receipt,
+ }
+}
+
+export function listReceipts(input: AgentEconomyListReceiptsInput = {}, db?: Database.Database): IdlePaidCallReceipt[] {
+ const profile = input.profileId ? requireProfile(input.profileId, db) : null
+ const projectId = profile?.projectId ?? cleanString(input.projectId)
+ const agentId = profile?.agentId ?? null
+ const rows = dbFrom(db).prepare(`
+ SELECT *
+ FROM idle_paid_call_receipts
+ WHERE (? IS NULL OR project_id = ?)
+ AND (? IS NULL OR agent_id = ?)
+ ORDER BY created_at DESC
+ LIMIT ?
+ `).all(projectId, projectId, agentId, agentId, normalizeLimit(input.limit)) as Array>
+ return rows.map(receiptFromRow)
+}
+
+export async function registerDevnetAgent(input: AgentEconomyRegisterDevnetAgentInput, db?: Database.Database): Promise {
+ const profile = requireProfile(input.profileId, db)
+ const walletId = cleanString(input.walletId) ?? profile.walletId
+ if (!walletId) throw new Error('Profile wallet id is required before registration.')
+ if (input.acknowledgement !== MINT_REGISTERED_AGENT_ACKNOWLEDGEMENT) {
+ throw new Error(`Type ${MINT_REGISTERED_AGENT_ACKNOWLEDGEMENT} before registering the devnet agent.`)
+ }
+
+ const receipt = await MetaplexOperatorService.mintRegisteredAgent({
+ walletId,
+ network: 'devnet',
+ rpcUrl: input.rpcUrl,
+ name: cleanString(input.name) ?? profile.name,
+ description: input.description,
+ uri: input.uri,
+ serviceUrl: input.serviceUrl,
+ priceUsdc: input.priceUsdc,
+ confirmedAt: input.confirmedAt,
+ acknowledgement: input.acknowledgement,
+ })
+ const identity = await MetaplexOperatorService.readAgentIdentity({
+ network: 'devnet',
+ rpcUrl: input.rpcUrl,
+ assetAddress: receipt.asset,
+ })
+
+ dbFrom(db).prepare(`
+ UPDATE agent_economy_profiles
+ SET wallet_id = ?, registry_asset = ?, agent_identity_pda = ?, service_url = ?, updated_at = ?
+ WHERE id = ?
+ `).run(walletId, receipt.asset, identity.agentIdentityPda, cleanString(input.serviceUrl), Date.now(), profile.id)
+ const updated = requireProfile(profile.id, db)
+ return {
+ ...updated,
+ profile: updated,
+ receipt: { ...receipt },
+ identity: { ...identity },
+ }
+}
+
+export async function readAgentIdentity(input: AgentEconomyReadAgentIdentityInput, db?: Database.Database): Promise {
+ const profile = input.profileId ? requireProfile(input.profileId, db) : null
+ const assetAddress = cleanString(input.assetAddress) ?? profile?.registryAsset
+ if (!assetAddress) throw new Error('Agent asset address is required.')
+
+ const identity = await MetaplexOperatorService.readAgentIdentity({
+ network: input.network,
+ rpcUrl: input.rpcUrl,
+ assetAddress,
+ })
+ if (profile && identity.registered) {
+ dbFrom(db).prepare('UPDATE agent_economy_profiles SET agent_identity_pda = ?, updated_at = ? WHERE id = ?')
+ .run(identity.agentIdentityPda, Date.now(), profile.id)
+ }
+
+ return { profile: profile ? requireProfile(profile.id, db) : null, identity: { ...identity } }
+}
diff --git a/electron/services/AriaAgentService.ts b/electron/services/AriaAgentService.ts
index bc8a5d4a..3bcd921e 100644
--- a/electron/services/AriaAgentService.ts
+++ b/electron/services/AriaAgentService.ts
@@ -11,7 +11,7 @@ import crypto from 'node:crypto'
import { getDb } from '../db/db'
import { runClaudeAgentTurn } from './providers/ClaudeProvider'
import * as ProviderRegistry from './providers/ProviderRegistry'
-import { resolveOperatorBackend, getGlmEndpoint, type OperatorEndpoint } from './providers/glmConfig'
+import { resolveOperatorBackend, getGlmEndpoint, type OperatorBackend, type OperatorEndpoint } from './providers/glmConfig'
import { recordLocalAiUsage } from './DaemonAIService'
import { ARIA_TOOLS, getTool } from './aria/toolCatalog'
import * as MemoryService from './MemoryService'
@@ -19,7 +19,8 @@ import { assembleSystemPrompt } from './aria/contextAssembler'
import { clusterMark } from './aria/tools/shared'
import { toAnthropicTools, type AriaTool, type AriaContextSnapshot, type AriaUiEffect } from './aria/AriaTool'
import { laneToClaudeModel, buildPlanSteps, buildPatchProposal } from './aria/patchUtils'
-import type { AgentMessage } from './providers/agentTurn'
+import type { AgentMessage, AgentToolUse } from './providers/agentTurn'
+import type { ProviderId } from './providers/ProviderInterface'
import type {
AriaMessage, AriaSession, AriaResponse, AriaToolCallRecord, AriaToolEvent,
AriaPatchProposalLite, AriaPatchAction, AriaPlanStep, DaemonAiModelLane, MemoryKind,
@@ -30,9 +31,18 @@ const AGENT_DEADLINE_MS = 120_000
const MAX_HISTORY = 40
const DEFAULT_LANE: DaemonAiModelLane = 'auto'
+/** An emit-time event: messageId may be omitted at the call site because the
+ * transport stamps it with the session id before it reaches the renderer (which
+ * requires it for session isolation). The wire type AriaToolEvent still requires
+ * messageId, so the guarantee holds at the boundary. */
+export type AriaEmitEvent =
+ | AriaToolEvent
+ | Omit, 'messageId'>
+ | Omit, 'messageId'>
+
export interface AriaTransport {
/** Stream a transcript event to the renderer. */
- emit: (event: AriaToolEvent) => void
+ emit: (event: AriaEmitEvent) => void
/** Pause for a write/sensitive tool; resolves true on approval. */
requestApproval: (req: {
callId: string
@@ -53,6 +63,45 @@ type ConversationEntry = { role: 'user' | 'assistant'; content: string }
const conversations = new Map()
const textHistory = new Map()
+function isProviderAuthed(conn: { isAuthenticated: boolean; authMode: string } | null): boolean {
+ return Boolean(conn && (conn.isAuthenticated || conn.authMode !== 'none'))
+}
+
+async function verifyPreferredProvider(id: ProviderId): Promise {
+ try {
+ const provider = ProviderRegistry.get(id)
+ // Fast path: a cached connection (in-memory or a <24h app_settings row) avoids
+ // re-spawning `claude --version` on every turn. Only do the full async verify
+ // on a cold cache.
+ const cached = provider.getConnection()
+ if (cached) return isProviderAuthed(cached)
+ return isProviderAuthed(await provider.verifyConnection())
+ } catch {
+ return false
+ }
+}
+
+/**
+ * Warm the backend-resolution caches off the critical path so the first turn
+ * doesn't pay for a `claude --version` verify. Safe to call fire-and-forget at
+ * startup; errors are swallowed (resolution re-runs lazily on the first turn).
+ */
+export async function prewarmBackend(): Promise {
+ try {
+ await resolveAriaBackend()
+ } catch { /* lazy re-resolve on first turn */ }
+}
+
+async function resolveAriaBackend(): Promise {
+ const preferred = ProviderRegistry.getPreferences().aria.provider
+ const backend = resolveOperatorBackend()
+ if (backend === 'glm') return backend
+ if (backend === 'claude' && await verifyPreferredProvider('claude')) return backend
+ if (preferred === 'codex') return null
+ if (backend === 'codex') return null
+ return null
+}
+
/**
* Trim old turns without orphaning a tool_use/tool_result pair: drop from the
* front, then ensure the first remaining message isn't a tool_result-only user
@@ -74,6 +123,99 @@ function isToolResultTurn(msg: AgentMessage): boolean {
)
}
+function isStaleNoToolResponse(text: string): boolean {
+ const lower = text.toLowerCase()
+ return [
+ "don't have a list",
+ "do not have a list",
+ "don't have directory",
+ "can't browse",
+ "can't enumerate",
+ 'cannot enumerate',
+ 'directory-listing',
+ 'list directory',
+ 'tell me a filename',
+ 'name a file',
+ 'give me the path',
+ 'specific file if you give me',
+ ].some((pattern) => lower.includes(pattern))
+}
+
+/**
+ * Fast path for a handful of read-only tools when the message is an UNAMBIGUOUS command, not a
+ * natural-language question. Prior behavior matched bare keyword containment ('status',
+ * 'directory', any Windows path), which hijacked ordinary questions ('why does C:\\...\\x.ts
+ * throw?', 'check the swap status') away from the model and silently switched projects. Now this
+ * only fires on an exact tool name or a short imperative at the START of the message; anything
+ * conversational falls through to the LLM. Returns [] to mean "let the model handle it".
+ */
+function directToolUsesForMessage(message: string): AgentToolUse[] {
+ const text = message.trim()
+ // Guard: never intercept a longer, sentence-like message — those are questions for the model.
+ if (text.length > 64 || /[?]/.test(text)) return []
+
+ const uses: AgentToolUse[] = []
+ const add = (name: string, input: Record = {}) => {
+ uses.push({ id: `direct_${crypto.randomUUID()}`, name, input })
+ }
+
+ // Bare project-status command only (exact, not the word 'status' inside a sentence).
+ if (/^(read_project_status|project status|status)$/i.test(text)) {
+ add('read_project_status')
+ } else if (/^(recall_memories|recall memories|stored facts|what do you know)$/i.test(text)) {
+ add('recall_memories')
+ } else if (/^(list_project_tree|list files|show files|list directory|ls)$/i.test(text)) {
+ add('list_project_tree', { path: '.', limit: 80 })
+ } else {
+ // Imperative tool invocations: an explicit verb followed by an argument.
+ const open = text.match(/^(?:open_file|open file|open)\s+(.+)$/i)?.[1]?.trim()
+ if (open) add('open_file', { path: open })
+ const read = text.match(/^(?:read_file|read file|read)\s+(.+)$/i)?.[1]?.trim()
+ if (read) add('read_file', { path: read })
+ // Explicit project switch only ('open/switch to '), never a bare path in prose.
+ const activate = text.match(/^(?:activate_project|activate|switch to|open project)\s+([A-Za-z]:\\[^\r\n"'`]+)$/i)?.[1]?.trim()
+ if (activate) add('activate_project', { project: activate.replace(/[)\],;]+$/, '') })
+ }
+
+ const readOnly = new Set(['activate_project', 'read_project_status', 'recall_memories', 'list_project_tree', 'open_file', 'read_file'])
+ return uses.filter((use) => readOnly.has(use.name))
+}
+
+function summarizeDirectToolCalls(calls: AriaToolCallRecord[]): string {
+ const sections: string[] = []
+ for (const call of calls) {
+ if (call.status !== 'done') {
+ sections.push(`${call.name}: ${call.summary}`)
+ continue
+ }
+ if (call.name === 'list_project_tree') {
+ const entries = ((call.result as { entries?: Array<{ path: string }> })?.entries ?? []).slice(0, 80)
+ sections.push(entries.length > 0
+ ? `Files:\n${entries.map((entry) => `- ${entry.path}`).join('\n')}`
+ : 'Files: none found.')
+ continue
+ }
+ if (call.name === 'recall_memories') {
+ const memories = (call.result as { memories?: Array<{ kind: string; title: string; value: string }> })?.memories ?? []
+ sections.push(memories.length > 0
+ ? `Stored facts:\n${memories.map((m) => `- ${m.kind}: ${m.title} - ${m.value}`).join('\n')}`
+ : 'Stored facts: none.')
+ continue
+ }
+ if (call.name === 'read_project_status' && typeof call.result === 'object') {
+ sections.push(`Project status:\n\`\`\`json\n${JSON.stringify(call.result, null, 2)}\n\`\`\``)
+ continue
+ }
+ if (call.name === 'read_file' && typeof call.result === 'object') {
+ const result = call.result as { path?: string; content?: string; truncated?: boolean }
+ sections.push(`${result.path ?? 'file'}${result.truncated ? ' (truncated)' : ''}:\n\`\`\`\n${result.content ?? ''}\n\`\`\``)
+ continue
+ }
+ sections.push(call.summary)
+ }
+ return sections.join('\n\n')
+}
+
function persistMessage(msg: Omit): string {
const db = getDb()
const id = crypto.randomUUID()
@@ -110,11 +252,44 @@ export async function sendMessage(
persistMessage({ role: 'user', content: userMessage, metadata: '{}', session_id: sessionId })
touchSession(sessionId, userMessage)
+ const tools = toAnthropicTools(ARIA_TOOLS)
+ const { system, recalled } = await assembleSystemPrompt(snapshot)
+ if (recalled.length > 0) {
+ transport.emit({ kind: 'memory-recall', messageId: sessionId, recalled })
+ }
+ // Mutable plan/patch state the special-cased tools fill in as the loop runs.
+ const turnState: TurnState = { plan: [], patch: null, planApproved: false }
+ const ctx = { sessionId, snapshot, runUiEffect: transport.runUiEffect }
+
+ const toolCalls: AriaToolCallRecord[] = []
+ const directUses = directToolUsesForMessage(userMessage)
+ if (directUses.length > 0) {
+ for (const use of directUses) {
+ toolCalls.push(await executeTool(use, ctx, transport, turnState))
+ }
+ const finalText = summarizeDirectToolCalls(toolCalls)
+ messages.push({ role: 'assistant', content: finalText })
+ trimHistory(messages)
+ text.push({ role: 'assistant', content: finalText })
+ persistMessage({
+ role: 'assistant',
+ content: finalText,
+ metadata: JSON.stringify({ toolCalls }),
+ session_id: sessionId,
+ })
+ touchSession(sessionId)
+ transport.emit({ kind: 'done', messageId: sessionId, text: finalText })
+ return { text: finalText, actions: [], toolCalls }
+ }
+
// Pick the operator backend by what's actually usable (GLM preferred — cheapest).
// GLM and Claude both run the full Anthropic tool-loop; Codex is chat-only; none -> error.
- const backend = resolveOperatorBackend()
+ const backend = await resolveAriaBackend()
if (!backend) {
- const message = 'No AI provider is ready. Add a Z.AI (GLM) key or an Anthropic API key in Settings, or sign into Codex.'
+ const preferred = ProviderRegistry.getPreferences().aria.provider
+ const message = preferred === 'codex'
+ ? 'ARIA needs a tool-capable backend for operator actions. Codex is text-only here; add a ZAI_API_KEY or sign in to Claude in Settings.'
+ : `ARIA is set to ${preferred}, but that provider is not ready. Update AI Providers in Settings or sign in to Claude.`
transport.emit({ kind: 'done', messageId: sessionId, text: message })
text.push({ role: 'assistant', content: message })
persistMessage({ role: 'assistant', content: message, metadata: '{}', session_id: sessionId })
@@ -127,19 +302,10 @@ export async function sendMessage(
// backend is 'glm' or 'claude' — both drive the tool-loop below.
const endpoint = backend === 'glm' ? getGlmEndpoint() ?? undefined : undefined
- const tools = toAnthropicTools(ARIA_TOOLS)
- const { system, recalled } = await assembleSystemPrompt(snapshot)
- if (recalled.length > 0) {
- transport.emit({ kind: 'memory-recall', messageId: sessionId, recalled })
- }
- // Mutable plan/patch state the special-cased tools fill in as the loop runs.
- const turnState: TurnState = { plan: [], patch: null, planApproved: false }
- const ctx = { sessionId, snapshot, runUiEffect: transport.runUiEffect }
-
- const toolCalls: AriaToolCallRecord[] = []
let finalText = ''
const deadline = Date.now() + AGENT_DEADLINE_MS
let promptForUsage = userMessage
+ let retriedStaleNoToolResponse = false
try {
for (let iter = 0; iter < MAX_ITERATIONS; iter++) {
@@ -150,13 +316,28 @@ export async function sendMessage(
const turn = await runClaudeAgentTurn({ messages, system, model, tools, maxTokens: 2048 }, endpoint)
promptForUsage = system
+
+ if (turn.toolUses.length === 0) {
+ if (!retriedStaleNoToolResponse && isStaleNoToolResponse(turn.text)) {
+ retriedStaleNoToolResponse = true
+ messages.push({
+ role: 'user',
+ content: 'You answered without using tools. Use the available ARIA tools now: activate_project for project paths, list_project_tree for directory contents, read_file/open_file for files, and read_project_status plus recall_memories for project knowledge. Do not ask for a filename unless the user specifically requests opening one.',
+ })
+ continue
+ }
+ if (turn.text) {
+ finalText = turn.text
+ transport.emit({ kind: 'assistant-text', messageId: sessionId, text: turn.text })
+ }
+ break
+ }
+
if (turn.text) {
finalText = turn.text
transport.emit({ kind: 'assistant-text', messageId: sessionId, text: turn.text })
}
- if (turn.toolUses.length === 0) break
-
// Record the assistant turn (with its tool_use blocks) verbatim.
messages.push({
role: 'assistant',
@@ -180,6 +361,10 @@ export async function sendMessage(
messages.push({ role: 'user', content: toolResults })
}
} catch (err) {
+ const fallback = ProviderRegistry.getFeatureProvider('aria')
+ if (fallback.id === 'claude' && await verifyPreferredProvider('claude')) {
+ return legacyAnswer(sessionId, userMessage, fallback, text, transport)
+ }
finalText = `Error: ${(err as Error).message}`
}
@@ -434,7 +619,10 @@ async function handlePresentPlan(
turnState.plan = buildPlanSteps(rawSteps.map((s) => String(s?.title ?? '')))
if (turnState.plan.length > 0) {
turnState.plan[0].status = 'active'
- transport.emit({ kind: 'plan', messageId: use.id, steps: turnState.plan.map((s) => ({ ...s })) })
+ // messageId is the session id on every other emit; the renderer drops events
+ // whose messageId != the active session, so this must be ctx.sessionId (not
+ // the tool-call id) or plan events would be discarded after a session switch.
+ transport.emit({ kind: 'plan', messageId: ctx.sessionId, steps: turnState.plan.map((s) => ({ ...s })) })
}
// Plan mode: pause for one approval. Reuses the approval transport with a
@@ -509,10 +697,18 @@ async function handleProposePatch(
}
function describeIntent(tool: AriaTool, input: Record): string {
- const arg = Object.values(input)[0]
- const intent = `${tool.name}${arg !== undefined ? `: ${String(arg).slice(0, 80)}` : ''}`
- // Approval cards must make the network unmistakable before the user decides.
- return tool.risk === 'read' ? intent : clusterMark(intent)
+ // For read tools a short first-arg summary is fine. For write/sensitive tools the user is
+ // typed-confirming a real action, so show EVERY material field (amount, side, size, leverage,
+ // destination, cap) — not just the first value — so a money movement's amount is never hidden.
+ if (tool.risk === 'read') {
+ const arg = Object.values(input)[0]
+ return `${tool.name}${arg !== undefined ? `: ${String(arg).slice(0, 80)}` : ''}`
+ }
+ const entries = Object.entries(input)
+ .filter(([, v]) => v !== undefined && v !== null && v !== '')
+ .map(([k, v]) => `${k}=${String(v).slice(0, 60)}`)
+ const detail = entries.length > 0 ? ` — ${entries.join(', ')}` : ''
+ return clusterMark(`${tool.name}${detail}`)
}
/** Non-Claude providers: single-shot text answer, no tools. */
diff --git a/electron/services/AriaTerminalBackendService.ts b/electron/services/AriaTerminalBackendService.ts
new file mode 100644
index 00000000..fa7ff350
--- /dev/null
+++ b/electron/services/AriaTerminalBackendService.ts
@@ -0,0 +1,439 @@
+import crypto from 'node:crypto'
+import fs from 'node:fs'
+import path from 'node:path'
+import readline from 'node:readline'
+import { app } from 'electron'
+import { getDb } from '../db/db'
+import * as AriaAgentService from './AriaAgentService'
+import * as SettingsService from './SettingsService'
+import type { AriaTransport, AriaEmitEvent } from './AriaAgentService'
+import { ARIA_TOOLS, getTool } from './aria/toolCatalog'
+import type { AriaToolContext } from './aria/AriaTool'
+import { getCommand, commandManifest } from './aria/cli/commandRegistry'
+import type { AriaCommandActions } from './aria/cli/commandRegistry'
+import { buildBanner } from './aria/cli/aria-boot-banner'
+import { ARIA_THEME_COLORS, type AriaThemeColor } from './aria/cli/ansi-theme'
+import type { AriaToolManifestEntry } from './aria/cli/frames'
+import type {
+ AriaContextSnapshot,
+ AriaPatchAction,
+ AriaPatchProposalLite,
+ AriaSession,
+ AriaToolEvent,
+ DaemonAiModelLane,
+ Project,
+} from '../shared/types'
+
+type AriaMode = 'plan' | 'coding' | 'ask'
+type PendingKind = 'approval' | 'patch'
+
+/** Internal tools intercepted by the agent loop, not real operator actions. */
+const INTERNAL_TOOL_NAMES = new Set(['present_plan', 'propose_patch'])
+
+interface AriaServerOptions {
+ cwd: string
+ projectId: string | null
+ sessionId: string | null
+ modelLane: DaemonAiModelLane
+ mode: AriaMode
+ initialPrompt: string | null
+}
+
+interface RuntimeState {
+ session: AriaSession
+ snapshot: AriaContextSnapshot
+ modelLane: DaemonAiModelLane
+ mode: AriaMode
+}
+
+interface PendingRequest {
+ kind: PendingKind
+ resolve: (value: boolean | AriaPatchAction) => void
+}
+
+const MODEL_LANES = new Set(['auto', 'fast', 'standard', 'reasoning', 'premium'])
+const MODES = new Set(['plan', 'coding', 'ask'])
+const pending = new Map()
+
+function emit(type: string, payload: Record = {}): void {
+ process.stdout.write(`${JSON.stringify({ type, ...payload })}\n`)
+}
+
+function parseArgs(argv: string[]): AriaServerOptions {
+ const options: AriaServerOptions = {
+ cwd: process.cwd(),
+ projectId: null,
+ sessionId: null,
+ modelLane: 'auto',
+ mode: 'coding',
+ initialPrompt: null,
+ }
+ const positional: string[] = []
+
+ for (let i = 0; i < argv.length; i += 1) {
+ const arg = argv[i]
+ if (arg === '--aria-server') continue
+ if (arg === '--cwd' && argv[i + 1]) {
+ options.cwd = argv[++i]
+ continue
+ }
+ if (arg === '--project' && argv[i + 1]) {
+ options.projectId = argv[++i]
+ continue
+ }
+ if (arg === '--session' && argv[i + 1]) {
+ options.sessionId = argv[++i]
+ continue
+ }
+ if (arg === '--model' && argv[i + 1]) {
+ const lane = argv[++i] as DaemonAiModelLane
+ if (!MODEL_LANES.has(lane)) throw new Error(`Unknown model lane "${lane}".`)
+ options.modelLane = lane
+ continue
+ }
+ if (arg === '--plan') {
+ options.mode = 'plan'
+ continue
+ }
+ if (arg === '--mode' && argv[i + 1]) {
+ const mode = argv[++i] as AriaMode
+ if (!MODES.has(mode)) throw new Error(`Unknown mode "${mode}".`)
+ options.mode = mode
+ continue
+ }
+ positional.push(arg)
+ }
+
+ options.initialPrompt = positional.join(' ').trim() || null
+ return options
+}
+
+function normalizePath(value: string): string {
+ return path.resolve(value).replace(/\\/g, '/').replace(/\/$/, '').toLowerCase()
+}
+
+function getProjectById(id: string): Project | null {
+ return getDb().prepare('SELECT * FROM projects WHERE id = ?').get(id) as Project | undefined ?? null
+}
+
+function findProjectByPath(projectPath: string): Project | null {
+ const normalized = normalizePath(projectPath)
+ const rows = getDb().prepare('SELECT * FROM projects').all() as Project[]
+ return rows.find((project) => normalizePath(project.path) === normalized) ?? null
+}
+
+function createSnapshot(project: Project | null, cwd: string, mode: AriaMode): AriaContextSnapshot {
+ return {
+ activeProjectId: project?.id ?? null,
+ activeProjectPath: project?.path ?? cwd,
+ currentPanelId: null,
+ openFilePath: null,
+ chips: {
+ activeFile: false,
+ projectTree: true,
+ gitDiff: true,
+ terminalLogs: false,
+ // Off by default: injecting wallet context blocks every turn on a Helius
+ // dashboard RPC (slow, rate-limited). The agent fetches wallet data on
+ // demand via read_wallet / read_project_status when actually asked.
+ walletContext: false,
+ projectMemory: true,
+ },
+ planMode: mode === 'plan',
+ }
+}
+
+function resolveSession(sessionId: string | null, projectId: string | null): AriaSession {
+ if (sessionId) {
+ const row = getDb().prepare('SELECT * FROM aria_sessions WHERE id = ?').get(sessionId) as AriaSession | undefined
+ if (row) return row
+ getDb().prepare('INSERT INTO aria_sessions (id, title, project_id) VALUES (?,?,?)')
+ .run(sessionId, 'Aria terminal', projectId)
+ return getDb().prepare('SELECT * FROM aria_sessions WHERE id = ?').get(sessionId) as AriaSession
+ }
+
+ return AriaAgentService.createSession(projectId, 'Aria terminal')
+}
+
+function getDefaultWalletName(): string | null {
+ const row = getDb().prepare(
+ 'SELECT name FROM wallets ORDER BY is_default DESC, created_at ASC LIMIT 1'
+ ).get() as { name: string } | undefined
+ return row?.name ?? null
+}
+
+function emitState(state: RuntimeState): void {
+ const infra = SettingsService.getWalletInfrastructureSettings()
+ emit('state', {
+ session: state.session,
+ projectPath: state.snapshot.activeProjectPath,
+ network: `${infra.cluster} (${infra.rpcProvider})`,
+ wallet: getDefaultWalletName(),
+ modelLane: state.modelLane,
+ mode: state.mode,
+ })
+}
+
+/** Ship the command catalog + theme tokens so the launcher generates its own UI. */
+function emitManifest(): void {
+ emit('manifest', {
+ commands: commandManifest(),
+ themeTokens: Object.keys(ARIA_THEME_COLORS) as AriaThemeColor[],
+ })
+}
+
+/** Ship the boot banner computed from real version/cluster/session state. */
+function emitBanner(state: RuntimeState): void {
+ const infra = SettingsService.getWalletInfrastructureSettings()
+ const { type: _type, ...banner } = buildBanner({
+ version: app.getVersion(),
+ cluster: infra.cluster,
+ rpcProvider: infra.rpcProvider,
+ wallet: getDefaultWalletName(),
+ projectPath: state.snapshot.activeProjectPath,
+ session: state.session.id,
+ modelLane: state.modelLane,
+ mode: state.mode,
+ })
+ emit('banner', banner)
+}
+
+function makeTransport(state: RuntimeState): AriaTransport {
+ return {
+ emit: (event: AriaEmitEvent) => emit('event', { event }),
+ requestApproval: async (req) => {
+ const id = crypto.randomUUID()
+ emit('approval', { id, request: req })
+ return new Promise((resolve) => {
+ pending.set(id, { kind: 'approval', resolve: resolve as PendingRequest['resolve'] })
+ })
+ },
+ requestPatchDecision: async (proposal: AriaPatchProposalLite): Promise => {
+ const id = crypto.randomUUID()
+ emit('patchDecision', { id, proposal })
+ return new Promise((resolve) => {
+ pending.set(id, { kind: 'patch', resolve: resolve as PendingRequest['resolve'] })
+ })
+ },
+ runUiEffect: async (effect) => {
+ if (effect.type === 'set_active_project') {
+ state.snapshot.activeProjectId = effect.projectId
+ state.snapshot.activeProjectPath = effect.projectPath
+ emitState(state)
+ return undefined
+ }
+ if (effect.type === 'open_file') {
+ state.snapshot.openFilePath = effect.path
+ emit('log', { level: 'info', message: `Open file: ${effect.path}` })
+ return undefined
+ }
+ emit('log', { level: 'info', message: `UI effect skipped in terminal: ${effect.type}` })
+ return undefined
+ },
+ }
+}
+
+async function sendLine(line: string, state: RuntimeState): Promise {
+ emit('busy', { busy: true })
+ try {
+ const response = await AriaAgentService.sendMessage(
+ state.session.id,
+ line,
+ state.snapshot,
+ makeTransport(state),
+ state.modelLane,
+ )
+ emit('response', { text: response.text, toolCalls: response.toolCalls ?? [] })
+ } catch (err) {
+ emit('error', { message: err instanceof Error ? err.message : String(err) })
+ } finally {
+ emit('busy', { busy: false })
+ }
+}
+
+/** A no-op tool context for read-only catalog tools (no UI effects in the CLI). */
+function toolContext(state: RuntimeState): AriaToolContext {
+ return { sessionId: state.session.id, snapshot: state.snapshot, runUiEffect: async () => undefined }
+}
+
+/** Project the ARIA tool catalog for /tools (drops internal planning tools). */
+function listToolCatalog(): AriaToolManifestEntry[] {
+ return ARIA_TOOLS
+ .filter((tool) => !INTERNAL_TOOL_NAMES.has(tool.name))
+ .map((tool) => ({ name: tool.name, kind: tool.kind, risk: tool.risk, description: tool.description }))
+}
+
+async function emitStatus(state: RuntimeState): Promise {
+ const tool = getTool('read_project_status')
+ if (!tool) return
+ const result = await tool.handler({}, toolContext(state)).catch((err) => ({ ok: false, summary: String(err), data: undefined }))
+ if (!result.ok) { emit('log', { level: 'warn', message: result.summary }); return }
+ emit('status', { status: result.data })
+}
+
+async function emitMemories(state: RuntimeState): Promise {
+ const tool = getTool('recall_memories')
+ if (!tool) return
+ const result = await tool.handler({}, toolContext(state)).catch((err) => ({ ok: false, summary: String(err), data: undefined }))
+ if (!result.ok) { emit('log', { level: 'warn', message: result.summary }); return }
+ const memories = (result.data as { memories?: Array<{ kind: string; title: string; value: string }> })?.memories ?? []
+ emit('memories', { memories })
+}
+
+/** State-affecting operations a command may invoke, bound to the live session. */
+function makeCommandActions(state: RuntimeState): AriaCommandActions {
+ return {
+ exit: () => emit('exit'),
+ listSessions: () => emit('sessions', {
+ sessions: AriaAgentService.listSessions(state.snapshot.activeProjectId).slice(0, 20),
+ }),
+ resumeSession: (id) => {
+ const row = getDb().prepare('SELECT * FROM aria_sessions WHERE id = ?').get(id) as AriaSession | undefined
+ if (!row) return false
+ state.session = row
+ emitState(state)
+ return true
+ },
+ newSession: () => {
+ state.session = AriaAgentService.createSession(state.snapshot.activeProjectId, 'Aria terminal')
+ emitState(state)
+ },
+ clearSession: () => {
+ AriaAgentService.clearSession(state.session.id)
+ emit('log', { level: 'info', message: 'Session cleared.' })
+ },
+ setModelLane: (lane) => {
+ if (!MODEL_LANES.has(lane as DaemonAiModelLane)) return false
+ state.modelLane = lane as DaemonAiModelLane
+ emitState(state)
+ return true
+ },
+ setMode: (mode) => {
+ if (!MODES.has(mode as AriaMode)) return false
+ state.mode = mode as AriaMode
+ state.snapshot.planMode = state.mode === 'plan'
+ emitState(state)
+ return true
+ },
+ setPlan: (on) => {
+ state.mode = on ? 'plan' : 'coding'
+ state.snapshot.planMode = on
+ emitState(state)
+ },
+ showHelp: () => emit('help'),
+ listTools: () => emit('tools', { tools: listToolCatalog() }),
+ showStatus: () => emitStatus(state),
+ listMemories: () => emitMemories(state),
+ }
+}
+
+async function handleCommand(line: string, state: RuntimeState): Promise {
+ const [name, ...rest] = line.slice(1).trim().split(/\s+/)
+ const command = getCommand(name)
+ if (!command) {
+ emit('log', { level: 'warn', message: `Unknown command: /${name}` })
+ return true
+ }
+ const outcome = await command.handler({
+ arg: rest.join(' ').trim(),
+ emit,
+ actions: makeCommandActions(state),
+ })
+ return outcome?.continue !== false
+}
+
+async function handleInput(text: string, state: RuntimeState): Promise {
+ const line = text.trim()
+ if (!line) return true
+ emit('user', { text: line })
+ if (line.startsWith('/')) return handleCommand(line, state)
+ await sendLine(line, state)
+ return true
+}
+
+function resolvePending(payload: Record): boolean {
+ const id = typeof payload.id === 'string' ? payload.id : ''
+ const request = pending.get(id)
+ if (!request) return false
+ pending.delete(id)
+
+ if (request.kind === 'approval') {
+ request.resolve(Boolean(payload.approved))
+ return true
+ }
+ const action = payload.action === 'keep' || payload.action === 'run-tests' ? payload.action : 'discard'
+ request.resolve(action)
+ return true
+}
+
+export async function runAriaServer(argv = process.argv.slice(2)): Promise {
+ const options = parseArgs(argv)
+ const cwd = path.resolve(options.cwd)
+ if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) {
+ throw new Error(`Invalid cwd: ${cwd}`)
+ }
+
+ const project = options.projectId ? getProjectById(options.projectId) : findProjectByPath(cwd)
+ const snapshot = createSnapshot(project, cwd, options.mode)
+ const session = resolveSession(options.sessionId, snapshot.activeProjectId)
+ const state: RuntimeState = { session, snapshot, modelLane: options.modelLane, mode: options.mode }
+ emitManifest()
+ emitBanner(state)
+ emitState(state)
+ emit('ready')
+ // Warm backend resolution (provider auth check) off the critical path so the
+ // first message doesn't block on a `claude --version` verify.
+ void AriaAgentService.prewarmBackend()
+ if (options.initialPrompt) void handleInput(options.initialPrompt, state)
+
+ // Read fd 0 directly rather than process.stdin: under Electron's main process
+ // process.stdin is not wired to the real pipe and reads as immediate EOF, which
+ // would end the loop and exit the app before any frame is read.
+ const stdinStream = fs.createReadStream('', { fd: 0 })
+ stdinStream.on('error', () => { /* EPIPE when the parent exits; loop ends naturally */ })
+ const rl = readline.createInterface({ input: stdinStream, crlfDelay: Infinity })
+
+ // The frame loop must NOT block on a turn: an in-flight turn can pause for an
+ // approval/patch decision, and that resolution arrives as a later stdin frame.
+ // If we awaited handleInput here, the approval frame could never be read and the
+ // turn would deadlock. So input frames run through a serialized queue (one turn
+ // at a time), while approval/patchDecision/exit frames are processed inline —
+ // out-of-band — even while a turn is running.
+ let turnChain: Promise = Promise.resolve()
+ let exiting = false
+ const enqueueTurn = (text: string) => {
+ turnChain = turnChain.then(async () => {
+ if (exiting) return
+ try {
+ const shouldContinue = await handleInput(text, state)
+ if (!shouldContinue) { exiting = true; rl.close() }
+ } catch (err) {
+ emit('error', { message: err instanceof Error ? err.message : String(err) })
+ }
+ })
+ }
+
+ for await (const line of rl) {
+ let payload: Record
+ try {
+ payload = JSON.parse(line) as Record
+ } catch {
+ emit('error', { message: 'Invalid protocol frame.' })
+ continue
+ }
+ if (payload.type === 'exit') {
+ // Drain queued turns before exiting so piped input is fully answered.
+ exiting = true
+ break
+ }
+ if (payload.type === 'approval' || payload.type === 'patchDecision') {
+ resolvePending(payload)
+ continue
+ }
+ if (payload.type === 'input' && typeof payload.text === 'string') {
+ enqueueTurn(payload.text)
+ }
+ }
+ // Let any in-flight / queued turns settle before the process exits.
+ await turnChain
+}
diff --git a/electron/services/AutopilotScheduler.ts b/electron/services/AutopilotScheduler.ts
index 73e30ab4..011eccdb 100644
--- a/electron/services/AutopilotScheduler.ts
+++ b/electron/services/AutopilotScheduler.ts
@@ -1,5 +1,5 @@
import { BrowserWindow } from 'electron'
-import { listArmedMandates, tickMandate, getMandateOrThrow } from './AutopilotService'
+import { listArmedMandates, tickMandate, getMandateOrThrow, reconcileInterruptedTicks } from './AutopilotService'
// Autopilot scheduler.
// A single main-process loop that wakes due mandates and runs one tick each. Modeled on the
@@ -66,6 +66,14 @@ async function sweep(): Promise {
/** Start the scheduler loop. Idempotent — a second call is a no-op. */
export function start(): void {
if (timer) return
+ // Reconcile any tick interrupted mid-swap by a crash BEFORE the first sweep, so a mandate
+ // held for review is never replayed into a double-buy.
+ try {
+ const held = reconcileInterruptedTicks()
+ if (held > 0) console.warn(`[autopilot] held ${held} mandate(s) with an interrupted tick for review`)
+ } catch (err) {
+ console.warn('[autopilot] boot reconcile failed:', err instanceof Error ? err.message : String(err))
+ }
timer = setInterval(() => {
void sweep().catch((err) => {
console.warn('[autopilot] sweep error:', err instanceof Error ? err.message : String(err))
diff --git a/electron/services/AutopilotService.ts b/electron/services/AutopilotService.ts
index cc84ec56..fe1df52c 100644
--- a/electron/services/AutopilotService.ts
+++ b/electron/services/AutopilotService.ts
@@ -3,8 +3,7 @@ import { PublicKey } from '@solana/web3.js'
import { getAssociatedTokenAddress, getAccount, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '@solana/spl-token'
import { getDb } from '../db/db'
import { getConnectionStrict } from './SolanaService'
-import { getSwapQuote, executeSwap, getMintDecimals } from './WalletService'
-import { quoteExecutionFee } from './FeeService'
+import { getSwapQuote, executeSwap, getMintDecimals, getServerSwapImpactPct } from './WalletService'
import { getWalletInfrastructureSettings } from './SettingsService'
import type {
Mandate,
@@ -21,16 +20,31 @@ import type {
// ARIA) that the scheduler evaluates on a fixed cadence and executes UNATTENDED on mainnet.
// Arming a mandate is the human authorization; from then on the scheduler reproduces a
// reviewed Jupiter quote server-side and runs it through the SAME guarded executeSwap path
-// a manual trade uses — so the signer guard and execution fee meter both fire. No guard is
-// bypassed. Every tick writes one autopilot_actions row (BUY/SELL/HOLD/SKIP), uniquely keyed
-// by (mandate_id, tick_seq), so a restart mid-tick never double-fires and the Desk can replay
-// exactly what the agent did.
+// a manual trade uses — so the signer guard fires. No guard is bypassed.
+//
+// Crash safety: each tick FIRST claims its tick_seq by writing an 'executing' intent row and
+// advancing next_tick_at, THEN swaps, THEN flips the row to executed/failed. On boot the
+// scheduler reconciles any 'executing' rows (a crash between the swap and the flip) and holds
+// the mandate for review rather than replaying — so a restart mid-tick never double-buys.
+//
+// Cluster safety: arming asserts mainnet, and EVERY tick re-checks that the live cluster still
+// matches the mandate's cluster before quoting — a switch to devnet auto-holds armed mandates
+// instead of letting devnet balances green-light real mainnet spends.
+//
+// Note on fees: swaps sign Jupiter's prebuilt VersionedTransaction, which the execution fee
+// meter cannot append a transfer leg to, so autopilot swaps are NOT fee-metered — fee_lamports
+// is recorded as null (never a fabricated charge). Only SOL transfers carry the fee today.
const SOL_MINT = 'So11111111111111111111111111111111111111112'
// A mandate's exposure cap is a hard ceiling; never let a single clip exceed what remains.
const LAMPORTS_PER_SOL = 1e9
// Keep a little SOL back so a buy never drains the wallet below rent + tx fees.
const WALLET_SOL_RESERVE_LAMPORTS = 10_000_000 // 0.01 SOL
+// Unattended trades have no human at the approval card, so cap slippage far tighter than the
+// 5000bps a manually-confirmed swap may accept, and refuse a clip whose live price impact is
+// above this — an illiquid mint should skip, not bleed value tick after tick.
+const MAX_UNATTENDED_SLIPPAGE_BPS = 1_000 // 10%
+const MAX_UNATTENDED_IMPACT_PCT = 5 // matches the manual high-impact acknowledgement gate
// ----------------------------------------------------------------- helpers ---
@@ -51,10 +65,25 @@ interface MandateRow {
next_tick_at: number | null
last_error: string | null
armed_at: number | null
+ bought_raw_tokens: string
created_at: number
updated_at: number
}
+/** Raw token quantity this mandate has actually accumulated (internal accounting,
+ * stored as TEXT to avoid bigint precision loss). Exit sells value/liquidate ONLY
+ * this, never the wallet's pre-existing balance of the same mint. */
+function getBoughtRawTokens(mandateId: string): bigint {
+ const row = getDb()
+ .prepare('SELECT bought_raw_tokens FROM autopilot_mandates WHERE id = ?')
+ .get(mandateId) as { bought_raw_tokens: string | null } | undefined
+ try {
+ return BigInt(row?.bought_raw_tokens ?? '0')
+ } catch {
+ return 0n
+ }
+}
+
function rowToMandate(row: MandateRow): Mandate {
return {
id: row.id,
@@ -136,6 +165,23 @@ function assertMainnet(): void {
}
}
+/**
+ * Guard a live tick against a cluster mismatch. Jupiter's hosted execute lands on mainnet
+ * regardless of DAEMON's cluster setting, so if the wallet was switched to devnet after arming
+ * (devnet airdrop SOL would pass the balance pre-check), an armed mandate would spend REAL
+ * mainnet funds. Returns an error string to hold the mandate on, or null when it's safe to trade.
+ */
+function clusterMismatchReason(mandate: Mandate): string | null {
+ const live = getWalletInfrastructureSettings().cluster
+ if (live !== 'mainnet-beta') {
+ return `Cluster is "${live}", not mainnet-beta — held to avoid a devnet-balance-triggered mainnet spend.`
+ }
+ if (mandate.cluster && mandate.cluster !== live) {
+ return `Mandate cluster "${mandate.cluster}" no longer matches live cluster "${live}" — held.`
+ }
+ return null
+}
+
// ----------------------------------------------------------------- reads ---
export function getMandateOrThrow(id: string): Mandate {
@@ -195,12 +241,36 @@ export function validateStrategy(strategy: MandateStrategy, maxExposureLamports:
if (strategy.clipLamports > maxExposureLamports) {
throw new Error('A single clip cannot exceed the mandate exposure cap')
}
- if (!Number.isInteger(strategy.slippageBps) || strategy.slippageBps <= 0 || strategy.slippageBps > 5_000) {
- throw new Error('slippageBps must be between 1 and 5000')
+ // Unattended trades have no human at the card, so slippage is capped tighter than a manual swap.
+ if (!Number.isInteger(strategy.slippageBps) || strategy.slippageBps <= 0 || strategy.slippageBps > MAX_UNATTENDED_SLIPPAGE_BPS) {
+ throw new Error(`slippageBps must be between 1 and ${MAX_UNATTENDED_SLIPPAGE_BPS} for an unattended mandate`)
}
if (!Number.isInteger(maxExposureLamports) || maxExposureLamports <= 0) {
throw new Error('maxExposureLamports must be a positive integer')
}
+ validateRules(strategy.rules)
+}
+
+/**
+ * Exit rules come from an LLM parse of the user's sentence, so a malformed or unit-confused
+ * threshold (NaN, 0, negative, or absurd) is a realistic failure — and a silently-dead
+ * stop-loss lets a mandate trade unattended with no protection. Reject anything not finite and
+ * in a sane band so the model gets a correctable error instead.
+ */
+export function validateRules(rules: MandateRule[]): void {
+ if (!Array.isArray(rules)) throw new Error('strategy.rules must be an array')
+ for (const rule of rules) {
+ const t = Number(rule.threshold)
+ if (!Number.isFinite(t) || t <= 0) {
+ throw new Error(`Exit rule "${rule.kind}" needs a positive, finite threshold (got ${rule.threshold})`)
+ }
+ if ((rule.kind === 'take_profit' || rule.kind === 'stop_loss') && t > 10_000) {
+ throw new Error(`Exit rule "${rule.kind}" threshold ${t}% is out of range (expected a percent in (0, 10000])`)
+ }
+ if (rule.kind === 'liquidity_floor' && t > 1_000_000) {
+ throw new Error(`Exit rule "liquidity_floor" threshold ${t} SOL is implausibly large`)
+ }
+ }
}
// --------------------------------------------------------------- mutations ---
@@ -320,6 +390,47 @@ function recordAction(action: Omit): void {
)
}
+/**
+ * Claim a tick_seq by writing an 'executing' intent row BEFORE the swap is sent. The unique
+ * (mandate_id, tick_seq) index makes a concurrent duplicate throw, and on a crash after send
+ * the row is left 'executing' so boot reconciliation can detect and hold it. Returns the row id.
+ */
+function recordIntent(mandateId: string, tickSeq: number, decision: MandateDecision, reason: string, inputMint: string, outputMint: string, notionalLamports: number): string {
+ const id = crypto.randomUUID()
+ getDb()
+ .prepare(
+ `INSERT INTO autopilot_actions
+ (id, mandate_id, tick_seq, decision, reason, input_mint, output_mint, notional_lamports, fee_lamports, signature, status, error)
+ VALUES (?,?,?,?,?,?,?,?,?,?,'executing',?)`,
+ )
+ .run(id, mandateId, tickSeq, decision, reason, inputMint, outputMint, notionalLamports, null, null, null)
+ return id
+}
+
+/** Flip a claimed intent row to its terminal state once the swap resolves. */
+function finalizeAction(id: string, patch: { status: MandateAction['status']; signature: string | null; notionalLamports?: number; error: string | null }): void {
+ getDb()
+ .prepare('UPDATE autopilot_actions SET status = ?, signature = ?, notional_lamports = COALESCE(?, notional_lamports), error = ? WHERE id = ?')
+ .run(patch.status, patch.signature, patch.notionalLamports ?? null, patch.error, id)
+}
+
+/**
+ * Boot reconciliation: any action still 'executing' means the app died between sending a swap
+ * and recording its outcome. We cannot know from here whether it landed, so we mark it
+ * needs-review and HOLD the mandate (disarm) rather than risk a replay double-buy. The operator
+ * checks the chain and re-arms. Called once at scheduler start, before the first sweep.
+ */
+export function reconcileInterruptedTicks(): number {
+ const db = getDb()
+ const stuck = db.prepare("SELECT id, mandate_id FROM autopilot_actions WHERE status = 'executing'").all() as Array<{ id: string; mandate_id: string }>
+ const now = Date.now()
+ for (const row of stuck) {
+ db.prepare("UPDATE autopilot_actions SET status = 'needs-review', error = 'Interrupted mid-swap; verify on-chain before re-arming' WHERE id = ?").run(row.id)
+ db.prepare("UPDATE autopilot_mandates SET armed = 0, status = 'paused', next_tick_at = NULL, last_error = 'Held: a tick was interrupted mid-swap. Verify the last trade on-chain, then re-arm.', updated_at = ? WHERE id = ?").run(now, row.mandate_id)
+ }
+ return stuck.length
+}
+
// ----------------------------------------------------------------- position ---
/** Live snapshot of a mandate's open position, priced in SOL via a reverse Jupiter quote. */
@@ -368,7 +479,12 @@ async function readTokenBalance(
export async function valuePosition(mandate: Mandate): Promise {
const connection = getConnectionStrict()
const owner = new PublicKey(walletAddress(mandate.walletId))
- const rawTokens = await readTokenBalance(connection, owner, mandate.strategy.targetMint)
+ const liveBalance = await readTokenBalance(connection, owner, mandate.strategy.targetMint)
+ // Value ONLY the quantity this mandate accumulated, never the wallet's pre-existing holdings
+ // of the same mint (which the arm authorization never covered). Capped by the live balance in
+ // case the user manually sold some.
+ const bought = getBoughtRawTokens(mandate.id)
+ const rawTokens = bought > 0n ? (bought < liveBalance ? bought : liveBalance) : 0n
if (rawTokens <= 0n) {
return { rawTokens: 0n, tokenAmount: 0, valueLamports: 0, unrealizedLamports: -mandate.spentLamports, pnlPct: 0 }
}
@@ -464,11 +580,22 @@ export async function tickMandate(mandateId: string): Promise= MAX_UNATTENDED_IMPACT_PCT) {
+ finalizeAction(actionId, { status: 'failed', signature: null, error: `Skipped: price impact ${impactPct.toFixed(2)}% >= ${MAX_UNATTENDED_IMPACT_PCT}% unattended cap` })
+ getDb()
+ .prepare('UPDATE autopilot_mandates SET last_error = ?, updated_at = ? WHERE id = ?')
+ .run(`Skipped a clip: price impact ${impactPct.toFixed(2)}% too high`, now, mandateId)
+ return latestAction(mandateId, tickSeq)
+ }
const result = await executeSwap(
mandate.walletId,
SOL_MINT,
@@ -522,30 +665,25 @@ export async function tickMandate(mandateId: string): Promise= mandate.maxExposureLamports
+ // Track the tokens this buy accumulated (quoted human out * 10^decimals) so exit sells
+ // value/liquidate only these, never the wallet's pre-existing balance of the same mint.
+ const decimals = await getMintDecimals(mandate.strategy.targetMint, connection)
+ const acquiredRaw = BigInt(Math.round(parseFloat(quote.outAmount || '0') * 10 ** decimals))
getDb()
.prepare(
`UPDATE autopilot_mandates
- SET spent_lamports = ?, last_tick_at = ?, next_tick_at = ?, last_error = NULL,
+ SET spent_lamports = ?, bought_raw_tokens = ?, last_tick_at = ?, next_tick_at = ?, last_error = NULL,
armed = ?, status = ?, updated_at = ?
WHERE id = ?`,
)
.run(
spent,
+ (getBoughtRawTokens(mandateId) + acquiredRaw).toString(),
now,
exhausted ? null : nextTickAt,
exhausted ? 0 : 1,
@@ -556,33 +694,23 @@ export async function tickMandate(mandateId: string): Promise {
const tickSeq = nextTickSeq(mandate.id)
const nextTickAt = now + mandate.intervalSeconds * 1000
- const feeQuote = quoteExecutionFee(position.valueLamports)
+
+ // Claim the sell tick before sending, same crash-safety pattern as the buy path.
+ const actionId = recordIntent(mandate.id, tickSeq, 'sell', exit.reason, mandate.strategy.targetMint, SOL_MINT, position.valueLamports)
+ getDb()
+ .prepare('UPDATE autopilot_mandates SET last_tick_at = ?, next_tick_at = ?, updated_at = ? WHERE id = ?')
+ .run(now, nextTickAt, now, mandate.id)
try {
const quote = await getSwapQuote(mandate.walletId, mandate.strategy.targetMint, SOL_MINT, position.tokenAmount, mandate.strategy.slippageBps)
@@ -605,27 +738,17 @@ async function sellPosition(
quote.rawQuoteResponse,
{ restrictIntermediateTokens: true },
)
- // Realized P&L = SOL actually received this sale − everything the mandate ever spent.
+ // Realized P&L uses the quoted output as an estimate — the actual on-chain fill can differ
+ // with slippage, so this is a ledger approximation, not an exact settlement figure.
const receivedLamports = Math.round(Number(quote.outAmount) * LAMPORTS_PER_SOL)
const realized = receivedLamports - mandate.spentLamports
- recordAction({
- mandateId: mandate.id,
- tickSeq,
- decision: 'sell',
- reason: exit.reason,
- inputMint: mandate.strategy.targetMint,
- outputMint: SOL_MINT,
- notionalLamports: receivedLamports,
- feeLamports: feeQuote?.lamports ?? 0,
- signature: result.signature,
- status: 'executed',
- error: null,
- })
- // Position closed → disarm. realized_pnl accumulates across exit cycles.
+ // fee_lamports stays null (swaps aren't fee-metered — see the buy path).
+ finalizeAction(actionId, { status: 'executed', signature: result.signature, notionalLamports: receivedLamports, error: null })
+ // Position closed → disarm, zero the tracked quantity. realized_pnl accumulates across cycles.
getDb()
.prepare(
`UPDATE autopilot_mandates
- SET armed = 0, status = 'exhausted', next_tick_at = NULL,
+ SET armed = 0, status = 'exhausted', next_tick_at = NULL, bought_raw_tokens = '0',
realized_pnl_lamports = realized_pnl_lamports + ?, last_tick_at = ?, last_error = NULL, updated_at = ?
WHERE id = ?`,
)
@@ -633,19 +756,7 @@ async function sellPosition(
return latestAction(mandate.id, tickSeq)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
- recordAction({
- mandateId: mandate.id,
- tickSeq,
- decision: 'sell',
- reason: exit.reason,
- inputMint: mandate.strategy.targetMint,
- outputMint: SOL_MINT,
- notionalLamports: position.valueLamports,
- feeLamports: null,
- signature: null,
- status: 'failed',
- error: message,
- })
+ finalizeAction(actionId, { status: 'failed', signature: null, error: message })
getDb()
.prepare('UPDATE autopilot_mandates SET last_tick_at = ?, next_tick_at = ?, last_error = ?, updated_at = ? WHERE id = ?')
.run(now, nextTickAt, `Exit sell failed: ${message}`, now, mandate.id)
diff --git a/electron/services/ClaudeAgentService.ts b/electron/services/ClaudeAgentService.ts
index 1aca6165..4f77e75f 100644
--- a/electron/services/ClaudeAgentService.ts
+++ b/electron/services/ClaudeAgentService.ts
@@ -76,12 +76,12 @@ function parseSimpleFrontmatter(frontmatter: string): Record {
function normalizeModel(model: string | undefined): string {
switch ((model ?? '').toLowerCase()) {
case 'opus':
- return 'claude-opus-4-20250514'
+ return 'claude-opus-4-8'
case 'sonnet':
- return 'claude-sonnet-4-20250514'
+ return 'claude-sonnet-4-6'
case 'haiku':
return 'claude-haiku-4-5-20251001'
default:
- return model && model.length > 0 ? model : 'claude-sonnet-4-20250514'
+ return model && model.length > 0 ? model : 'claude-sonnet-4-6'
}
}
diff --git a/electron/services/ClaudeRouter.ts b/electron/services/ClaudeRouter.ts
index c3c75e67..adcdafc9 100644
--- a/electron/services/ClaudeRouter.ts
+++ b/electron/services/ClaudeRouter.ts
@@ -387,8 +387,8 @@ function buildPortMap(): string {
function resolveModelName(shorthand: string): string {
const modelMap: Record = {
'haiku': 'claude-haiku-4-5-20251001',
- 'sonnet': 'claude-sonnet-4-20250514',
- 'opus': 'claude-opus-4-20250514',
+ 'sonnet': 'claude-sonnet-4-6',
+ 'opus': 'claude-opus-4-8',
}
return modelMap[shorthand] ?? shorthand
}
diff --git a/electron/services/CodexMcpConfig.ts b/electron/services/CodexMcpConfig.ts
index fd5d8abe..61c7055f 100644
--- a/electron/services/CodexMcpConfig.ts
+++ b/electron/services/CodexMcpConfig.ts
@@ -93,7 +93,7 @@ export function removeCodexMcp(name: string): void {
export function getCodexModel(): string {
const config = readConfig()
- return config.model ?? 'gpt-5.4'
+ return config.model ?? 'gpt-5.5'
}
export function getCodexReasoningEffort(): string {
diff --git a/electron/services/SwarmOrchestrator.ts b/electron/services/SwarmOrchestrator.ts
index 49ae6f91..3e506644 100644
--- a/electron/services/SwarmOrchestrator.ts
+++ b/electron/services/SwarmOrchestrator.ts
@@ -22,8 +22,17 @@ import * as Worktree from './WorktreeService'
const MAX_CONCURRENT_LANES = 4
const LANE_MODEL = 'sonnet'
const LANE_MAX_TURNS = 30
-/** Lanes must never push; merging is a human step in the Git panel. */
-const DISALLOWED_TOOLS = 'Bash(git push:*)'
+/** Lanes must never push or reach a remote; merging is a human step in the Git panel. This is a
+ * best-effort belt on top of the real belt (the worktree's push remote is disabled at the git
+ * layer in WorktreeService), since a prefix pattern can't catch every shell wrapper. */
+const DISALLOWED_TOOLS = [
+ 'Bash(git push:*)',
+ 'Bash(git remote:*)',
+ 'Bash(git fetch:*)',
+ 'Bash(git pull:*)',
+ 'Bash(gh:*)',
+ 'Bash(git -c:*)',
+].join(',')
/** BrainBlast pre-flight: research a lane's task before it codes, then gate on CRITICAL risk. */
const PREFLIGHT_MAX_TURNS = 40
@@ -84,10 +93,27 @@ function latestReportPath(worktreePath: string): string | null {
return null
}
-/** Env that forces the CLI onto subscription OAuth and signals BrainBlast --ci mode. */
-function laneEnv(extra: Record = {}): NodeJS.ProcessEnv {
- const env: NodeJS.ProcessEnv = { ...process.env, TERM: 'xterm-256color', ...extra }
- // Strip API-key env so the CLI uses OAuth (an exhausted/restricted key exits 1 instantly).
+/**
+ * Env for a lane. A lane runs `--dangerously-skip-permissions` on UNTRUSTED task text, so it must
+ * NOT inherit the full parent env (a denylist leaks anything not explicitly stripped — ZAI keys,
+ * GitHub tokens, etc., which prompt-injected task text could exfiltrate). Build a minimal
+ * allowlist instead: only PATH/HOME/shell essentials plus the CLI's own config, and force OAuth
+ * by never passing an Anthropic key.
+ */
+function laneEnv(extra: Record = {}): Record {
+ const ALLOW = [
+ 'PATH', 'Path', 'HOME', 'USERPROFILE', 'TEMP', 'TMP', 'TMPDIR', 'SystemRoot', 'windir',
+ 'ComSpec', 'PATHEXT', 'LANG', 'LC_ALL', 'SHELL',
+ // Claude CLI config/state (OAuth session, config dir) — needed for the CLI to run at all.
+ 'CLAUDE_CONFIG_DIR', 'CLAUDE_HOME', 'XDG_CONFIG_HOME', 'APPDATA', 'LOCALAPPDATA',
+ ]
+ const env: Record = { TERM: 'xterm-256color' }
+ for (const key of ALLOW) {
+ const val = process.env[key]
+ if (val !== undefined) env[key] = val
+ }
+ Object.assign(env, extra)
+ // Belt-and-braces: even though we allowlisted, make certain no Anthropic key leaks in via extra.
delete env.ANTHROPIC_API_KEY
delete env.ANTHROPIC_AUTH_TOKEN
return env
@@ -138,7 +164,7 @@ async function runPreflight(task: string, worktreePath: string): Promise {
child = spawn(getClaudePath(), args, {
cwd: lane.worktree_path,
// OAuth-only env (see laneEnv) — an exhausted/restricted API key exits 1 instantly.
- env: laneEnv(),
+ env: laneEnv() as NodeJS.ProcessEnv,
windowsHide: true,
// Close stdin so `claude -p` doesn't block waiting for piped input
// (it otherwise times out after 3s and exits 1); keep stdout/stderr piped.
diff --git a/electron/services/WalletService.ts b/electron/services/WalletService.ts
index 6eba24b4..49e3ff2b 100644
--- a/electron/services/WalletService.ts
+++ b/electron/services/WalletService.ts
@@ -1703,6 +1703,9 @@ interface JupiterSwapDraft {
requestId: string
transaction: string
messageHash: string
+ /** Server-computed price impact %, captured at quote time. The high-impact
+ * acknowledgement gate is checked against THIS, never the renderer's quote. */
+ priceImpactPct: string
createdAt: number
}
@@ -1833,6 +1836,20 @@ function getJupiterSwapDraft(quoteId: string): JupiterSwapDraft {
return draft
}
+/** Server-authoritative price impact (as a percentage) for a stored quote draft.
+ * The high-impact acknowledgement gate must read THIS, not the renderer's quote,
+ * so a caller can't dodge it by sending a low/zero priceImpact in rawQuoteResponse.
+ * The draft field is already normalized to a true percentage by
+ * normalizeJupiterPriceImpactPct (Jupiter returns a 0..1 fraction; we scale x100
+ * once, at normalization), which is also the unit the renderer displays and
+ * thresholds. So this just reads the stored percent — no further scaling. */
+export function getServerSwapImpactPct(quoteId: string): number {
+ const draft = getJupiterSwapDraft(quoteId)
+ const pct = parseFloat(draft.priceImpactPct)
+ if (!Number.isFinite(pct)) return 0
+ return Math.abs(pct)
+}
+
function assertJupiterOrderMatchesDraft(
draft: JupiterSwapDraft,
order: JupiterSwapOrderResponse,
@@ -1882,8 +1899,16 @@ function isValidBase64Payload(value: string): boolean {
}
}
+/** Jupiter returns price impact as a 0..1 fraction on BOTH fields:
+ * `priceImpactPct` (a decimal string, e.g. "0.0025" = 0.25%) and `priceImpact`
+ * (a number). We normalize to a true percentage string once here so every
+ * consumer — the renderer display, the WalletSwapForm >=5% acknowledgement gate,
+ * and the server-authoritative getServerSwapImpactPct — reads the same unit. */
function normalizeJupiterPriceImpactPct(order: JupiterSwapOrderResponse): string {
- if (order.priceImpactPct !== undefined) return order.priceImpactPct
+ if (order.priceImpactPct !== undefined) {
+ const fraction = parseFloat(order.priceImpactPct)
+ return Number.isFinite(fraction) ? String(Math.abs(fraction) * 100) : '0'
+ }
if (order.priceImpact !== undefined) return String(Math.abs(order.priceImpact) * 100)
return '0'
}
@@ -2076,6 +2101,7 @@ export async function getSwapQuote(
requestId: data.requestId,
transaction: data.transaction,
messageHash,
+ priceImpactPct: normalizeJupiterPriceImpactPct(data),
})
const rawQuoteResponse = {
...data,
diff --git a/electron/services/WorktreeService.ts b/electron/services/WorktreeService.ts
index 038b8d4e..612b9b53 100644
--- a/electron/services/WorktreeService.ts
+++ b/electron/services/WorktreeService.ts
@@ -161,6 +161,17 @@ export async function addWorktree(projectPath: string, worktreePath: string, bra
const args = ['worktree', 'add', '-b', branch, worktreePath]
args.push(base && base.trim() ? base.trim() : 'HEAD')
await git.raw(args)
+
+ // Disable pushing from the lane at the GIT layer, so a push fails even if a shell wrapper slips
+ // past the tool-name denylist. Set a worktree-local push URL to a dead sink for every remote;
+ // fetch/read still works, push can't reach anything. Best-effort (no remotes = nothing to do).
+ const laneGit = simpleGit(worktreePath)
+ try {
+ const remotes = await laneGit.getRemotes(false)
+ for (const r of remotes) {
+ try { await laneGit.raw(['config', `remote.${r.name}.pushurl`, 'DISABLED_FOR_SWARM_LANE']) } catch { /* best-effort */ }
+ }
+ } catch { /* no remotes / detached config — nothing to disable */ }
}
export async function removeWorktree(projectPath: string, worktreePath: string, branch?: string | null): Promise {
diff --git a/electron/services/aria/cli/ansi-theme.ts b/electron/services/aria/cli/ansi-theme.ts
new file mode 100644
index 00000000..6f6264f4
--- /dev/null
+++ b/electron/services/aria/cli/ansi-theme.ts
@@ -0,0 +1,78 @@
+/**
+ * Canonical ANSI theme for the ARIA standalone CLI - the single source of truth
+ * for terminal styling. Both the compiled backend (this module) and the plain-JS
+ * launcher (`scripts/aria-shared/ansi-theme.mjs`) consume these tokens; the
+ * launcher mirror is a literal copy that `AriaCliThemeSync.test.ts` asserts is in
+ * sync. Never inline raw `\x1b[` escapes anywhere else - add a token here.
+ *
+ * Tokens map to the GUI design system semantics (see styles/tokens.css) so the
+ * CLI and GUI read as the same product. Status is shown via colored dots only,
+ * never emoji (CLAUDE.md hard rule).
+ */
+
+/** Semantic 24-bit color tokens. Hex strings; kept identical to the GUI palette. */
+export const ARIA_THEME_COLORS = {
+ green: '#3ECF8E',
+ greenDark: '#1A6B47',
+ blue: '#60A5FA',
+ amber: '#F0B429',
+ red: '#EF5350',
+ magenta: '#C084FC',
+ cyan: '#22D3EE',
+ void: '#0A0A0A',
+ surface: '#171919',
+ border: '#262928',
+ text: '#F0F0F0',
+ secondary: '#A0A0A0',
+ muted: '#6E706F',
+ disabled: '#4A4C4B',
+} as const
+
+export type AriaThemeColor = keyof typeof ARIA_THEME_COLORS
+
+/**
+ * Risk-tier → color token. Mirrors the ARIA tool risk gating and the GUI
+ * ToolCallRow kind colors (read=blue, write=amber, sensitive=red).
+ */
+export const ARIA_RISK_COLOR = {
+ read: 'blue',
+ write: 'amber',
+ sensitive: 'red',
+} as const satisfies Record
+
+/** Tool-kind → color token (read=blue, edit=amber, run=green). */
+export const ARIA_KIND_COLOR = {
+ read: 'blue',
+ edit: 'amber',
+ run: 'green',
+} as const satisfies Record
+
+/** Status dot glyph - a single Windows-safe filled circle. No emoji. */
+export const ARIA_DOT = '●'
+
+/** Convert a hex token to a 24-bit ANSI SGR foreground sequence. */
+function toForeground(hex: string): string {
+ const r = parseInt(hex.slice(1, 3), 16)
+ const g = parseInt(hex.slice(3, 5), 16)
+ const b = parseInt(hex.slice(5, 7), 16)
+ return `\x1b[38;2;${r};${g};${b}m`
+}
+
+const RESET = '\x1b[0m'
+
+/** True when color should be stripped - honors NO_COLOR and non-TTY output. */
+export function isColorDisabled(env = process.env, isTty = process.stdout.isTTY): boolean {
+ if (env.NO_COLOR) return true
+ return !isTty
+}
+
+/** Wrap text in a color token's escape sequence, or return it raw when disabled. */
+export function paint(token: AriaThemeColor, text: string, disabled = isColorDisabled()): string {
+ if (disabled) return text
+ return `${toForeground(ARIA_THEME_COLORS[token])}${text}${RESET}`
+}
+
+/** A colored status dot for a given semantic token. */
+export function dot(token: AriaThemeColor, disabled = isColorDisabled()): string {
+ return paint(token, ARIA_DOT, disabled)
+}
diff --git a/electron/services/aria/cli/aria-boot-banner.ts b/electron/services/aria/cli/aria-boot-banner.ts
new file mode 100644
index 00000000..2cce47f1
--- /dev/null
+++ b/electron/services/aria/cli/aria-boot-banner.ts
@@ -0,0 +1,61 @@
+/**
+ * ARIA boot panel - built backend-side from real runtime state and shipped to
+ * the launcher as a `banner` frame. Only the backend has the version/cluster/
+ * session state, so it is computed here; the launcher paints the wordmark with a
+ * magenta->cyan gradient and renders the meta below it.
+ */
+import type { AriaBannerFrame } from './frames'
+
+/** State the panel reads - supplied by the backend at startup. */
+export interface AriaBannerState {
+ version: string
+ cluster: string
+ rpcProvider: string
+ wallet: string | null
+ projectPath: string | null
+ session: string
+ modelLane: string
+ mode: string
+}
+
+/**
+ * ARIA wordmark in the "ANSI Shadow" figlet style - bold filled block letters
+ * with a box-drawing drop shadow. Six rows, all equal width. Glyphs are limited
+ * to the Windows-terminal-safe block + box-drawing set.
+ */
+const WORDMARK: string[] = [
+ ' █████╗ ██████╗ ██╗ █████╗ ',
+ '██╔══██╗██╔══██╗██║██╔══██╗',
+ '███████║██████╔╝██║███████║',
+ '██╔══██║██╔══██╗██║██╔══██║',
+ '██║ ██║██║ ██║██║██║ ██║',
+ '╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚═╝ ╚═╝',
+]
+
+function truncate(value: string, max: number): string {
+ if (value.length <= max) return value
+ return `${value.slice(0, Math.max(0, max - 3))}...`
+}
+
+function shortSession(id: string): string {
+ return id.length > 8 ? id.slice(0, 8) : id
+}
+
+/** Build the boot-panel frame from real runtime state. */
+export function buildBanner(state: AriaBannerState): AriaBannerFrame {
+ return {
+ type: 'banner',
+ wordmark: WORDMARK,
+ meta: {
+ project: truncate(state.projectPath ?? '(none)', 64),
+ network: `${state.cluster} (${state.rpcProvider})`,
+ wallet: state.wallet ?? '(none)',
+ session: shortSession(state.session),
+ engine: state.modelLane,
+ mode: state.mode,
+ },
+ version: state.version,
+ cluster: state.cluster,
+ session: state.session,
+ }
+}
diff --git a/electron/services/aria/cli/commandRegistry.ts b/electron/services/aria/cli/commandRegistry.ts
new file mode 100644
index 00000000..92717f40
--- /dev/null
+++ b/electron/services/aria/cli/commandRegistry.ts
@@ -0,0 +1,160 @@
+/**
+ * Single command catalog for the ARIA standalone CLI. The backend dispatches
+ * slash commands from this registry, and ships the metadata to the launcher as
+ * a `manifest` frame so /help, autocomplete, and the footer are all generated
+ * from here. Adding a command = one entry in COMMAND_REGISTRY.
+ *
+ * Commands are operator-local (they drive the CLI session, not on-chain side
+ * effects), so they all carry the `read` risk tier; the tier is used only for
+ * consistent color coding alongside the tool catalog.
+ */
+import type { AriaCommandManifestEntry } from './frames'
+
+/** Outcome of a command: keep the loop running, or stop it (e.g. /exit). */
+export interface AriaCommandOutcome {
+ /** False ends the session loop. Defaults to true when omitted. */
+ continue?: boolean
+}
+
+/**
+ * Context handed to a command handler by the backend. Kept minimal and
+ * behavior-shaped so the registry never imports the backend's internals.
+ */
+export interface AriaCommandContext {
+ /** Raw argument string after the command name (trimmed). */
+ arg: string
+ /** Emit a protocol frame to the launcher. */
+ emit: (type: string, payload?: Record) => void
+ /** Apply a setting/state change; resolves once state has been re-emitted. */
+ actions: AriaCommandActions
+}
+
+/** The state-affecting operations a command may perform, supplied by the backend. */
+export interface AriaCommandActions {
+ exit: () => void
+ listSessions: () => void
+ resumeSession: (id: string) => boolean
+ newSession: () => void
+ clearSession: () => void
+ setModelLane: (lane: string) => boolean
+ setMode: (mode: string) => boolean
+ setPlan: (on: boolean) => void
+ showHelp: () => void
+ listTools: () => void
+ /** Async: runs a read tool then emits a frame. Await before the loop advances. */
+ showStatus: () => Promise
+ /** Async: runs a read tool then emits a frame. Await before the loop advances. */
+ listMemories: () => Promise
+}
+
+export interface AriaCommand extends AriaCommandManifestEntry {
+ handler: (ctx: AriaCommandContext) => AriaCommandOutcome | void | Promise
+}
+
+export const COMMAND_REGISTRY: AriaCommand[] = [
+ {
+ name: 'help',
+ synopsis: 'List available commands.',
+ risk: 'read',
+ handler: ({ actions }) => { actions.showHelp() },
+ },
+ {
+ name: 'exit',
+ synopsis: 'Quit the ARIA CLI.',
+ risk: 'read',
+ handler: ({ actions }) => { actions.exit(); return { continue: false } },
+ },
+ {
+ name: 'quit',
+ synopsis: 'Quit the ARIA CLI.',
+ risk: 'read',
+ handler: ({ actions }) => { actions.exit(); return { continue: false } },
+ },
+ {
+ name: 'new',
+ synopsis: 'Start a fresh session.',
+ risk: 'read',
+ handler: ({ actions }) => { actions.newSession() },
+ },
+ {
+ name: 'clear',
+ synopsis: 'Clear the current session history.',
+ risk: 'read',
+ handler: ({ actions }) => { actions.clearSession() },
+ },
+ {
+ name: 'sessions',
+ synopsis: 'List recent sessions.',
+ risk: 'read',
+ keybinding: 'ctrl+b',
+ handler: ({ actions }) => { actions.listSessions() },
+ },
+ {
+ name: 'resume',
+ synopsis: 'Resume a session by id.',
+ args: '',
+ risk: 'read',
+ handler: ({ actions, arg, emit }) => {
+ if (!arg) { emit('log', { level: 'warn', message: 'Usage: /resume ' }); return }
+ if (!actions.resumeSession(arg)) emit('log', { level: 'warn', message: `Session not found: ${arg}` })
+ },
+ },
+ {
+ name: 'model',
+ synopsis: 'Set the model lane.',
+ args: 'auto|fast|standard|reasoning|premium',
+ risk: 'read',
+ keybinding: 'ctrl+t',
+ handler: ({ actions, arg, emit }) => {
+ if (!actions.setModelLane(arg)) emit('log', { level: 'warn', message: 'Usage: /model auto|fast|standard|reasoning|premium' })
+ },
+ },
+ {
+ name: 'mode',
+ synopsis: 'Switch operating mode.',
+ args: 'plan|coding|ask',
+ risk: 'read',
+ keybinding: 'shift+tab',
+ handler: ({ actions, arg, emit }) => {
+ if (!actions.setMode(arg)) emit('log', { level: 'warn', message: 'Usage: /mode plan|coding|ask' })
+ },
+ },
+ {
+ name: 'plan',
+ synopsis: 'Toggle plan mode.',
+ args: 'on|off',
+ risk: 'read',
+ handler: ({ actions, arg }) => { actions.setPlan(arg === 'on') },
+ },
+ {
+ name: 'tools',
+ synopsis: 'List the ARIA tool catalog grouped by risk.',
+ risk: 'read',
+ handler: ({ actions }) => { actions.listTools() },
+ },
+ {
+ name: 'status',
+ synopsis: 'Show active project, network, wallet, and packs.',
+ risk: 'read',
+ handler: ({ actions }) => actions.showStatus(),
+ },
+ {
+ name: 'memory',
+ synopsis: 'List durable facts stored for this project.',
+ risk: 'read',
+ handler: ({ actions }) => actions.listMemories(),
+ },
+]
+
+const COMMAND_BY_NAME = new Map(COMMAND_REGISTRY.map((cmd) => [cmd.name, cmd]))
+
+export function getCommand(name: string): AriaCommand | undefined {
+ return COMMAND_BY_NAME.get(name)
+}
+
+/** Manifest projection (drops the handler) shipped to the launcher. */
+export function commandManifest(): AriaCommandManifestEntry[] {
+ return COMMAND_REGISTRY.map(({ name, synopsis, args, risk, keybinding }) => ({
+ name, synopsis, args, risk, keybinding,
+ }))
+}
diff --git a/electron/services/aria/cli/frames.ts b/electron/services/aria/cli/frames.ts
new file mode 100644
index 00000000..ca02bfb6
--- /dev/null
+++ b/electron/services/aria/cli/frames.ts
@@ -0,0 +1,101 @@
+/**
+ * Frame protocol for the ARIA standalone CLI - the typed contract between the
+ * backend (`AriaTerminalBackendService`, `--aria-server`) and the launcher
+ * (`scripts/aria.mjs`). Frames are newline-delimited JSON on stdio.
+ *
+ * The launcher is plain JS and can't import these types, but the backend uses
+ * them so every `emit()` is type-checked against one source. New CLI behavior
+ * (a command, a banner, a structured list) is added here first, then surfaced
+ * over a frame rather than re-implemented on both sides.
+ */
+import type {
+ AriaSession,
+ AriaToolEvent,
+ AriaToolRiskTier,
+ DaemonAiModelLane,
+} from '../../../shared/types'
+import type { AriaThemeColor } from './ansi-theme'
+
+/** A command the launcher should know about (drives /help, autocomplete, footer). */
+export interface AriaCommandManifestEntry {
+ /** Slash name without the leading slash, e.g. "sessions". */
+ name: string
+ synopsis: string
+ /** Argument hint shown in help, e.g. "" or "auto|fast|...". */
+ args?: string
+ /** Risk tier for color coding; commands themselves are operator-local (read). */
+ risk: AriaToolRiskTier
+ /** Optional key hint shown in the footer, e.g. "shift+tab". */
+ keybinding?: string
+}
+
+/** One ARIA tool, projected for the /tools listing. */
+export interface AriaToolManifestEntry {
+ name: string
+ kind: string
+ risk: AriaToolRiskTier
+ description: string
+}
+
+/** A single rendered banner line with the theme token it should be painted in. */
+export interface AriaBannerLine {
+ text: string
+ color: AriaThemeColor
+}
+
+/** Emitted once at startup before `ready`: the launcher's view of capabilities. */
+export interface AriaManifestFrame {
+ type: 'manifest'
+ commands: AriaCommandManifestEntry[]
+ /** Theme token names the launcher may reference (parity check at runtime). */
+ themeTokens: AriaThemeColor[]
+}
+
+/** Compact key/value metadata shown under the wordmark. */
+export interface AriaBannerMeta {
+ project: string
+ network: string
+ wallet: string
+ session: string
+ engine: string
+ mode: string
+}
+
+/** Emitted once at startup before `ready`: the boot panel, computed from state. */
+export interface AriaBannerFrame {
+ type: 'banner'
+ /** ANSI Shadow wordmark rows; the launcher paints them with a gradient. */
+ wordmark: string[]
+ meta: AriaBannerMeta
+ version: string
+ cluster: string
+ session: string
+}
+
+/** Frames the backend sends to the launcher (stdout). */
+export type AriaServerFrame =
+ | AriaManifestFrame
+ | AriaBannerFrame
+ | { type: 'ready' }
+ | { type: 'state'; session: AriaSession; projectPath: string | null; network: string; wallet: string | null; modelLane: DaemonAiModelLane; mode: string }
+ | { type: 'busy'; busy: boolean }
+ | { type: 'user'; text: string }
+ | { type: 'response'; text: string; toolCalls: unknown[] }
+ | { type: 'event'; event: AriaToolEvent }
+ | { type: 'approval'; id: string; request: unknown }
+ | { type: 'patchDecision'; id: string; proposal: unknown }
+ | { type: 'sessions'; sessions: AriaSession[] }
+ | { type: 'tools'; tools: AriaToolManifestEntry[] }
+ | { type: 'status'; status: unknown }
+ | { type: 'memories'; memories: Array<{ kind: string; title: string; value: string }> }
+ | { type: 'help' }
+ | { type: 'log'; level: 'info' | 'warn'; message: string }
+ | { type: 'error'; message: string }
+ | { type: 'exit' }
+
+/** Frames the launcher sends to the backend (stdin). */
+export type AriaClientFrame =
+ | { type: 'input'; text: string }
+ | { type: 'approval'; id: string; approved: boolean }
+ | { type: 'patchDecision'; id: string; action: string }
+ | { type: 'exit' }
diff --git a/electron/services/aria/contextAssembler.ts b/electron/services/aria/contextAssembler.ts
index 13f01e84..57bc806f 100644
--- a/electron/services/aria/contextAssembler.ts
+++ b/electron/services/aria/contextAssembler.ts
@@ -12,7 +12,7 @@ import type { AriaContextSnapshot, AriaMemorySuggestionLite } from '../../shared
const ARIA_AGENT_SYSTEM = `You are ARIA, the operator agent for the DAEMON Solana development workbench. You DRIVE the app for the user by calling tools, not by describing steps.
CAPABILITIES (call the matching tool — do not just explain):
-- Workspace: open tools/panels, run commands, open/scaffold files, run engine actions.
+- Workspace: open tools/panels, run commands, list/search/read project files, open/scaffold files, run engine actions.
- Settings & integrations: change settings, enable/disable integrations, run integration checks.
- Wallets: read balances, generate wallets, set/assign the default wallet, create an agent wallet for the codebase (agentstation_create_agent_wallet).
- Clawpump agents: list/create/start/stop/chat (clawpump_*). List skills before referencing skill slugs.
@@ -27,7 +27,11 @@ RULES:
- Be concise and direct. No filler, no emoji.
- Format with markdown: bold section titles (no trailing colons — the weight signals the heading) and "-" bullets for lists. Keep prose in short paragraphs.
- For any request that needs more than one action, FIRST call present_plan with 3–6 short step titles, then execute. In Plan mode, present_plan pauses for the user's approval before any write action — once approved, execute every step without pausing again (money/key actions will still ask for their own typed confirm). If the user declines the plan, stop and ask how to adjust.
-- Read state with read_project_status / read_wallet / list tools before acting when unsure of ids. Use exact ids; never invent wallet addresses, mints, or keys.
+- Read state with read_project_status / read_wallet / list_project_tree / read_file / search_files before acting when unsure. Use exact ids and paths; never invent wallet addresses, mints, keys, or filenames.
+- Never claim you lack directory-listing, file-search, or file-reading ability. Use list_project_tree, search_files, and read_file; if a tool fails, report the tool error.
+- When the user asks what you know about the repo/project, or asks for project status, immediately call read_project_status and recall_memories. Do not ask whether to pull status or memories first.
+- When the user asks what files exist, immediately call list_project_tree. Do not ask the user to name a file unless they want a specific file opened.
+- When the user provides a project directory path, call activate_project with that path first. Then continue with the requested status, memory, or file operation using the new active project.
- BEFORE any token launch, ALWAYS call tokenlaunch_preflight and show the user the estimated SOL cost and any failing checks. Only call tokenlaunch_create after preflight is ready.
- Sensitive, money-adjacent tools (wallet, token launch, flywheel) pause for the user's typed approval — call them anyway; the user decides. flywheel_configure_split LOCKS on first create — make that clear.
- When the network is mainnet, tool summaries are prefixed [MAINNET]; treat those actions as real-money and confirm intent in your plan.
diff --git a/electron/services/aria/toolCatalog.ts b/electron/services/aria/toolCatalog.ts
index 08395a7b..0ed876cc 100644
--- a/electron/services/aria/toolCatalog.ts
+++ b/electron/services/aria/toolCatalog.ts
@@ -25,8 +25,10 @@ import { workspaceTools } from './tools/workspace'
import { walletTools } from './tools/wallet'
import { clawpumpTools } from './tools/clawpump'
import { hyperliquidTools } from './tools/hyperliquid'
+import { forensicsTools } from './tools/forensics'
import { venumTools } from './tools/venum'
import { agentStationTools } from './tools/agentStation'
+import { agentEconomyTools } from './tools/agentEconomy'
import { tokenLaunchTools } from './tools/tokenLaunch'
import { flywheelTools } from './tools/flywheel'
import { gitTools } from './tools/git'
@@ -83,8 +85,10 @@ export const ARIA_TOOLS: AriaTool[] = [
...walletTools,
...clawpumpTools,
...hyperliquidTools,
+ ...forensicsTools,
...venumTools,
...agentStationTools,
+ ...agentEconomyTools,
...tokenLaunchTools,
...flywheelTools,
...gitTools,
diff --git a/electron/services/aria/tools/agentEconomy.ts b/electron/services/aria/tools/agentEconomy.ts
new file mode 100644
index 00000000..0ce7c0bf
--- /dev/null
+++ b/electron/services/aria/tools/agentEconomy.ts
@@ -0,0 +1,227 @@
+import * as AgentEconomyService from '../../AgentEconomyService'
+import * as IdlePaidCallService from '../../IdlePaidCallService'
+import {
+ readAgentIdentity,
+ registerAgentIdentity,
+ type MetaplexReadAgentIdentityInput,
+ type MetaplexRegisterAgentIdentityInput,
+} from '../../MetaplexOperatorService'
+import type { AriaTool } from '../AriaTool'
+
+const DEFAULT_DEVNET_RPC = 'https://api.devnet.solana.com'
+
+function stringValue(value: unknown): string {
+ return String(value ?? '').trim()
+}
+
+function numberValue(value: unknown, fallback: number): number {
+ const parsed = Number(value)
+ return Number.isFinite(parsed) ? parsed : fallback
+}
+
+function stringArray(value: unknown): string[] {
+ return Array.isArray(value) ? value.map(String).map((item) => item.trim()).filter(Boolean) : []
+}
+
+function objectValue(value: unknown): Record {
+ return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}
+}
+
+function limitValue(value: unknown, fallback: number): number {
+ return Math.min(Math.max(1, Math.floor(numberValue(value, fallback))), 100)
+}
+
+function toAgentEconomyCheck(input: Record, projectId: string | null) {
+ return {
+ profileId: stringValue(input.profileId),
+ resourceId: stringValue(input.resourceId),
+ projectId: stringValue(input.projectId) || projectId || undefined,
+ taskId: stringValue(input.taskId) || null,
+ }
+}
+
+const POLICY_SCHEMA: AriaTool['input'] = {
+ type: 'object',
+ properties: {
+ profileId: { type: 'string' },
+ resourceId: { type: 'string' },
+ projectId: { type: 'string' },
+ taskId: { type: 'string' },
+ },
+ required: ['profileId', 'resourceId'],
+}
+
+export const agentEconomyTools: AriaTool[] = [
+ {
+ name: 'agenteconomy_list_profiles',
+ description: 'List Meterflow paid-agent profiles/sessions (read-only).',
+ kind: 'read',
+ risk: 'read',
+ input: { type: 'object', properties: { projectId: { type: 'string' } } },
+ async handler(input, ctx) {
+ const profiles = AgentEconomyService.listProfiles(stringValue(input.projectId) || ctx.snapshot.activeProjectId)
+ return {
+ ok: true,
+ summary: `Found ${profiles.length} paid-agent profile(s).`,
+ data: profiles,
+ }
+ },
+ },
+ {
+ name: 'agenteconomy_list_resources',
+ description: 'List cached Agent Economy paid resources from the local IDLE/x402 registry cache (read-only).',
+ kind: 'read',
+ risk: 'read',
+ input: { type: 'object', properties: { limit: { type: 'number' } } },
+ async handler(input) {
+ const resources = IdlePaidCallService.listResources(limitValue(input.limit, 50))
+ return {
+ ok: true,
+ summary: `Found ${resources.length} paid resource(s).`,
+ data: resources,
+ }
+ },
+ },
+ {
+ name: 'agenteconomy_check_policy',
+ description: 'Check whether a cached paid resource is allowed by the supplied budget, domain, network, asset, payee, and receipt policy (read-only).',
+ kind: 'read',
+ risk: 'read',
+ input: POLICY_SCHEMA,
+ async handler(input, ctx) {
+ const result = AgentEconomyService.checkPolicy(toAgentEconomyCheck(input, ctx.snapshot.activeProjectId))
+ return {
+ ok: true,
+ summary: result.allowed ? 'Policy allows this paid resource.' : `Policy blocks this paid resource: ${result.reasons.join(' ')}`,
+ data: result,
+ }
+ },
+ },
+ {
+ name: 'agenteconomy_list_receipts',
+ description: 'List recent Agent Economy paid-call receipts (read-only).',
+ kind: 'read',
+ risk: 'read',
+ input: {
+ type: 'object',
+ properties: {
+ profileId: { type: 'string' },
+ projectId: { type: 'string' },
+ limit: { type: 'number' },
+ },
+ },
+ async handler(input, ctx) {
+ const receipts = await AgentEconomyService.listReceipts({
+ profileId: stringValue(input.profileId) || undefined,
+ projectId: stringValue(input.projectId) || ctx.snapshot.activeProjectId || undefined,
+ limit: limitValue(input.limit, 25),
+ })
+ return { ok: true, summary: `Found ${receipts.length} receipt(s).`, data: receipts }
+ },
+ },
+ {
+ name: 'agenteconomy_read_devnet_identity',
+ description: 'Read a Metaplex Agent Identity for a devnet Core asset (read-only).',
+ kind: 'read',
+ risk: 'read',
+ input: {
+ type: 'object',
+ properties: {
+ assetAddress: { type: 'string' },
+ rpcUrl: { type: 'string' },
+ },
+ required: ['assetAddress'],
+ },
+ async handler(input) {
+ const assetAddress = stringValue(input.assetAddress)
+ if (!assetAddress) return { ok: false, summary: 'An agent asset address is required.' }
+ const identityInput: MetaplexReadAgentIdentityInput = {
+ network: 'devnet',
+ rpcUrl: stringValue(input.rpcUrl) || DEFAULT_DEVNET_RPC,
+ assetAddress,
+ }
+ const identity = await readAgentIdentity(identityInput)
+ return {
+ ok: true,
+ summary: identity.registered ? 'Read registered devnet agent identity.' : 'No devnet agent identity found for this asset.',
+ data: identity,
+ }
+ },
+ },
+ {
+ name: 'agenteconomy_register_devnet_identity',
+ description: 'Register a Metaplex Agent Identity for a devnet Core asset. Requires ARIA sensitive approval and the service confirmation acknowledgement REGISTER AGENT IDENTITY.',
+ kind: 'run',
+ risk: 'sensitive',
+ input: {
+ type: 'object',
+ properties: {
+ walletId: { type: 'string' },
+ rpcUrl: { type: 'string' },
+ assetAddress: { type: 'string' },
+ agentRegistrationUri: { type: 'string' },
+ confirmedAt: { type: 'number' },
+ acknowledgement: { type: 'string' },
+ },
+ required: ['walletId', 'assetAddress', 'agentRegistrationUri', 'confirmedAt', 'acknowledgement'],
+ },
+ async handler(input) {
+ const registerInput: MetaplexRegisterAgentIdentityInput = {
+ walletId: stringValue(input.walletId),
+ network: 'devnet',
+ rpcUrl: stringValue(input.rpcUrl) || DEFAULT_DEVNET_RPC,
+ assetAddress: stringValue(input.assetAddress),
+ agentRegistrationUri: stringValue(input.agentRegistrationUri),
+ confirmedAt: numberValue(input.confirmedAt, 0),
+ acknowledgement: stringValue(input.acknowledgement),
+ }
+ const receipt = await registerAgentIdentity(registerInput)
+ return {
+ ok: true,
+ summary: `Registered devnet agent identity for ${receipt.asset}.`,
+ data: receipt,
+ }
+ },
+ },
+ {
+ name: 'agenteconomy_execute_paid_call',
+ description: 'Execute a paid IDLE/x402 resource call after ARIA sensitive approval. The handler rechecks policy, requires a payment signature, and stores a redacted receipt.',
+ kind: 'run',
+ risk: 'sensitive',
+ input: {
+ type: 'object',
+ properties: {
+ profileId: { type: 'string' },
+ resourceId: { type: 'string' },
+ projectId: { type: 'string' },
+ taskId: { type: 'string' },
+ agentId: { type: 'string' },
+ requestBody: { type: 'object' },
+ paymentSignature: { type: 'string' },
+ approvedBy: { type: 'string' },
+ },
+ required: ['profileId', 'resourceId', 'paymentSignature'],
+ },
+ async handler(input, ctx) {
+ const paymentSignature = stringValue(input.paymentSignature)
+ if (!paymentSignature) return { ok: false, summary: 'A payment signature is required.' }
+ const result = await AgentEconomyService.executePaidCall({
+ ...toAgentEconomyCheck(input, ctx.snapshot.activeProjectId),
+ requestBody: input.requestBody ?? {},
+ paymentSignature,
+ approvedBy: stringValue(input.approvedBy) || 'aria',
+ })
+ const execution = objectValue(result)
+ const receipt = objectValue(execution.receipt)
+ const reasons = stringArray(execution.reasons)
+ const status = stringValue(receipt.status) || stringValue(execution.status)
+ return {
+ ok: status === 'settled' || status === 'executed',
+ summary: status === 'settled' || status === 'executed'
+ ? 'Paid call executed and receipt recorded.'
+ : `Paid call ${status || 'blocked'}: ${reasons.join(' ') || stringValue(receipt.errorMessage) || 'receipt recorded.'}`,
+ data: result,
+ }
+ },
+ },
+]
diff --git a/electron/services/aria/tools/forensics.ts b/electron/services/aria/tools/forensics.ts
new file mode 100644
index 00000000..b4f6293a
--- /dev/null
+++ b/electron/services/aria/tools/forensics.ts
@@ -0,0 +1,122 @@
+/**
+ * Forensics (RicoMaps) ARIA tools: investigate a Solana token or wallet for
+ * cabal funders, snipers, and bundle clusters, and trace funding chains.
+ *
+ * Backed by the in-process RicoMaps engine (RicoMapsService.scan / expandNode),
+ * the same engine that powers the RicoMaps forensics panel. Read-only: these
+ * tools query chain state via Helius and never sign or mutate, so they auto-run.
+ *
+ * Handlers return the compact stats + security verdict, not the full graph — the
+ * node/link arrays are huge and meant for the canvas, not the model context.
+ */
+import * as RicoMapsService from '../../RicoMapsService'
+import type { AriaTool } from '../AriaTool'
+import type { ForensicsScanResult, ForensicsExpandInput } from '../../../shared/types'
+
+function isAddress(value: unknown): value is string {
+ return typeof value === 'string' && /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(value.trim())
+}
+
+/** Pull the model-relevant signal out of a scan result (drop the graph payload). */
+function summarizeScan(result: ForensicsScanResult) {
+ const s = result.stats
+ return {
+ mode: result.mode,
+ cabalConnections: s.cabalConnectionsFound ?? 0,
+ snipersDetected: s.snipersDetected ?? 0,
+ bundleClusters: s.bundleClustersDetected ?? 0,
+ suspiciousWallets: s.suspiciousWallets ?? [],
+ sniperWallets: s.sniperWallets ?? [],
+ bundledWallets: s.bundledWallets ?? [],
+ totalHolders: s.totalHolders ?? null,
+ analyzedHolders: s.analyzedHolders ?? null,
+ analysisIncomplete: Boolean(s.analysisIncomplete),
+ tokenSecurity: result.tokenSecurity ?? null,
+ tokenMetadata: result.tokenMetadata
+ ? { name: result.tokenMetadata.name, symbol: result.tokenMetadata.symbol }
+ : null,
+ }
+}
+
+/** A one-line verdict the model can lead its narration with. */
+function verdict(result: ForensicsScanResult): string {
+ const s = result.stats
+ const flags: string[] = []
+ if (s.cabalConnectionsFound) flags.push(`${s.cabalConnectionsFound} cabal connection(s)`)
+ if (s.snipersDetected) flags.push(`${s.snipersDetected} sniper(s)`)
+ if (s.bundleClustersDetected) flags.push(`${s.bundleClustersDetected} bundle cluster(s)`)
+ const risk = result.tokenSecurity?.riskLevel
+ if (risk && risk !== 'low') flags.push(`${risk} token-security risk`)
+ return flags.length ? `Flags: ${flags.join(', ')}.` : 'No coordinated-actor flags detected.'
+}
+
+export const forensicsTools: AriaTool[] = [
+ {
+ name: 'forensic_scan_token',
+ description: 'Run a RicoMaps forensic scan on a Solana token mint. Maps the top holders, traces who funded each, and detects cabal funders, snipers (first-block buyers), and bundle clusters. Also reports mint/freeze authority and a token-security risk level. Read-only.',
+ kind: 'read',
+ risk: 'read',
+ input: {
+ type: 'object',
+ properties: {
+ mint: { type: 'string', description: 'Solana token mint address.' },
+ topHolders: { type: 'number', description: 'How many top holders to analyze (default 20).' },
+ },
+ required: ['mint'],
+ },
+ async handler(input) {
+ const mint = String(input.mint ?? '').trim()
+ if (!isAddress(mint)) return { ok: false, summary: 'A valid Solana token mint is required.' }
+ const topHolders = typeof input.topHolders === 'number' && input.topHolders > 0 ? Math.floor(input.topHolders) : undefined
+ const result = await RicoMapsService.scan({ address: mint, mode: 'token', topHolders })
+ return { ok: true, summary: `Scanned token ${mint}. ${verdict(result)}`, data: summarizeScan(result) }
+ },
+ },
+ {
+ name: 'forensic_trace_wallet',
+ description: 'Trace a Solana wallet backwards through its funding chain with RicoMaps to find who funded it and surface shared funders. Read-only.',
+ kind: 'read',
+ risk: 'read',
+ input: {
+ type: 'object',
+ properties: {
+ wallet: { type: 'string', description: 'Solana wallet address to trace.' },
+ maxDepth: { type: 'number', description: 'How many funding hops to trace (default engine value).' },
+ },
+ required: ['wallet'],
+ },
+ async handler(input) {
+ const wallet = String(input.wallet ?? '').trim()
+ if (!isAddress(wallet)) return { ok: false, summary: 'A valid Solana wallet address is required.' }
+ const maxDepth = typeof input.maxDepth === 'number' && input.maxDepth > 0 ? Math.floor(input.maxDepth) : undefined
+ const result = await RicoMapsService.scan({ address: wallet, mode: 'wallet', maxDepth })
+ return { ok: true, summary: `Traced wallet ${wallet}. ${verdict(result)}`, data: summarizeScan(result) }
+ },
+ },
+ {
+ name: 'forensic_expand_wallet',
+ description: 'Expand one wallet node in a RicoMaps graph to reveal its funders or the wallets it funded. Use after a scan to drill into a flagged address. Read-only.',
+ kind: 'read',
+ risk: 'read',
+ input: {
+ type: 'object',
+ properties: {
+ wallet: { type: 'string', description: 'Wallet address to expand.' },
+ mode: { type: 'string', enum: ['funding', 'funded'], description: 'Trace upstream funders or downstream recipients.' },
+ },
+ required: ['wallet'],
+ },
+ async handler(input) {
+ const wallet = String(input.wallet ?? '').trim()
+ if (!isAddress(wallet)) return { ok: false, summary: 'A valid wallet address is required.' }
+ const mode = input.mode === 'funded' ? 'funded' : 'funding'
+ const result = await RicoMapsService.expandNode({ wallet, mode, existingNodes: [] } as ForensicsExpandInput)
+ const added = result.newNodes?.length ?? 0
+ return {
+ ok: true,
+ summary: `Expanded ${wallet} (${mode}): ${added} connected wallet(s).`,
+ data: { newNodes: result.newNodes, newLinks: result.newLinks },
+ }
+ },
+ },
+]
diff --git a/electron/services/aria/tools/hyperliquid.ts b/electron/services/aria/tools/hyperliquid.ts
index fcc5f483..254ac374 100644
--- a/electron/services/aria/tools/hyperliquid.ts
+++ b/electron/services/aria/tools/hyperliquid.ts
@@ -314,9 +314,13 @@ export const hyperliquidTools: AriaTool[] = [
},
{
name: 'hl_update_leverage',
- description: 'Update leverage for a Hyperliquid coin. No funds move. Requires approval.',
+ // Sensitive, not write: it signs a live exchange action that alters liquidation risk on open
+ // positions. As 'write' it would auto-run after a single plan approval (plan steps are free
+ // text, not bound to the tools executed), so an innocuous plan could license a leverage change
+ // with no per-call gate. Sensitive forces a typed confirmation every time.
+ description: 'Update leverage for a Hyperliquid coin. No funds move but it signs a live exchange action and changes liquidation risk. Requires typed confirmation.',
kind: 'run',
- risk: 'write',
+ risk: 'sensitive',
input: {
type: 'object',
properties: {
@@ -333,7 +337,7 @@ export const hyperliquidTools: AriaTool[] = [
if (!coin) return { ok: false, summary: 'A coin is required.' }
if (leverage === undefined || leverage <= 0) return { ok: false, summary: 'A positive leverage value is required.' }
const data = await Hl.updateLeverage(coin, leverage, Boolean(input.isolated))
- return { ok: true, summary: `Set ${coin} leverage to ${leverage}x.`, data }
+ return { ok: true, summary: netMark(`Set ${coin} leverage to ${leverage}x.`), data }
},
},
{
diff --git a/electron/services/aria/tools/navigation.ts b/electron/services/aria/tools/navigation.ts
index a4f0a4a5..e5bb103d 100644
--- a/electron/services/aria/tools/navigation.ts
+++ b/electron/services/aria/tools/navigation.ts
@@ -2,10 +2,74 @@
* Navigation / read-only tools: open panels, run commands, open files,
* read project + wallet status.
*/
+import fs from 'node:fs/promises'
+import path from 'node:path'
import * as SettingsService from '../../SettingsService'
import * as WalletService from '../../WalletService'
+import { PACK_IPC_DOMAINS, type PackId } from '../../../shared/packManifest'
+import { getDb } from '../../../db/db'
import type { AriaTool } from '../AriaTool'
import { resolveScopedPath } from './shared'
+import type { Project } from '../../../shared/types'
+
+const MAX_TREE_ENTRIES = 300
+const MAX_SEARCH_RESULTS = 50
+const MAX_READ_BYTES = 120_000
+const MAX_SEARCH_FILE_BYTES = 512_000
+const IGNORED_DIRS = new Set([
+ '.git', 'node_modules', 'dist', 'dist-electron', 'dist-bridge', 'build',
+ 'target', 'release', 'test-results', '.next', '.turbo', '.cache',
+])
+
+function summarizeEnabledPacks(packs: Record) {
+ const packIds = Object.keys(PACK_IPC_DOMAINS) as PackId[]
+ const enabledPacks = packIds.filter((id) => packs[id] !== false)
+ const disabledPacks = packIds.filter((id) => packs[id] === false)
+ const enabledIpcDomains = Array.from(new Set(enabledPacks.flatMap((id) => PACK_IPC_DOMAINS[id]))).sort()
+ return { enabledPacks, disabledPacks, enabledIpcDomains }
+}
+
+function toProjectRelative(abs: string, root: string): string {
+ return path.relative(root, abs).replace(/\\/g, '/') || '.'
+}
+
+function normalizeFsPath(value: string): string {
+ return path.resolve(value).replace(/\\/g, '/').replace(/\/$/, '').toLowerCase()
+}
+
+function findRegisteredProject(input: string): Project | null {
+ const needle = input.trim()
+ if (!needle) return null
+ const normalized = normalizeFsPath(needle)
+ const rows = getDb().prepare('SELECT * FROM projects').all() as Project[]
+ return rows.find((project) =>
+ project.id === needle ||
+ project.name.toLowerCase() === needle.toLowerCase() ||
+ normalizeFsPath(project.path) === normalized
+ ) ?? null
+}
+
+async function walkProject(root: string, start: string, limit: number): Promise> {
+ const entries: Array<{ path: string; type: 'file' | 'dir'; size?: number }> = []
+ const pending = [start]
+ while (pending.length > 0 && entries.length < limit) {
+ const current = pending.shift()!
+ const dirents = await fs.readdir(current, { withFileTypes: true })
+ for (const dirent of dirents.sort((a, b) => a.name.localeCompare(b.name))) {
+ if (entries.length >= limit) break
+ if (dirent.isDirectory() && IGNORED_DIRS.has(dirent.name)) continue
+ const abs = path.join(current, dirent.name)
+ if (dirent.isDirectory()) {
+ entries.push({ path: toProjectRelative(abs, root), type: 'dir' })
+ pending.push(abs)
+ } else if (dirent.isFile()) {
+ const stat = await fs.stat(abs)
+ entries.push({ path: toProjectRelative(abs, root), type: 'file', size: stat.size })
+ }
+ }
+ }
+ return entries
+}
/** Workspace tool ids the model may open (mirror of src/constants/toolRegistry). */
const OPEN_TOOL_IDS = new Set([
@@ -43,6 +107,27 @@ export const navigationTools: AriaTool[] = [
return { ok: true, summary: `Ran ${commandId}.`, uiEffect: { type: 'run_command', commandId } }
},
},
+ {
+ name: 'activate_project',
+ description: 'Set a registered DAEMON project as active by exact id, name, or path. Use when the user provides a project path or asks to switch projects.',
+ kind: 'read',
+ risk: 'read',
+ input: { type: 'object', properties: { project: { type: 'string' } }, required: ['project'] },
+ async handler(input, ctx) {
+ const query = String(input.project ?? '').trim()
+ const project = findRegisteredProject(query)
+ if (!project) return { ok: false, summary: `No registered project matches "${query}".` }
+ ctx.snapshot.activeProjectId = project.id
+ ctx.snapshot.activeProjectPath = project.path
+ await ctx.runUiEffect({ type: 'set_active_project', projectId: project.id, projectPath: project.path }, false)
+ return {
+ ok: true,
+ summary: `Activated ${project.name}.`,
+ data: { id: project.id, name: project.name, path: project.path },
+ uiEffect: { type: 'set_active_project', projectId: project.id, projectPath: project.path },
+ }
+ },
+ },
{
name: 'open_file',
description: 'Open a file in the editor by path (relative to the active project).',
@@ -56,6 +141,114 @@ export const navigationTools: AriaTool[] = [
return { ok: true, summary: `Opened ${rel}.`, uiEffect: { type: 'open_file', path: abs } }
},
},
+ {
+ name: 'list_project_tree',
+ description: 'List files and folders under the active project. Use before asking the user for filenames. Optional path is relative to the active project.',
+ kind: 'read',
+ risk: 'read',
+ input: {
+ type: 'object',
+ properties: {
+ path: { type: 'string' },
+ limit: { type: 'number' },
+ },
+ },
+ async handler(input, ctx) {
+ const root = path.resolve(ctx.snapshot.activeProjectPath ?? '')
+ const rel = typeof input.path === 'string' && input.path.trim() ? input.path.trim() : '.'
+ const start = resolveScopedPath(rel, ctx.snapshot.activeProjectPath)
+ const stat = await fs.stat(start)
+ const limit = Math.min(Math.max(Number(input.limit) || MAX_TREE_ENTRIES, 1), MAX_TREE_ENTRIES)
+ const entries = stat.isDirectory()
+ ? await walkProject(root, start, limit)
+ : [{ path: toProjectRelative(start, root), type: 'file' as const, size: stat.size }]
+ return {
+ ok: true,
+ summary: `Listed ${entries.length} item${entries.length === 1 ? '' : 's'}${entries.length >= limit ? ' (truncated)' : ''}.`,
+ data: { entries, truncated: entries.length >= limit },
+ }
+ },
+ },
+ {
+ name: 'read_file',
+ description: 'Read UTF-8 text from a file in the active project. Use this before answering questions about file contents.',
+ kind: 'read',
+ risk: 'read',
+ input: {
+ type: 'object',
+ properties: {
+ path: { type: 'string' },
+ maxBytes: { type: 'number' },
+ },
+ required: ['path'],
+ },
+ async handler(input, ctx) {
+ const rel = String(input.path ?? '').trim()
+ if (!rel) return { ok: false, summary: 'A file path is required.' }
+ const abs = resolveScopedPath(rel, ctx.snapshot.activeProjectPath)
+ const stat = await fs.stat(abs)
+ if (!stat.isFile()) return { ok: false, summary: `${rel} is not a file.` }
+ const maxBytes = Math.min(Math.max(Number(input.maxBytes) || MAX_READ_BYTES, 1), MAX_READ_BYTES)
+ const handle = await fs.open(abs, 'r')
+ try {
+ const buffer = Buffer.alloc(Math.min(stat.size, maxBytes))
+ await handle.read(buffer, 0, buffer.length, 0)
+ return {
+ ok: true,
+ summary: `Read ${rel}${stat.size > maxBytes ? ' (truncated)' : ''}.`,
+ data: { path: rel, content: buffer.toString('utf8'), truncated: stat.size > maxBytes, size: stat.size },
+ }
+ } finally {
+ await handle.close()
+ }
+ },
+ },
+ {
+ name: 'search_files',
+ description: 'Search text files in the active project for a literal query. Skips dependency and build directories.',
+ kind: 'read',
+ risk: 'read',
+ input: {
+ type: 'object',
+ properties: {
+ query: { type: 'string' },
+ path: { type: 'string' },
+ limit: { type: 'number' },
+ },
+ required: ['query'],
+ },
+ async handler(input, ctx) {
+ const query = String(input.query ?? '').trim()
+ if (!query) return { ok: false, summary: 'A search query is required.' }
+ const root = path.resolve(ctx.snapshot.activeProjectPath ?? '')
+ const rel = typeof input.path === 'string' && input.path.trim() ? input.path.trim() : '.'
+ const start = resolveScopedPath(rel, ctx.snapshot.activeProjectPath)
+ const limit = Math.min(Math.max(Number(input.limit) || MAX_SEARCH_RESULTS, 1), MAX_SEARCH_RESULTS)
+ const startStat = await fs.stat(start)
+ const files = startStat.isFile()
+ ? [{ path: toProjectRelative(start, root), type: 'file' as const, size: startStat.size }]
+ : (await walkProject(root, start, 2_000)).filter((entry) => entry.type === 'file')
+ const needle = query.toLowerCase()
+ const results: Array<{ path: string; line: number; text: string }> = []
+ for (const file of files) {
+ if (results.length >= limit) break
+ if ((file.size ?? 0) > MAX_SEARCH_FILE_BYTES) continue
+ const content = await fs.readFile(resolveScopedPath(file.path, ctx.snapshot.activeProjectPath), 'utf8').catch(() => '')
+ if (!content) continue
+ const lines = content.split(/\r?\n/)
+ for (let index = 0; index < lines.length && results.length < limit; index++) {
+ if (lines[index].toLowerCase().includes(needle)) {
+ results.push({ path: file.path, line: index + 1, text: lines[index].slice(0, 240) })
+ }
+ }
+ }
+ return {
+ ok: true,
+ summary: `Found ${results.length} match${results.length === 1 ? '' : 'es'}${results.length >= limit ? ' (truncated)' : ''}.`,
+ data: { results, truncated: results.length >= limit },
+ }
+ },
+ },
{
name: 'read_project_status',
description: 'Read a summary of the active project: wallet, enabled integrations, network. Use before acting.',
@@ -65,6 +258,7 @@ export const navigationTools: AriaTool[] = [
async handler(_input, ctx) {
const dashboard = await WalletService.getDashboard(ctx.snapshot.activeProjectId).catch(() => null)
const infra = SettingsService.getWalletInfrastructureSettings()
+ const packs = summarizeEnabledPacks(SettingsService.getEnabledPacks())
return {
ok: true,
summary: 'Read project status.',
@@ -75,6 +269,9 @@ export const navigationTools: AriaTool[] = [
defaultWallet: dashboard?.activeWallet?.name ?? null,
walletCount: dashboard?.portfolio.walletCount ?? 0,
heliusConfigured: dashboard?.heliusConfigured ?? false,
+ enabledPacks: packs.enabledPacks,
+ disabledPacks: packs.disabledPacks,
+ enabledIpcDomains: packs.enabledIpcDomains,
},
}
},
diff --git a/electron/services/aria/tools/shared.ts b/electron/services/aria/tools/shared.ts
index 4be07ae3..8ebc27fe 100644
--- a/electron/services/aria/tools/shared.ts
+++ b/electron/services/aria/tools/shared.ts
@@ -2,9 +2,33 @@
* Shared helpers for the ARIA tool domain modules.
*/
import path from 'node:path'
+import fs from 'node:fs'
import * as SettingsService from '../../SettingsService'
-/** Resolve a project-relative path, rejecting anything that escapes the root. */
+/** Secret-bearing files that read tools must never surface, even inside the project root.
+ * These reads auto-run (risk 'read'), including for external bridge agents supplying their own
+ * cwd, so a .env or keypair leak would be silent. Matched on the basename, case-insensitive. */
+const SECRET_FILE_PATTERNS: RegExp[] = [
+ /^\.env(\..*)?$/i, // .env, .env.local, .env.production, ...
+ /\.pem$/i,
+ /\.key$/i,
+ /(^|[._-])secret/i,
+ /^id_(rsa|ed25519|ecdsa|dsa)/i,
+ /keypair.*\.json$/i,
+ /wallet.*\.json$/i,
+]
+
+function isSecretFile(abs: string): boolean {
+ const base = path.basename(abs)
+ return SECRET_FILE_PATTERNS.some((re) => re.test(base))
+}
+
+/**
+ * Resolve a project-relative path, rejecting anything that escapes the root or names a secret
+ * file. Containment is enforced against the REAL path (fs.realpath) so an in-project symlink
+ * pointing outside the root can't escape; secret-file basenames (.env, keypairs, *.key) are
+ * always denied regardless of location.
+ */
export function resolveScopedPath(rel: string, projectRoot: string | null): string {
if (!projectRoot) throw new Error('No active project — open a project first.')
const abs = path.resolve(projectRoot, rel)
@@ -12,6 +36,24 @@ export function resolveScopedPath(rel: string, projectRoot: string | null): stri
if (abs !== root && !abs.startsWith(root + path.sep)) {
throw new Error('Path escapes the active project root.')
}
+ if (isSecretFile(abs)) {
+ throw new Error('Refusing to read a secret-bearing file (.env, keypair, or key material).')
+ }
+ // Realpath both sides so a symlink inside the root can't point outside it. Fall back to the
+ // lexical check when the target doesn't exist yet (e.g. a not-yet-created file).
+ try {
+ const realAbs = fs.realpathSync(abs)
+ const realRoot = fs.realpathSync(root)
+ if (realAbs !== realRoot && !realAbs.startsWith(realRoot + path.sep)) {
+ throw new Error('Path escapes the active project root (via symlink).')
+ }
+ if (isSecretFile(realAbs)) {
+ throw new Error('Refusing to read a secret-bearing file (.env, keypair, or key material).')
+ }
+ } catch (err) {
+ // ENOENT is fine (path may not exist yet); rethrow a containment/secret rejection.
+ if (err instanceof Error && /escapes|secret-bearing/.test(err.message)) throw err
+ }
return abs
}
diff --git a/electron/services/bridge/BridgeToolGateway.ts b/electron/services/bridge/BridgeToolGateway.ts
index e75a9295..45b211fc 100644
--- a/electron/services/bridge/BridgeToolGateway.ts
+++ b/electron/services/bridge/BridgeToolGateway.ts
@@ -22,6 +22,7 @@ const DEFAULT_APPROVAL_TIMEOUT_MS = 120_000
const PROJECT_REQUIRED_TOOLS = new Set([
'remember_fact', 'recall_memories', 'forget_memory', 'update_memory',
'assign_project_wallet',
+ 'list_project_tree', 'read_file', 'search_files',
])
export interface BridgeCallRequest {
diff --git a/electron/services/bridge/bridgeManifest.ts b/electron/services/bridge/bridgeManifest.ts
index 2f6ff0ff..3766e45d 100644
--- a/electron/services/bridge/bridgeManifest.ts
+++ b/electron/services/bridge/bridgeManifest.ts
@@ -34,4 +34,7 @@ export const BRIDGE_TOOL_ALLOWLIST: readonly BridgeAllowlistEntry[] = [
{ name: 'update_memory', packId: 'memory' },
// core
{ name: 'read_project_status', packId: null },
+ { name: 'list_project_tree', packId: null },
+ { name: 'read_file', packId: null },
+ { name: 'search_files', packId: null },
]
diff --git a/electron/services/daemon-ai-cloud/AnthropicMessagesProvider.ts b/electron/services/daemon-ai-cloud/AnthropicMessagesProvider.ts
index b4833eec..506e4abc 100644
--- a/electron/services/daemon-ai-cloud/AnthropicMessagesProvider.ts
+++ b/electron/services/daemon-ai-cloud/AnthropicMessagesProvider.ts
@@ -17,9 +17,9 @@ function anthropicModelForLane(lane: DaemonAiModelLane): string {
return process.env.DAEMON_AI_ANTHROPIC_FAST_MODEL || 'claude-haiku-4-5-20251001'
case 'reasoning':
case 'premium':
- return process.env.DAEMON_AI_ANTHROPIC_REASONING_MODEL || 'claude-opus-4-20250514'
+ return process.env.DAEMON_AI_ANTHROPIC_REASONING_MODEL || 'claude-opus-4-8'
default:
- return process.env.DAEMON_AI_ANTHROPIC_STANDARD_MODEL || 'claude-sonnet-4-20250514'
+ return process.env.DAEMON_AI_ANTHROPIC_STANDARD_MODEL || 'claude-sonnet-4-6'
}
}
diff --git a/electron/services/garrison/GarrisonReferralService.ts b/electron/services/garrison/GarrisonReferralService.ts
new file mode 100644
index 00000000..fca32acf
--- /dev/null
+++ b/electron/services/garrison/GarrisonReferralService.ts
@@ -0,0 +1,340 @@
+import crypto from 'node:crypto'
+import nacl from 'tweetnacl'
+import bs58 from 'bs58'
+import { PublicKey, SystemProgram } from '@solana/web3.js'
+import { getDb } from '../../db/db'
+import {
+ executeInstructions,
+ getConnectionStrict,
+ withKeypair,
+} from '../SolanaService'
+import { getFeeSettings, MIN_FEE_LAMPORTS } from '../FeeService'
+import { tierFor } from './GarrisonStakeService'
+
+// Garrison subsystem A — referral attribution + commission accrual + payout.
+//
+// A referrer earns a commission on REAL on-chain volume from wallets they referred — the
+// same economic category as paying a KOL/affiliate. Two things are NON-NEGOTIABLE even in
+// v0 (GARRISON_BACKEND_SPEC.md §12):
+// 1. The §4 fee-floor cap, enforced PER fee_event at accrual time, so the protocol always
+// keeps at least FEE_FLOOR_HEADROOM_BPS of its net fee. This is the solvency floor.
+// 2. The signed-nonce verified-referee gate. Unverified referees accrue NOTHING. This is
+// the Sybil floor.
+// Commission is never a yield on the stake; a stake with zero referred volume earns zero.
+
+// ---- §2.6 constants (compiled ceilings; config may only lower, never raise) ----
+export const BASE_REFERRAL_BPS = 1000 // 10% of net protocol fee, base (pre-stake)
+export const MAX_REFERRAL_BPS = 3000 // hard ceiling on (base+bonus) share of net fee
+export const FEE_FLOOR_HEADROOM_BPS = 5000 // protocol always keeps >= 50% of net fee
+export const KOL_RESERVED_BPS = 0 // no KOL carve-out wired in v0
+export const MIN_CLAIM_LAMPORTS = 10_000_000 // 0.01 SOL — below this a claim costs more than it pays
+export const PER_REFEREE_EPOCH_CAP_LAMPORTS = 5_000_000_000 // anti-wash: one referee's notional cap
+const BPS_DENOM = 10_000
+const EPOCH_MS = 86_400_000 // day-index epoch bucket
+
+// -------------------------------------------------------------- attribution ---
+
+interface AttributionRow {
+ id: string
+ referred_wallet: string
+ referrer_wallet: string
+ verified: number
+ status: string
+}
+
+/** First-touch, immutable binding. Rejects self-referral and already-bound referees. */
+export function bindReferral(
+ refCode: string,
+ referredWallet: string,
+ referrerWallet: string,
+ boundVia: 'link' | 'launch' | 'manual' = 'link',
+): { attributionId: string; referrerWallet: string; verified: boolean } {
+ if (referrerWallet === referredWallet) throw new Error('Cannot refer yourself')
+ const db = getDb()
+ const existing = db
+ .prepare('SELECT id, referrer_wallet, verified FROM garrison_referral_attributions WHERE referred_wallet = ?')
+ .get(referredWallet) as { id: string; referrer_wallet: string; verified: number } | undefined
+ if (existing) {
+ // First-touch wins — a referee can never be re-attributed (anti-hijack).
+ return { attributionId: existing.id, referrerWallet: existing.referrer_wallet, verified: existing.verified === 1 }
+ }
+ const id = crypto.randomUUID()
+ db.prepare(
+ 'INSERT INTO garrison_referral_attributions (id, referred_wallet, referrer_wallet, ref_code, bound_via, verified, status) ' +
+ 'VALUES (?,?,?,?,?,0,?)',
+ ).run(id, referredWallet, referrerWallet, refCode || null, boundVia, 'active')
+ return { attributionId: id, referrerWallet, verified: false }
+}
+
+/** Prove ownership of the referred wallet via the signed-nonce challenge flow. */
+export function verifyReferred(referredWallet: string, nonce: string, signature: string): { verified: true } {
+ const db = getDb()
+ const challenge = db
+ .prepare('SELECT wallet_address, message, expires_at, used_at FROM daemon_holder_challenges WHERE nonce = ?')
+ .get(nonce) as { wallet_address: string; message: string; expires_at: number; used_at: number | null } | undefined
+ if (!challenge || challenge.wallet_address !== referredWallet) throw new Error('Invalid challenge')
+ if (challenge.used_at !== null) throw new Error('Challenge already used')
+ if (challenge.expires_at <= Date.now()) throw new Error('Challenge expired')
+
+ const verified = nacl.sign.detached.verify(
+ Buffer.from(challenge.message, 'utf8'),
+ bs58.decode(signature),
+ new PublicKey(referredWallet).toBytes(),
+ )
+ if (!verified) throw new Error('Invalid signature')
+
+ db.transaction(() => {
+ db.prepare('UPDATE daemon_holder_challenges SET used_at = ? WHERE nonce = ?').run(Date.now(), nonce)
+ db.prepare('UPDATE garrison_referral_attributions SET verified = 1 WHERE referred_wallet = ?').run(referredWallet)
+ })()
+ return { verified: true }
+}
+
+// ------------------------------------------------------------ §4 commission math ---
+
+/**
+ * The floor-safe commission for one metered execution. All shares are bps of the net fee
+ * (`feeLamports`), never of notional. Returns base/bonus/total in lamports; the protocol
+ * always keeps >= FEE_FLOOR_HEADROOM_BPS of the net fee, even with every knob maxed.
+ */
+export function commissionForEvent(
+ feeLamports: number,
+ multiplierBps: number,
+): { base: number; bonus: number; total: number } {
+ const baseShare = Math.floor((feeLamports * BASE_REFERRAL_BPS) / BPS_DENOM)
+ const multipliedShare = Math.floor((baseShare * multiplierBps) / BPS_DENOM)
+
+ // 1. cap the referral line at MAX_REFERRAL_BPS of the net fee
+ const referralCap = Math.floor((feeLamports * MAX_REFERRAL_BPS) / BPS_DENOM)
+ const referralLine = Math.min(multipliedShare, referralCap)
+
+ // 2. cap the total outbound so the protocol keeps its floor
+ const maxOutbound = Math.floor((feeLamports * (BPS_DENOM - FEE_FLOOR_HEADROOM_BPS)) / BPS_DENOM)
+ const kol = Math.floor((feeLamports * KOL_RESERVED_BPS) / BPS_DENOM)
+ const allowedReferral = Math.max(0, maxOutbound - kol)
+ const total = Math.min(referralLine, allowedReferral)
+ const base = Math.min(baseShare, total)
+ return { base, bonus: total - base, total }
+}
+
+// --------------------------------------------------------------- accrual ---
+
+interface FeeEventRow {
+ wallet: string
+ notional_lamports: number
+ fee_lamports: number
+ signature: string | null
+ created_at: number
+}
+
+function epochOf(createdAt: number): number {
+ return Math.floor(createdAt / EPOCH_MS)
+}
+
+/**
+ * Fold new mainnet fee_events into per-(referrer, epoch) commission rows. Idempotent: each
+ * referrer row tracks the max fee_events.created_at it has consumed (`high_water_fee_event_at`),
+ * so a row is never double-counted. Only VERIFIED, active, non-self referrers accrue. The §4
+ * fee-floor cap is applied per event before folding.
+ */
+export function runAccrual(sinceMs = 0): { rowsConsidered: number; accrued: number } {
+ const db = getDb()
+ const events = db
+ .prepare(
+ "SELECT wallet, notional_lamports, fee_lamports, signature, created_at FROM fee_events " +
+ "WHERE cluster = 'mainnet-beta' AND created_at > ? ORDER BY created_at ASC",
+ )
+ .all(sinceMs) as FeeEventRow[]
+
+ let accrued = 0
+ for (const ev of events) {
+ if (ev.fee_lamports < MIN_FEE_LAMPORTS) continue // skip dust (anti-wash)
+ const attr = db
+ .prepare(
+ 'SELECT id, referred_wallet, referrer_wallet, verified, status FROM garrison_referral_attributions WHERE referred_wallet = ?',
+ )
+ .get(ev.wallet) as AttributionRow | undefined
+ if (!attr || attr.verified !== 1 || attr.status !== 'active') continue
+ if (attr.referrer_wallet === ev.wallet) continue // self-ref hard skip
+
+ const epoch = epochOf(ev.created_at)
+ const existing = db
+ .prepare('SELECT * FROM garrison_referral_commissions WHERE referrer_wallet = ? AND epoch = ?')
+ .get(attr.referrer_wallet, epoch) as
+ | {
+ id: string
+ attributed_notional_lamports: number
+ high_water_fee_event_at: number
+ }
+ | undefined
+
+ // Replay guard: skip an event already folded into this epoch row.
+ if (existing && ev.created_at <= existing.high_water_fee_event_at) continue
+
+ // Per-referee notional cap (anti-wash): clamp the notional credited this epoch.
+ const priorNotional = existing?.attributed_notional_lamports ?? 0
+ const headroom = Math.max(0, PER_REFEREE_EPOCH_CAP_LAMPORTS - priorNotional)
+ if (headroom <= 0) continue
+ const creditedNotional = Math.min(ev.notional_lamports, headroom)
+ // Scale the fee basis proportionally so the cap also bounds the payout.
+ const creditedFee =
+ ev.notional_lamports > 0
+ ? Math.floor((ev.fee_lamports * creditedNotional) / ev.notional_lamports)
+ : ev.fee_lamports
+
+ const { multiplierBps } = tierFor(attr.referrer_wallet)
+ const { base, bonus, total } = commissionForEvent(creditedFee, multiplierBps)
+ if (total <= 0) continue
+
+ upsertCommission(attr.referrer_wallet, epoch, {
+ notional: creditedNotional,
+ fee: creditedFee,
+ base,
+ bonus,
+ gross: total,
+ tierId: tierFor(attr.referrer_wallet).tier?.id ?? null,
+ highWater: ev.created_at,
+ })
+ accrued++
+ }
+ return { rowsConsidered: events.length, accrued }
+}
+
+function upsertCommission(
+ referrerWallet: string,
+ epoch: number,
+ d: { notional: number; fee: number; base: number; bonus: number; gross: number; tierId: string | null; highWater: number },
+): void {
+ const db = getDb()
+ const now = Date.now()
+ const existing = db
+ .prepare('SELECT id FROM garrison_referral_commissions WHERE referrer_wallet = ? AND epoch = ?')
+ .get(referrerWallet, epoch) as { id: string } | undefined
+ if (existing) {
+ db.prepare(
+ 'UPDATE garrison_referral_commissions SET ' +
+ 'attributed_notional_lamports = attributed_notional_lamports + ?, ' +
+ 'attributed_fee_lamports = attributed_fee_lamports + ?, ' +
+ 'base_commission_lamports = base_commission_lamports + ?, ' +
+ 'bonus_commission_lamports = bonus_commission_lamports + ?, ' +
+ 'gross_commission_lamports = gross_commission_lamports + ?, ' +
+ 'tier_id_snapshot = ?, high_water_fee_event_at = ?, updated_at = ? WHERE id = ?',
+ ).run(d.notional, d.fee, d.base, d.bonus, d.gross, d.tierId, d.highWater, now, existing.id)
+ return
+ }
+ db.prepare(
+ 'INSERT INTO garrison_referral_commissions (id, referrer_wallet, epoch, attributed_notional_lamports, ' +
+ 'attributed_fee_lamports, base_commission_lamports, bonus_commission_lamports, gross_commission_lamports, ' +
+ 'tier_id_snapshot, high_water_fee_event_at, status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)',
+ ).run(
+ crypto.randomUUID(), referrerWallet, epoch, d.notional, d.fee, d.base, d.bonus, d.gross,
+ d.tierId, d.highWater, 'accrued', now, now,
+ )
+}
+
+// ---------------------------------------------------------------- reads ---
+
+export function getReferralEarnings(referrerWallet: string): {
+ totalAccruedLamports: number
+ totalClaimableLamports: number
+ totalPaidLamports: number
+ referredCount: number
+ verifiedReferredCount: number
+} {
+ const db = getDb()
+ const sums = db
+ .prepare(
+ "SELECT " +
+ "COALESCE(SUM(CASE WHEN status != 'paid' THEN gross_commission_lamports ELSE 0 END),0) AS unpaid, " +
+ "COALESCE(SUM(CASE WHEN status = 'paid' THEN gross_commission_lamports ELSE 0 END),0) AS paid " +
+ "FROM garrison_referral_commissions WHERE referrer_wallet = ?",
+ )
+ .get(referrerWallet) as { unpaid: number; paid: number }
+ const counts = db
+ .prepare(
+ 'SELECT COUNT(*) AS total, COALESCE(SUM(verified),0) AS verified ' +
+ "FROM garrison_referral_attributions WHERE referrer_wallet = ? AND status = 'active'",
+ )
+ .get(referrerWallet) as { total: number; verified: number }
+ return {
+ totalAccruedLamports: sums.unpaid,
+ totalClaimableLamports: sums.unpaid,
+ totalPaidLamports: sums.paid,
+ referredCount: counts.total,
+ verifiedReferredCount: counts.verified,
+ }
+}
+
+/**
+ * The no-double-spend guard: a lamport accrued as commission is no longer "protocol kept",
+ * so any meter-funded buyback/sweep must subtract the outstanding commission liability first.
+ */
+export function availableForBuyback(treasuryBalanceLamports: number): number {
+ const db = getDb()
+ const { liability } = db
+ .prepare(
+ "SELECT COALESCE(SUM(gross_commission_lamports),0) AS liability " +
+ "FROM garrison_referral_commissions WHERE status != 'paid'",
+ )
+ .get() as { liability: number }
+ return Math.max(0, treasuryBalanceLamports - liability)
+}
+
+// ---------------------------------------------------------------- claim ---
+
+/**
+ * Pay out a referrer's accrued commission from the meter treasury. The covered rows flip to
+ * 'paid' in the SAME tx that inserts the payout row, so an epoch can't be double-claimed; the
+ * SOL transfer's signature is persisted right after it lands for idempotent crash-resume.
+ */
+export async function claim(
+ referrerWallet: string,
+ treasuryWalletId: string,
+): Promise<{ payoutId: string; signature: string; amountLamports: number; commissionsPaid: number }> {
+ const db = getDb()
+ const settings = getFeeSettings()
+ if (!settings.treasuryAddress) throw new Error('Fee treasury is not configured')
+
+ const rows = db
+ .prepare(
+ "SELECT id, gross_commission_lamports FROM garrison_referral_commissions " +
+ "WHERE referrer_wallet = ? AND status != 'paid'",
+ )
+ .all(referrerWallet) as { id: string; gross_commission_lamports: number }[]
+ const amount = rows.reduce((sum, r) => sum + r.gross_commission_lamports, 0)
+ if (amount < MIN_CLAIM_LAMPORTS) {
+ throw new Error(`Nothing to claim above the ${MIN_CLAIM_LAMPORTS / 1e9} SOL minimum`)
+ }
+ const commissionIds = rows.map((r) => r.id)
+
+ // Reserve the payout row + flip the covered commissions in one transaction.
+ const payoutId = crypto.randomUUID()
+ db.transaction(() => {
+ db.prepare(
+ 'INSERT INTO garrison_commission_payouts (id, referrer_wallet, amount_lamports, commission_ids_json, treasury_wallet, status) ' +
+ 'VALUES (?,?,?,?,?,?)',
+ ).run(payoutId, referrerWallet, amount, JSON.stringify(commissionIds), settings.treasuryAddress, 'pending')
+ const flip = db.prepare("UPDATE garrison_referral_commissions SET status = 'paid', updated_at = ? WHERE id = ?")
+ const now = Date.now()
+ for (const id of commissionIds) flip.run(now, id)
+ })()
+
+ // Send from the meter treasury, then record the signature for idempotent resume.
+ const connection = getConnectionStrict()
+ const signature = await withKeypair(treasuryWalletId, async (keypair) => {
+ const ix = SystemProgram.transfer({
+ fromPubkey: keypair.publicKey,
+ toPubkey: new PublicKey(referrerWallet),
+ lamports: amount,
+ })
+ const res = await executeInstructions(connection, [ix], [keypair], {
+ payer: keypair.publicKey,
+ guardSource: 'garrison:claim',
+ })
+ return res.signature
+ })
+ db.prepare("UPDATE garrison_commission_payouts SET payout_signature = ?, status = 'sent' WHERE id = ?")
+ .run(signature, payoutId)
+
+ return { payoutId, signature, amountLamports: amount, commissionsPaid: commissionIds.length }
+}
diff --git a/electron/services/garrison/GarrisonStakeService.ts b/electron/services/garrison/GarrisonStakeService.ts
new file mode 100644
index 00000000..3c76a478
--- /dev/null
+++ b/electron/services/garrison/GarrisonStakeService.ts
@@ -0,0 +1,316 @@
+import crypto from 'node:crypto'
+import { PublicKey } from '@solana/web3.js'
+import {
+ getAssociatedTokenAddress,
+ createTransferInstruction,
+ createAssociatedTokenAccountInstruction,
+ getAccount,
+} from '@solana/spl-token'
+import { getDb } from '../../db/db'
+import {
+ executeInstructions,
+ getConnectionStrict,
+ withKeypair,
+} from '../SolanaService'
+import { generateWallet } from '../WalletService'
+
+// Garrison subsystem A — custodial $DAEMON staking vault.
+//
+// Staking gates the referral COMMISSION MULTIPLIER (and the rank bag-multiplier in a
+// later subsystem). It never pays a yield on its own: a stake with zero referred volume
+// earns zero. This is a separate, off-chain custodial vault — NOT the landing-site
+// Streamflow staking. See GARRISON_BACKEND_SPEC.md §3.1 / §13.
+//
+// Discipline mirrors FlywheelService: deposits/withdrawals are ledgered with a UNIQUE
+// on-chain signature so a replay/crash can't double-credit, and the credited amount is
+// read authoritatively from the confirmed tx, never trusted from the client.
+
+const DAEMON_MINT = '4vpf4qNtNVkvz2dm5qL2mT6jBXH9gDY8qH2QsHN5pump'
+// 24h hold before a stake counts toward the multiplier — kills flash-staking.
+export const STAKE_MATURITY_MS = 86_400_000
+const ESCROW_WALLET_NAME = 'Garrison Vault'
+
+export type StakeTier = { id: string; name: string; multiplierBps: number }
+export type StakeStatus = {
+ stakedRaw: number
+ tier: StakeTier | null
+ multiplierBps: number
+ effectiveFrom: number | null
+ isMature: boolean
+ escrowWallet: string
+ status: string
+}
+
+interface StakeRow {
+ id: string
+ holder_wallet: string
+ staked_raw: number
+ escrow_wallet: string
+ tier_id: string | null
+ effective_from: number | null
+ status: string
+}
+
+interface TierRow {
+ id: string
+ name: string
+ min_stake_raw: number
+ multiplier_bps: number
+}
+
+// ----------------------------------------------------------------- escrow ---
+
+let _escrowWalletId: string | null = null
+
+/** The single DAEMON-controlled vault that holds staked $DAEMON. Created once. */
+export function getOrCreateEscrowWallet(): { walletId: string; address: string } {
+ const db = getDb()
+ const existing = db
+ .prepare('SELECT id, address FROM wallets WHERE name = ? LIMIT 1')
+ .get(ESCROW_WALLET_NAME) as { id: string; address: string } | undefined
+ if (existing) {
+ _escrowWalletId = existing.id
+ return { walletId: existing.id, address: existing.address }
+ }
+ const created = generateWallet(ESCROW_WALLET_NAME, 'user') as { id: string; address: string }
+ _escrowWalletId = created.id
+ return { walletId: created.id, address: created.address }
+}
+
+function escrowWalletIdFor(address: string): string {
+ if (_escrowWalletId) return _escrowWalletId
+ const db = getDb()
+ const row = db.prepare('SELECT id FROM wallets WHERE address = ? LIMIT 1').get(address) as
+ | { id: string }
+ | undefined
+ if (!row) throw new Error('Garrison escrow wallet not found')
+ _escrowWalletId = row.id
+ return row.id
+}
+
+// ------------------------------------------------------------------ tiers ---
+
+/** Highest active tier whose threshold the matured stake clears; 1.0x otherwise. */
+export function tierFor(holderWallet: string): { tier: StakeTier | null; multiplierBps: number } {
+ const status = getStakeStatus(holderWallet)
+ if (!status || status.stakedRaw <= 0 || !status.isMature) return { tier: null, multiplierBps: 10000 }
+ const db = getDb()
+ const row = db
+ .prepare(
+ 'SELECT id, name, min_stake_raw, multiplier_bps FROM garrison_multiplier_tiers ' +
+ 'WHERE active = 1 AND min_stake_raw <= ? ORDER BY min_stake_raw DESC LIMIT 1',
+ )
+ .get(status.stakedRaw) as TierRow | undefined
+ if (!row) return { tier: null, multiplierBps: 10000 }
+ return {
+ tier: { id: row.id, name: row.name, multiplierBps: row.multiplier_bps },
+ multiplierBps: row.multiplier_bps,
+ }
+}
+
+function recomputeTier(stake: StakeRow): string | null {
+ const db = getDb()
+ const row = db
+ .prepare(
+ 'SELECT id FROM garrison_multiplier_tiers WHERE active = 1 AND min_stake_raw <= ? ' +
+ 'ORDER BY min_stake_raw DESC LIMIT 1',
+ )
+ .get(stake.staked_raw) as { id: string } | undefined
+ return row?.id ?? null
+}
+
+// ------------------------------------------------------------------ reads ---
+
+export function getStakeStatus(holderWallet: string): StakeStatus | null {
+ const db = getDb()
+ const stake = db
+ .prepare('SELECT * FROM garrison_stakes WHERE holder_wallet = ?')
+ .get(holderWallet) as StakeRow | undefined
+ if (!stake) return null
+ const tier = stake.tier_id
+ ? (db
+ .prepare('SELECT id, name, min_stake_raw, multiplier_bps FROM garrison_multiplier_tiers WHERE id = ?')
+ .get(stake.tier_id) as TierRow | undefined)
+ : undefined
+ const isMature =
+ stake.effective_from != null && Date.now() - stake.effective_from >= STAKE_MATURITY_MS
+ return {
+ stakedRaw: stake.staked_raw,
+ tier: tier && isMature ? { id: tier.id, name: tier.name, multiplierBps: tier.multiplier_bps } : null,
+ multiplierBps: tier && isMature ? tier.multiplier_bps : 10000,
+ effectiveFrom: stake.effective_from,
+ isMature,
+ escrowWallet: stake.escrow_wallet,
+ status: stake.status,
+ }
+}
+
+// ------------------------------------------------------------- on-chain ---
+
+async function tokenTransfer(
+ fromWalletId: string,
+ fromOwner: PublicKey,
+ toOwner: PublicKey,
+ amountRaw: number,
+ guardSource: string,
+): Promise {
+ const connection = getConnectionStrict()
+ const mint = new PublicKey(DAEMON_MINT)
+ const fromAta = await getAssociatedTokenAddress(mint, fromOwner)
+ const toAta = await getAssociatedTokenAddress(mint, toOwner)
+ return withKeypair(fromWalletId, async (keypair) => {
+ const ixs = []
+ try {
+ await getAccount(connection, toAta)
+ } catch {
+ ixs.push(createAssociatedTokenAccountInstruction(keypair.publicKey, toAta, toOwner, mint))
+ }
+ ixs.push(createTransferInstruction(fromAta, toAta, fromOwner, amountRaw))
+ const res = await executeInstructions(connection, ixs, [keypair], {
+ payer: keypair.publicKey,
+ guardSource,
+ })
+ return res.signature
+ })
+}
+
+/** Read how many $DAEMON base units a confirmed tx credited to `owner`'s ATA. */
+async function readCreditedTokens(signature: string, owner: PublicKey): Promise {
+ const connection = getConnectionStrict()
+ const ata = (await getAssociatedTokenAddress(new PublicKey(DAEMON_MINT), owner)).toBase58()
+ for (let attempt = 0; attempt < 5; attempt++) {
+ const tx = await connection.getTransaction(signature, { maxSupportedTransactionVersion: 0 })
+ if (tx?.meta) {
+ const pre = tx.meta.preTokenBalances?.find((b) => b.owner === owner.toBase58())
+ const post = tx.meta.postTokenBalances?.find((b) => b.owner === owner.toBase58())
+ void ata
+ const before = Number(pre?.uiTokenAmount.amount ?? 0)
+ const after = Number(post?.uiTokenAmount.amount ?? 0)
+ return Math.max(0, after - before)
+ }
+ await new Promise((r) => setTimeout(r, 800))
+ }
+ return 0
+}
+
+// ----------------------------------------------------------------- stake ---
+
+/** Holder -> escrow $DAEMON deposit. Credited amount read from the confirmed tx. */
+export async function stake(
+ holderWallet: string,
+ holderWalletId: string,
+ amountRaw: number,
+): Promise<{ stakeId: string; signature: string; stakedRaw: number; tier: StakeTier | null; effectiveFrom: number }> {
+ if (amountRaw <= 0) throw new Error('Stake amount must be positive')
+ const escrow = getOrCreateEscrowWallet()
+ const db = getDb()
+
+ const signature = await tokenTransfer(
+ holderWalletId,
+ new PublicKey(holderWallet),
+ new PublicKey(escrow.address),
+ amountRaw,
+ 'garrison:stake',
+ )
+ const credited = (await readCreditedTokens(signature, new PublicKey(escrow.address))) || amountRaw
+
+ const now = Date.now()
+ const stakeId = upsertStake(holderWallet, escrow.address, credited, now)
+ insertMovement(stakeId, 'deposit', credited, signature)
+
+ const status = getStakeStatus(holderWallet)
+ const tier = status ? tierFor(holderWallet).tier : null
+ return { stakeId, signature, stakedRaw: status?.stakedRaw ?? credited, tier, effectiveFrom: now }
+}
+
+/** Escrow -> holder withdrawal. Omitting amountRaw withdraws the full position. */
+export async function unstake(
+ holderWallet: string,
+ amountRaw?: number,
+): Promise<{ signature: string; remainingRaw: number; newTier: StakeTier | null }> {
+ const db = getDb()
+ const stake = db
+ .prepare('SELECT * FROM garrison_stakes WHERE holder_wallet = ?')
+ .get(holderWallet) as StakeRow | undefined
+ if (!stake) throw new Error('No stake position for this wallet')
+ if (stake.status === 'frozen') throw new Error('Stake is frozen (incident hold)')
+ const amount = amountRaw == null ? stake.staked_raw : Math.min(amountRaw, stake.staked_raw)
+ if (amount <= 0) throw new Error('Nothing to unstake')
+
+ const escrowWalletId = escrowWalletIdFor(stake.escrow_wallet)
+ const signature = await tokenTransfer(
+ escrowWalletId,
+ new PublicKey(stake.escrow_wallet),
+ new PublicKey(holderWallet),
+ amount,
+ 'garrison:unstake',
+ )
+
+ const remaining = stake.staked_raw - amount
+ const now = Date.now()
+ // Lowering the stake resets maturity only if it drops the tier; otherwise hold it.
+ const prevTier = recomputeTier(stake)
+ const updated: StakeRow = { ...stake, staked_raw: remaining }
+ const newTierId = recomputeTier(updated)
+ const effectiveFrom = newTierId !== prevTier ? now : stake.effective_from
+ db.prepare(
+ 'UPDATE garrison_stakes SET staked_raw = ?, tier_id = ?, effective_from = ?, updated_at = ? WHERE id = ?',
+ ).run(remaining, newTierId, effectiveFrom, now, stake.id)
+ insertMovement(stake.id, 'withdraw', amount, signature)
+
+ return { signature, remainingRaw: remaining, newTier: tierFor(holderWallet).tier }
+}
+
+// ------------------------------------------------------------- mutations ---
+
+function upsertStake(holderWallet: string, escrowWallet: string, addRaw: number, now: number): string {
+ const db = getDb()
+ const existing = db
+ .prepare('SELECT * FROM garrison_stakes WHERE holder_wallet = ?')
+ .get(holderWallet) as StakeRow | undefined
+ if (existing) {
+ const next: StakeRow = { ...existing, staked_raw: existing.staked_raw + addRaw }
+ const tierId = recomputeTier(next)
+ // A deposit that lifts the tier resets the maturity clock for the new level.
+ const effectiveFrom = tierId !== existing.tier_id ? now : existing.effective_from
+ db.prepare(
+ 'UPDATE garrison_stakes SET staked_raw = ?, tier_id = ?, effective_from = ?, updated_at = ? WHERE id = ?',
+ ).run(next.staked_raw, tierId, effectiveFrom, now, existing.id)
+ return existing.id
+ }
+ const id = crypto.randomUUID()
+ const fresh: StakeRow = {
+ id,
+ holder_wallet: holderWallet,
+ staked_raw: addRaw,
+ escrow_wallet: escrowWallet,
+ tier_id: null,
+ effective_from: now,
+ status: 'active',
+ }
+ const tierId = recomputeTier(fresh)
+ db.prepare(
+ 'INSERT INTO garrison_stakes (id, holder_wallet, staked_raw, escrow_wallet, tier_id, effective_from, status, created_at, updated_at) ' +
+ 'VALUES (?,?,?,?,?,?,?,?,?)',
+ ).run(id, holderWallet, addRaw, escrowWallet, tierId, now, 'active', now, now)
+ return id
+}
+
+function insertMovement(stakeId: string, kind: 'deposit' | 'withdraw', amountRaw: number, signature: string): void {
+ const db = getDb()
+ db.prepare(
+ 'INSERT OR IGNORE INTO garrison_stake_movements (id, stake_id, kind, amount_raw, signature, status) VALUES (?,?,?,?,?,?)',
+ ).run(crypto.randomUUID(), stakeId, kind, amountRaw, signature, 'confirmed')
+}
+
+// --------------------------------------------------------- incident response ---
+
+export function freeze(holderWallet: string): void {
+ getDb().prepare('UPDATE garrison_stakes SET status = ?, updated_at = ? WHERE holder_wallet = ?')
+ .run('frozen', Date.now(), holderWallet)
+}
+
+export function unfreeze(holderWallet: string): void {
+ getDb().prepare('UPDATE garrison_stakes SET status = ?, updated_at = ? WHERE holder_wallet = ?')
+ .run('active', Date.now(), holderWallet)
+}
diff --git a/electron/services/providers/ClaudeProvider.ts b/electron/services/providers/ClaudeProvider.ts
index 5004b2c4..cbc7f875 100644
--- a/electron/services/providers/ClaudeProvider.ts
+++ b/electron/services/providers/ClaudeProvider.ts
@@ -20,8 +20,8 @@ let cachedClaudePath: string | null = null
const MODEL_MAP: Record = {
'haiku': 'claude-haiku-4-5-20251001',
- 'sonnet': 'claude-sonnet-4-20250514',
- 'opus': 'claude-opus-4-20250514',
+ 'sonnet': 'claude-sonnet-4-6',
+ 'opus': 'claude-opus-4-8',
}
function resolveModelName(shorthand: string): string {
@@ -357,6 +357,18 @@ export interface AgentTurnEndpoint {
model?: string
}
+/**
+ * Tag the last tool with an ephemeral cache breakpoint. Anthropic caches the
+ * tools array as a prefix up to the final cache_control marker, so this caches
+ * the whole (large, static) schema across turns within the 5-min window.
+ */
+function withToolCache(tools: RunAgentTurnOpts['tools']): unknown[] {
+ if (tools.length === 0) return tools
+ return tools.map((tool, i) =>
+ i === tools.length - 1 ? { ...tool, cache_control: { type: 'ephemeral' } } : tool
+ )
+}
+
export async function runClaudeAgentTurn(
opts: RunAgentTurnOpts,
endpoint?: AgentTurnEndpoint,
@@ -372,7 +384,10 @@ export async function runClaudeAgentTurn(
model: resolvedModel,
max_tokens: opts.maxTokens ?? 4096,
system: [{ type: 'text', text: opts.system, cache_control: { type: 'ephemeral' } }],
- tools: opts.tools as Parameters[0]['tools'],
+ // Cache the (large, static) tools schema as a prompt-cache prefix: marking the
+ // LAST tool caches the whole tools block, so the ~74-tool schema is processed
+ // once per 5-min window instead of on every turn.
+ tools: withToolCache(opts.tools) as Parameters[0]['tools'],
messages: opts.messages as Parameters[0]['messages'],
})
diff --git a/electron/services/providers/CodexProvider.ts b/electron/services/providers/CodexProvider.ts
index 442c92e9..fe14e250 100644
--- a/electron/services/providers/CodexProvider.ts
+++ b/electron/services/providers/CodexProvider.ts
@@ -244,7 +244,7 @@ export const CodexProvider: ProviderInterface = {
const {
prompt,
systemPrompt,
- model = 'gpt-5.4',
+ model = 'gpt-5.5',
cwd,
timeoutMs,
} = opts
@@ -307,12 +307,12 @@ export const CodexProvider: ProviderInterface = {
// --- Model Resolution ---
const CLAUDE_TO_CODEX_MAP: Record = {
- 'claude-opus-4-20250514': 'gpt-5.4',
- 'claude-sonnet-4-20250514': 'gpt-5.4',
+ 'claude-opus-4-8': 'gpt-5.5',
+ 'claude-sonnet-4-6': 'gpt-5.5',
'claude-haiku-4-5-20251001': 'o4-mini',
'haiku': 'o4-mini',
- 'sonnet': 'gpt-5.4',
- 'opus': 'gpt-5.4',
+ 'sonnet': 'gpt-5.5',
+ 'opus': 'gpt-5.5',
}
function resolveCodexModel(model: string): string {
diff --git a/electron/services/providers/ProviderRegistry.ts b/electron/services/providers/ProviderRegistry.ts
index a67a40cf..1cc461da 100644
--- a/electron/services/providers/ProviderRegistry.ts
+++ b/electron/services/providers/ProviderRegistry.ts
@@ -111,7 +111,7 @@ export function getPreferences(): ProviderPreferences {
const fallback = getDefaultId()
const defaults: ProviderPreferences = {
aria: {
- provider: providers.has('codex') ? 'codex' : fallback,
+ provider: providers.has('claude') ? 'claude' : fallback,
model: 'fast',
},
daemonAi: {
diff --git a/electron/shared/channels.ts b/electron/shared/channels.ts
index 067d1a38..5e580535 100644
--- a/electron/shared/channels.ts
+++ b/electron/shared/channels.ts
@@ -73,6 +73,7 @@ export interface ChannelMap {
// --- Terminal ---
'terminal:create': { input: { cwd?: string; startupCommand?: string; userInitiated?: boolean }; output: TerminalCreateOutput }
'terminal:spawnAgent': { input: { agentId: string; projectId: string }; output: TerminalCreateOutput }
+ 'terminal:spawnProvider': { input: { providerId: 'claude' | 'codex' | 'spettro' | 'aria'; projectId?: string; cwd?: string; initialPrompt?: string }; output: TerminalCreateOutput }
'terminal:kill': { input: string; output: void }
'terminal:paste-from-clipboard': { input: string; output: { pasted: boolean } }
diff --git a/electron/shared/providerLaunch.ts b/electron/shared/providerLaunch.ts
index 907b827f..16fbca97 100644
--- a/electron/shared/providerLaunch.ts
+++ b/electron/shared/providerLaunch.ts
@@ -1,4 +1,6 @@
-export type ProviderShellId = 'claude' | 'codex' | 'spettro'
+import path from 'node:path'
+
+export type ProviderShellId = 'claude' | 'codex' | 'spettro' | 'aria'
const SPETTRO_WINDOWS_STARTUP = [
'$spettro = @("$env:USERPROFILE\\spettro\\bin\\spettro.exe", "$env:USERPROFILE\\Projects\\spettro\\bin\\spettro.exe")',
@@ -6,6 +8,23 @@ const SPETTRO_WINDOWS_STARTUP = [
'Select-Object -First 1',
].join(' | ')
+function quotePowerShellLiteral(value: string): string {
+ return `'${value.replace(/'/g, "''")}'`
+}
+
+function quotePosixLiteral(value: string): string {
+ return `'${value.replace(/'/g, "'\\''")}'`
+}
+
+function getAriaStartupCommand(): string {
+ const root = process.env.APP_ROOT
+ if (!root) return 'aria'
+ const scriptPath = path.join(root, 'scripts', 'aria.mjs')
+ return process.platform === 'win32'
+ ? `node ${quotePowerShellLiteral(scriptPath)}`
+ : `node ${quotePosixLiteral(scriptPath)}`
+}
+
export function getEmbeddedProviderArgs(providerId: ProviderShellId): string[] {
switch (providerId) {
case 'claude':
@@ -14,6 +33,8 @@ export function getEmbeddedProviderArgs(providerId: ProviderShellId): string[] {
return []
case 'spettro':
return []
+ case 'aria':
+ return []
default:
return []
}
@@ -29,6 +50,8 @@ export function getEmbeddedProviderStartupCommand(providerId: ProviderShellId):
return process.platform === 'win32'
? `${SPETTRO_WINDOWS_STARTUP}; if ($spettro) { & $spettro } else { spettro }`
: 'spettro'
+ case 'aria':
+ return getAriaStartupCommand()
default:
return providerId
}
diff --git a/electron/shared/types.ts b/electron/shared/types.ts
index 0f9a3fd1..4e8f0672 100644
--- a/electron/shared/types.ts
+++ b/electron/shared/types.ts
@@ -1421,7 +1421,9 @@ export interface Mandate {
}
export type MandateDecision = 'buy' | 'sell' | 'hold' | 'skip'
-export type MandateActionStatus = 'decided' | 'executed' | 'failed'
+// 'executing' claims a tick_seq (and its intent) BEFORE the swap is sent, so a crash between
+// send and confirmation is detectable on boot and never silently replayed into a double-buy.
+export type MandateActionStatus = 'decided' | 'executing' | 'executed' | 'failed' | 'needs-review'
export interface MandateAction {
id: string
@@ -2065,6 +2067,123 @@ export interface IdlePaidCallReceipt {
updatedAt: number
}
+// --- Agent economy control tower ---
+
+export type AgentEconomyNetwork = 'solana-devnet' | 'solana-mainnet' | 'solana-mainnet-beta' | string
+
+export interface AgentEconomyProfile {
+ id: string
+ projectId: string | null
+ agentId: string | null
+ name: string
+ walletId: string | null
+ walletAddress: string | null
+ registryAsset: string | null
+ agentIdentityPda: string | null
+ serviceUrl: string | null
+ capabilities: string[]
+ policy: AgentEconomySpendPolicy | null
+ createdAt: number
+ updatedAt: number
+}
+
+export interface AgentEconomySpendPolicy {
+ id: string
+ profileId: string
+ asset: string
+ network: AgentEconomyNetwork
+ allowedDomains: string[]
+ allowedPayees: string[]
+ maxPerCallUsdc: number
+ maxPerDayUsdc: number
+ expiresAt: number | null
+ enabled: boolean
+ createdAt: number
+ updatedAt: number
+}
+
+export interface AgentEconomyUpsertProfileInput {
+ id?: string | null
+ projectId?: string | null
+ agentId?: string | null
+ name: string
+ walletId?: string | null
+ walletAddress?: string | null
+ registryAsset?: string | null
+ agentIdentityPda?: string | null
+ serviceUrl?: string | null
+ capabilities?: string[]
+}
+
+export interface AgentEconomySetPolicyInput {
+ profileId: string
+ asset: string
+ network: AgentEconomyNetwork
+ allowedDomains: string[]
+ allowedPayees: string[]
+ maxPerCallUsdc: number
+ maxPerDayUsdc: number
+ expiresAt?: number | null
+ enabled: boolean
+}
+
+export interface AgentEconomyPolicyCheckInput {
+ profileId: string
+ resourceId: string
+ projectId?: string | null
+ taskId?: string | null
+}
+
+export interface AgentEconomyPolicyCheckResult extends IdlePolicyCheckResult {
+ profile: AgentEconomyProfile
+ policy: AgentEconomySpendPolicy | null
+ spentTodayUsdc: number
+ remainingDayBudgetUsdc: number
+}
+
+export interface AgentEconomyExecutePaidCallInput extends AgentEconomyPolicyCheckInput {
+ requestBody?: unknown
+ paymentSignature?: string | null
+ approvedBy?: string | null
+}
+
+export interface AgentEconomyExecutePaidCallResult {
+ status: 'blocked' | 'ready' | 'executed'
+ allowed: boolean
+ reasons: string[]
+ requiresSignature: boolean
+ check: AgentEconomyPolicyCheckResult
+ receipt: IdlePaidCallReceipt | null
+}
+
+export interface AgentEconomyListReceiptsInput {
+ profileId?: string | null
+ projectId?: string | null
+ limit?: number
+}
+
+export type AgentEconomyReceipt = IdlePaidCallReceipt
+
+export interface AgentEconomyRegisterDevnetAgentInput {
+ profileId: string
+ walletId: string
+ rpcUrl: string
+ name: string
+ description: string
+ uri: string
+ serviceUrl: string
+ priceUsdc: string
+ confirmedAt: number
+ acknowledgement: string
+}
+
+export interface AgentEconomyReadAgentIdentityInput {
+ profileId?: string | null
+ network: 'devnet' | 'mainnet-beta'
+ rpcUrl: string
+ assetAddress: string
+}
+
export interface IdleRegistryStatus {
registryConfigured: boolean
registryUrl: string | null
@@ -2075,6 +2194,28 @@ export interface IdleRegistryStatus {
blockers: string[]
}
+export interface AgentEconomyOverviewInput {
+ projectId?: string | null
+ profileId?: string | null
+}
+
+export interface AgentEconomyOverview {
+ profiles: AgentEconomyProfile[]
+ policies: AgentEconomySpendPolicy[]
+ receipts: IdlePaidCallReceipt[]
+}
+
+export interface AgentEconomyRegisterDevnetAgentResult extends AgentEconomyProfile {
+ profile: AgentEconomyProfile
+ receipt: Record
+ identity: Record | null
+}
+
+export interface AgentEconomyReadAgentIdentityResult {
+ profile: AgentEconomyProfile | null
+ identity: Record
+}
+
// --- Meterflow control plane ---
export type MeterflowKeySource = 'secure' | 'env' | 'none'
@@ -2749,6 +2890,7 @@ export type AriaUiEffect =
| { type: 'open_tool'; toolId: string }
| { type: 'run_command'; commandId: string }
| { type: 'open_file'; path: string }
+ | { type: 'set_active_project'; projectId: string; projectPath: string }
| { type: 'add_terminal'; terminalId: string; name: string; agentId?: string }
| { type: 'run_integration'; actionId: string }
| { type: 'set_integration_enabled'; integrationId: string; enabled: boolean }
@@ -2758,6 +2900,7 @@ export type AriaToolEvent =
| { kind: 'assistant-text'; messageId: string; text: string }
| {
kind: 'tool-call'
+ messageId: string
callId: string
name: string
label: string
@@ -2766,7 +2909,7 @@ export type AriaToolEvent =
status: AriaToolCallStatus
meta?: string
}
- | { kind: 'approval-request'; callId: string; name: string; risk: AriaToolRiskTier; summary: string; input: unknown; fee?: { bps: number; lamports: number; treasury: string } }
+ | { kind: 'approval-request'; messageId: string; callId: string; name: string; risk: AriaToolRiskTier; summary: string; input: unknown; fee?: { bps: number; lamports: number; treasury: string } }
| { kind: 'plan'; messageId: string; steps: AriaPlanStep[] }
| { kind: 'patch-proposal'; messageId: string; proposal: AriaPatchProposalLite }
| { kind: 'action-result'; proposalId: string; action: AriaPatchAction; status: 'applied' | 'rejected' | 'failed'; meta?: string }
diff --git a/package.json b/package.json
index e05bdfd0..04b31233 100644
--- a/package.json
+++ b/package.json
@@ -1,11 +1,14 @@
{
"name": "daemon",
- "version": "4.5.0",
+ "version": "4.6.2",
"main": "dist-electron/main/index.js",
"description": "Solana-native agent workbench for verifiable AI development",
"author": "nullxnothing",
"license": "MIT",
"private": true,
+ "bin": {
+ "aria": "scripts/aria.mjs"
+ },
"debug": {
"env": {
"VITE_DEV_SERVER_URL": "http://127.0.0.1:7777/"
@@ -16,6 +19,7 @@
"dev": "vite",
"dev:debug": "powershell -NoProfile -Command \"$env:DAEMON_OPEN_DEVTOOLS='1'; vite\"",
"build": "tsc && vite build",
+ "aria": "node scripts/aria.mjs",
"build:daemon-ai-cloud": "vite build --config vite.cloud.config.ts",
"build:bridge": "vite build --config vite.bridge.config.ts",
"typecheck": "tsc --noEmit",
@@ -105,12 +109,14 @@
"express": "^5.1.0",
"express-rate-limit": "^8.5.2",
"imapflow": "^1.3.3",
+ "ink": "^7.1.0",
"mailparser": "^3.9.8",
"node-pty": "1.1.0",
"nodemailer": "^9.0.1",
"pidusage": "^4.0.1",
"ps-list": "^8.1.1",
"pyright": "^1.1.409",
+ "react": "^19.2.5",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"simple-git": "^3.36.0",
@@ -148,7 +154,6 @@
"pixelmatch": "^7.1.0",
"playwright": "^1.59.0",
"pngjs": "^7.0.0",
- "react": "^19.2.5",
"react-dom": "^19.2.5",
"vite": "^6.4.3",
"vite-plugin-electron": "^0.29.0",
@@ -168,6 +173,7 @@
"form-data": "4.0.6",
"hono": "4.12.25",
"js-yaml": "4.2.0",
+ "linkify-it": "5.0.1",
"lodash": "4.18.1",
"nodemailer": "9.0.1",
"follow-redirects": "1.16.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2537a4b1..2b193649 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -15,6 +15,7 @@ overrides:
form-data: 4.0.6
hono: 4.12.25
js-yaml: 4.2.0
+ linkify-it: 5.0.1
lodash: 4.18.1
nodemailer: 9.0.1
follow-redirects: 1.16.0
@@ -127,6 +128,9 @@ importers:
imapflow:
specifier: ^1.3.3
version: 1.3.3
+ ink:
+ specifier: ^7.1.0
+ version: 7.1.0(@types/react@19.2.14)(bufferutil@4.1.0)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react@19.2.5)(utf-8-validate@6.0.6)
mailparser:
specifier: ^3.9.8
version: 3.9.8
@@ -145,6 +149,9 @@ importers:
pyright:
specifier: ^1.1.409
version: 1.1.409
+ react:
+ specifier: ^19.2.5
+ version: 19.2.5
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.14)(react@19.2.5)
@@ -251,9 +258,6 @@ importers:
pngjs:
specifier: ^7.0.0
version: 7.0.0
- react:
- specifier: ^19.2.5
- version: 19.2.5
react-dom:
specifier: ^19.2.5
version: 19.2.5(react@19.2.5)
@@ -379,6 +383,10 @@ packages:
'@adraffy/ens-normalize@1.11.1':
resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==}
+ '@alcalzone/ansi-tokenize@0.3.0':
+ resolution: {integrity: sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==}
+ engines: {node: '>=18'}
+
'@anthropic-ai/sdk@0.98.0':
resolution: {integrity: sha512-N7aXtCvC5g6T1Y4V29lJjceu/zTkVkIZF0jdBvagr0TRFHuKeImffalGWEfqZKrvjH+IQbzJWw6TmSmUzrlMgg==}
hasBin: true
@@ -2950,6 +2958,10 @@ packages:
resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==}
engines: {node: '>=8'}
+ ansi-escapes@7.3.0:
+ resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==}
+ engines: {node: '>=18'}
+
ansi-regex@4.1.1:
resolution: {integrity: sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==}
engines: {node: '>=6'}
@@ -2958,6 +2970,10 @@ packages:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
+ ansi-regex@6.2.2:
+ resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
+ engines: {node: '>=12'}
+
ansi-styles@3.2.1:
resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==}
engines: {node: '>=4'}
@@ -2970,6 +2986,10 @@ packages:
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
engines: {node: '>=10'}
+ ansi-styles@6.2.3:
+ resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
+ engines: {node: '>=12'}
+
apg-js@4.4.0:
resolution: {integrity: sha512-fefmXFknJmtgtNEXfPwZKYkMFX4Fyeyz+fNF6JWp87biGOPslJbCBVU158zvKRZfHBKnJDy8CMM40oLFGkXT8Q==}
@@ -3041,6 +3061,10 @@ packages:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'}
+ auto-bind@5.0.1:
+ resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
axe-core@4.11.4:
resolution: {integrity: sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==}
engines: {node: '>=4'}
@@ -3396,10 +3420,18 @@ packages:
resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==}
engines: {node: '>=8'}
+ cli-boxes@4.0.1:
+ resolution: {integrity: sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==}
+ engines: {node: '>=18.20 <19 || >=20.10'}
+
cli-cursor@2.1.0:
resolution: {integrity: sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==}
engines: {node: '>=4'}
+ cli-cursor@4.0.0:
+ resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
cli-spinners@2.9.2:
resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==}
engines: {node: '>=6'}
@@ -3408,6 +3440,10 @@ packages:
resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==}
engines: {node: '>=8'}
+ cli-truncate@6.0.1:
+ resolution: {integrity: sha512-2FPVnc3JxdRLONB/9edO1RwuUFFPJ3U2c6XvyccEhjqV5xw6mS22aH27OFdD1u4IYQOEUzXsT6ZU06d1VCSu+Q==}
+ engines: {node: '>=22'}
+
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
@@ -3426,6 +3462,10 @@ packages:
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
engines: {node: '>=0.8'}
+ code-excerpt@4.0.0:
+ resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
color-convert@1.9.3:
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
@@ -3503,6 +3543,10 @@ packages:
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+ convert-to-spaces@2.0.1:
+ resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
cookie-signature@1.2.2:
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
engines: {node: '>=6.6.0'}
@@ -3802,6 +3846,10 @@ packages:
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
engines: {node: '>=6'}
+ environment@1.1.0:
+ resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==}
+ engines: {node: '>=18'}
+
err-code@2.0.3:
resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==}
@@ -3830,6 +3878,9 @@ packages:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
+ es-toolkit@1.49.0:
+ resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==}
+
es6-error@4.1.1:
resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==}
@@ -3855,6 +3906,10 @@ packages:
resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==}
engines: {node: '>=0.8.0'}
+ escape-string-regexp@2.0.0:
+ resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==}
+ engines: {node: '>=8'}
+
escape-string-regexp@4.0.0:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
@@ -4288,6 +4343,10 @@ packages:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
+ get-east-asian-width@1.6.0:
+ resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==}
+ engines: {node: '>=18'}
+
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
@@ -4510,6 +4569,10 @@ packages:
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
engines: {node: '>=8'}
+ indent-string@5.0.0:
+ resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==}
+ engines: {node: '>=12'}
+
inflight@1.0.6:
resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
@@ -4520,6 +4583,19 @@ packages:
ini@1.3.8:
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
+ ink@7.1.0:
+ resolution: {integrity: sha512-VWE6/yeLtFCJBNLflyI2OSylyXK1Rc24LuXup8Qt+icwkmmycFNdbn8IkSp6Frc0h1iA0NOvvi1ajW44U/w3Qg==}
+ engines: {node: '>=22'}
+ peerDependencies:
+ '@types/react': '>=19.2.0'
+ react: '>=19.2.0'
+ react-devtools-core: '>=6.1.2'
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ react-devtools-core:
+ optional: true
+
inline-style-parser@0.2.7:
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
@@ -4572,9 +4648,18 @@ packages:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
+ is-fullwidth-code-point@5.1.0:
+ resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==}
+ engines: {node: '>=18'}
+
is-hexadecimal@2.0.1:
resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==}
+ is-in-ci@2.0.0:
+ resolution: {integrity: sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==}
+ engines: {node: '>=20'}
+ hasBin: true
+
is-number@7.0.0:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
@@ -4870,8 +4955,8 @@ packages:
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
- linkify-it@5.0.0:
- resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
+ linkify-it@5.0.1:
+ resolution: {integrity: sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==}
lodash.debounce@4.0.8:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
@@ -5263,6 +5348,10 @@ packages:
resolution: {integrity: sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==}
engines: {node: '>=4'}
+ mimic-fn@2.1.0:
+ resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
+ engines: {node: '>=6'}
+
mimic-response@1.0.1:
resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==}
engines: {node: '>=4'}
@@ -5498,6 +5587,10 @@ packages:
resolution: {integrity: sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==}
engines: {node: '>=4'}
+ onetime@5.1.2:
+ resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
+ engines: {node: '>=6'}
+
open@7.4.2:
resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==}
engines: {node: '>=8'}
@@ -5571,6 +5664,10 @@ packages:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'}
+ patch-console@2.0.0:
+ resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
patch-package@8.0.1:
resolution: {integrity: sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==}
engines: {node: '>=14', npm: '>5'}
@@ -5942,6 +6039,12 @@ packages:
'@types/react':
optional: true
+ react-reconciler@0.33.0:
+ resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==}
+ engines: {node: '>=0.10.0'}
+ peerDependencies:
+ react: ^19.2.0
+
react-refresh@0.14.2:
resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==}
engines: {node: '>=0.10.0'}
@@ -6075,6 +6178,10 @@ packages:
resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==}
engines: {node: '>=4'}
+ restore-cursor@4.0.0:
+ resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
retry@0.12.0:
resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==}
engines: {node: '>= 4'}
@@ -6270,6 +6377,10 @@ packages:
resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==}
engines: {node: '>=8'}
+ slice-ansi@9.0.0:
+ resolution: {integrity: sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==}
+ engines: {node: '>=22'}
+
slugify@1.6.9:
resolution: {integrity: sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==}
engines: {node: '>=8.0.0'}
@@ -6325,6 +6436,10 @@ packages:
sprintf-js@1.1.3:
resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==}
+ stack-utils@2.0.6:
+ resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==}
+ engines: {node: '>=10'}
+
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
@@ -6377,6 +6492,10 @@ packages:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
+ string-width@8.2.1:
+ resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==}
+ engines: {node: '>=20'}
+
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
@@ -6391,6 +6510,10 @@ packages:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
+ strip-ansi@7.2.0:
+ resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
+ engines: {node: '>=12'}
+
strip-bom@3.0.0:
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
engines: {node: '>=4'}
@@ -6449,6 +6572,10 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
+ tagged-tag@1.0.0:
+ resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
+ engines: {node: '>=20'}
+
tar-fs@2.1.4:
resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==}
@@ -6480,6 +6607,10 @@ packages:
resolution: {integrity: sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==}
engines: {node: '>=8'}
+ terminal-size@4.0.1:
+ resolution: {integrity: sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==}
+ engines: {node: '>=18'}
+
terser@5.46.2:
resolution: {integrity: sha512-uxfo9fPcSgLDYob/w1FuL0c99MWiJDnv+5qXSQc5+Ki5NjVNsYi66INnMFBjf6uFz6OnX12piJQPF4IpjJTNTw==}
engines: {node: '>=10'}
@@ -6603,6 +6734,10 @@ packages:
resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==}
engines: {node: '>=8'}
+ type-fest@5.7.0:
+ resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==}
+ engines: {node: '>=20'}
+
type-is@2.0.1:
resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
engines: {node: '>= 0.6'}
@@ -6934,6 +7069,14 @@ packages:
engines: {node: '>=8'}
hasBin: true
+ widest-line@6.0.0:
+ resolution: {integrity: sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==}
+ engines: {node: '>=20'}
+
+ wrap-ansi@10.0.0:
+ resolution: {integrity: sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==}
+ engines: {node: '>=20'}
+
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -7013,6 +7156,9 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
+ yoga-layout@3.2.1:
+ resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==}
+
zod-to-json-schema@3.25.2:
resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==}
peerDependencies:
@@ -7056,6 +7202,11 @@ snapshots:
'@adraffy/ens-normalize@1.11.1': {}
+ '@alcalzone/ansi-tokenize@0.3.0':
+ dependencies:
+ ansi-styles: 6.2.3
+ is-fullwidth-code-point: 5.1.0
+
'@anthropic-ai/sdk@0.98.0(zod@4.4.3)':
dependencies:
json-schema-to-ts: 3.1.1
@@ -10277,10 +10428,16 @@ snapshots:
dependencies:
type-fest: 0.21.3
+ ansi-escapes@7.3.0:
+ dependencies:
+ environment: 1.1.0
+
ansi-regex@4.1.1: {}
ansi-regex@5.0.1: {}
+ ansi-regex@6.2.2: {}
+
ansi-styles@3.2.1:
dependencies:
color-convert: 1.9.3
@@ -10291,6 +10448,8 @@ snapshots:
ansi-styles@5.2.0: {}
+ ansi-styles@6.2.3: {}
+
apg-js@4.4.0: {}
app-builder-bin@5.0.0-alpha.12: {}
@@ -10378,6 +10537,8 @@ snapshots:
atomic-sleep@1.0.0: {}
+ auto-bind@5.0.1: {}
+
axe-core@4.11.4: {}
axios@1.16.1:
@@ -10794,10 +10955,16 @@ snapshots:
ci-info@4.4.0: {}
+ cli-boxes@4.0.1: {}
+
cli-cursor@2.1.0:
dependencies:
restore-cursor: 2.0.0
+ cli-cursor@4.0.0:
+ dependencies:
+ restore-cursor: 4.0.0
+
cli-spinners@2.9.2: {}
cli-truncate@2.1.0:
@@ -10806,6 +10973,11 @@ snapshots:
string-width: 4.2.3
optional: true
+ cli-truncate@6.0.1:
+ dependencies:
+ slice-ansi: 9.0.0
+ string-width: 8.2.1
+
client-only@0.0.1: {}
cliui@8.0.1:
@@ -10828,6 +11000,10 @@ snapshots:
clone@1.0.4: {}
+ code-excerpt@4.0.0:
+ dependencies:
+ convert-to-spaces: 2.0.1
+
color-convert@1.9.3:
dependencies:
color-name: 1.1.3
@@ -10902,6 +11078,8 @@ snapshots:
convert-source-map@2.0.0: {}
+ convert-to-spaces@2.0.1: {}
+
cookie-signature@1.2.2: {}
cookie@0.7.2: {}
@@ -11226,6 +11404,8 @@ snapshots:
env-paths@2.2.1: {}
+ environment@1.1.0: {}
+
err-code@2.0.3: {}
error-ex@1.3.4:
@@ -11253,6 +11433,8 @@ snapshots:
has-tostringtag: 1.0.2
hasown: 2.0.4
+ es-toolkit@1.49.0: {}
+
es6-error@4.1.1:
optional: true
@@ -11297,6 +11479,8 @@ snapshots:
escape-string-regexp@1.0.5: {}
+ escape-string-regexp@2.0.0: {}
+
escape-string-regexp@4.0.0: {}
escape-string-regexp@5.0.0: {}
@@ -11795,6 +11979,8 @@ snapshots:
get-caller-file@2.0.5: {}
+ get-east-asian-width@1.6.0: {}
+
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -12098,6 +12284,8 @@ snapshots:
indent-string@4.0.0: {}
+ indent-string@5.0.0: {}
+
inflight@1.0.6:
dependencies:
once: 1.4.0
@@ -12107,6 +12295,41 @@ snapshots:
ini@1.3.8: {}
+ ink@7.1.0(@types/react@19.2.14)(bufferutil@4.1.0)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react@19.2.5)(utf-8-validate@6.0.6):
+ dependencies:
+ '@alcalzone/ansi-tokenize': 0.3.0
+ ansi-escapes: 7.3.0
+ ansi-styles: 6.2.3
+ auto-bind: 5.0.1
+ chalk: 5.6.2
+ cli-boxes: 4.0.1
+ cli-cursor: 4.0.0
+ cli-truncate: 6.0.1
+ code-excerpt: 4.0.0
+ es-toolkit: 1.49.0
+ indent-string: 5.0.0
+ is-in-ci: 2.0.0
+ patch-console: 2.0.0
+ react: 19.2.5
+ react-reconciler: 0.33.0(react@19.2.5)
+ scheduler: 0.27.0
+ signal-exit: 3.0.7
+ slice-ansi: 9.0.0
+ stack-utils: 2.0.6
+ string-width: 8.2.1
+ terminal-size: 4.0.1
+ type-fest: 5.7.0
+ widest-line: 6.0.0
+ wrap-ansi: 10.0.0
+ ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
+ yoga-layout: 3.2.1
+ optionalDependencies:
+ '@types/react': 19.2.14
+ react-devtools-core: 6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6)
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+
inline-style-parser@0.2.7: {}
inline-style-prefixer@7.0.1:
@@ -12146,8 +12369,14 @@ snapshots:
is-fullwidth-code-point@3.0.0: {}
+ is-fullwidth-code-point@5.1.0:
+ dependencies:
+ get-east-asian-width: 1.6.0
+
is-hexadecimal@2.0.1: {}
+ is-in-ci@2.0.0: {}
+
is-number@7.0.0: {}
is-plain-obj@4.1.0: {}
@@ -12423,7 +12652,7 @@ snapshots:
lines-and-columns@1.2.4: {}
- linkify-it@5.0.0:
+ linkify-it@5.0.1:
dependencies:
uc.micro: 2.1.0
@@ -12485,7 +12714,7 @@ snapshots:
html-to-text: 9.0.5
iconv-lite: 0.7.2
libmime: 5.3.8
- linkify-it: 5.0.0
+ linkify-it: 5.0.1
nodemailer: 9.0.1
punycode.js: 2.3.1
tlds: 1.261.0
@@ -13238,6 +13467,8 @@ snapshots:
mimic-fn@1.2.0: {}
+ mimic-fn@2.1.0: {}
+
mimic-response@1.0.1: {}
mimic-response@3.1.0: {}
@@ -13434,6 +13665,10 @@ snapshots:
dependencies:
mimic-fn: 1.2.0
+ onetime@5.1.2:
+ dependencies:
+ mimic-fn: 2.1.0
+
open@7.4.2:
dependencies:
is-docker: 2.2.1
@@ -13562,6 +13797,8 @@ snapshots:
parseurl@1.3.3: {}
+ patch-console@2.0.0: {}
+
patch-package@8.0.1:
dependencies:
'@yarnpkg/lockfile': 1.1.0
@@ -14030,6 +14267,11 @@ snapshots:
- supports-color
- utf-8-validate
+ react-reconciler@0.33.0(react@19.2.5):
+ dependencies:
+ react: 19.2.5
+ scheduler: 0.27.0
+
react-refresh@0.14.2: {}
react-refresh@0.18.0: {}
@@ -14173,6 +14415,11 @@ snapshots:
onetime: 2.0.1
signal-exit: 3.0.7
+ restore-cursor@4.0.0:
+ dependencies:
+ onetime: 5.1.2
+ signal-exit: 3.0.7
+
retry@0.12.0: {}
retry@0.13.1: {}
@@ -14448,6 +14695,11 @@ snapshots:
is-fullwidth-code-point: 3.0.0
optional: true
+ slice-ansi@9.0.0:
+ dependencies:
+ ansi-styles: 6.2.3
+ is-fullwidth-code-point: 5.1.0
+
slugify@1.6.9: {}
smart-buffer@4.2.0: {}
@@ -14496,6 +14748,10 @@ snapshots:
sprintf-js@1.1.3:
optional: true
+ stack-utils@2.0.6:
+ dependencies:
+ escape-string-regexp: 2.0.0
+
stackback@0.0.2: {}
stackframe@1.3.4: {}
@@ -14544,6 +14800,11 @@ snapshots:
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
+ string-width@8.2.1:
+ dependencies:
+ get-east-asian-width: 1.6.0
+ strip-ansi: 7.2.0
+
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
@@ -14561,6 +14822,10 @@ snapshots:
dependencies:
ansi-regex: 5.0.1
+ strip-ansi@7.2.0:
+ dependencies:
+ ansi-regex: 6.2.2
+
strip-bom@3.0.0: {}
strip-indent@3.0.0:
@@ -14614,6 +14879,8 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
+ tagged-tag@1.0.0: {}
+
tar-fs@2.1.4:
dependencies:
chownr: 1.1.4
@@ -14682,6 +14949,8 @@ snapshots:
ansi-escapes: 4.3.2
supports-hyperlinks: 2.3.0
+ terminal-size@4.0.1: {}
+
terser@5.46.2:
dependencies:
'@jridgewell/source-map': 0.3.11
@@ -14784,6 +15053,10 @@ snapshots:
type-fest@0.7.1: {}
+ type-fest@5.7.0:
+ dependencies:
+ tagged-tag: 1.0.0
+
type-is@2.0.1:
dependencies:
content-type: 1.0.5
@@ -15119,6 +15392,16 @@ snapshots:
siginfo: 2.0.0
stackback: 0.0.2
+ widest-line@6.0.0:
+ dependencies:
+ string-width: 8.2.1
+
+ wrap-ansi@10.0.0:
+ dependencies:
+ ansi-styles: 6.2.3
+ string-width: 8.2.1
+ strip-ansi: 7.2.0
+
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
@@ -15179,6 +15462,8 @@ snapshots:
yocto-queue@0.1.0: {}
+ yoga-layout@3.2.1: {}
+
zod-to-json-schema@3.25.2(zod@4.4.3):
dependencies:
zod: 4.4.3
diff --git a/scripts/aria-shared/ansi-theme.mjs b/scripts/aria-shared/ansi-theme.mjs
new file mode 100644
index 00000000..1d37200a
--- /dev/null
+++ b/scripts/aria-shared/ansi-theme.mjs
@@ -0,0 +1,41 @@
+/**
+ * Launcher-side mirror of electron/services/aria/cli/ansi-theme.ts. The compiled
+ * backend can't be imported by the plain-JS launcher (single Electron bundle), so
+ * the theme tokens are duplicated here and asserted identical by
+ * test/services/AriaCliThemeSync.test.ts - do not edit one without the other.
+ */
+
+/** Semantic 24-bit color tokens. Hex strings; identical to the GUI palette. */
+export const ARIA_THEME_COLORS = {
+ green: '#3ECF8E',
+ greenDark: '#1A6B47',
+ blue: '#60A5FA',
+ amber: '#F0B429',
+ red: '#EF5350',
+ magenta: '#C084FC',
+ cyan: '#22D3EE',
+ void: '#0A0A0A',
+ surface: '#171919',
+ border: '#262928',
+ text: '#F0F0F0',
+ secondary: '#A0A0A0',
+ muted: '#6E706F',
+ disabled: '#4A4C4B',
+}
+
+/** Risk-tier → color token (read=blue, write=amber, sensitive=red). */
+export const ARIA_RISK_COLOR = {
+ read: 'blue',
+ write: 'amber',
+ sensitive: 'red',
+}
+
+/** Tool-kind → color token (read=blue, edit=amber, run=green). */
+export const ARIA_KIND_COLOR = {
+ read: 'blue',
+ edit: 'amber',
+ run: 'green',
+}
+
+/** Status dot glyph - a single Windows-safe filled circle. No emoji. */
+export const ARIA_DOT = '●'
diff --git a/scripts/aria-shared/render.mjs b/scripts/aria-shared/render.mjs
new file mode 100644
index 00000000..866cba0c
--- /dev/null
+++ b/scripts/aria-shared/render.mjs
@@ -0,0 +1,64 @@
+/**
+ * Launcher-side render helpers for scripts/aria.mjs. Ink takes hex colors on the
+ * `color` prop directly, so these resolve theme tokens to hex (or undefined when
+ * color is disabled) and provide width math the banner/composer share.
+ */
+import { ARIA_THEME_COLORS, ARIA_RISK_COLOR, ARIA_KIND_COLOR } from './ansi-theme.mjs'
+
+/** True when color should be stripped - honors NO_COLOR and non-TTY output. */
+export function isColorDisabled(env = process.env, isTty = process.stdout.isTTY) {
+ if (env.NO_COLOR) return true
+ return !isTty
+}
+
+/** Resolve a theme token to its hex value, or undefined when color is disabled. */
+export function themeColor(token, disabled = isColorDisabled()) {
+ if (disabled) return undefined
+ return ARIA_THEME_COLORS[token]
+}
+
+/** Hex color for a risk tier (read/write/sensitive). */
+export function riskColor(risk, disabled = isColorDisabled()) {
+ return themeColor(ARIA_RISK_COLOR[risk] ?? 'muted', disabled)
+}
+
+/** Hex color for a tool kind (read/edit/run). */
+export function kindColor(kind, disabled = isColorDisabled()) {
+ return themeColor(ARIA_KIND_COLOR[kind] ?? 'muted', disabled)
+}
+
+/** Truncate to width with a trailing ellipsis. */
+export function fit(value, width) {
+ if (value.length <= width) return value
+ return `${value.slice(0, Math.max(0, width - 1))}...`
+}
+
+/** Left-pad to center within width (no right pad, terminal-friendly). */
+export function center(value, width) {
+ const pad = Math.max(0, Math.floor((width - value.length) / 2))
+ return `${' '.repeat(pad)}${value}`
+}
+
+function channels(hex) {
+ return [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)]
+}
+
+function toHex(n) {
+ return Math.round(n).toString(16).padStart(2, '0')
+}
+
+/**
+ * Linearly interpolate `steps` hex colors between two endpoints (inclusive).
+ * Returns an array of `#rrggbb`. With steps <= 1, returns just the start color.
+ */
+export function gradient(fromHex, toHex2, steps) {
+ if (steps <= 1) return [fromHex]
+ const [r1, g1, b1] = channels(fromHex)
+ const [r2, g2, b2] = channels(toHex2)
+ const out = []
+ for (let i = 0; i < steps; i++) {
+ const t = i / (steps - 1)
+ out.push(`#${toHex(r1 + (r2 - r1) * t)}${toHex(g1 + (g2 - g1) * t)}${toHex(b1 + (b2 - b1) * t)}`)
+ }
+ return out
+}
diff --git a/scripts/aria.mjs b/scripts/aria.mjs
new file mode 100644
index 00000000..6c52dc56
--- /dev/null
+++ b/scripts/aria.mjs
@@ -0,0 +1,602 @@
+#!/usr/bin/env node
+import fs from 'node:fs'
+import path from 'node:path'
+import readline from 'node:readline'
+import { spawn } from 'node:child_process'
+import { createRequire } from 'node:module'
+import { fileURLToPath } from 'node:url'
+import React, { useEffect, useMemo, useRef, useState } from 'react'
+import { Box, Text, render, useApp, useStdin, useStdout } from 'ink'
+import { themeColor, riskColor, isColorDisabled, gradient } from './aria-shared/render.mjs'
+import { ARIA_DOT, ARIA_THEME_COLORS } from './aria-shared/ansi-theme.mjs'
+
+const require = createRequire(import.meta.url)
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
+const mainEntry = path.join(root, 'dist-electron', 'main', 'index.js')
+const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'))
+const args = process.argv.slice(2)
+const h = React.createElement
+
+// Exit codes: 0 clean · 1 build-missing / runtime error · 2 bad args.
+const EXIT_OK = 0
+const EXIT_RUNTIME = 1
+
+// Minimal command set used only when the backend sends no manifest (protocol
+// skew / very old bundle). The live manifest replaces this on connect.
+const FALLBACK_COMMANDS = [
+ { name: 'help', synopsis: 'List available commands.' },
+ { name: 'exit', synopsis: 'Quit the ARIA CLI.' },
+ { name: 'new', synopsis: 'Start a fresh session.' },
+ { name: 'clear', synopsis: 'Clear the current session.' },
+ { name: 'sessions', synopsis: 'List recent sessions.' },
+ { name: 'resume', synopsis: 'Resume a session by id.', args: '' },
+ { name: 'model', synopsis: 'Set the model lane.', args: 'auto|fast|standard|reasoning|premium' },
+ { name: 'mode', synopsis: 'Switch operating mode.', args: 'plan|coding|ask' },
+ { name: 'plan', synopsis: 'Toggle plan mode.', args: 'on|off' },
+ { name: 'tools', synopsis: 'List the ARIA tool catalog.' },
+ { name: 'status', synopsis: 'Show project status.' },
+ { name: 'memory', synopsis: 'List stored facts.' },
+]
+
+function printHelpText(commands) {
+ const lines = [
+ 'Usage: aria [message] [--cwd ] [--project ] [--session ] [--model ] [--plan]',
+ '',
+ 'Commands:',
+ ...commands.map((cmd) => {
+ const sig = ` /${cmd.name}${cmd.args ? ` ${cmd.args}` : ''}`
+ return `${sig.padEnd(44)} ${cmd.synopsis ?? ''}`
+ }),
+ '',
+ 'Flags: --help --version --plan',
+ 'Env: NO_COLOR=1 disables ANSI color',
+ '',
+ ]
+ console.log(lines.join('\n'))
+}
+
+if (args.includes('--version') || args.includes('-v')) {
+ console.log(pkg.version)
+ process.exit(EXIT_OK)
+}
+
+if (args.includes('--help') || args.includes('-h')) {
+ printHelpText(FALLBACK_COMMANDS)
+ process.exit(EXIT_OK)
+}
+
+if (!fs.existsSync(mainEntry)) {
+ console.error('ARIA CLI is not built. Run: pnpm run build')
+ process.exit(EXIT_RUNTIME)
+}
+
+function writeFrame(child, frame) {
+ if (!child.stdin?.writable) return
+ child.stdin.write(`${JSON.stringify(frame)}\n`)
+}
+
+function createBackend(onFrame, onExit) {
+ const electronPath = require('electron')
+ const child = spawn(electronPath, [mainEntry, '--aria-server', ...args], {
+ cwd: process.cwd(),
+ env: {
+ ...process.env,
+ DAEMON_ARIA_CLI: '1',
+ NODE_NO_WARNINGS: '1',
+ },
+ stdio: ['pipe', 'pipe', 'pipe'],
+ windowsHide: true,
+ })
+
+ child.stdout.setEncoding('utf8')
+ child.stderr.setEncoding('utf8')
+
+ let buffer = ''
+ child.stdout.on('data', (chunk) => {
+ buffer += chunk
+ const lines = buffer.split(/\r?\n/)
+ buffer = lines.pop() ?? ''
+ for (const line of lines) {
+ if (!line.trim()) continue
+ try {
+ onFrame(JSON.parse(line))
+ } catch {
+ onFrame({ type: 'log', level: 'warn', message: line })
+ }
+ }
+ })
+
+ child.stderr.on('data', (chunk) => {
+ const message = String(chunk).trim()
+ if (message) onFrame({ type: 'log', level: 'warn', message })
+ })
+
+ child.on('exit', (code) => onExit(code ?? 0))
+ child.on('error', (err) => onFrame({ type: 'error', message: err.message }))
+ return child
+}
+
+function nextMode(mode) {
+ if (mode === 'plan') return 'coding'
+ if (mode === 'coding') return 'ask'
+ return 'plan'
+}
+
+function formatEvent(event) {
+ if (event.kind === 'tool-call') return `[${event.status}] ${event.name}${event.meta ? ` - ${event.meta}` : ''}`
+ if (event.kind === 'plan') return `plan: ${event.steps.map((step) => `${step.index}.${step.status}:${step.title}`).join(' | ')}`
+ if (event.kind === 'memory-recall') return `recalled ${event.recalled.length} memories`
+ if (event.kind === 'memory-suggestion') return `memory: ${event.suggestion.title} - ${event.suggestion.value}`
+ if (event.kind === 'action-result') return `patch ${event.status}: ${event.action}${event.meta ? ` - ${event.meta}` : ''}`
+ if (event.kind === 'patch-proposal') return `patch proposal: ${event.proposal.title}`
+ return null
+}
+
+function appendItem(setItems, item) {
+ setItems((items) => [...items, item].slice(-200))
+}
+
+// Backend/process chatter that shouldn't reach the transcript: RPC rate-limit
+// retries and npm/pnpm env warnings are noise, not operator output.
+const NOISE = [/Too Many Requests/i, /Retrying after/i, /npm warn/i, /Unknown env config/i]
+function isNoise(message) {
+ return NOISE.some((re) => re.test(message))
+}
+
+const MODE_COLOR = { plan: 'magenta', coding: 'green', ask: 'blue' }
+
+// One compact top line: brand · mode dot · engine/network. No banner wall.
+function TopBar({ mode, modelLane, network }) {
+ return h(Box, { justifyContent: 'space-between' },
+ h(Box, null,
+ h(Text, { color: themeColor('green'), bold: true }, 'aria'),
+ h(Text, { color: themeColor('muted') }, ` v${pkg.version}`),
+ ),
+ h(Box, null,
+ h(Text, { color: themeColor(MODE_COLOR[mode] ?? 'muted') }, `${ARIA_DOT} `),
+ h(Text, { color: themeColor('text') }, mode),
+ h(Text, { color: themeColor('muted') }, ` ${modelLane} · ${network}`),
+ ),
+ )
+}
+
+// Boot panel: ANSI Shadow ARIA wordmark with a green gradient + "by daemon"
+// tagline, then a compact meta box. Shown once, collapses after first message.
+function Banner({ banner }) {
+ if (!banner) return null
+ const meta = banner.meta
+ const rows = banner.wordmark ?? []
+ const noColor = isColorDisabled()
+ const ramp = gradient(ARIA_THEME_COLORS.green, ARIA_THEME_COLORS.greenDark, rows.length)
+ const wordmarkWidth = rows.length ? [...rows[0]].length : 0
+ const metaRow = (k, v) => h(Box, { key: k },
+ h(Text, { color: themeColor('muted') }, k.padEnd(9)),
+ h(Text, { color: themeColor('secondary') }, v),
+ )
+ return h(Box, { flexDirection: 'column', marginBottom: 1 },
+ h(Box, { flexDirection: 'column' },
+ ...rows.map((line, i) => h(Text, { key: `w${i}`, color: noColor ? undefined : ramp[i], bold: true }, line)),
+ ),
+ h(Box, { width: wordmarkWidth, justifyContent: 'flex-end' },
+ h(Text, { color: themeColor('muted') }, 'by daemon'),
+ ),
+ h(Box, { flexDirection: 'column', marginTop: 1 },
+ h(Box, null,
+ h(Text, { color: themeColor('muted') }, 'version'.padEnd(9)),
+ h(Text, { color: themeColor('secondary') }, `v${banner.version}`),
+ ),
+ metaRow('project', meta.project),
+ metaRow('network', meta.network),
+ metaRow('wallet', meta.wallet),
+ ),
+ )
+}
+
+const ROLE = {
+ user: { dot: 'green', text: 'text' },
+ assistant: { dot: 'blue', text: 'text' },
+ error: { dot: 'red', text: 'red' },
+ tool: { dot: 'amber', text: 'secondary' },
+ system: { dot: 'muted', text: 'muted' },
+}
+
+function Transcript({ items, height, scrollOffset }) {
+ const start = Math.max(0, items.length - height - scrollOffset)
+ const visible = items.slice(start, start + height)
+ return h(Box, { flexDirection: 'column', height, overflow: 'hidden' },
+ ...visible.map((item, index) => {
+ const role = ROLE[item.role] ?? ROLE.system
+ return h(Box, { key: `${start + index}` },
+ h(Text, { color: themeColor(role.dot) }, `${ARIA_DOT} `),
+ h(Text, { color: themeColor(role.text) }, item.text),
+ )
+ }),
+ )
+}
+
+// Inline autocomplete hint: the single best completion, shown ghosted.
+function ghostFor(input, matches) {
+ if (!input.startsWith('/') || input.includes(' ') || matches.length === 0) return ''
+ const head = matches[0].name
+ const typed = input.slice(1)
+ return head.startsWith(typed) ? head.slice(typed.length) : ''
+}
+
+const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
+const WORK_WORDS = ['thinking', 'working', 'cooking', 'reasoning', 'crunching', 'wiring', 'computing']
+
+// Claude-style working indicator: braille spinner + cycling verb + elapsed timer.
+function WorkingIndicator({ tick, elapsedMs }) {
+ const frame = SPINNER[tick % SPINNER.length]
+ const word = WORK_WORDS[Math.floor(tick / 12) % WORK_WORDS.length]
+ const secs = Math.floor(elapsedMs / 1000)
+ return h(Box, null,
+ h(Text, { color: themeColor('green') }, `${frame} `),
+ h(Text, { color: themeColor('text') }, word),
+ h(Text, { color: themeColor('muted') }, `… ${secs}s`),
+ )
+}
+
+function Composer({ input, busy, pendingApproval, pendingPatch, mode, matches }) {
+ const cue = pendingApproval ? { color: 'red', hint: 'type APPROVE, y, or n' }
+ : pendingPatch ? { color: 'amber', hint: 'keep / run-tests / discard' }
+ : { color: MODE_COLOR[mode] ?? 'green', hint: '' }
+ const ghost = ghostFor(input, matches)
+ return h(Box, null,
+ h(Text, { color: themeColor(cue.color), bold: true }, '> '),
+ input
+ ? h(Box, null,
+ h(Text, { color: themeColor('text') }, input),
+ ghost ? h(Text, { color: themeColor('disabled') }, ghost) : null,
+ h(Text, { color: themeColor('muted') }, '▏'),
+ )
+ : h(Text, { color: themeColor('muted') }, cue.hint || 'message · / for commands'),
+ )
+}
+
+function Footer({ contextUsed, scrollOffset }) {
+ const hints = 'enter send · tab complete · ⇧tab mode · ^b sessions · pgup scroll'
+ return h(Box, { justifyContent: 'space-between' },
+ h(Text, { color: themeColor('muted') }, hints),
+ h(Text, { color: themeColor('muted') }, `${scrollOffset > 0 ? `▲${scrollOffset} ` : ''}${contextUsed}`),
+ )
+}
+
+function AriaApp() {
+ const { exit } = useApp()
+ const { stdout } = useStdout()
+ const childRef = useRef(null)
+ const [ready, setReady] = useState(false)
+ const [busy, setBusy] = useState(false)
+ const [tick, setTick] = useState(0)
+ const [busyStart, setBusyStart] = useState(0)
+ const [input, setInput] = useState('')
+ const [items, setItems] = useState([])
+ const [banner, setBanner] = useState(null)
+ const [commands, setCommands] = useState(FALLBACK_COMMANDS)
+ const [scrollOffset, setScrollOffset] = useState(0)
+ const [pendingApproval, setPendingApproval] = useState(null)
+ const [pendingPatch, setPendingPatch] = useState(null)
+ const inputRef = useRef('')
+ const commandsRef = useRef(FALLBACK_COMMANDS)
+ const scrollRef = useRef(0)
+ const pendingApprovalRef = useRef(null)
+ const pendingPatchRef = useRef(null)
+ const stateRef = useRef(null)
+ const [state, setState] = useState({
+ mode: args.includes('--plan') ? 'plan' : 'coding',
+ modelLane: 'auto',
+ projectPath: process.cwd(),
+ network: 'solana',
+ wallet: null,
+ session: null,
+ })
+ stateRef.current = state
+ const width = stdout.columns ?? process.stdout.columns ?? 100
+ const height = stdout.rows ?? process.stdout.rows ?? 30
+ // Show the boot card only until the first message; then collapse it so the
+ // chat gets the whole window. Chrome: topbar(1) + rule(1) + composer(1) +
+ // footer(1) + the card when shown (~8).
+ const showBanner = banner && items.length === 0
+ const chrome = 4 + (showBanner ? 13 : 0) + (busy ? 1 : 0)
+ const transcriptHeight = Math.max(3, height - chrome)
+
+ const send = (frame) => writeFrame(childRef.current, frame)
+ const setInputValue = (value) => {
+ inputRef.current = value
+ setInput(value)
+ }
+ const setScroll = (value) => {
+ const clamped = Math.max(0, value)
+ scrollRef.current = clamped
+ setScrollOffset(clamped)
+ }
+
+ const submitText = (rawText) => {
+ const text = rawText.trim()
+ setInputValue('')
+ setScroll(0)
+ if (!text) return
+ if (pendingApprovalRef.current) {
+ const pending = pendingApprovalRef.current
+ const answer = text.toLowerCase()
+ send({ type: 'approval', id: pending.id, approved: text === 'APPROVE' || answer === 'y' || answer === 'yes' })
+ pendingApprovalRef.current = null
+ setPendingApproval(null)
+ return
+ }
+ if (pendingPatchRef.current) {
+ const pending = pendingPatchRef.current
+ send({ type: 'patchDecision', id: pending.id, action: text })
+ pendingPatchRef.current = null
+ setPendingPatch(null)
+ return
+ }
+ send({ type: 'input', text })
+ if (text === '/exit' || text === '/quit') exit()
+ }
+
+ // Tab-complete a partial slash command from the live manifest.
+ const completeSlash = () => {
+ const value = inputRef.current
+ if (!value.startsWith('/') || value.includes(' ')) return
+ const partial = value.slice(1).toLowerCase()
+ const matches = commandsRef.current.filter((cmd) => cmd.name.startsWith(partial))
+ if (matches.length === 1) setInputValue(`/${matches[0].name} `)
+ }
+
+ const appendInput = (value) => {
+ const normalized = value.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
+ if (!normalized.includes('\n')) {
+ setInputValue(inputRef.current + normalized)
+ return
+ }
+ const parts = normalized.split('\n')
+ let nextInput = inputRef.current
+ parts.forEach((part, index) => {
+ nextInput += part
+ if (index < parts.length - 1) {
+ submitText(nextInput)
+ nextInput = ''
+ }
+ })
+ setInputValue(nextInput)
+ }
+
+ useEffect(() => {
+ pendingApprovalRef.current = pendingApproval
+ }, [pendingApproval])
+
+ useEffect(() => {
+ pendingPatchRef.current = pendingPatch
+ }, [pendingPatch])
+
+ // Drive the working-indicator animation only while busy (no idle redraws).
+ useEffect(() => {
+ if (!busy) return undefined
+ const id = setInterval(() => setTick((t) => t + 1), 120)
+ return () => clearInterval(id)
+ }, [busy])
+
+ useEffect(() => {
+ childRef.current = createBackend((frame) => {
+ if (frame.type === 'ready') { setReady(true); return }
+ if (frame.type === 'manifest') {
+ commandsRef.current = frame.commands ?? FALLBACK_COMMANDS
+ setCommands(frame.commands ?? FALLBACK_COMMANDS)
+ return
+ }
+ if (frame.type === 'banner') { setBanner(frame); return }
+ if (frame.type === 'state') { setState((current) => ({ ...current, ...frame })); return }
+ if (frame.type === 'busy') {
+ const isBusy = Boolean(frame.busy)
+ setBusy(isBusy)
+ if (isBusy) setBusyStart(Date.now())
+ return
+ }
+ if (frame.type === 'user') { appendItem(setItems, { role: 'user', text: frame.text }); return }
+ if (frame.type === 'response') {
+ if (frame.text?.trim()) appendItem(setItems, { role: 'assistant', text: frame.text.trim() })
+ return
+ }
+ if (frame.type === 'event') {
+ const text = formatEvent(frame.event)
+ if (text) appendItem(setItems, { role: 'tool', text })
+ return
+ }
+ if (frame.type === 'tools') {
+ appendItem(setItems, { role: 'system', text: formatTools(frame.tools) })
+ return
+ }
+ if (frame.type === 'status') {
+ appendItem(setItems, { role: 'system', text: formatStatus(frame.status) })
+ return
+ }
+ if (frame.type === 'memories') {
+ appendItem(setItems, { role: 'system', text: formatMemories(frame.memories) })
+ return
+ }
+ if (frame.type === 'approval') {
+ setPendingApproval(frame)
+ appendItem(setItems, { role: 'system', text: `approval required: ${frame.request.name}` })
+ return
+ }
+ if (frame.type === 'patchDecision') {
+ setPendingPatch(frame)
+ appendItem(setItems, { role: 'system', text: `patch decision: ${frame.proposal.title}` })
+ return
+ }
+ if (frame.type === 'sessions') {
+ const sessions = frame.sessions?.map((session) => `${session.id} ${session.title ?? '(untitled)'}`).join('\n') || '(none)'
+ appendItem(setItems, { role: 'system', text: sessions })
+ return
+ }
+ if (frame.type === 'help') {
+ appendItem(setItems, { role: 'system', text: formatHelp(commandsRef.current) })
+ return
+ }
+ if (frame.type === 'log') {
+ if (isNoise(frame.message)) return
+ appendItem(setItems, { role: frame.level === 'warn' ? 'error' : 'system', text: frame.message })
+ return
+ }
+ if (frame.type === 'error') {
+ if (isNoise(frame.message)) return
+ appendItem(setItems, { role: 'error', text: frame.message })
+ return
+ }
+ if (frame.type === 'exit') exit()
+ }, (code) => {
+ if (code !== 0) appendItem(setItems, { role: 'error', text: `backend exited with code ${code}` })
+ })
+
+ return () => {
+ writeFrame(childRef.current, { type: 'exit' })
+ childRef.current?.kill()
+ }
+ }, [])
+
+ const { stdin, setRawMode, isRawModeSupported } = useStdin()
+
+ useEffect(() => {
+ if (!isRawModeSupported) {
+ appendItem(setItems, { role: 'error', text: 'interactive input is not supported by this terminal' })
+ return undefined
+ }
+ readline.emitKeypressEvents(stdin)
+ setRawMode(true)
+ const onKeypress = (value, key = {}) => {
+ const keyName = key.name ?? ''
+ if (key.ctrl && keyName === 'c') { send({ type: 'exit' }); exit(); return }
+ if ((key.shift && keyName === 'tab') || key.sequence === '[Z') {
+ send({ type: 'input', text: `/mode ${nextMode(stateRef.current?.mode ?? 'coding')}` })
+ return
+ }
+ if (keyName === 'tab') { completeSlash(); return }
+ if (keyName === 'pageup') { setScroll(scrollRef.current + Math.max(1, transcriptHeight - 2)); return }
+ if (keyName === 'pagedown') { setScroll(scrollRef.current - Math.max(1, transcriptHeight - 2)); return }
+ if (key.ctrl && keyName === 'b') { send({ type: 'input', text: '/sessions' }); return }
+ if (key.ctrl && keyName === 't') {
+ appendItem(setItems, { role: 'system', text: 'models: auto fast standard reasoning premium; use /model ' })
+ return
+ }
+ if (keyName === 'return' || keyName === 'enter') { submitText(inputRef.current); return }
+ if (keyName === 'backspace' || keyName === 'delete') { setInputValue(inputRef.current.slice(0, -1)); return }
+ if (value && !key.ctrl && !key.meta) appendInput(value)
+ }
+ stdin.on('keypress', onKeypress)
+ return () => {
+ stdin.off('keypress', onKeypress)
+ setRawMode(false)
+ }
+ }, [stdin, setRawMode, isRawModeSupported, transcriptHeight])
+
+ const contextUsed = useMemo(() => (items.reduce((sum, item) => sum + item.text.length, 0) / 1000).toFixed(1) + 'k', [items])
+ const divider = '─'.repeat(Math.max(40, width - 2))
+ const slashMatches = input.startsWith('/') && !input.includes(' ')
+ ? commands.filter((cmd) => cmd.name.startsWith(input.slice(1).toLowerCase()))
+ : []
+
+ return h(Box, { flexDirection: 'column', paddingX: 1 },
+ h(TopBar, { mode: state.mode, modelLane: state.modelLane, network: state.network ?? 'solana' }),
+ h(Text, { color: themeColor('border') }, divider),
+ showBanner ? h(Banner, { banner }) : null,
+ h(Transcript, { items, height: transcriptHeight, scrollOffset }),
+ busy ? h(WorkingIndicator, { tick, elapsedMs: Date.now() - busyStart }) : null,
+ h(Composer, { input, mode: state.mode, busy, pendingApproval, pendingPatch, matches: slashMatches }),
+ h(Footer, { contextUsed, scrollOffset }),
+ )
+}
+
+// --- Structured-frame formatters (shared by TUI transcript + plain mode) ---
+
+const dotFor = (risk) => isColorDisabled() ? ARIA_DOT : `\x1b[38;2;${hexParts(riskColor(risk))}m${ARIA_DOT}\x1b[0m`
+function hexParts(hex) {
+ if (!hex) return '110;112;111'
+ return `${parseInt(hex.slice(1, 3), 16)};${parseInt(hex.slice(3, 5), 16)};${parseInt(hex.slice(5, 7), 16)}`
+}
+
+function formatHelp(commands) {
+ return ['Commands:', ...commands.map((cmd) => ` /${cmd.name}${cmd.args ? ` ${cmd.args}` : ''} - ${cmd.synopsis ?? ''}`)].join('\n')
+}
+
+function formatTools(tools) {
+ if (!tools?.length) return 'No tools available.'
+ const byRisk = { read: [], write: [], sensitive: [] }
+ for (const tool of tools) (byRisk[tool.risk] ?? byRisk.read).push(tool)
+ const sections = []
+ for (const risk of ['read', 'write', 'sensitive']) {
+ const group = byRisk[risk]
+ if (!group.length) continue
+ sections.push(`${dotFor(risk)} ${risk} (${group.length})`)
+ for (const tool of group) sections.push(` ${tool.name} - ${tool.description.slice(0, 80)}`)
+ }
+ return sections.join('\n')
+}
+
+function formatStatus(status) {
+ if (!status || typeof status !== 'object') return 'No status available.'
+ const s = status
+ return [
+ 'Project status:',
+ ` project ${s.project ?? '(none)'}`,
+ ` cluster ${s.cluster ?? '?'} (${s.rpcProvider ?? '?'})`,
+ ` wallet ${s.defaultWallet ?? '(none)'} · ${s.walletCount ?? 0} wallet(s)`,
+ ` helius ${s.heliusConfigured ? 'configured' : 'not configured'}`,
+ ` packs ${(s.enabledPacks ?? []).join(', ') || '(none)'}`,
+ ].join('\n')
+}
+
+function formatMemories(memories) {
+ if (!memories?.length) return 'No stored facts yet for this project.'
+ return ['Stored facts:', ...memories.map((m) => ` ${m.kind}: ${m.title} - ${m.value}`)].join('\n')
+}
+
+async function runPlain() {
+ const inputText = fs.readFileSync(0, 'utf8')
+ const lines = inputText.split(/\r?\n/).filter((line) => line.trim())
+ let ready = false
+ const child = createBackend((frame) => {
+ if (frame.type === 'ready') {
+ ready = true
+ for (const line of lines) writeFrame(child, { type: 'input', text: line })
+ // Always send exit: the backend processes frames in order and drains queued
+ // turns before exiting, so this fires after the last input is answered. Any
+ // approval a turn needs is auto-rejected below, so piped mode never hangs.
+ writeFrame(child, { type: 'exit' })
+ return
+ }
+ if (frame.type === 'state') { console.log(`aria ${frame.projectPath ?? ''}`); return }
+ if (frame.type === 'response' && frame.text?.trim()) console.log(frame.text.trim())
+ if (frame.type === 'user' && frame.text?.trim()) console.log(`> ${frame.text.trim()}`)
+ if (frame.type === 'help') console.log(formatHelp(frame.commands ?? FALLBACK_COMMANDS))
+ if (frame.type === 'manifest') return
+ if (frame.type === 'banner') return
+ if (frame.type === 'tools') console.log(formatTools(frame.tools))
+ if (frame.type === 'status') console.log(formatStatus(frame.status))
+ if (frame.type === 'memories') console.log(formatMemories(frame.memories))
+ if (frame.type === 'sessions') console.log(frame.sessions?.map((session) => `${session.id} ${session.title ?? '(untitled)'}`).join('\n') || '(none)')
+ if (frame.type === 'event') {
+ const text = formatEvent(frame.event)
+ if (text) console.log(text)
+ }
+ if (frame.type === 'log' || frame.type === 'error') console.error(frame.message)
+ if (frame.type === 'approval') writeFrame(child, { type: 'approval', id: frame.id, approved: false })
+ if (frame.type === 'patchDecision') writeFrame(child, { type: 'patchDecision', id: frame.id, action: 'discard' })
+ if (frame.type === 'exit') writeFrame(child, { type: 'exit' })
+ }, (code) => setTimeout(() => process.exit(code), 25))
+
+ setTimeout(() => {
+ if (!ready && child.exitCode === null) writeFrame(child, { type: 'exit' })
+ }, 30_000)
+}
+
+const hasConsoleInput = fs.fstatSync(0).isCharacterDevice()
+if (!hasConsoleInput) {
+ runPlain().catch((err) => {
+ console.error(err instanceof Error ? err.message : String(err))
+ process.exit(EXIT_RUNTIME)
+ })
+} else {
+ render(h(AriaApp), { alternateScreen: true, exitOnCtrlC: true })
+}
diff --git a/scripts/garrison-wash-analysis.mjs b/scripts/garrison-wash-analysis.mjs
new file mode 100644
index 00000000..8f0a183d
--- /dev/null
+++ b/scripts/garrison-wash-analysis.mjs
@@ -0,0 +1,553 @@
+#!/usr/bin/env node
+// @ts-check
+/**
+ * garrison-wash-analysis.mjs — Garrison riskiest-assumption gate (offline).
+ *
+ * Validates §11 of .planning/GARRISON_BACKEND_SPEC.md BEFORE scaling the
+ * referral-commission feature:
+ *
+ * "Referred wallets generate enough INDEPENDENT, ORGANIC on-chain volume —
+ * volume the referrer is not themselves manufacturing — to make commissions
+ * meaningful without self-wash."
+ *
+ * If the only way to earn is to wash-trade your own referees, the mechanism is
+ * an obfuscated self-pay that drains the fee floor and inflates fake activity.
+ *
+ * This script READS ONLY. It never writes to the DB and never touches chain.
+ * It opens the live better-sqlite3 DB the app uses, measures historical
+ * mainnet `fee_events`, estimates a wash fraction from the forensics
+ * shared-funder graph, applies the §4 commission math, and prints a
+ * GREEN / YELLOW / RED verdict.
+ *
+ * Usage:
+ * node scripts/garrison-wash-analysis.mjs [--db ] [--json]
+ * DAEMON_DB= node scripts/garrison-wash-analysis.mjs
+ *
+ * Runtime note: better-sqlite3 is a NATIVE module compiled against ONE ABI.
+ * In this repo it is built for Electron (`pnpm run rebuild:sqlite`). Plain
+ * `node` has a different ABI and the require WILL throw NODE_MODULE_VERSION.
+ * The script catches that and prints exact remediation rather than crashing.
+ */
+
+import path from 'node:path'
+import fs from 'node:fs'
+import os from 'node:os'
+import process from 'node:process'
+import { createRequire } from 'node:module'
+import { fileURLToPath } from 'node:url'
+
+const require = createRequire(import.meta.url)
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
+const REPO_ROOT = path.resolve(__dirname, '..')
+
+// ───────────────────────── §2.6 / §4 constants (mirror the spec) ─────────────
+// Compiled ceilings — config may only LOWER these, never raise.
+const BASE_REFERRAL_BPS = 1000 // 10% of net protocol fee → referrer, base (pre-stake)
+const MAX_REFERRAL_BPS = 3000 // hard ceiling on (base+bonus) referral share of net fee
+const FEE_FLOOR_HEADROOM_BPS = 5000 // protocol keeps >= 50% of net fee, always
+const KOL_RESERVED_BPS = 1000 // example carve-out for a manual/KOL deal on a referee
+const MIN_CLAIM_LAMPORTS = 10_000_000 // 0.01 SOL — dust claims cost more than they pay
+const PER_REFEREE_EPOCH_CAP_LAMPORTS = 5_000_000_000 // cap one referee's notional/epoch (anti-wash)
+const DEFAULT_TIER_MULTIPLIER_BPS = 15_000 // v0 seeds ONE tier at 1.5x (§12 v0)
+
+// From FeeService.ts — the dust floor below which the meter charges nothing.
+const MIN_FEE_LAMPORTS = 5_000
+
+const LAMPORTS_PER_SOL = 1_000_000_000
+const MS_PER_DAY = 86_400_000
+
+// ───────────────────────── CLI / env arg parsing ────────────────────────────
+function parseArgs(argv) {
+ const out = { db: null, json: false }
+ for (let i = 0; i < argv.length; i++) {
+ const a = argv[i]
+ if (a === '--db') out.db = argv[++i]
+ else if (a.startsWith('--db=')) out.db = a.slice('--db='.length)
+ else if (a === '--json') out.json = true
+ else if (a === '-h' || a === '--help') out.help = true
+ }
+ return out
+}
+
+/**
+ * Resolve the DB path the app actually uses. The app stores it at
+ * `app.getPath('userData')/daemon.db` (see electron/db/db.ts). On Windows that
+ * userData dir is `%APPDATA%/`. We try the known candidates and
+ * default to the most-recently-written one, but let --db / DAEMON_DB override.
+ */
+function resolveDbPath(cliDb) {
+ if (cliDb) return { path: path.resolve(cliDb), source: '--db' }
+ if (process.env.DAEMON_DB) return { path: path.resolve(process.env.DAEMON_DB), source: 'DAEMON_DB' }
+
+ const home = os.homedir()
+ const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming')
+ const candidates = [
+ path.join(appData, 'daemon', 'daemon.db'),
+ path.join(appData, 'DAEMON', 'daemon.db'),
+ path.join(appData, 'Electron', 'daemon.db'), // dev runs unpackaged → productName "Electron"
+ // macOS
+ path.join(home, 'Library', 'Application Support', 'daemon', 'daemon.db'),
+ path.join(home, 'Library', 'Application Support', 'DAEMON', 'daemon.db'),
+ path.join(home, 'Library', 'Application Support', 'Electron', 'daemon.db'),
+ // Linux
+ path.join(home, '.config', 'daemon', 'daemon.db'),
+ path.join(home, '.config', 'DAEMON', 'daemon.db'),
+ path.join(home, '.config', 'Electron', 'daemon.db'),
+ ]
+ const existing = candidates
+ .filter((p) => fs.existsSync(p))
+ .map((p) => ({ p, mtime: fs.statSync(p).mtimeMs }))
+ .sort((a, b) => b.mtime - a.mtime)
+ if (existing.length) return { path: existing[0].p, source: 'auto', candidates }
+ return { path: null, source: 'none', candidates }
+}
+
+function printAbiHelp() {
+ console.error('')
+ console.error(' Cause: the native better-sqlite3 module is built for a DIFFERENT runtime')
+ console.error(' ABI than the node you ran this with (DAEMON builds it for Electron).')
+ console.error('')
+ console.error(' Fix — pick ONE:')
+ console.error(' A) Rebuild for your system node, then re-run:')
+ console.error(' npm rebuild better-sqlite3 (in ' + REPO_ROOT + ')')
+ console.error(' then `pnpm run rebuild:sqlite` afterward to restore the Electron ABI.')
+ console.error(' B) Run under Electron’s bundled node:')
+ console.error(' set ELECTRON_RUN_AS_NODE=1 && npx electron scripts/garrison-wash-analysis.mjs')
+ console.error(' C) Install a throwaway copy for system node in a temp dir and run with')
+ console.error(' NODE_PATH pointing at it.')
+}
+
+/**
+ * better-sqlite3 binds its native addon lazily — `require()` can succeed and the
+ * NODE_MODULE_VERSION error only surfaces on first `new Database()`. So we both
+ * require AND construct against a temp in-memory handle to surface ABI errors
+ * here, where we can print actionable guidance.
+ */
+function loadDatabase() {
+ try {
+ const Database = require('better-sqlite3')
+ const probe = new Database(':memory:') // forces the native binding to load
+ probe.close()
+ return { Database, error: null }
+ } catch (err) {
+ return { Database: null, error: err }
+ }
+}
+
+// ───────────────────────── §4 commission math (per metered execution) ───────
+/**
+ * Floor-safe per-event commission. Mirrors §4.4 exactly. All shares are bps of
+ * the net protocol fee (`fee_lamports`), never of notional. Returns the lamports
+ * that would accrue to the referrer for this single fee event.
+ *
+ * @param {number} netFee fee_events.fee_lamports
+ * @param {number} multiplierBps tier multiplier (10000 = 1.0x, 15000 = 1.5x)
+ * @param {boolean} kolApplies whether a KOL carve-out applies to this referee
+ */
+function commissionForEvent(netFee, multiplierBps, kolApplies) {
+ const baseShare = Math.floor((netFee * BASE_REFERRAL_BPS) / 10_000)
+ const multipliedShare = Math.floor((baseShare * multiplierBps) / 10_000)
+
+ // 1. cap the referral line at MAX_REFERRAL_BPS of net fee
+ const referralCap = Math.floor((netFee * MAX_REFERRAL_BPS) / 10_000)
+ const referralLine = Math.min(multipliedShare, referralCap)
+
+ // 2. cap the SUM (referral + KOL) so the protocol keeps the floor
+ const maxOutbound = Math.floor((netFee * (10_000 - FEE_FLOOR_HEADROOM_BPS)) / 10_000)
+ const kol = kolApplies ? Math.floor((netFee * KOL_RESERVED_BPS) / 10_000) : 0
+ const allowedReferral = Math.max(0, maxOutbound - kol)
+ return Math.min(referralLine, allowedReferral) // FINAL, floor-safe
+}
+
+const epochOf = (createdAtMs) => Math.floor(createdAtMs / MS_PER_DAY)
+const sol = (lamports) => (Number(lamports) / LAMPORTS_PER_SOL).toFixed(6)
+const pct = (x) => (x * 100).toFixed(1) + '%'
+
+// ───────────────────────── median / percentile helpers ──────────────────────
+function quantile(sortedAsc, q) {
+ if (!sortedAsc.length) return 0
+ const idx = (sortedAsc.length - 1) * q
+ const lo = Math.floor(idx)
+ const hi = Math.ceil(idx)
+ if (lo === hi) return sortedAsc[lo]
+ return sortedAsc[lo] + (sortedAsc[hi] - sortedAsc[lo]) * (idx - lo)
+}
+
+// ───────────────────────── analysis core ────────────────────────────────────
+function analyze(db) {
+ const tableExists = (t) =>
+ db.prepare("SELECT count(*) c FROM sqlite_master WHERE type='table' AND name=?").get(t).c > 0
+
+ const required = ['fee_events', 'forensic_bundle_clusters', 'forensic_bundle_wallet_index']
+ const missing = required.filter((t) => !tableExists(t))
+ if (missing.includes('fee_events')) {
+ return { fatal: `fee_events table not found (schema too old?). Missing: ${missing.join(', ')}` }
+ }
+
+ const MAINNET = 'mainnet-beta'
+
+ // (a) totals over mainnet fee_events
+ const totals = db
+ .prepare(
+ `SELECT count(*) rows,
+ COALESCE(sum(notional_lamports),0) notional,
+ COALESCE(sum(fee_lamports),0) fee,
+ COALESCE(min(created_at),0) firstAt,
+ COALESCE(max(created_at),0) lastAt,
+ count(DISTINCT wallet) wallets
+ FROM fee_events WHERE cluster = ?`,
+ )
+ .get(MAINNET)
+
+ if (totals.rows === 0) {
+ return { empty: true, totals, otherClusters: clusterSpread(db) }
+ }
+
+ // (b) per-wallet notional distribution (mainnet only)
+ const perWallet = db
+ .prepare(
+ `SELECT wallet,
+ count(*) events,
+ sum(notional_lamports) notional,
+ sum(fee_lamports) fee
+ FROM fee_events WHERE cluster = ?
+ GROUP BY wallet`,
+ )
+ .all(MAINNET)
+
+ // Build wallet → shared_funder set via the forensics graph.
+ // forensic_bundle_wallet_index(wallet, cluster_id) ⋈ forensic_bundle_clusters(shared_funder)
+ const walletFunders = new Map() // wallet → Set(shared_funder)
+ const funderMembers = new Map() // shared_funder → Set(wallet) (co-referee detection)
+ let clustersWithFunder = 0
+ if (tableExists('forensic_bundle_wallet_index') && tableExists('forensic_bundle_clusters')) {
+ const rows = db
+ .prepare(
+ `SELECT wi.wallet wallet, c.shared_funder funder
+ FROM forensic_bundle_wallet_index wi
+ JOIN forensic_bundle_clusters c ON c.id = wi.cluster_id
+ WHERE c.shared_funder IS NOT NULL AND c.shared_funder <> ''`,
+ )
+ .all()
+ clustersWithFunder = new Set(rows.map((r) => r.funder)).size
+ for (const r of rows) {
+ if (!walletFunders.has(r.wallet)) walletFunders.set(r.wallet, new Set())
+ walletFunders.get(r.wallet).add(r.funder)
+ if (!funderMembers.has(r.funder)) funderMembers.set(r.funder, new Set())
+ funderMembers.get(r.funder).add(r.wallet)
+ }
+ }
+
+ // (c) wash fraction.
+ // Without seed referral attributions (none exist pre-launch), we use the
+ // strongest available proxy for self-wash: a wallet's notional is "wash-tainted"
+ // if the wallet sits in a forensic cluster that shares a funder with ANOTHER
+ // active fee-paying wallet. Co-funded fee-payers are the co-referee / round-trip
+ // signal §4.5 calls out. This is an UPPER-BOUND proxy (no counterparty data in
+ // fee_events) and is deliberately conservative — it over-counts wash if anything.
+ const activeWallets = new Set(perWallet.map((w) => w.wallet))
+ let washNotional = 0
+ let cleanNotional = 0
+ const taintedWallets = new Set()
+ for (const w of perWallet) {
+ const funders = walletFunders.get(w.wallet)
+ let tainted = false
+ if (funders) {
+ for (const f of funders) {
+ const members = funderMembers.get(f)
+ if (!members) continue
+ // shared funder links this wallet to >=1 OTHER active fee-payer → wash signal
+ for (const m of members) {
+ if (m !== w.wallet && activeWallets.has(m)) {
+ tainted = true
+ break
+ }
+ }
+ if (tainted) break
+ }
+ }
+ if (tainted) {
+ washNotional += Number(w.notional)
+ taintedWallets.add(w.wallet)
+ } else {
+ cleanNotional += Number(w.notional)
+ }
+ }
+ const washFraction = totals.notional > 0 ? washNotional / Number(totals.notional) : 0
+
+ // (d) median ORGANIC notional per active wallet per day-epoch.
+ // Build per (wallet, epoch) organic notional, dropping wash-tainted wallets and
+ // dust-fee events, then take the median across the population.
+ const perWalletEpoch = db
+ .prepare(
+ `SELECT wallet,
+ CAST(created_at / ${MS_PER_DAY} AS INTEGER) epoch,
+ sum(notional_lamports) notional,
+ sum(fee_lamports) fee,
+ count(*) events
+ FROM fee_events
+ WHERE cluster = ? AND fee_lamports >= ${MIN_FEE_LAMPORTS}
+ GROUP BY wallet, epoch`,
+ )
+ .all(MAINNET)
+
+ const organicPerWalletEpoch = perWalletEpoch.filter((r) => !taintedWallets.has(r.wallet))
+ const organicNotionals = organicPerWalletEpoch
+ .map((r) => Math.min(Number(r.notional), PER_REFEREE_EPOCH_CAP_LAMPORTS))
+ .sort((a, b) => a - b)
+ const medianOrganicNotional = quantile(organicNotionals, 0.5)
+ const p25 = quantile(organicNotionals, 0.25)
+ const p75 = quantile(organicNotionals, 0.75)
+
+ // Per-wallet-epoch organic FEE (the real pie that gets split) — for commission.
+ const organicFeesByWalletEpoch = organicPerWalletEpoch.map((r) => Number(r.fee))
+
+ return {
+ empty: false,
+ totals,
+ perWalletCount: perWallet.length,
+ washNotional,
+ cleanNotional,
+ washFraction,
+ taintedWalletCount: taintedWallets.size,
+ clustersWithFunder,
+ forensicsPopulated: walletFunders.size > 0,
+ medianOrganicNotional,
+ p25OrganicNotional: p25,
+ p75OrganicNotional: p75,
+ organicSampleSize: organicNotionals.length,
+ organicFeesByWalletEpoch,
+ windowDays: (Number(totals.lastAt) - Number(totals.firstAt)) / MS_PER_DAY,
+ }
+}
+
+function clusterSpread(db) {
+ if (!db.prepare("SELECT count(*) c FROM sqlite_master WHERE type='table' AND name='fee_events'").get().c)
+ return []
+ return db.prepare('SELECT cluster, count(*) c FROM fee_events GROUP BY cluster ORDER BY c DESC').all()
+}
+
+// ───────────────────────── commission projection + verdict ──────────────────
+/**
+ * Project the commission a typical referrer earns per epoch under the §4 math,
+ * for both an unstaked (1.0x) and a v0-staked (1.5x) referrer, using the
+ * MEDIAN organic per-wallet-epoch fee. Then compare to MIN_CLAIM_LAMPORTS.
+ */
+function projectCommission(a) {
+ const fees = a.organicFeesByWalletEpoch.slice().sort((x, y) => x - y)
+ const medianFee = quantile(fees, 0.5)
+ const p75Fee = quantile(fees, 0.75)
+ const p90Fee = quantile(fees, 0.9)
+
+ const proj = (feeLamports, multiplierBps) => commissionForEvent(feeLamports, multiplierBps, false)
+
+ // A referrer typically has >1 referee; model the single-referee-epoch case
+ // (the worst case for clearing the floor) AND report how many median referees
+ // it takes to clear MIN_CLAIM_LAMPORTS in one epoch.
+ const medianCommission1x = proj(medianFee, 10_000)
+ const medianCommission15x = proj(medianFee, DEFAULT_TIER_MULTIPLIER_BPS)
+ const p75Commission15x = proj(p75Fee, DEFAULT_TIER_MULTIPLIER_BPS)
+
+ const refereesToClear15x =
+ medianCommission15x > 0 ? Math.ceil(MIN_CLAIM_LAMPORTS / medianCommission15x) : Infinity
+ const refereesToClear1x =
+ medianCommission1x > 0 ? Math.ceil(MIN_CLAIM_LAMPORTS / medianCommission1x) : Infinity
+
+ // Fraction of organic wallet-epochs whose SINGLE-referee commission already
+ // clears the floor at 1.5x — i.e. one referee in one day is enough.
+ const clearingSingle = fees.filter((f) => proj(f, DEFAULT_TIER_MULTIPLIER_BPS) >= MIN_CLAIM_LAMPORTS).length
+ const fracClearingSingle = fees.length ? clearingSingle / fees.length : 0
+
+ return {
+ medianFee,
+ p75Fee,
+ p90Fee,
+ medianCommission1x,
+ medianCommission15x,
+ p75Commission15x,
+ refereesToClear1x,
+ refereesToClear15x,
+ fracClearingSingle,
+ }
+}
+
+function decideVerdict(a, c) {
+ // GREEN: low wash AND a non-trivial fraction of stakers plausibly clear the
+ // floor from organic volume in a reasonable window.
+ // YELLOW: clears, but only with many referees / high wash / thin sample →
+ // ship with conservative rates + tight caps + monitoring.
+ // RED: wash dominates, or organic volume can't clear the floor at all →
+ // it's a wash-farm; stop.
+ const reasons = []
+ let verdict = 'YELLOW'
+
+ const washHigh = a.washFraction >= 0.5
+ const washMed = a.washFraction >= 0.2
+ const clearsSometimes = c.fracClearingSingle > 0 || Number.isFinite(c.refereesToClear15x)
+ const clearsEasily = c.fracClearingSingle >= 0.1 || c.refereesToClear15x <= 5
+
+ if (washHigh) {
+ verdict = 'RED'
+ reasons.push(`wash fraction ${pct(a.washFraction)} ≥ 50% — referred volume is dominated by self-funded/co-funded wallets; commission would be obfuscated self-pay`)
+ } else if (!clearsSometimes) {
+ verdict = 'RED'
+ reasons.push(`organic per-referee fee is ~0 (median ${sol(c.medianFee)} SOL) — no plausible number of organic referees clears MIN_CLAIM (0.01 SOL); the multiplier can't move behavior`)
+ } else if (clearsEasily && !washMed) {
+ verdict = 'GREEN'
+ reasons.push(`${pct(c.fracClearingSingle)} of organic wallet-epochs clear the floor from a SINGLE referee at 1.5x; ~${c.refereesToClear15x} median referees clear it in one epoch`)
+ reasons.push(`wash fraction ${pct(a.washFraction)} < 20% — organic volume is the dominant signal`)
+ } else {
+ verdict = 'YELLOW'
+ reasons.push(`organic volume clears the floor but only with ~${c.refereesToClear15x} median referees per epoch (or wash fraction ${pct(a.washFraction)} is non-trivial)`)
+ reasons.push('ship with conservative base rate, tight PER_REFEREE_EPOCH_CAP, and live wash monitoring before scaling tiers')
+ }
+
+ return { verdict, reasons }
+}
+
+// ───────────────────────── reporting ────────────────────────────────────────
+function printHeader(dbInfo) {
+ console.log('═'.repeat(74))
+ console.log(' GARRISON — Riskiest-Assumption Gate (offline wash analysis)')
+ console.log(' Spec: .planning/GARRISON_BACKEND_SPEC.md §4, §4.5, §11')
+ console.log('═'.repeat(74))
+ console.log(` DB: ${dbInfo.path} (source: ${dbInfo.source})`)
+ console.log('')
+}
+
+function printEmpty(a, dbInfo) {
+ console.log('RESULT: NO MAINNET fee_events DATA YET (pre-launch).')
+ console.log('')
+ console.log(` mainnet-beta fee_events rows: ${a.totals.rows}`)
+ if (a.otherClusters && a.otherClusters.length) {
+ console.log(' fee_events on other clusters:')
+ for (const r of a.otherClusters) console.log(` ${r.cluster}: ${r.c}`)
+ } else {
+ console.log(' fee_events on all clusters: 0 (table is empty)')
+ }
+ console.log('')
+ console.log('VERDICT: INSUFFICIENT DATA — cannot make the GREEN/YELLOW/RED call yet.')
+ console.log('')
+ console.log('The script is correct and ready. Re-run once the meter has logged real')
+ console.log('mainnet executions. To make a defensible call you need roughly:')
+ console.log(' • ≥ 30 distinct referred (fee-paying) wallets on mainnet-beta,')
+ console.log(' • ≥ 14 day-epochs of history (so median-per-epoch is stable),')
+ console.log(' • the forensics bundle scanner run over those wallets (so')
+ console.log(' forensic_bundle_clusters.shared_funder is populated for the')
+ console.log(' wash-fraction estimate), and ideally')
+ console.log(' • a handful of seed referral attributions to replace the')
+ console.log(' co-funded-wallet proxy with true counterparty linkage.')
+ console.log('')
+ console.log('Until then: see the analytical reasoning in the run report.')
+}
+
+function printFull(a, c, decision) {
+ const T = a.totals
+ console.log('── Volume (mainnet-beta fee_events) ─────────────────────────────────')
+ console.log(` rows: ${T.rows}`)
+ console.log(` distinct wallets: ${T.wallets}`)
+ console.log(` window: ${a.windowDays.toFixed(1)} days`)
+ console.log(` total notional: ${sol(T.notional)} SOL`)
+ console.log(` total fees (pie): ${sol(T.fee)} SOL`)
+ console.log('')
+ console.log('── Wash estimate (forensics shared-funder graph) ────────────────────')
+ console.log(` forensics populated: ${a.forensicsPopulated ? 'yes' : 'NO (wash undercount risk)'}`)
+ console.log(` clusters w/ funder: ${a.clustersWithFunder}`)
+ console.log(` wash-tainted wallets: ${a.taintedWalletCount} / ${a.perWalletCount}`)
+ console.log(` wash notional: ${sol(a.washNotional)} SOL`)
+ console.log(` clean notional: ${sol(a.cleanNotional)} SOL`)
+ console.log(` WASH FRACTION: ${pct(a.washFraction)}`)
+ console.log('')
+ console.log('── Organic per-wallet-epoch (wash-tainted wallets removed) ───────────')
+ console.log(` sample (wallet-epochs): ${a.organicSampleSize}`)
+ console.log(` median organic notional: ${sol(a.medianOrganicNotional)} SOL (p25 ${sol(a.p25OrganicNotional)} / p75 ${sol(a.p75OrganicNotional)})`)
+ console.log(` median organic fee: ${sol(c.medianFee)} SOL (p75 ${sol(c.p75Fee)} / p90 ${sol(c.p90Fee)})`)
+ console.log('')
+ console.log('── Commission projection (§4 math, floor-safe) ──────────────────────')
+ console.log(` per median organic referee-epoch:`)
+ console.log(` unstaked 1.0x: ${sol(c.medianCommission1x)} SOL`)
+ console.log(` staked 1.5x: ${sol(c.medianCommission15x)} SOL`)
+ console.log(` p75 ref 1.5x: ${sol(c.p75Commission15x)} SOL`)
+ console.log(` MIN_CLAIM: ${sol(MIN_CLAIM_LAMPORTS)} SOL`)
+ console.log(` median referees to clear MIN_CLAIM in one epoch: 1.0x → ${c.refereesToClear1x}, 1.5x → ${c.refereesToClear15x}`)
+ console.log(` fraction of organic referee-epochs clearing MIN_CLAIM from ONE referee (1.5x): ${pct(c.fracClearingSingle)}`)
+ console.log('')
+ console.log('═'.repeat(74))
+ console.log(` VERDICT: ${decision.verdict}`)
+ for (const r of decision.reasons) console.log(` • ${r}`)
+ console.log('═'.repeat(74))
+}
+
+// ───────────────────────── main ─────────────────────────────────────────────
+function main() {
+ const args = parseArgs(process.argv.slice(2))
+ if (args.help) {
+ console.log('Usage: node scripts/garrison-wash-analysis.mjs [--db ] [--json]')
+ console.log(' DAEMON_DB= node scripts/garrison-wash-analysis.mjs')
+ process.exit(0)
+ }
+
+ const dbInfo = resolveDbPath(args.db)
+ if (!dbInfo.path) {
+ console.error('Could not locate daemon.db. No candidate path exists.')
+ console.error('Tried:')
+ for (const c of dbInfo.candidates || []) console.error(' ' + c)
+ console.error('')
+ console.error('Pass it explicitly: node scripts/garrison-wash-analysis.mjs --db ')
+ console.error('Or set DAEMON_DB=.')
+ process.exit(2)
+ }
+ if (!fs.existsSync(dbInfo.path)) {
+ console.error(`DB path does not exist: ${dbInfo.path}`)
+ console.error('Pass --db or set DAEMON_DB to the real daemon.db.')
+ process.exit(2)
+ }
+
+ const { Database, error } = loadDatabase()
+ if (error) {
+ console.error('Failed to load better-sqlite3.')
+ if (String(error.message || '').includes('NODE_MODULE_VERSION')) printAbiHelp()
+ else console.error(' ' + error.message)
+ process.exit(3)
+ }
+
+ let db
+ try {
+ db = new Database(dbInfo.path, { readonly: true, fileMustExist: true })
+ } catch (err) {
+ console.error('Could not open DB read-only: ' + (err && err.message))
+ process.exit(3)
+ }
+
+ let a
+ try {
+ a = analyze(db)
+ } finally {
+ db.close()
+ }
+
+ if (a.fatal) {
+ console.error(a.fatal)
+ process.exit(4)
+ }
+
+ if (args.json && !a.empty) {
+ const c = projectCommission(a)
+ const decision = decideVerdict(a, c)
+ // strip the large raw array before serializing
+ const { organicFeesByWalletEpoch, ...rest } = a
+ console.log(JSON.stringify({ db: dbInfo.path, analysis: rest, projection: c, decision }, null, 2))
+ return
+ }
+
+ printHeader(dbInfo)
+ if (a.empty) {
+ printEmpty(a, dbInfo)
+ return
+ }
+ const c = projectCommission(a)
+ const decision = decideVerdict(a, c)
+ printFull(a, c, decision)
+}
+
+main()
diff --git a/src/App.tsx b/src/App.tsx
index 0ea122d8..7a18f80d 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -346,6 +346,17 @@ function App() {
})
}, [])
+ // Reconcile the terminal store when a PTY exits on its own (shell `exit`, agent
+ // CLI crash, external kill). The per-instance handler only prints "[Process
+ // exited]" to xterm; without this, the dead session stays in the store as a
+ // ghost tab with a live dot and writes to it become silent no-ops. App-level so
+ // it fires even while the terminal panel is collapsed.
+ useEffect(() => {
+ return daemon.terminal.onExit((payload) => {
+ useUIStore.getState().reconcileTerminalExit(payload.id)
+ })
+ }, [])
+
useEffect(() => {
return daemon.settings.onUiRecoveryApplied((result) => {
const cleared = result.clearedKeys.length
diff --git a/src/components/CommandDrawer/CommandDrawer.tsx b/src/components/CommandDrawer/CommandDrawer.tsx
index 1fb1fd22..b6ce1316 100644
--- a/src/components/CommandDrawer/CommandDrawer.tsx
+++ b/src/components/CommandDrawer/CommandDrawer.tsx
@@ -5,6 +5,7 @@ import {
ChartLineUp,
ClockCounterClockwise,
CloudArrowUp,
+ Coins,
Cpu,
Database,
DesktopTower,
@@ -128,6 +129,7 @@ const ActivityIcon = createPhosphorIcon(Sparkle)
const AgentStationIcon = createPhosphorIcon(DesktopTower)
const AgentWorkIcon = createPhosphorIcon(Briefcase)
const MeterflowIcon = createPhosphorIcon(Receipt)
+const AgentEconomyIcon = createPhosphorIcon(Coins)
const ClawpumpIcon = ClawpumpGlyph
const DegenToolsIcon = createPhosphorIcon(Sparkle)
const SignalhouseIcon = SignalhouseGlyph
@@ -144,6 +146,7 @@ export const TOOL_ICONS: Record> = {
'agent-station': AgentStationIcon,
'agent-work': AgentWorkIcon,
meterflow: MeterflowIcon,
+ 'agent-economy': AgentEconomyIcon,
'clawpump': ClawpumpIcon,
'degentools': DegenToolsIcon,
'signalhouse': SignalhouseIcon,
@@ -154,7 +157,7 @@ export const TOOL_ICONS: Record> = {
}
// Tool name lookup
-export const TOOL_NAMES: Record = { ...TOOL_DISPLAY_NAMES }
+export const TOOL_NAMES: Record = { ...TOOL_DISPLAY_NAMES, 'agent-economy': 'Agent Economy' }
// Lazy-load all tool components
const loadGitPanel = () => import('../../panels/GitPanel/GitPanel')
@@ -189,6 +192,7 @@ const loadAgentStation = () => import('../../panels/AgentStation/AgentStation')
const loadReplayEngine = () => import('../../panels/ReplayEngine/ReplayEngine')
const loadAgentWork = () => import('../../panels/AgentWork/AgentWork')
const loadMeterflow = () => import('../../panels/Meterflow/MeterflowPanel')
+const loadAgentEconomy = () => import('../../panels/AgentEconomy/AgentEconomyPanel')
const loadClawpump = () => import('../../panels/Clawpump/ClawpumpPanel')
const loadDegenTools = () => import('../../panels/DegenTools/DegenToolsPanel')
const loadSignalhouse = () => import('../../panels/Signalhouse/SignalhousePanel')
@@ -228,6 +232,7 @@ const AgentStationPanel = lazyNamedWithReload('agent-station', loadAgentStation,
const ReplayEngine = lazyNamedWithReload('replay-engine', loadReplayEngine, (m) => m.ReplayEngine)
const AgentWork = lazyNamedWithReload('agent-work', loadAgentWork, (m) => m.AgentWork)
const MeterflowPanel = lazyNamedWithReload('meterflow', loadMeterflow, (m) => m.MeterflowPanel)
+const AgentEconomyPanel = lazyNamedWithReload('agent-economy', loadAgentEconomy, (m) => m.AgentEconomyPanel)
const ClawpumpPanel = lazyNamedWithReload('clawpump', loadClawpump, (m) => m.ClawpumpPanel)
const DegenToolsPanel = lazyNamedWithReload('degentools', loadDegenTools, (m) => m.DegenToolsPanel)
const SignalhousePanel = lazyNamedWithReload('signalhouse', loadSignalhouse, (m) => m.SignalhousePanel)
@@ -264,6 +269,7 @@ export const BUILTIN_TOOLS: DrawerTool[] = [
{ id: 'dashboard', name: 'Dashboard', description: 'Market data and watchlist', icon: DashboardIcon, component: DashboardCanvas, preload: () => { void loadDashboardCanvas() }, category: 'markets', folded: true },
{ id: 'agent-work', name: 'Agent Work', description: 'Wallet-funded agent jobs, receipts, verification, and settlement', icon: AgentWorkIcon, component: AgentWork, preload: () => { void loadAgentWork() }, category: 'agents', folded: true },
{ id: 'meterflow', name: 'Meterflow', description: 'x402 receipts, meters, budgets, and paid agent call readiness', icon: MeterflowIcon, component: MeterflowPanel, preload: () => { void loadMeterflow() }, category: 'agents', folded: true },
+ { id: 'agent-economy', name: 'Agent Economy', description: 'Profiles, spend policy, paid resources, receipts, and devnet identity', icon: AgentEconomyIcon, component: AgentEconomyPanel, preload: () => { void loadAgentEconomy() }, category: 'agents' },
{ id: 'sessions', name: 'Sessions', description: 'Agent session history', icon: SessionsIcon, component: SessionHistory, preload: () => { void loadSessionHistory() }, category: 'dev' },
{ id: 'hackathon', name: 'Hackathon', description: 'Colosseum tracker', icon: HackathonIcon, component: HackathonPanel, preload: () => { void loadHackathonPanel() }, category: 'markets', folded: true },
{ id: 'daemon-ai', name: 'Daemon AI', description: 'AI workbench for chat, runs, approvals, patches, and receipts', icon: DaemonAIIcon, component: DaemonAIPanel, preload: () => { void loadDaemonAIPanel() }, category: 'agents' },
diff --git a/src/components/Panel/ModelDropdown.tsx b/src/components/Panel/ModelDropdown.tsx
index 9f5f728c..09567dbd 100644
--- a/src/components/Panel/ModelDropdown.tsx
+++ b/src/components/Panel/ModelDropdown.tsx
@@ -10,6 +10,14 @@ const LANE_LABEL: Record = {
premium: 'claude-opus-4.8',
}
+const CODEX_LANE_LABEL: Record = {
+ auto: 'codex-auto',
+ fast: 'o4-mini',
+ standard: 'gpt-5.5',
+ reasoning: 'gpt-5.5',
+ premium: 'gpt-5.5',
+}
+
const FALLBACK_LANES: DaemonAiModelLane[] = ['auto', 'fast', 'standard', 'reasoning', 'premium']
/** Lane selector for the ARIA agent. Mirrors the composer's "claude-sonnet-4.6 ▾". */
@@ -17,8 +25,10 @@ export function ModelDropdown({ className }: { className?: string }) {
const selectedLane = useAriaStore((s) => s.selectedLane)
const setLane = useAriaStore((s) => s.setLane)
const models = useAriaStore((s) => s.availableModels)
+ const provider = useAriaStore((s) => s.providerPreferences?.aria.provider ?? 'claude')
const lanes = models.length ? models.map((m) => m.lane) : FALLBACK_LANES
+ const labels = provider === 'codex' ? CODEX_LANE_LABEL : LANE_LABEL
return (