diff --git a/.bg-shell/manifest.json b/.bg-shell/manifest.json new file mode 100644 index 0000000..09f370e Binary files /dev/null and b/.bg-shell/manifest.json differ diff --git a/CHANGELOG.md b/CHANGELOG.md index 145526d..4b44418 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,43 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this ## [Unreleased] +## [0.4.0] - 2026-04-18 + +### Added + +- **`arc chat` CLI** — interactive REPL with streaming responses, tool use, and permission modes (`read-only` / `supervised` / `autonomous`). REPL slash commands: `/exit`, `/save`, `/new`, `/mode`, `/clear`, `/sessions`, `/resume`, `/help`. One-shot mode via `--once ""`. +- **`ChatSession` per-profile persistence** — sessions stored at `~/.arc/profiles//chat-sessions/.json` with atomic writes; `--session ` / `/resume ` to pick up where you left off. +- **Roundtable orchestrator** — `RoundtableOrchestrator` class drives the existing roundtable hook over multiple profiles with adaptive delivery pacing (EMA latency) and a designated synthesizer returning a consensus score. Ported from Agent-Forge. +- **Staged workflow state machine** — `StagedWorkflowManager` implements PLAN → EXEC → VERIFY with per-phase completion patterns and timeouts. +- **Agent stall watchdog** — `AgentWatchdog` nudges agents at 3 min, marks them stalled at 5 min, and runs a decision protocol (ported from Agent-Forge). +- **Agent client abstraction** — `packages/core/src/agent-client/` one-shot CLI invocation for Claude / Codex / Gemini with MCP config injection per `mcpMode` variant and per-tool stream parsers. +- **Tool registry + agent loop** — `packages/core/src/agent/` with three permission modes and ~16 ARC tools (11 read, 4 write, 1 dangerous) wired to existing handlers. +- **Knowledge endowment** — `packages/core/src/knowledge/` with static ARC catalog (architecture + 52-entry command reference + 16-term glossary), 33-entry feature index, and `buildSystemPrompt()` runtime composer under 4K tokens (~1.3K typical). +- **Launch modes** — `launchMode: "native" | "worker"` field on Profile (default `native`). `--native` / `--worker` CLI overrides. TUI `m` key in ProfilesView toggles. Doctor check for deprecated `CLAUDE_CODE_NO_FLICKER` env var. +- **Bare launch** — `arc run ` and `arc launch --bare ` skip the ARC overlay entirely (no env injection, no hook pipeline). Tool-name inference falls through to bare when no matching profile exists. +- **Clearable active profile** — `arc profile switch none` and `arc profile clear-active` set `activeProfile` to `null`. Rendered as `(none)` in CLI and TUI. +- **Agent instructions** — `instructions` / `instructionsFile` fields on Profile, injected as `ARC_AGENT_INSTRUCTIONS` env var at launch. `arc instructions` CLI: `show` / `set` / `edit` / `clear`. +- **OpenAI-compatible providers** — `openai-compat` auth type + `ProviderConfig` (`baseUrl`, `model`, `apiKeyEnvVar`, `displayName`) on Profile; 7 presets (OpenRouter, Ollama, LM Studio, Together, Groq, MiniMax, DeepSeek). `arc provider` CLI: `set` / `show` / `clear` / `presets`. +- **Backup / export / import** — `arc backup create/restore/list` for a gzipped `~/.arc/` archive (credentials excluded by default); `arc profile export` / `arc profile import-file` for single-profile transport with inlined instructions. +- **Profile cloning** — `cloneProfile()` core function + `arc profile clone [--no-copy-dir]` CLI + `Shift+C` inline clone in ProfilesView. +- **Launch history** — `~/.arc/history.json` records each launch (profile, tool, timestamp, outcome, exitCode); DashView shows recent launches + activity log entries. +- **Toast notifications** — `ToastProvider` + `useToast()` with auto-dismiss (2.5 s); mounted globally in the Dashboard. +- **Interactive sidebar queue** — Enter on a profile row in the Sidebar quick-launches without switching views. + +### Documentation + +- `user-docs/guide/chat.md` — Chat Guide (quickstart, permission modes, REPL commands, session persistence, known limitations). +- `user-docs/guide/roundtable.md` — Running Roundtables (concepts, programmatic API, adaptive pacing, worker-mode requirement). +- `user-docs/guide/multi-agent-pipelines.md` — PLAN → EXEC → VERIFY state machine with completion patterns and timeouts. +- `user-docs/architecture/index.md` — extended "Agent Client + Chat + Orchestration" section. + +### Coming in 0.4.x / 0.5.x + +- `arc roundtable` CLI with streaming transcript and per-agent color coding (Phase 6). +- MCP tools: `arc_chat`, `arc_roundtable`, and the 6-tool `team_*` contract (Phase 6). +- Dashboard chat view with per-session WebSocket streaming and tool-call visualization (Phase 7). +- Dashboard roundtable + pipelines views (Phase 8). + ## [0.2.0] - 2026-04-03 All 25 phases of the [v2.0 spec](./docs/spec/SPEC.md) are now implemented. ARC has evolved from a profile manager into a unified agent runtime control plane, absorbing the [Axiom-Supervisor](https://github.com/Codename-11/axiom-supervisor) project. @@ -186,6 +223,7 @@ All 25 phases of the [v2.0 spec](./docs/spec/SPEC.md) are now implemented. ARC h - **Light mode contrast** — WCAG AA compliant dimmed/border colors, explicit `colors.text` on import hint - **React hooks violation** — `useScreenSize()` moved above conditional returns in DashView -[Unreleased]: https://github.com/Codename-11/ARC/compare/v0.2.0...HEAD +[Unreleased]: https://github.com/Codename-11/ARC/compare/v0.4.0...HEAD +[0.4.0]: https://github.com/Codename-11/ARC/compare/v0.2.0...v0.4.0 [0.2.0]: https://github.com/Codename-11/ARC/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/Codename-11/ARC/releases/tag/v0.1.0 diff --git a/CLAUDE.md b/CLAUDE.md index b0c9af7..22ffd14 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,8 +29,17 @@ ARC (Agent Runtime Control) is a CLI + TUI for managing multiple agent profiles - **Landing site:** `site/` — React 19 + Vite + Tailwind v4, Nothing-design marketing page - **Deployment:** Root `Dockerfile` + `nginx.conf` — multi-stage build merging `site/` at `/` and `user-docs/` at `/docs/` into single nginx container - **Web Dashboard:** 13 view components (Overview, Sessions, Traces, Risk, Tasks, Skills, Memory, Agents, Factory + Profiles, Diagnostics, Sync, Plugins) -- **Orchestration layer:** Hook pipeline (8 hooks in priority order), roundtable multi-agent discussions, task delegation protocol, interagent routing, source classification -- **Adapters:** Claude Code (SDK + plugin + hooks), Codex CLI, Gemini CLI, OpenClaw (native plugin), Hermes Agent (MCP bridge), Generic (fallback for any tool) +- **Orchestration layer:** Hook pipeline (8 hooks in priority order), roundtable multi-agent discussions, task delegation protocol, interagent routing, source classification, `arc chat` REPL, `RoundtableOrchestrator`, `StagedWorkflowManager` (PLAN/EXEC/VERIFY), `AgentWatchdog` (stall detection) +- **Adapters:** Claude Code (SDK + plugin + hooks), Codex CLI, Gemini CLI, OpenClaw (native plugin), Hermes Agent (MCP bridge), OpenAI Compatible (custom providers), Generic (fallback for any tool) +- **Agent instructions:** `instructions` / `instructionsFile` fields on Profile; resolved at launch, injected as `ARC_AGENT_INSTRUCTIONS` env var; `arc instructions` CLI for show/set/edit/clear +- **Custom providers:** `openai-compat` auth type + `ProviderConfig` (baseUrl, model, apiKeyEnvVar) on Profile; 7 presets (OpenRouter, Ollama, LM Studio, Together, Groq, MiniMax, DeepSeek); `arc provider` CLI for set/show/clear/presets +- **Launch modes:** `launchMode: "native" | "worker"` on Profile (default `native`). Native uses full TTY handoff so the tool paints its own TUI; worker uses `spawnManagedProcess` for ARC-supervised orchestration. CLI flags `--native` / `--worker` override. TUI: `m` in ProfilesView toggles. Roundtable forces worker regardless. +- **Bare launch:** `arc run ` and `arc launch --bare ` skip ARC overlay entirely (no env injection, no hook pipeline). Tool-name inference falls through to bare when no matching profile exists. `activeProfile` may be `null` — cleared via `arc profile switch none` or `arc profile clear-active`, rendered as `(none)`. +- **Agent client:** `packages/core/src/agent-client/` — CLI-spawn clients for Claude/Codex/Gemini with MCP config injection per `mcpMode` variant and per-tool stream parsers. Substrate for `arc chat` + `RoundtableOrchestrator`. See `docs/plans/ai-and-roundtable.md`. +- **Agent loop + tool registry:** `packages/core/src/agent/` — tool registry with read-only/supervised/autonomous permission modes, `runAgent` generator for tool-use dispatch, ~16 ARC tools wired to existing handlers (list_profiles, clone_profile, switch_active_profile, query_logs, etc.). +- **Knowledge:** `packages/core/src/knowledge/` — static ARC catalog (architecture + 52-entry command reference + 16-term glossary) + 33-entry feature index + `buildSystemPrompt()` runtime composer under 4K tokens. +- **Chat:** `packages/core/src/chat/` — `ChatSession` primitive + per-profile store at `~/.arc/profiles//chat-sessions/` (atomic writes, resume support). Consumed by `arc chat` CLI (`packages/cli/src/commands/chat.ts`). +- **Orchestration:** `packages/core/src/orchestration/` — `RoundtableOrchestrator` (driver over the roundtable hook with adaptive pacing + synthesizer), `StagedWorkflowManager` (PLAN → EXEC → VERIFY with completion patterns + per-phase timeouts), `AgentWatchdog` (3-min nudge / 5-min stall), `AgentDeliveryPolicy` + EMA latency tracking (ported from Agent-Forge). ## Key Conventions diff --git a/FEATURES.md b/FEATURES.md index 248174d..c4b53d5 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -2,6 +2,10 @@ Tracking file for planned features, enhancements, and ideas. Checked items are shipped. See `docs/expansion-ideas.md` for broader product direction and `docs/spec/SPEC.md` for the full v2.0 spec. +## v3 — Daemon + many clients (planned) + +See [docs/plans/arc-v3-daemon.md](./docs/plans/arc-v3-daemon.md) for the full 14-phase plan. Targeting `1.0.0` (breaking). Daemon on :7272 + binary-mux WS protocol + client SDK; TUI/CLI/dashboard/Electron/mobile all become peer clients; E2E-encrypted relay for remote access; SQLite canonical store; provider `extends`; `arc loop`; chat rooms; enhanced roundtable + handoff; Docker server mode. + ## Priority 1 — Core UX Gaps - [x] **Profile creation in TUI** — stepped overlay form (name -> tool -> auth type -> done) so users don't have to exit the TUI to create profiles @@ -15,6 +19,8 @@ Tracking file for planned features, enhancements, and ideas. Checked items are s - [x] **Workspace-aware profile auto-selection** — `arc.json` in repo root specifies preferred profile/tool; workspace overrides applied on launch (Phase 9) - [x] **Workspace shell syntax highlighting** — tokenized input with color-coded `/commands` (green), `@profiles` (blue), `#tags` (dimmed); invalid tokens show in red - [x] **Workspace shell auto-complete** — suggestion overlay for `/` commands and `@profile` mentions; Tab/Enter accepts, arrows navigate, Escape dismisses +- [x] **Launch modes (native / worker)** — `launchMode` field on Profile, `arc launch --native` / `--worker` CLI flags, `m` key toggle in ProfilesView, doctor check for deprecated `CLAUDE_CODE_NO_FLICKER` +- [x] **Bare launch / clearable active profile** — `arc run `, `arc launch --bare `, tool-name inference when no matching profile exists, `arc profile switch none` / `arc profile clear-active`, `activeProfile: null` renders as `(none)` - [ ] **Quick profile switch overlay** — global `Ctrl+S` or palette action that shows a focused profile picker from any view - [x] **Doctor repair actions** — inline install hints, re-auth instructions, and PATH/shell fix hints on actionable diagnostics - [ ] **Profile search/filter** — `/` search in Profiles view and queue for scaling to 10+ profiles @@ -27,19 +33,31 @@ Tracking file for planned features, enhancements, and ideas. Checked items are s - [x] **Tool-adapter architecture** — `RuntimeAdapter` interface with lifecycle methods; Claude, Codex, Gemini, OpenClaw, and Generic adapters (Phase 2, 5-6) - [x] **Profile inheritance** — `inherits` field + `resolveProfile()` engine for base + override resolution (Phase 9) - [x] **Project-local config** (`arc.json`) — preferred tool, profile, workspace overrides per repo (Phase 9) +- [x] **Agent instructions** — `instructions` / `instructionsFile` fields on Profile, resolved at launch, injected as `ARC_AGENT_INSTRUCTIONS` env var; `arc instructions` CLI (show/set/edit/clear) +- [x] **OpenAI-compatible providers** — `openai-compat` auth type + `ProviderConfig` on Profile (baseUrl, model, apiKeyEnvVar); 7 presets (OpenRouter, Ollama, LM Studio, Together, Groq, MiniMax, DeepSeek); `arc provider` CLI (set/show/clear/presets) - [ ] **Team/shared config** — repo-checked config with local secret overlays -- [ ] **Backup/export/import** — move profiles and settings between machines +- [x] **Backup/export/import** — `arc backup create/restore/list` (gzipped archive of `~/.arc/`, credentials excluded by default) + `arc profile export` / `arc profile import-file` (single-profile JSON transport with inlined instructions) - [x] **Managed updates** — self-update system with npm registry check and TUI update banner +- [x] **Agent client foundation** — internal CLI-spawn agent client at `packages/core/src/agent-client/` (Claude/Codex/Gemini), MCP config injection per `mcpMode`, stream parsers. Plan Phase 1 +- [x] **Tool registry + agent loop** — `packages/core/src/agent/` with ~16 ARC tools spanning read / write / dangerous tiers; three permission modes (read-only / supervised / autonomous); `runAgent` generator. Plan Phase 2 +- [x] **Knowledge endowment** — `packages/core/src/knowledge/` system prompt composition (ARC architecture + 52-entry command catalog + 33-entry feature index + 16-term glossary + runtime state). Plan Phase 3 +- [x] **`arc chat` CLI** — terminal REPL using active profile's agent client with streaming output, permission-gated tool calls, per-profile session persistence at `~/.arc/profiles//chat-sessions/`, REPL slash commands. Plan Phase 4 (0.4.0) +- [x] **Roundtable orchestrator** — `RoundtableOrchestrator` driving the existing roundtable hook with adaptive pacing (EMA latency) and synthesizer-driven consensus score. Plan Phase 5 (0.4.0) +- [x] **Staged workflow state machine** — `StagedWorkflowManager` PLAN → EXEC → VERIFY with completion patterns and per-phase timeouts (ported from Agent-Forge) +- [x] **Agent stall watchdog** — nudge at 3 min, mark stalled at 5 min, decision protocol (ported from Agent-Forge) +- [x] **`arc roundtable` CLI + team MCP tools** — `arc roundtable --agents a,b,c` with streaming transcript; `arc_chat` / `arc_roundtable` / 6 `team_*` MCP tools. Plan Phase 6 (0.4.0) +- [x] **Dashboard chat view** — per-session WS streaming, tool-call visualization, permission-mode toggle, confirmation modal. Plan Phase 7 (0.4.0) +- [x] **Dashboard roundtable + pipelines view** — configure + run multi-agent flows from the browser with live transcript; per-run history persisted to `~/.arc/roundtables/.json` and `~/.arc/pipelines/.json`. Plan Phase 8 (0.4.0) ## Priority 4 — Observability & Polish -- [ ] **Launch history on Dash** — recent launches list (`{ profile, tool, timestamp }`) in `~/.arc/history.json`, displayed on Dash after first session +- [x] **Launch history on Dash** — `~/.arc/history.json` records each launch (profile, tool, timestamp, outcome, exitCode); DashView RightColumn shows recent launches + recent activity log entries (polled) - [x] **Shared layer visibility** — SettingsView shows per-profile sync details; ProfileList shows shared indicator column -- [ ] **Toast notifications** — brief auto-dismiss messages for confirmations/errors that work across all views -- [ ] **Interactive sidebar queue** — Enter on sidebar profile list to quick-launch without switching views +- [x] **Toast notifications** — `ToastProvider` + `useToast()` hook with auto-dismiss (2.5s); `ToastContainer` mounted in Dashboard +- [x] **Interactive sidebar queue** — combined nav+profile selection in Sidebar; `↑/↓` cycles through nav items then profiles; Enter on a profile row quick-launches without switching views - [x] **MCP server management** — MCP host manager with connect/disconnect/list/getTools + callTool with risk classification (Phase 8) - [x] **Policy layer** — three-tier permission model (coordinator/interactive/worker) with deny > ask > allow precedence (Phase 20) -- [ ] **Profile cloning/duplication** — create a new profile from an existing one as template +- [x] **Profile cloning/duplication** — `cloneProfile()` core fn + `arc profile clone [--no-copy-dir]` CLI + `Shift+C` inline clone in ProfilesView - [x] **Usage/audit log** — structured JSONL log with `arc logs` CLI, level/component/profile filtering (Phase 3) ## v2.0 Spec Features (All 25 Phases Complete) @@ -53,7 +71,8 @@ Tracking file for planned features, enhancements, and ideas. Checked items are s - [x] OpenClaw adapter (plugin manifest, RuntimeAdapter, 3 lifecycle hooks) - [x] Hermes Agent adapter (MCP bridge, lifecycle, process management) - [x] Generic adapter factory (fallback for any unknown tool, health monitoring) -- [x] 48 adapter registry + generic adapter tests +- [x] OpenAI Compatible adapter (custom provider endpoints, 7 presets) +- [x] 50+ adapter registry + generic adapter tests ### Logging & Lifecycle (Phases 3-4) - [x] Structured JSONL log at `~/.arc/logs/structured.jsonl` @@ -210,8 +229,3 @@ These items from the original v0.1 backlog are still open: - [ ] Profile search/filter in Profiles view - [ ] Environment preview before launch - [ ] Team/shared config (repo-checked config with local secret overlays) -- [ ] Backup/export/import (move profiles between machines) -- [ ] Launch history on Dash -- [ ] Toast notifications -- [ ] Interactive sidebar queue -- [ ] Profile cloning/duplication diff --git a/README.md b/README.md index 7a5fe47..b875c7d 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ One binary. One config directory (`~/.arc/`). Every agent runtime — Claude Cod | Layer | Capabilities | |-------|-------------| +| **Chat** | Chat interactively with your profile's model, with tool use and permission gates — `arc chat` | | **Identity** | Named profiles, credentials, auth (OAuth/API key/Bedrock/Vertex/Foundry), OS keyring, env isolation | | **Launch** | Tool detection, shell shims, per-profile flags, workspace-aware auto-selection (`arc.json`) | | **Adapters** | Claude Code (SDK bridge + hooks + plugin), Codex CLI, Gemini CLI, OpenClaw, Generic (MCP/HTTP) | @@ -137,6 +138,16 @@ See [Getting Started](https://arc-cli.dev/docs/guide/getting-started) for requir ## Quick Start +**Fastest path — no profile:** + +```bash +arc run claude # Native passthrough — no env injection, no overlay +arc run gemini +arc run codex +``` + +**Full path — with a profile:** + ```bash arc # Open TUI — onboarding wizard on first run ``` @@ -145,11 +156,22 @@ The onboarding wizard auto-detects installed tools (Claude, Gemini, Codex) and o ```bash arc create work --tool claude --auth-type oauth -arc launch work +arc launch work # native by default (full TTY handoff) +arc launch work --worker # under ARC supervision for hooks/orchestration arc use personal arc status ``` +**Chat with your profile:** + +```bash +arc chat # REPL over the active profile, with tool use +arc chat --once "list my profiles" # one-shot, exit when done +arc chat --mode read-only # safe mode — no write-tool calls +``` + +The model is your profile's CLI tool (Claude, Codex, or Gemini). ARC composes a system prompt from its knowledge layer, streams the response back, and dispatches any ARC tool calls the model makes. See the [Chat Guide](https://arc-cli.dev/docs/guide/chat). + ## Screenshots | | | @@ -172,16 +194,33 @@ arc use # Switch active profile arc profile show [name] # Show profile details arc profile delete # Delete a profile arc profile import # Import existing tool config +arc profile switch none # Clear the active profile +arc profile clear-active # Same — activeProfile becomes null arc which # Show resolved profile source ``` ### Launch ```bash -arc launch [name] # Launch agent tool with profile +arc run # Native passthrough (no profile, no overlay) +arc launch [name] # Launch agent tool with profile (native by default) +arc launch [name] --native # Force full TTY handoff +arc launch [name] --worker # Force ARC-supervised mode (for orchestration) +arc launch --bare # Same as `arc run` arc launch [name] -- --model opus # Pass flags through to the tool ``` +### Chat + +```bash +arc chat # Interactive REPL over the active profile +arc chat --once "" # One-shot, exit when done +arc chat --profile # Override the active profile +arc chat --mode read-only # Forbid any write tools +arc chat --session # Resume a prior session +arc chat --new # Start fresh +``` + ### Dashboard ```bash diff --git a/docs/plans/ai-and-roundtable.md b/docs/plans/ai-and-roundtable.md new file mode 100644 index 0000000..b7bd96d --- /dev/null +++ b/docs/plans/ai-and-roundtable.md @@ -0,0 +1,455 @@ +# Plan: AI Chat + Full Roundtable Integration + +**Status:** ✅ All 10 phases complete (0, 0.5, 0.7, 1–9) — shipped in 0.4.0 on 2026-04-18 +**Last updated:** 2026-04-18 +**Owner:** Bailey + +## Decisions (approved 2026-04-18) + +1. **Permission default:** `supervised` — writes require confirmation, dangerous tools allowed with explicit confirm. +2. **Backend approach (revised):** **CLI-spawn, not HTTP.** The dashboard AI and roundtable orchestrator spawn the profile's actual CLI tool (`claude`, `codex`, `gemini`) as a child process — same pattern as Agent-Forge. Prompts are delivered via configured input method (sendKeys / pasteFromFile / direct arg); responses captured via stdout. Tool use flows through MCP injected at launch (the three `mcpMode` variants). **No direct HTTP LLM client is built** — we orchestrate the existing agents' own tool use. +3. **License:** Agent-Forge is Bailey's project; copy freely with attribution comments. +4. **Session storage:** per profile — `~/.arc/profiles//chat-sessions/`. +5. **Roundtable composition:** support **both** real-profile agents (each agent = its own ARC profile) and virtual agents (N role-differentiated agents all using the same profile). +6. **Dangerous tool scope:** allowed in dashboard AI with explicit confirm modal; always logged to activity.log regardless of mode. + +--- + +## Goal + +Ship three interlocking capabilities that let end users fully leverage ARC: + +1. **Dashboard AI chat** — a chat panel in the web dashboard that uses the user's chosen ARC profile's provider, has deep knowledge of ARC's features/state/config, and can **act** on ARC (create/clone profiles, configure providers, import/export, run doctor, start roundtables, etc.) via a tool-use layer. +2. **Full roundtable feature** — promote the existing `roundtable` hook from a state-tracking hook into a first-class feature with CLI (`arc roundtable`), MCP tool, and dashboard UI. Preserve the hook's state machine; add the missing orchestrator loop. +3. **Multi-agent pipelines from the dashboard** — UI to configure and run multi-agent flows (roundtable, PLAN→EXEC→VERIFY, consensus gates) with live progress, transcript, and outcome. + +Surfaces required for all three: **CLI + MCP + Dashboard**. + +--- + +## Ground Truth (from recon) + +### What ARC has today + +| Surface | Status | +|---|---| +| Roundtable hook (`packages/core/src/hooks/roundtable.ts`, 580 lines, priority 50) | Production-quality turn/state/mode machinery. Zero test coverage. No driver loop. | +| Interagent routing (bypass during active roundtable) | Working, tested. | +| Adapters (Claude/Gemini/Codex/OpenClaw/Hermes/openai-compat) | Process-spawn only. **No direct LLM calls anywhere.** | +| `ProviderConfig` on Profile (baseUrl, model, apiKeyEnvVar, displayName) | Stored, never used for HTTP calls. | +| `LLMCompleteFn` placeholder type in `completion-auditor.ts:42` | Intentional stub for "future M004 milestone". | +| MCP server (`@axiom-labs/arc-mcp`) | 5 tools: classify_risk, audit_completion, expand_intent, derive_completion, explain_trace. Clean authoring pattern. Stdio + HTTP transport. | +| Dashboard (`packages/dashboard/`) | Raw `node:http` + hand-rolled RFC6455 WS. Vanilla JS SPA. `ws.broadcast()` is all-clients only. Clean route registration pattern. | +| Dark Factory Mode | State machine (idle→planning→executing→verifying→gating→completed) exists but is disconnected from roundtable. | + +### What Agent-Forge has (at `C:\Users\Bailey\Desktop\Open-Projects\agent-forge`) + +| Component | Port decision | +|---|---| +| `AgentDeliveryPolicy` + `computeAdaptiveGraceMs` + `updateReplyLatencyAverage` (`lib/agent-delivery.ts`) — model-aware adaptive pacing with EMA latency tracking | **Port verbatim** — zero deps, pure functions, directly useful | +| `StagedWorkflowManager` (`lib/staged-workflow.ts`) — PLAN→EXEC→VERIFY state machine with cursor-based message polling | **Port** as generic pipeline primitive | +| `AgentWatchdog` (`lib/agent-watchdog.ts`) — stall detection, nudge at 3min, mark stalled at 5min, decision messages | **Port** adapted to ARC's process model | +| 6-tool MCP contract: `team_say` / `team_read` / `team_status` / `team_done` / `team_plan` / `team_ask` | **Port contract**, reimplement on `@modelcontextprotocol/sdk` | +| `agents.json` + `mcpMode` variants (`config-file` / `mcp-add` / `config-args`) | **Absorb as tribal knowledge** into ARC adapter layer | +| `collab-templates.json` (4 role templates) | **Reimplement** as ARC roundtable templates | +| REST server, React dashboard, RBAC, tmux runtime | **Skip** — wrong fit | + +### Critical Gap + +**Neither ARC nor Agent-Forge has a direct LLM client.** Every feature the user wants (chat, headless roundtable, consensus pipelines) requires building one. That is the blocker for all phases. + +--- + +## Architectural Decisions + +### AD-1: CLI-spawn agent client (revised) + +**Decision:** do not build a direct HTTP LLM client. Instead, build an `AgentClient` abstraction that spawns the profile's CLI tool (`claude`, `codex`, `gemini`) with an input prompt and captures its response — the Agent-Forge pattern. + +Why: +- ARC's whole investment is in CLI adapters; reuse it. +- The agent tools already have their own streaming, tool use, and MCP integration — we orchestrate, not reimplement. +- MCP is the clean interop surface: inject ARC's tool server at spawn time, every agent (Claude / Codex / Gemini) can call ARC tools through the same contract. +- No per-provider auth reinvention. OAuth/API keys are already resolved by the native CLI. + +New module: `packages/core/src/agent-client/`. + +```typescript +interface AgentClient { + // One-shot: send a prompt, stream response until the agent signals done + send(prompt: string, opts?: { + mcpConfig?: McpConfigInjection; + instructions?: string; + signal?: AbortSignal; + }): AsyncIterable; + shutdown(): Promise; +} + +type AgentChunk = + | { type: "text"; content: string } + | { type: "tool_call"; tool: string; input: unknown } + | { type: "tool_result"; tool: string; result: unknown } + | { type: "done"; reason: "end_turn" | "max_turns" | "stop" }; +``` + +Three implementations, one per tool, each derived from Agent-Forge's `agents.json` entries: +- **`ClaudeAgentClient`** — `claude` binary, `--mcp-config ` injection, stdout parser +- **`CodexAgentClient`** — `codex` binary, `-c mcp.servers.arc={json}` injection +- **`GeminiAgentClient`** — `gemini` binary, `gemini mcp add` pre-launch + +Dispatcher: `getAgentClientForProfile(profile): AgentClient` — picks by `profile.tool`. + +Input delivery methods ported from Agent-Forge (`inputMethod` field): +- `sendKeys` — line-by-line stdin write +- `pasteFromFile` — write prompt to temp file, send `/paste ` command +- `direct` — pass as CLI arg (one-shot non-TUI mode) + +For the first cut, use each tool's **one-shot non-TUI mode** where possible (`claude -p ""`, `gemini -p ""`, `codex exec --json`). This sidesteps TUI capture complexity. Upgrade to persistent TTY sessions in a later phase if needed for multi-turn roundtables. + +### AD-2: Tool Registry + agent loop + +Separate from the LLM client: + +```typescript +interface Tool { + name: string; + description: string; + schema: z.ZodSchema; + permission: "read" | "write" | "dangerous"; + handler: (input: unknown, ctx: ToolContext) => Promise; +} + +class ToolRegistry { + register(tool: Tool): void; + getSchemas(filter?: (t: Tool) => boolean): ToolDefinition[]; + async execute(name: string, input: unknown, ctx: ToolContext): Promise; +} +``` + +Agent loop (`runAgent(client, registry, prompt, mode)`): +1. Send prompt + tool schemas to client +2. For each chunk: if text → emit; if tool_use → execute via registry, gate by permission mode, append tool_result to conversation, loop +3. Stop on end_turn + +Three permission modes: +- `read-only` — only `read` tools available +- `supervised` (default) — `write` tools require user confirmation via UI; `dangerous` tools always blocked +- `autonomous` — all tools available, all writes logged to `activity.log` + +### AD-3: ARC tool set + +Core tools (all map to existing CLI handlers or core functions): + +**Read:** `list_profiles`, `show_profile`, `get_active_profile`, `list_launches`, `query_logs`, `doctor_report`, `list_mcp_servers`, `list_skills`, `list_memories`, `list_tasks`, `list_remote_agents`, `get_arc_feature` (returns info about any ARC feature from a bundled knowledge index) + +**Write:** `create_profile`, `clone_profile`, `switch_active_profile`, `set_profile_flags`, `set_instructions`, `configure_provider`, `backup_create`, `profile_export`, `profile_import`, `mcp_connect`, `delegate_task` + +**Dangerous:** `delete_profile`, `backup_restore`, `prune`, `mcp_tool_call` (calling arbitrary MCP tools) + +**Meta:** `start_roundtable`, `run_pipeline` (PLAN→EXEC→VERIFY) + +### AD-4: Roundtable as hook + orchestrator + +Keep the existing hook. Add `RoundtableOrchestrator` that: + +1. Accepts `{ topic, agents: { profile, role }[], rounds, synthesizer }` +2. Initializes state via existing hook (triggering with `@roundtable` prefix) +3. Loops: read current turn from `RoundtableState`, get that profile's `LlmClient`, call with built prompt (role + transcript so far), post response back into `HookBus.runPost()` to advance state +4. On state transition to `"synthesizing"`: call designated synthesizer with structured prompt requesting consensus score + summary +5. Returns `{ transcript, synthesis, consensus: 0-1, durationMs }` + +Uses Agent-Forge's `AgentDeliveryPolicy` for between-turn pacing. + +### AD-5: Dashboard per-session streaming + +Extend WS server: +- Add `sessionId` negotiation on connect (client sends `{ type: "hello", sessionId: "uuid" }`) +- `ws.broadcastTo(sessionId, event, data)` method +- `ws.broadcast()` preserved (no sessionId filter = all clients) + +Chat streaming uses `broadcastTo` — text chunks stream to only the originating session. Roundtable runner uses `broadcast` — all viewers see live progress. + +### AD-6: Knowledge endowment + +Build-time + runtime system prompt composition: + +**Static** (baked at build): +- ARC purpose + architecture summary (~300 words) +- Command reference (extracted from `cli.ts` via codegen, ~50 commands × one-line desc) +- Tool catalog (auto-generated from `ToolRegistry`) +- Links to doc pages (for the AI to cite) + +**Runtime** (per chat session): +- Active profile + provider + model +- Profile count, last 3 launches, any failing doctor checks +- Current ARC version +- Warning if shared layer has unresolved conflicts + +No embeddings, no vector DB. Scope is bounded enough that a well-curated prompt beats retrieval. + +### AD-7: License + porting hygiene + +- Check `agent-forge/LICENSE` before copying any code. If MIT/Apache-compatible: copy with attribution comment pointing to upstream file path. If GPL or proprietary: reimplement from the design, not the code. +- Put ported code in clearly-named files (`packages/core/src/orchestration/delivery-policy.ts`) with a top-of-file comment: `// Ported from agent-forge/lib/agent-delivery.ts — see docs/plans/ai-and-roundtable.md AD-7`. + +--- + +## Phased Delivery + +### Phase 0 — Scaffolding +**Deliverables:** +- Create `packages/core/src/agent-client/` + `packages/core/src/orchestration/` + `packages/core/src/knowledge/` with placeholder index files +- Port `agents.json` → `packages/core/src/agent-client/registry.ts` as a typed constant (Claude, Codex, Gemini entries with `command`, `flags`, `readyMarker`, `inputMethod`, `mcpMode`, `promptDelivery`) +- Stub `AgentClient` interface in `types.ts` + +**Exit criteria:** directory structure + types in place, clean build + +--- + +### Phase 0.5 — Launch hygiene (native vs orchestrated) + +**Context:** Currently adapters use `spawnManagedProcess()` which captures stdout for monitoring — this puts tools in "worker mode" and prevents their native TUI chrome (e.g., Claude's statusLine) from rendering. Users need the option to launch a tool in its full native experience. + +**Deliverables:** +- [ ] Add `launchMode?: "native" | "worker"` to `Profile` type (default `"native"`) +- [ ] `native` mode: use `spawnSync` with inherited stdio (full TTY handoff, ARC TUI exits) — same as the existing fallback path in `launch.ts:511-526` +- [ ] `worker` mode: keep existing `spawnManagedProcess` path (for roundtable, team sessions, programmatic orchestration) +- [ ] `arc launch --native` / `--worker` CLI flags override profile setting +- [ ] Doctor check: detect deprecated `CLAUDE_CODE_NO_FLICKER=1` in env, warn + hint "v2.1.110+ uses `/tui fullscreen` — unset this var" +- [ ] ProfilesView: show launch mode in detail pane; `m` key toggles native/worker +- [ ] Update docs (`user-docs/profiles.md`) with the two modes + +**Acceptance:** +- `arc launch claude-profile` (native default) → Claude paints its own TUI with statusLine +- `arc launch claude-profile --worker` → Claude runs under ARC supervision for orchestration +- Roundtable orchestrator (Phase 5) forces worker mode regardless of profile setting +- Doctor flags stale `CLAUDE_CODE_NO_FLICKER` + +**Non-blocking:** can ship independently of the rest of the plan. + +--- + +### Phase 1 — Agent client (CLI-spawn) foundation +**Deliverables:** +- [ ] `packages/core/src/agent-client/types.ts` — `AgentClient`, `AgentChunk`, `McpConfigInjection`, `InputMethod` +- [ ] `packages/core/src/agent-client/claude.ts` — one-shot mode: `claude -p "" --output-format stream-json --mcp-config `; line-parse `stream-json` output into `AgentChunk` +- [ ] `packages/core/src/agent-client/codex.ts` — one-shot mode: `codex exec --json` with prompt on stdin; parse JSON event stream +- [ ] `packages/core/src/agent-client/gemini.ts` — one-shot mode: `gemini -p ""`; plain text capture (no structured tool events — tool use surfaced via MCP server side-channel) +- [ ] `packages/core/src/agent-client/dispatch.ts` — `getAgentClientForProfile(profile): AgentClient` +- [ ] `packages/core/src/agent-client/mcp-injection.ts` — writes temp MCP config per `mcpMode` variant +- [ ] Unit tests: mock child process, verify prompt delivery + chunk parsing for each client +- [ ] Export from `packages/core/src/index.ts` + +**Acceptance:** +- With a Claude profile + API key or OAuth, `agentClient.send("list 3 facts about TypeScript")` yields text chunks and a `{type:"done"}` terminator +- Same for Codex and Gemini profiles +- MCP config injection writes to the right location per agent (validated by inspecting the temp file) +- Typecheck + build + tests clean + +**Blocks:** Phases 2, 4, 5, 6, 7, 8 + +--- + +### Phase 2 — Tool registry + agent loop +**Deliverables:** +- [ ] `packages/core/src/agent/tools.ts` — `Tool`, `ToolRegistry`, `ToolContext` +- [ ] `packages/core/src/agent/loop.ts` — `runAgent(client, registry, ctx)` generator +- [ ] `packages/core/src/agent/arc-tools.ts` — ARC tool definitions (list_profiles, clone_profile, etc.) wired to existing handlers +- [ ] Permission gating: `read-only` / `supervised` / `autonomous` modes with confirmation callback +- [ ] Unit tests for loop: mock client emitting tool_use, verify registry dispatch + result injection + +**Acceptance:** +- Agent loop can answer "what profiles do I have?" using `list_profiles` tool +- Supervised mode blocks `clone_profile` until confirm callback returns true +- 20+ ARC tools wired and callable + +--- + +### Phase 3 — Knowledge endowment +**Deliverables:** +- [ ] `packages/core/src/knowledge/index.ts` — static knowledge object (ARC purpose, architecture, command ref, doc links) +- [ ] `scripts/build-command-ref.js` — codegen script reading `cli.ts` to extract commands into a TS constant (run in `prebuild`) +- [ ] `packages/core/src/knowledge/runtime.ts` — `buildSystemPrompt(ctx)` composing static + live state snapshot +- [ ] `packages/core/src/knowledge/feature-index.ts` — structured feature catalog from FEATURES.md + `get_arc_feature` tool implementation + +**Acceptance:** +- System prompt is deterministic, reproducible, under 4000 tokens +- Live snapshot section reflects current config within 10s of change + +--- + +### Phase 4 — CLI surface: `arc chat` +**Deliverables:** +- [ ] `packages/cli/src/commands/chat.ts` — interactive terminal chat using `readline`, streams to stdout +- [ ] Flags: `--profile ` (override active), `--mode read-only|supervised|autonomous`, `--once ` (one-shot), `--no-tools` +- [ ] CLI registration in `cli.ts` +- [ ] Integration test: one-shot mode with a fake LLM client + +**Acceptance:** +- `arc chat` opens REPL using active profile's LLM client +- `arc chat --once "list my profiles"` returns tool-call-driven answer and exits +- Supervised mode shows confirmation prompts in terminal + +--- + +### Phase 5 — Roundtable orchestrator +**Deliverables:** +- [ ] `packages/core/src/orchestration/delivery-policy.ts` — port `AgentDeliveryPolicy` + `computeAdaptiveGraceMs` + EMA latency +- [ ] `packages/core/src/orchestration/staged-workflow.ts` — port `StagedWorkflowManager` (PLAN/EXEC/VERIFY) +- [ ] `packages/core/src/orchestration/roundtable.ts` — `RoundtableOrchestrator` driving the existing hook +- [ ] Watchdog port: `packages/core/src/orchestration/watchdog.ts` +- [ ] Tests: roundtable with 3 mocked agents, state progression, synthesis, consensus score +- [ ] First tests for the roundtable hook itself (fill the coverage gap) + +**Acceptance:** +- `RoundtableOrchestrator.run({ topic, agents, rounds: 2 })` produces a full transcript + synthesis with consensus float +- Adaptive pacing reduces throttling for fast providers +- Roundtable hook now has ≥ 80% line coverage + +--- + +### Phase 6 — `arc roundtable` CLI + MCP tools +**Deliverables:** +- [ ] `packages/cli/src/commands/roundtable.ts` — `arc roundtable --agents --rounds 2` +- [ ] Streaming transcript to terminal with per-agent color coding +- [ ] `packages/mcp/src/tools/roundtable.ts` — `arc_roundtable` MCP tool +- [ ] `packages/mcp/src/tools/chat.ts` — `arc_chat` MCP tool (one-shot, no streaming) +- [ ] `packages/mcp/src/tools/team/` — port 6-tool contract (`team_say`, `team_read`, etc.) for inter-agent comms in team sessions + +**Acceptance:** +- `arc roundtable "should we rewrite X?" --agents fast-opus,claude-sonnet,codex` produces usable transcript +- MCP inspector shows new tools; invoking them works end-to-end +- Existing 5 MCP tools still pass integration tests + +--- + +### Phase 7 — Dashboard chat view +**Deliverables:** +- [ ] `packages/dashboard/src/ws.ts` — add `sessionId` negotiation + `broadcastTo(sessionId, event, data)` +- [ ] `packages/dashboard/src/api.ts` — new `POST /api/chat/message` endpoint; emits chunks via `broadcastTo` +- [ ] `packages/dashboard/public/components/chat.js` — chat view with message list, streaming incoming chunks, tool-call visualization +- [ ] Sidebar: add "Chat" item +- [ ] Settings panel: permission mode toggle (`read-only` / `supervised` / `autonomous`) +- [ ] Confirmation modal for supervised writes + +**Acceptance:** +- End user opens dashboard, picks a profile, chats about ARC +- Tool calls render as expandable panels showing input + result +- Clone/export/backup actions work through chat with confirmations +- Session history persists across page reload (stored in `~/.arc/chat-sessions.json`) + +--- + +### Phase 8 — Dashboard roundtable + pipelines view +**Deliverables:** +- [ ] `packages/dashboard/public/components/roundtable.js` — configure roundtable (topic, agents from profile picker, rounds), start, watch live transcript, see synthesis +- [ ] `packages/dashboard/public/components/pipelines.js` — configure staged workflow (PLAN→EXEC→VERIFY), watch phase progression, see phase messages +- [ ] `POST /api/roundtable/run` + `POST /api/pipeline/run` endpoints with WS broadcast updates +- [ ] Persist past runs to `~/.arc/roundtables/.json` and `~/.arc/pipelines/.json` with a history list + +**Acceptance:** +- User configures + runs a 2-round roundtable from dashboard +- Live updates via WS, no polling +- History view shows past runs with result summary + +--- + +### Phase 9 — Docs + polish +**Deliverables:** +- [ ] `user-docs/` page: "AI Chat Guide" (what it can do, permission modes, safety) +- [ ] `user-docs/` page: "Running Roundtables" (CLI + dashboard examples) +- [ ] `user-docs/` page: "Multi-Agent Pipelines" (PLAN/EXEC/VERIFY pattern) +- [ ] FEATURES.md updates: mark new items shipped +- [ ] DEVLOG.md entry summarizing design choices +- [ ] Version bump: 0.3.0 → 0.4.0 (minor, new features) + +**Acceptance:** +- Docs buildable, linked from nav +- Version bump consistent across CLI + site + +--- + +## Open Questions — all answered 2026-04-18 + +See **Decisions** section at the top of this doc. + +--- + +## Out of Scope (explicitly) + +- Embedding/vector store for doc retrieval — bounded domain, skip. +- Fine-tuning or custom models — providers handle this upstream. +- Voice chat or image input — text only for v1. +- Dashboard authentication beyond the existing token — chat inherits the dashboard's auth model, no new identity layer. +- Multi-user chat or shared sessions — single-user context. +- Running roundtables across machines — localhost only for v1. Remote agents (Phase 24) already handle cross-machine agent registry but orchestration stays local. + +--- + +## Progress Tracking + +Update checkboxes in-place as phases complete. Add a `Completed YYYY-MM-DD` marker at the bottom of each phase. + +### Phase 0 — Scaffolding +- [x] **Completed 2026-04-18** — folded into Phase 1 commit `6ff876b` + +### Phase 0.5 — Launch hygiene (native vs orchestrated) +- [x] **Completed 2026-04-18** — commit `6ff876b`. `launchMode` field, `--native`/`--worker` flags, doctor check, `m` toggle in ProfilesView, docs section in user-docs/guide/profiles.md + +### Phase 1 — Agent client (CLI-spawn) foundation +- [x] **Completed 2026-04-18** — commit `6ff876b`. `packages/core/src/agent-client/` with Claude/Codex/Gemini clients, MCP injection per mcpMode variant, stream parsers, 48 unit tests. Unverified CLI flags flagged for Phase 4 smoke test. + +### Phase 0.7 — Bare launch + clearable profile +- [x] **Completed 2026-04-18** — commit `443a78c`. `activeProfile: null` valid, `arc run `, `arc launch --bare`, tool-name inference, `arc profile switch none` / `clear-active`, TUI `x` key to clear, empty-state copy in Dash/Session. + +### Phase 2 — Tool registry + agent loop +- [x] **Completed 2026-04-18** — commit `443a78c`. 16 ARC tools wired (11 read, 4 write, 1 dangerous), 3 permission modes, runAgent generator, 43 tests. Tool_result round-trip to LLM deferred to Phase 4 persistent sessions (noted in `loop.ts`). + +### Phase 3 — Knowledge endowment +- [x] **Completed 2026-04-18** — commit `443a78c`. `ARC_KNOWLEDGE` (architecture + 52-entry command catalog + 16-term glossary), `FEATURES_INDEX` (33 entries), `buildSystemPrompt()` composes 6 sections under 4K tokens (~1284 typical). 27 tests. + +### Phase 4 — CLI `arc chat` +- [x] **Completed 2026-04-18** — commit `a14bedc`. `arc chat` REPL with streaming output, `--profile` / `--mode` / `--once` / `--no-tools` / `--session` / `--new` flags. Slash commands (`/exit`, `/save`, `/new`, `/mode`, `/clear`, `/sessions`, `/resume`, `/help`). `ChatSession` primitive + per-profile store at `~/.arc/profiles//chat-sessions/`. Supervised-mode confirm gate, read-only / supervised / autonomous permission modes. O(n²) context replay noted as known limitation. + +### Phase 5 — Roundtable orchestrator +- [x] **Completed 2026-04-18** — commit `a14bedc`. `RoundtableOrchestrator` driving the existing roundtable hook, adaptive pacing + EMA latency, synthesizer-driven consensus score (tolerant JSON parsing). `StagedWorkflowManager` PLAN/EXEC/VERIFY and `AgentWatchdog` ported from Agent-Forge. `launchMode: "worker"` forced for every participating agent. + +### Phase 6 — `arc roundtable` CLI + MCP tools +- [x] **Completed 2026-04-18** — `arc roundtable` CLI (streaming + JSON), `arc_chat` + `arc_roundtable` MCP tools, 6-tool team contract (`team_say/read/status/done/plan/ask`) with in-memory shared bus. MCP server now exposes 13 tools. 4 new integration tests (49 tests pass). Limitation: team tools use process-wide bus — MCP SDK doesn't surface per-call session id. + +### Phase 7 — Dashboard chat view +- [x] **Completed 2026-04-18** — commit `b3c7538`. Per-session WS streaming (`broadcastTo`), `POST /api/chat/message` + confirm/sessions routes, `public/components/chat.js` (session list, streaming message thread, expandable tool-call cards, confirmation modal), 13 new dashboard tests. Vitest config extended to include `packages/*/tests/**`. + +### Phase 8 — Dashboard roundtable + pipelines +- [x] **Completed 2026-04-18** — Roundtable + Pipelines views with live WS progress (`roundtable-event` / `pipeline-event` fan-out via `broadcast`). 6 new REST routes: `POST /api/{roundtable,pipeline}/run`, `GET /api/{roundtable,pipeline}/history` + `/:id`. Atomic persistence to `~/.arc/roundtables/.json` + `~/.arc/pipelines/.json`. Strict UUID validation blocks path traversal. 11 new integration tests. + +### Phase 9 — Docs + polish +- [x] **Completed 2026-04-18** — commit `b3c7538`. New guides: `user-docs/guide/chat.md`, `roundtable.md`, `multi-agent-pipelines.md`. Architecture page updated. FEATURES.md / CLAUDE.md / README.md / CHANGELOG.md updated. Version bumped 0.3.0 → 0.4.0 via `scripts/version.js`. + +--- + +## Risk Register + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| Agent-Forge license incompatibility | Low | Medium | Check before Phase 5; reimplement from design if needed | +| Streaming SSE parsing bugs across providers | Medium | Medium | Test matrix against 3 providers (OpenRouter, Ollama, LM Studio) before Phase 7 | +| Tool schemas grow unwieldy | Medium | Low | Auto-generate from existing zod schemas on CLI commands where possible | +| Dashboard WS broadcast refactor breaks existing views | Low | High | Preserve `broadcast()` as alias for broadcast-to-all; add `broadcastTo()` alongside | +| Chat context window blown by tool results | Medium | Medium | Truncate large tool results (default 4KB); summarize after N turns using context-manager (Phase 19 infra already exists) | +| Roundtable LLM costs balloon | Medium | Low | Default to 2 rounds, surface cost estimate before run, allow `--dry-run` | +| Users abuse autonomous mode, lose data | Low | High | Ship supervised as default; `arc config` flag required to enable autonomous; prominent disclaimer | + +--- + +## References + +- Recon: `C:\Users\Bailey\Desktop\Open-Projects\agent-forge\` (see Ground Truth section) +- Roundtable hook: `packages/core/src/hooks/roundtable.ts` +- Interagent routing: `packages/core/src/hooks/interagent-routing.ts` +- Hook bus: `packages/core/src/hooks/create-default-bus.ts` +- Profile types: `packages/core/src/types.ts:35-59` +- Dashboard server: `packages/dashboard/src/server.ts` +- Dashboard WS: `packages/dashboard/src/ws.ts` +- MCP server: `packages/mcp/src/server.ts` +- MCP tool pattern: `packages/mcp/src/tools/classify-risk.ts` diff --git a/docs/plans/arc-v3-daemon.md b/docs/plans/arc-v3-daemon.md new file mode 100644 index 0000000..c2c2d01 --- /dev/null +++ b/docs/plans/arc-v3-daemon.md @@ -0,0 +1,643 @@ +# Plan: ARC v3 — One daemon, many clients + +**Status:** 📋 Planning — not started +**Target release:** `1.0.0` (v3, breaking) +**Owner:** Bailey +**Last updated:** 2026-04-19 + +## Vision + +ARC becomes a **persistent local daemon** with a single binary WebSocket protocol. The TUI, CLI, web dashboard, Electron desktop app, Android/iOS mobile app, and a self-hosted relay are all thin clients of that daemon. Agents run independently of any UI, survive UI disconnect, and can be controlled from anywhere the relay reaches. + +One daemon. Many mouths. Your dev environment, everywhere. + +--- + +## Decisions (locked 2026-04-19) + +| Decision | Choice | +|---|---| +| Daemon language | **Node/TS** — reuses `packages/core` wholesale | +| Default port | **7272** (TCP, loopback by default) | +| Auth | Shared secret in `~/.arc/auth.json` + per-client tokens rotated on pair | +| Session storage | **SQLite from day one** at `~/.arc/arc.db`, canonical store | +| Relay hosting | **Self-host only** at v3 launch — no hosted `relay.arc.sh` yet | +| v2 compat | **Break freely.** No `--legacy` flag, no v1/v2 message envelopes. | +| Plan doc layout | This single file. | + +--- + +## Ground Truth (current state, 2026-04-19) + +- `packages/dashboard/src/server.ts` + `ws.ts` — raw `node:http` + hand-rolled RFC6455 WS, vanilla JS SPA. This is the **seed of the daemon**. +- `packages/core/src/` — agent adapters, agent-client, orchestration, hooks, knowledge. Process-model only; no long-running service layer. +- `packages/cli/` — Commander.js commands; each invocation is short-lived. +- `src/tui/` (Ink) — owns agent lifecycles today. Process dies → agents die. +- Storage: flat JSON under `~/.arc/` (`config.json`, per-profile session dirs, `history.json`, `activity.log`, `update-check.json`). +- Hooks + orchestration (`RoundtableOrchestrator`, `StagedWorkflowManager`, `AgentWatchdog`) live in `packages/core/src/orchestration/`. All in-process today. + +What's reusable verbatim once daemonised: agent-client, adapters, orchestration, knowledge, hooks, tool registry, runAgent generator. What gets rewritten: the shell that hosts them. + +--- + +## Target architecture + +``` +┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ +│ TUI │ │ CLI │ │ Dashboard│ │ Electron │ │ Mobile │ +│ (Ink) │ │ (short) │ │ (SPA) │ │ (desktop)│ │ (Expo) │ +└────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ + │ │ │ │ │ + └─────────────┴──────┬──────┴─────────────┴─────────────┘ + │ + @axiom-labs/arc-client SDK + │ + ▼ WebSocket binary-mux @ :7272 (local) + │ or via relay (remote, NaCl box) + │ + ┌────────┴────────┐ + │ ARC Daemon │ + │ (packages/ │ + │ daemon) │ + │ │ + │ ┌───────────┐ │ + │ │ Agent Mgr │──┼─► adapters spawn CLIs + │ │ Chat │ │ (claude/codex/gemini/...) + │ │ Orchestr. │ │ + │ │ Hook bus │ │ + │ │ Profile │ │ + │ │ registry │ │ + │ └────┬──────┘ │ + └───────┼─────────┘ + │ + ┌─────────┴──────────┐ + │ ~/.arc/ │ + │ arc.db (SQLite)│ + │ auth.json │ + │ daemon.log │ + │ profiles/… │ + │ shared/… │ + └────────────────────┘ +``` + +--- + +## Phase 0 — Repo prep (1 day) + +- [ ] Create `packages/daemon` + `packages/client` + `packages/relay` workspace entries (empty scaffolds, TS + tsup, wired into pnpm). +- [ ] Create `packages/mobile` and `packages/desktop` placeholders (README stubs only; implementation is Phases 11–12). +- [ ] Add `FEATURES.md` section "v3 Daemon" pointing here. +- [ ] Tag `v0.4.x` branch as `archive/v2` so the pre-daemon code is recoverable. +- [ ] Bump working version to `1.0.0-alpha.0`. + +--- + +## Phase 1 — Daemon skeleton (~1 week) + +**Deliverable:** `arc daemon start` runs a persistent process that serves `/health` on :7272, writes logs to `~/.arc/daemon.log`, and can be stopped cleanly. + +### Files to create + +- `packages/daemon/src/bootstrap.ts` — lifecycle, signal handling, PID file, port bind. +- `packages/daemon/src/config.ts` — `$ARC_HOME` override, port override via `ARC_PORT`. +- `packages/daemon/src/logger.ts` — structured JSONL to `~/.arc/daemon.log` with rotation at 50 MB. +- `packages/daemon/src/db.ts` — SQLite init, migrations, connection pool. +- `packages/daemon/src/db/migrations/001_init.sql` — see schema below. +- `packages/daemon/src/health.ts` — reuses existing `buildHealthReport()`. +- `packages/daemon/src/server.ts` — HTTP + WS bind on :7272. +- `packages/daemon/src/index.ts` — exported `startDaemon()`. + +### CLI surface (added to `packages/cli`) + +```bash +arc daemon start [--port 7272] [--foreground] +arc daemon stop +arc daemon status +arc daemon restart +arc daemon logs [--tail] [--since 10m] +``` + +Auto-start: any `arc` invocation that needs the daemon probes `/health`; if no response, spawns `arc daemon start --detached` and waits up to 5s. + +### SQLite schema (v1) + +```sql +-- Profile state is still config.json-owned (for now); +-- SQLite is for runtime/session data. + +CREATE TABLE IF NOT EXISTS agents ( + id TEXT PRIMARY KEY, + profile TEXT NOT NULL, + cwd TEXT NOT NULL, + status TEXT NOT NULL, -- starting | running | idle | stalled | completed | failed + launch_mode TEXT NOT NULL, -- native | worker + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + completed_at INTEGER, + worktree TEXT, + metadata JSON +); +CREATE INDEX idx_agents_status ON agents(status); +CREATE INDEX idx_agents_profile ON agents(profile); + +CREATE TABLE IF NOT EXISTS agent_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + epoch INTEGER NOT NULL, -- each new run = new epoch, timeline appends + seq INTEGER NOT NULL, -- within epoch + ts INTEGER NOT NULL, + kind TEXT NOT NULL, -- stdout | stderr | tool_call | tool_result | status | error + payload JSON NOT NULL +); +CREATE INDEX idx_events_agent_epoch ON agent_events(agent_id, epoch, seq); + +CREATE TABLE IF NOT EXISTS chat_rooms ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + metadata JSON +); + +CREATE TABLE IF NOT EXISTS chat_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + room_id TEXT NOT NULL REFERENCES chat_rooms(id) ON DELETE CASCADE, + author TEXT NOT NULL, -- agent id or "user" + reply_to INTEGER REFERENCES chat_messages(id), + mentions JSON, -- ["@agent-abc", "@everyone"] + body TEXT NOT NULL, + ts INTEGER NOT NULL +); +CREATE INDEX idx_chat_room_ts ON chat_messages(room_id, ts); + +CREATE TABLE IF NOT EXISTS loops ( + id TEXT PRIMARY KEY, + worker_profile TEXT NOT NULL, + verify_profile TEXT, + verify_check TEXT, + status TEXT NOT NULL, + iteration INTEGER DEFAULT 0, + max_iterations INTEGER, + max_time_ms INTEGER, + started_at INTEGER, + completed_at INTEGER, + archive_path TEXT, + metadata JSON +); + +CREATE TABLE IF NOT EXISTS handoffs ( + id TEXT PRIMARY KEY, + from_agent TEXT, + to_profile TEXT NOT NULL, + template_path TEXT NOT NULL, + created_at INTEGER NOT NULL, + consumed_at INTEGER +); + +CREATE TABLE IF NOT EXISTS clients ( + id TEXT PRIMARY KEY, + label TEXT, -- "Bailey's phone", "laptop-tui" + token_hash TEXT NOT NULL, -- argon2 + paired_at INTEGER NOT NULL, + last_seen INTEGER, + source TEXT NOT NULL -- local | relay +); +``` + +All writes go through a small query layer in `packages/daemon/src/db.ts` — no ORM. + +--- + +## Phase 2 — Wire protocol v1 (~4 days) + +**Deliverable:** versioned, tested WS protocol. All daemon ↔ client traffic flows through it. + +### Frame format + +``` +┌────┬──────┬──────────────────────────────────┐ +│ ch │ flag │ payload (length-prefixed bytes) │ +│ 1B │ 1B │ │ +└────┴──────┴──────────────────────────────────┘ +``` + +- `ch`: + - `0x00` — control (JSON, Zod-validated) + - `0x01` — terminal bytes (raw, for agent PTY streaming) + - `0x02` — file transfer chunks (reserved, Phase 12+) + - `0x03` — audio (reserved, Phase 12+) + - `0x04..0xFF` — reserved +- `flag` bit 0 = fragmented (more frames follow), bits 1–7 reserved. + +Terminal bytes bypass JSON entirely — straight through to the client's renderer. + +### Control message envelope + +```ts +// packages/client/src/protocol.ts +export const Envelope = z.object({ + v: z.literal(1), + id: z.string().uuid(), + type: z.enum([ + "request", "response", "event", "subscribe", "unsubscribe", "error", + ]), + // for request/response + method: z.string().optional(), + params: z.unknown().optional(), + result: z.unknown().optional(), + // for events + topic: z.string().optional(), + payload: z.unknown().optional(), + // for errors + code: z.string().optional(), + message: z.string().optional(), +}); +``` + +### Core methods (v1) + +| Method | Purpose | +|---|---| +| `auth.login` | Bearer token → session id | +| `profile.list` / `profile.get` / `profile.create` / `profile.update` / `profile.delete` / `profile.clone` | Profile CRUD | +| `profile.switch` | Set active profile (or `null`) | +| `agent.run` | Launch an agent: `{profile, prompt?, cwd?, worktree?, launchMode?}` → `{agent_id}` | +| `agent.attach` | Subscribe to terminal + events for `agent_id` | +| `agent.send` | Push input to running agent (stdin or tool-reply) | +| `agent.stop` / `agent.archive` | Terminate / persist + remove | +| `agent.list` | Active + recent agents | +| `chat.post` / `chat.read` / `chat.wait` | Room mailbox (Phase 7) | +| `loop.start` / `loop.status` / `loop.stop` | Worker/verifier loop (Phase 6) | +| `handoff.create` / `handoff.list` | Handoff template (Phase 9) | +| `roundtable.start` / `roundtable.join` | Roundtable (Phase 9) | +| `doctor.run` / `doctor.fix` | Diagnostics | + +### Topics (events) + +- `agent:` — all events for one agent +- `agents` — high-level list churn +- `profiles` — registry changes +- `chat:` — room messages +- `loop:` — loop iterations +- `daemon` — health/status + +### Auth flow + +``` +client → { auth.login, token } +server → { ok, session_id, client_id, server_version } +all subsequent frames must carry session_id in the envelope +``` + +Token generation: `arc daemon pair --label "laptop-tui"` → prints token once. Stored as argon2 hash in `clients` table. + +### Deliverables + +- `packages/daemon/src/ws/` — bind, frame codec, channel router, Zod validation, auth middleware. +- `packages/daemon/src/rpc/` — one file per method domain (`profile.ts`, `agent.ts`, `chat.ts`, ...). Pure functions; hand in DB + runtime. +- `packages/client/src/protocol.ts` — Zod schemas shared. +- Tests: frame codec round-trip, envelope validation, method dispatch, subscription fan-out. + +--- + +## Phase 3 — Client SDK `packages/client` (~3 days) + +**Deliverable:** `@axiom-labs/arc-client`, the single SDK every surface uses. + +- `ArcClient` class: connect, reconnect (exp backoff, jitter), resubscribe on reconnect. +- `call(method, params)` → Promise +- `subscribe(topic, handler)` → unsubscribe fn +- `attachTerminal(agentId, sink)` → pipes channel 1 bytes directly to `sink.write()` +- Typed wrappers per domain: `client.agents.run(...)`, `client.chat.post(...)`. +- Works in Node and browser (isomorphic WS, no Node-only deps). +- Offline queue for control messages (not terminal bytes). +- Token management: reads `~/.arc/auth.json` (Node) or prompts (browser). + +Published to npm as `@axiom-labs/arc-client`. + +--- + +## Phase 4 — Port TUI, CLI, dashboard to the daemon (~2 weeks) + +**Deliverable:** all existing surfaces go through the daemon. No direct adapter spawn from TUI or CLI. + +### TUI + +- Becomes a pure client. `arc` (no args) still opens it. +- Every view that currently reads `~/.arc/config.json` now subscribes to `profiles` + `agents` topics and calls RPC methods. +- Multiple TUIs can attach simultaneously — first user sees no lock. +- **Deleted:** direct adapter calls, in-process hooks, in-process orchestration from TUI. + +### CLI — Docker-style verbs + +| v2 command | v3 command | +|---|---| +| `arc launch ` | `arc run [prompt]` | +| `arc launch --bare ` | `arc run --bare ` | +| (new) | `arc ls` — agent list | +| (new) | `arc attach ` — live stream | +| (new) | `arc send ""` — follow-up | +| (new) | `arc stop ` | +| (new) | `arc archive ` | +| (new) | `arc inspect ` — JSON dump | +| (new) | `arc wait ` — block until done (exit code = agent exit) | +| (new) | `arc --host ` — remote daemon | +| (new) | `arc --relay ` — remote via relay | + +Keep: `arc profile`, `arc provider`, `arc doctor`, `arc chat`, `arc roundtable`, `arc instructions`, `arc backup`, `arc swap`. All reimplemented as client calls. + +### Dashboard + +- Drops its server-side state. All fetches become subscriptions. +- Permission-mode toggle → RPC. +- Reuses existing 13 view components; swap their data sources. + +### Deliverables + +- `packages/cli/src/commands/*.ts` — refactored to use `ArcClient`. +- `packages/dashboard/public/components/*.js` — swap data layer. +- `src/tui/` — swap data layer. +- Smoke test: fresh `~/.arc/`, `arc daemon start`, `arc run claude`, `arc ls`, `arc attach `, `arc stop `. + +--- + +## Phase 5 — Provider `extends` (~2 days) + +**Deliverable:** `ProviderConfig.extends` lets a profile inherit a builtin adapter and override env/models. + +- `ProviderConfig.extends: "claude" | "codex" | "gemini" | "opencode"`. +- `resolveProfile()` merges base + override. +- Ship presets: Z.AI / GLM-4.6, Alibaba Qwen, DeepSeek-via-claude-adapter, `claude-work` vs `claude-personal` multi-account template. +- `arc provider presets` lists them. +- Migrates cleanly: existing `openai-compat` profiles keep working; new profiles can opt into `extends`. + +--- + +## Phase 6 — `arc loop` (~1 week) + +**Deliverable:** worker/verifier iteration loop, cross-provider by default. + +```bash +arc loop run \ + --worker claude-opus \ + --prompt "implement X" \ + --verify-provider codex-gpt5 \ + --verify "does this pass acceptance?" \ + --verify-check "npm test" \ + --max-iterations 10 \ + --max-time 2h \ + --archive +``` + +Semantics: + +1. Spawn worker with prompt. +2. On worker completion, run `verify-check` (shell). If green, done. +3. Else spawn verify agent with the prompt + worker's diff; if verify agent says "good", done. +4. Else feed verify agent's critique back to worker as follow-up, increment iteration. +5. Hit `--max-iterations` or `--max-time` → archive + exit. + +Storage: `~/.arc/loops//` with `meta.json`, `iter-01/worker.log`, `iter-01/verify.log`, etc. Mirrored into `loops` table. + +WS event: `loop:` streams iteration transitions. + +Dashboard view: `/loops` — live list + per-loop transcript. + +--- + +## Phase 7 — Chat rooms primitive (~1 week) + +**Deliverable:** async multi-agent mailbox with mentions. + +### CLI + +```bash +arc chat create +arc chat post "message text" [--as @agent-id] [--reply-to ] +arc chat read [--since 10m] [--mentions-only] +arc chat wait --mentioning @me [--timeout 10m] +arc chat rooms # list +``` + +### Agent integration + +When launched under the daemon, every agent receives `ARC_AGENT_ID` + `ARC_CHAT_ROOMS` (comma-separated rooms it's a member of). An ARC MCP tool `arc_chat_read` / `arc_chat_post` makes the rooms callable from inside agents. + +Mentions: `@`, `@everyone`, `@humans`. Stored in `chat_messages.mentions` JSON. Wait-style subscription fires when a matching mention lands. + +### Why this complements roundtable + +Roundtable is synchronous turn-based consensus. Chat rooms are the async substrate — long-running agents can leave notes, ask questions, get picked up when the other agent is free. Roundtable can optionally use a chat room as its transport for fully-async sessions. + +--- + +## Phase 8 — Worktree as first-class (~3 days) + +**Deliverable:** worktrees are tracked per-agent, archived on completion. + +```bash +arc run --worktree feature-x --base master claude "implement X" +arc worktree ls +arc worktree archive feature-x +arc worktree gc # remove worktrees whose agents are done +``` + +Implementation: `agents.worktree` column stores path + base branch. Daemon creates via `git worktree add` pre-launch, archives or removes on `agent.archive`. Dashboard Profiles view gains a worktree indicator per running agent. + +--- + +## Phase 9 — Enhanced roundtable + handoff (~4 days) + +**Deliverable:** committee mode, handoff command, plan-file-on-disk, self-scheduled heartbeat. + +### Committee mode + +```bash +arc roundtable --mode committee --agents opus-thinking,gpt5-medium \ + "root cause this bug" --no-edits +``` + +Analysis-only. Both agents launched with `--no-edits` wrapper prompt. Kept alive post-plan for drift review (new message triggers re-analysis). + +### Handoff + +```bash +arc handoff [--template full|short] +``` + +Writes `~/.arc/handoffs/.md` with sections: **Task / Context / Relevant Files / Current State / What Was Tried / Decisions / Acceptance Criteria / Constraints**. Pulls context from the source agent's event log. Passes as first prompt to the receiver. + +### Plan-file-on-disk + +`StagedWorkflowManager` writes `~/.arc/plans/.md`. Each phase transition re-reads it. Survives context compaction across long runs. + +### Self-scheduled heartbeat + +Replace `AgentWatchdog` timer-based nudge with a **self-prompt re-entry** via MCP tool `arc_schedule_self_nudge`. Agent schedules its own 5-min re-entry; on fire, daemon re-prompts with plan-file contents. More robust than wall-clock watchdog because context regenerates from disk. + +--- + +## Phase 10 — Relay (`packages/relay`) + remote access (~2 weeks) + +**Deliverable:** self-hosted, E2E-encrypted tunnel. Daemon ↔ client through a zero-knowledge middle. + +### Crypto + +- Daemon keypair: Curve25519 (`libsodium` box). +- Client keypair: Curve25519. +- Pairing: daemon shows QR containing `{relay_url, daemon_pubkey, pair_code, label}`. Client scans, generates keypair, sends pubkey + pair_code to relay, relay holds it for the daemon to pick up. Daemon verifies pair_code, stores client pubkey in `clients` table. +- Traffic: every WS frame payload encrypted with `crypto_box(shared_secret)`. Relay sees only opaque bytes + routing header. + +### Relay server (`packages/relay`) + +- Stateless WebSocket multiplexer. Routes by connection pair id. +- No persistence. No logs of payload content. +- Docker image: `ghcr.io/axiom-labs/arc-relay:`. +- Ships with `docker-compose.yml` example: relay + nginx + certbot. + +### CLI on daemon side + +```bash +arc daemon relay enable --url wss://my-relay.example.com +arc daemon relay disable +arc daemon relay status +arc daemon pair --label "my phone" # prints QR +arc daemon clients # list paired clients +arc daemon revoke +``` + +### CLI on client side + +```bash +arc --host 192.168.1.50:7272 ls # LAN, no relay +arc --relay ls # via relay +``` + +### Security (documented in `SECURITY.md`) + +- Threat model: relay compromise, MITM, replay, DNS rebinding, pair-code leak. +- Mitigations: NaCl box + session nonces, argon2 on tokens, host-header validation, CORS allowlist, 5-min pair-code TTL, per-client revocable tokens. + +--- + +## Phase 11 — Electron wrapper (`packages/desktop`) (~1 week) + +**Deliverable:** `.dmg`, `.exe`, `.AppImage`. Auto-starts daemon on launch; dashboard UI served inside. + +- Thin Electron shell. No business logic; just spawns `arc daemon start` + loads `http://127.0.0.1:7272`. +- Native touches: dock badge on agent completion, tray icon, file-dialog bridge for picking repos, native menu. +- Auto-updates via existing `src/update.ts` (npm-based) or GitHub releases. +- CI workflow: build on all three OSes, publish to releases. + +--- + +## Phase 12 — Mobile app (`packages/mobile`, Expo) (~3–4 weeks) + +**Deliverable:** iOS + Android app. Scans pairing QR, talks to daemon via relay. + +- Expo app. React Native. +- Reuses `@axiom-labs/arc-client` (RN-compatible build). +- Screens: Agents list, Agent detail (live terminal + events), Chat rooms, New agent, Settings. +- Features: voice dictation (Expo Speech), push notifications on agent completion / stall, QR scanner for pairing. +- Deploy: TestFlight + Play Store internal track first. Public release gated on relay stability. + +--- + +## Phase 13 — Docker "server" mode (~3 days) + +**Deliverable:** official `ghcr.io/axiom-labs/arc-daemon` image for headless hosts. + +- Runs daemon headless in container. Mount `~/.arc`, expose 7272. +- No TUI; no Electron. Pure daemon. +- Use case: dev workstation, home server, shared team box. +- Compose example: daemon + relay + watchtower. + +```yaml +services: + arc-daemon: + image: ghcr.io/axiom-labs/arc-daemon:1 + ports: ["7272:7272"] + volumes: ["./arc-data:/home/arc/.arc"] + restart: unless-stopped +``` + +--- + +## Phase 14 — Polish, notifications, migration (~1 week) + +- **Push notifications:** per-client `notifyOnFinish` flag; daemon emits on agent complete/stall/fail. +- **No-poll discipline:** bake into skills, docs, client SDK — subscriptions only. +- **Timeline compaction:** after N epochs or M events, rewrite snapshot row; prune events older than archive window. +- **Migration script:** `arc migrate v2-to-v3` — ingests `~/.arc/history.json`, per-profile session JSON, `activity.log` into SQLite. +- **Deprecate TUI-as-host code paths** — confirm no direct adapter spawn remains outside the daemon. + +--- + +## Timeline estimate + +| Phase | Effort | Cumulative | +|---|---|---| +| 0 Prep | 1 day | 1 day | +| 1 Daemon | 1 wk | ~1.5 wk | +| 2 Protocol | 4 d | ~2.5 wk | +| 3 Client SDK | 3 d | ~3 wk | +| 4 Port surfaces | 2 wk | ~5 wk | +| 5 Provider `extends` | 2 d | ~5.5 wk | +| 6 `arc loop` | 1 wk | ~6.5 wk | +| 7 Chat rooms | 1 wk | ~7.5 wk | +| 8 Worktrees | 3 d | ~8 wk | +| 9 Roundtable/handoff | 4 d | ~8.5 wk | +| 10 Relay | 2 wk | ~10.5 wk | +| 11 Electron | 1 wk | ~11.5 wk | +| 12 Mobile | 3–4 wk | ~15 wk | +| 13 Docker | 3 d | ~15.5 wk | +| 14 Polish | 1 wk | ~16.5 wk | + +**~4 months solo, linear.** Phases 1→4 are the critical path; 5–9 can be interleaved with 10–14 once the protocol is stable. + +--- + +## Open questions (to answer before each phase kicks off) + +- **Phase 1:** any existing `packages/dashboard/src/server.ts` logic worth extracting wholesale vs rewrite clean in `packages/daemon`? Probably extract the WS accept path, rewrite the rest. +- **Phase 2:** do we want protobuf for frames instead of length-prefixed JSON? Not yet — start with JSON, keep the option open since frame header is opaque to content. +- **Phase 4:** TUI rewrite scope — do we keep all 13 views or simplify on the way? +- **Phase 6:** should loops be resumable across daemon restarts? Yes if the worker is a tool with persistent session (claude); no if the CLI doesn't support it. Adapter-specific flag. +- **Phase 10:** relay TLS — do we require it (refuse `ws://`) or allow self-signed for LAN? Require `wss://` on relay, allow `ws://` only on 127.0.0.1. +- **Phase 12:** voice mode parity with Paseo — nice-to-have or MVP? Defer to a later release. + +--- + +## Cross-cutting conventions + +- **Every new control message type starts with Zod schema + test.** Schema lives in `packages/client/src/protocol.ts` so daemon and client share it. +- **Never remove a field.** Deprecate (stop sending, keep accepting). Only major version bump removes. +- **No polling in clients.** Subscriptions only. If a feature seems to need polling, the daemon is missing an event — add the event. +- **All long-running work emits status events.** "silent work" is a bug. +- **SQLite migrations are additive until 2.0.** Never drop a column in a minor. + +--- + +## Out of scope for v3 + +- Hosted relay at `relay.arc.sh` — self-host only. +- Cloud-sync of profiles or sessions — purely local. +- Non-WebSocket transports (gRPC, HTTP/2, SSE fallback). +- Browser extension integrations. +- AI model fine-tuning / RAG on ARC's own logs. + +These go into `docs/expansion-ideas.md` if they come up. + +--- + +## Acceptance criteria for v3 (1.0.0) shipping + +1. `arc daemon start` runs headless on Mac, Windows, Linux; Electron app boots it. +2. TUI, dashboard, and CLI are all clients. Zero direct adapter calls outside daemon. +3. `arc run claude "hello"`, `arc ls`, `arc attach `, `arc stop ` work end-to-end. +4. `arc loop` completes a full worker/verifier cycle with cross-provider verify. +5. Chat rooms: two agents post/read/mention each other. +6. Mobile app (TestFlight + Play internal): can attach to a remote daemon via relay and stream terminal output. +7. Docker image boots a headless daemon reachable from the mobile app. +8. `arc migrate v2-to-v3` ingests a real v0.4.0 `~/.arc/` without loss. +9. v3 docs site updated; `paseo.sh`-equivalent page published. +10. Security audit of relay (self): threat model documented, all items mitigated or accepted. diff --git a/package.json b/package.json index 4e3a533..2eef8ff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@axiom-labs/arc-cli", - "version": "0.2.0", + "version": "0.4.0", "type": "module", "description": "ARC — Agent Runtime Control. Unified CLI for managing profiles and environments for agent tools (Claude, Gemini, Codex, and more).", "main": "./dist/index.js", @@ -20,7 +20,7 @@ "dev:tui": "tsx src/index.ts dashboard", "predev:tui:watch": "node scripts/kill-stale.js tsx", "dev:tui:watch": "node scripts/dev-tui.mjs", - "predev:dashboard": "node scripts/kill-stale.js tsx", + "predev:dashboard": "node scripts/kill-stale.js tsx --port 3700", "dev:dashboard": "tsx --watch packages/dashboard/src/dev.ts", "dev:dashboard:no-watch": "tsx packages/dashboard/src/dev.ts", "predev:watch": "node scripts/kill-stale.js tsup", @@ -109,7 +109,8 @@ "tsx": "^4", "typescript": "^6.0", "vitepress": "^1.6.4", - "vitest": "^4.1.2" + "vitest": "^4.1.2", + "zod": "^3.25.0" }, "optionalDependencies": { "@inquirer/prompts": "^8", diff --git a/packages/adapter-claude/src/auth.ts b/packages/adapter-claude/src/auth.ts index 722e8e5..1af6164 100644 --- a/packages/adapter-claude/src/auth.ts +++ b/packages/adapter-claude/src/auth.ts @@ -123,6 +123,12 @@ export async function getClaudeCredentialStatus(profile: Profile): Promise false); + return { + authenticated: hasKey, + authType: "openai-compat", + method: "api-key", + }; + } } }, async buildProfileEnv(profile: Profile): Promise> { @@ -181,6 +192,11 @@ function createBasicAdapter(config: { if (config.configEnvVar) { env[config.configEnvVar] = profile.configDir; } + // Inject provider config as env vars for OpenAI-compatible endpoints + if (profile.provider) { + if (profile.provider.baseUrl) env["OPENAI_BASE_URL"] = profile.provider.baseUrl; + if (profile.provider.model) env["OPENAI_MODEL"] = profile.provider.model; + } for (const [key, value] of Object.entries(profile.envOverrides ?? {})) { env[key] = value; } @@ -590,12 +606,122 @@ const codexAdapter = createBasicAdapter({ lifecycle: codexLifecycle, }); +// ─── OpenAI-compatible adapter ────────────────────────────────────── + +const openaiCompatProcessHandles = new Map(); + +const openaiCompatLifecycle: LifecycleOverrides = { + async launch(profile: Profile, options: LaunchOptions): Promise { + // Determine binary: use envOverrides.OPENAI_COMPAT_BINARY or fall back to "codex" + const binary = profile.envOverrides?.["OPENAI_COMPAT_BINARY"] || "codex"; + const args = [...options.args]; + + // Inject provider config into env + const env: NodeJS.ProcessEnv = { ...process.env, ...options.env }; + + if (profile.provider) { + if (profile.provider.baseUrl) { + env["OPENAI_BASE_URL"] = profile.provider.baseUrl; + } + if (profile.provider.model) { + // Pass as --model arg for Codex; also set env for tools that read it + args.unshift("--model", profile.provider.model); + env["OPENAI_MODEL"] = profile.provider.model; + } + } + + if (options.beforeSpawn) { + await options.beforeSpawn(); + } + + const command = process.platform === "win32" ? "cmd" : binary; + const spawnArgs = process.platform === "win32" ? ["/c", binary, ...args] : args; + + const handle = spawnManagedProcess({ + command, + args: spawnArgs, + env, + cwd: options.cwd, + component: "openai-compat", + }); + + openaiCompatProcessHandles.set(handle.pid, handle); + handle.child.once("exit", () => { + openaiCompatProcessHandles.delete(handle.pid); + }); + + return { + pid: handle.pid, + tool: "openai-compat", + profile: "default", + startedAt: new Date(), + }; + }, + + async terminate(agentProcess: AgentProcess): Promise { + openaiCompatProcessHandles.delete(agentProcess.pid); + await terminateProcess(agentProcess.pid, "openai-compat"); + }, + + isRunning(agentProcess: AgentProcess): boolean { + const alive = isProcessRunning(agentProcess.pid); + writeLogEvent({ + level: "debug", + component: "openai-compat", + action: alive ? "process:alive" : "process:dead", + message: `pid=${agentProcess.pid}`, + data: { pid: agentProcess.pid }, + }); + return alive; + }, + + onOutput(agentProcess: AgentProcess, handler: (event: OutputEvent) => void): void { + const handle = openaiCompatProcessHandles.get(agentProcess.pid); + if (!handle?.child.stdout) return; + + const rl = createInterface({ input: handle.child.stdout }); + rl.on("line", (line) => { + handler({ + type: "raw", + content: line, + timestamp: new Date(), + }); + }); + }, +}; + +const openaiCompatAdapter = createBasicAdapter({ + id: "openai-compat", + displayName: "OpenAI Compatible", + dirName: ".openai-compat", + markerFiles: ["config.json", ".env"], + installHint: + "Configure with: arc create --tool openai-compat --auth-type openai-compat\n" + + "Then set provider: arc provider set --base-url --model ", + configEnvVar: "OPENAI_COMPAT_HOME", + capabilities: { + hooks: false, + sdkControl: false, + pluginSystem: false, + mcpSupport: false, + jsonOutput: false, + sandboxing: false, + processWrap: true, + remoteSupport: false, + permissionTier: "interactive", + }, + lifecycle: openaiCompatLifecycle, +}); + +// ─── Adapter registry ─────────────────────────────────────────────── + const adapters = new Map([ [claudeAdapter.id, claudeAdapter], [geminiAdapter.id, geminiAdapter], [codexAdapter.id, codexAdapter], [openclawAdapter.id, openclawAdapter], [hermesAdapter.id, hermesAdapter], + [openaiCompatAdapter.id, openaiCompatAdapter], ]); export function listAdapters(): RuntimeAdapter[] { diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 3347e92..b2f19df 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -51,7 +51,7 @@ export function createProgram(): Command { .description("Create a new profile") .option( "--auth-type ", - "Auth type (oauth, api-key, bedrock, vertex, foundry)" + "Auth type (oauth, api-key, bedrock, vertex, foundry, openai-compat)" ) .option("--tool ", "Agent tool binary (claude, gemini, codex, ...)") .option("--description ", "Profile description") @@ -202,12 +202,67 @@ export function createProgram(): Command { } ); + // === Daemon Commands (v3) === + + const daemon = program + .command("daemon") + .description("ARC v3 daemon — long-running local service (Phases 1–3)"); + + daemon + .command("start") + .description("Start the ARC daemon (detached by default)") + .option("--port ", "Port to bind (default 7272)") + .option("--foreground", "Run in foreground (block the terminal)") + .action(async (opts: { port?: string; foreground?: boolean }) => { + const mod = await import("./commands/daemon.js"); + await mod.handleDaemonStart(opts); + }); + + daemon + .command("stop") + .description("Stop the running daemon") + .action(async () => { + const mod = await import("./commands/daemon.js"); + await mod.handleDaemonStop(); + }); + + daemon + .command("status") + .description("Show daemon status") + .option("--json", "Machine-readable output") + .action(async (opts: { json?: boolean }) => { + const mod = await import("./commands/daemon.js"); + await mod.handleDaemonStatus(opts); + }); + + daemon + .command("restart") + .description("Restart the daemon") + .option("--port ", "Port to bind (default 7272)") + .action(async (opts: { port?: string }) => { + const mod = await import("./commands/daemon.js"); + await mod.handleDaemonRestart(opts); + }); + + daemon + .command("logs") + .description("Show daemon log") + .option("-n ", "Number of lines to tail (default 50)") + .option("--tail", "Stream new log lines as they arrive") + .action(async (opts: { n?: string; tail?: boolean }) => { + const mod = await import("./commands/daemon.js"); + await mod.handleDaemonLogs(opts); + }); + // === Session Commands === program .command("launch [name]") .description("Launch agent tool with a profile") .option("-d, --dashboard", "Start web dashboard alongside agent") + .option("--native", "Run the tool with full TTY handoff (ARC exits, tool paints its own TUI)") + .option("--worker", "Run the tool under ARC supervision (stdout captured for orchestration)") + .option("--bare", "Skip profile resolution and env injection — spawn the named tool natively") .passThroughOptions() .allowUnknownOption() .allowExcessArguments() @@ -216,26 +271,173 @@ export function createProgram(): Command { ` All flags after the profile name are forwarded to the agent tool. +Launch modes: + --native Full TTY handoff (default). ARC exits; tool paints its own TUI + (e.g. Claude's statusLine). Best for daily interactive use. + --worker Run under ARC supervision. Stdout captured for monitoring / + orchestration (roundtable, pipelines). Suppresses native TUI chrome. + +If neither flag is given, the profile's \`launchMode\` setting is used +(fallback: native). + Examples: $ arc launch work + $ arc launch work --native + $ arc launch work --worker $ arc launch work --model sonnet $ arc launch work --dashboard $ arc launch work --dangerously-skip-permissions $ arc launch work -p "explain this code" $ arc launch -- --model sonnet (use -- when omitting profile name) + $ arc launch --bare claude (native launch, no profile env) ` ) .action( async ( name: string | undefined, - opts: { dashboard?: boolean }, + opts: { dashboard?: boolean; native?: boolean; worker?: boolean; bare?: boolean }, cmd: Command ) => { + // Re-inject parsed launch-mode flags into the args array so handleLaunch can see them. + // (Commander strips recognized options, but handleLaunch's CLI-flag extractor expects + // them in the raw-args stream.) + const extraArgs: string[] = []; + if (opts.native) extraArgs.push("--native"); + if (opts.worker) extraArgs.push("--worker"); + const mergedArgs = extraArgs.length > 0 ? [...cmd.args, ...extraArgs] : cmd.args; const mod = await import("./commands/launch.js"); - await mod.handleLaunch(name, cmd.args, { dashboard: opts.dashboard }); + await mod.handleLaunch(name, mergedArgs, { + dashboard: opts.dashboard, + bare: opts.bare, + }); + } + ); + + program + .command("run [args...]") + .description("Run a native agent tool (claude/codex/gemini/…) with no profile env injection") + .passThroughOptions() + .allowUnknownOption() + .allowExcessArguments() + .addHelpText( + "after", + ` +Thin alias for 'arc launch --bare '. Ambient environment only — +no CLAUDE_CONFIG_DIR, GEMINI_CLI_HOME, CODEX_HOME, or ARC env vars set. + +Examples: + $ arc run claude --version + $ arc run gemini --help + $ arc run codex -- some-flag +` + ) + .action( + async ( + tool: string, + _args: string[], + _opts: Record, + cmd: Command + ) => { + // `cmd.args` = [tool, ...rest]; pass the rest through to the binary. + const rest = cmd.args.slice(1); + const mod = await import("./commands/run.js"); + await mod.handleRun(tool, rest); } ); + program + .command("chat") + .description("Interactive chat with your active profile's agent (with ARC tool use)") + .option("--profile ", "Profile to use (default: active)") + .option("--mode ", "Permission mode (read-only|supervised|autonomous)", "supervised") + .option("--once ", "One-shot mode: send prompt, stream response, exit") + .option("--no-tools", "Disable ARC tool use (plain chat only)") + .option("--session ", "Resume a previous chat session") + .option("--new", "Force a new session (default when --session is absent)") + .addHelpText( + "after", + ` +Examples: + $ arc chat (interactive REPL) + $ arc chat --once "list my profiles" (one-shot) + $ arc chat --mode read-only (no writes) + $ arc chat --session abc-123 (resume) + $ arc chat --no-tools (plain chat only) + +REPL commands: + /exit /quit End the session + /save Save the session to disk + /new Start a new session + /mode Switch permission mode + /sessions List saved sessions + /resume Resume a saved session + /help Show command list +`, + ) + .action( + async (opts: { + profile?: string; + mode?: string; + once?: string; + tools?: boolean; + session?: string; + new?: boolean; + }) => { + const mod = await import("./commands/chat.js"); + await mod.handleChat({ + profile: opts.profile, + mode: opts.mode as "read-only" | "supervised" | "autonomous" | undefined, + once: opts.once, + noTools: opts.tools === false, + session: opts.session, + new: opts.new, + }); + }, + ); + + program + .command("roundtable ") + .description("Run a multi-agent roundtable discussion across profiles") + .option("--agents ", "Comma-separated profile names (e.g. work-claude,work-codex)") + .option("--rounds ", "Number of discussion rounds", "2") + .option("--synthesizer ", "Profile that writes the final summary (default: first agent)") + .option("--roles ", "Comma-separated roles matching --agents order (advocate|critic|neutral)") + .option("--format ", "Output mode (plain|json)", "plain") + .option("--no-pacing", "Disable adaptive delivery pacing (faster, for tests)") + .addHelpText( + "after", + ` +Examples: + $ arc roundtable "should we rewrite X?" --agents work-claude,work-codex,work-gemini + $ arc roundtable "approach for auth?" --agents a,b --rounds 3 --synthesizer b + $ arc roundtable "risk review" --agents a,b,c --roles advocate,critic,neutral + $ arc roundtable "ship it?" --agents a,b --format json +`, + ) + .action( + async ( + topic: string, + opts: { + agents?: string; + rounds?: string; + synthesizer?: string; + roles?: string; + format?: string; + pacing?: boolean; + }, + ) => { + const mod = await import("./commands/roundtable.js"); + await mod.handleRoundtable(topic, { + agents: opts.agents, + rounds: opts.rounds, + synthesizer: opts.synthesizer, + roles: opts.roles, + format: opts.format as "plain" | "json" | undefined, + pacing: opts.pacing, + }); + }, + ); + program .command("set-key [name]") .description("Store an API key for a profile") @@ -360,7 +562,7 @@ Examples: .description("Create a new profile") .option( "--auth-type ", - "Auth type (oauth, api-key, bedrock, vertex, foundry)" + "Auth type (oauth, api-key, bedrock, vertex, foundry, openai-compat)" ) .option("--tool ", "Agent tool binary (claude, gemini, codex, ...)") .option("--description ", "Profile description") @@ -392,12 +594,27 @@ Examples: profile .command("switch ") .alias("use") - .description("Switch active profile") + .description("Switch active profile (use 'none' or 'off' to clear)") + .addHelpText("after", ` +Examples: + $ arc profile switch work + $ arc profile switch none (clear active profile) + $ arc profile switch off (same — native launches via 'arc run ') +`) .action(async (name: string) => { const mod = await import("./commands/profile.js"); await mod.handleSwitch(name); }); + profile + .command("clear-active") + .alias("clear") + .description("Clear the active profile — tools launch natively via 'arc run'") + .action(async () => { + const mod = await import("./commands/profile.js"); + await mod.handleClearActive(); + }); + profile .command("delete ") .alias("rm") @@ -408,6 +625,15 @@ Examples: await mod.handleDelete(name, opts); }); + profile + .command("clone ") + .description("Clone an existing profile (copies config directory by default)") + .option("--no-copy-dir", "Clone the profile record only, skipping the config directory copy") + .action(async (src: string, dst: string, opts: { copyDir?: boolean }) => { + const mod = await import("./commands/profile.js"); + await mod.handleClone(src, dst, opts); + }); + profile .command("import") .description("Import existing agent tool config into a profile") @@ -420,6 +646,25 @@ Examples: await mod.handleImport(opts); }); + profile + .command("export ") + .description("Export a profile to a portable JSON file (inlines instructions)") + .option("--out ", "Output path (default: ./.arc-profile.json)") + .action(async (name: string, opts: { out?: string }) => { + const mod = await import("./commands/export.js"); + await mod.handleProfileExport(name, opts); + }); + + profile + .command("import-file ") + .description("Import a profile from a JSON file produced by `arc profile export`") + .option("--as ", "Rename the profile on import") + .option("--force", "Overwrite an existing profile with the same name") + .action(async (file: string, opts: { as?: string; force?: boolean }) => { + const mod = await import("./commands/export.js"); + await mod.handleProfileImport(file, opts); + }); + profile .command("set-flags ") .description("Set persistent launch flags for a profile (prepended on every launch)") @@ -453,6 +698,89 @@ Examples: showInfo("These flags will be prepended on every `arc launch`."); }); + // === Agent Instructions === + + const instructions = program + .command("instructions") + .alias("inst") + .description("Manage agent instructions / system prompts per profile"); + + instructions + .command("show [name]") + .description("Show resolved instructions for a profile (default: active)") + .action(async (name?: string) => { + const mod = await import("./commands/instructions.js"); + await mod.handleInstructionsShow(name); + }); + + instructions + .command("set ") + .description("Set inline instructions for a profile") + .option("--from-file ", "Read instructions from a file instead of inline") + .option("--file ", "Set instructionsFile path (read at launch time)") + .action(async (name: string, opts: { fromFile?: string; file?: string }) => { + const mod = await import("./commands/instructions.js"); + await mod.handleInstructionsSet(name, opts); + }); + + instructions + .command("edit ") + .description("Open instructions in $EDITOR") + .action(async (name: string) => { + const mod = await import("./commands/instructions.js"); + await mod.handleInstructionsEdit(name); + }); + + instructions + .command("clear ") + .description("Remove instructions from a profile") + .action(async (name: string) => { + const mod = await import("./commands/instructions.js"); + await mod.handleInstructionsClear(name); + }); + + // === Provider Configuration === + + const provider = program + .command("provider") + .description("Configure custom OpenAI-compatible providers for profiles"); + + provider + .command("set ") + .description("Set provider config on a profile") + .option("--base-url ", "API base URL (e.g. https://openrouter.ai/api/v1)") + .option("--model ", "Model identifier (e.g. anthropic/claude-3.5-sonnet)") + .option("--api-key-var ", "Env var name for the API key (default: OPENAI_API_KEY)") + .option("--display-name ", "Provider display name (e.g. OpenRouter, Ollama)") + .action(async (name: string, opts: { baseUrl?: string; model?: string; apiKeyVar?: string; displayName?: string }) => { + const mod = await import("./commands/provider.js"); + await mod.handleProviderSet(name, opts); + }); + + provider + .command("show [name]") + .description("Show provider config for a profile (default: active)") + .action(async (name?: string) => { + const mod = await import("./commands/provider.js"); + await mod.handleProviderShow(name); + }); + + provider + .command("clear ") + .description("Remove provider config from a profile") + .action(async (name: string) => { + const mod = await import("./commands/provider.js"); + await mod.handleProviderClear(name); + }); + + provider + .command("presets") + .description("List known provider presets (OpenRouter, Ollama, LM Studio, etc.)") + .action(async () => { + const mod = await import("./commands/provider.js"); + await mod.handleProviderPresets(); + }); + // === Shared Layer === const shared = program @@ -531,8 +859,8 @@ Examples: } const profileName = name ?? config.activeProfile; - if (!config.profiles[profileName]) { - showError(`Profile "${profileName}" not found.`); + if (!profileName || !config.profiles[profileName]) { + showError(profileName ? `Profile "${profileName}" not found.` : "No active profile — pass a name."); process.exit(1); } @@ -552,10 +880,10 @@ Examples: const config = loadConfig(); const profileName = name ?? config.activeProfile; - const profile = config.profiles[profileName]; + const profile = profileName ? config.profiles[profileName] : undefined; - if (!profile) { - showError(`Profile "${profileName}" not found.`); + if (!profile || !profileName) { + showError(profileName ? `Profile "${profileName}" not found.` : "No active profile — pass a name."); process.exit(1); } @@ -773,6 +1101,42 @@ Examples: } }); + // === Backup / Restore === + + const backup = program + .command("backup") + .description("Back up, restore, and list archives of the full ~/.arc/ state"); + + backup + .command("create") + .description("Create a gzipped archive of ~/.arc/ (excludes credentials/ by default)") + .option("--out ", "Output path (default: ~/.arc/backups/arc-backup-.tar.gz)") + .option("--exclude-credentials", "Exclude ~/.arc/credentials/ (default: on)", true) + .option("--include-credentials", "Include ~/.arc/credentials/ in the archive") + .action(async (opts: { out?: string; excludeCredentials?: boolean; includeCredentials?: boolean }) => { + const mod = await import("./commands/backup.js"); + const excludeCredentials = opts.includeCredentials ? false : opts.excludeCredentials ?? true; + await mod.handleBackupCreate({ out: opts.out, excludeCredentials }); + }); + + backup + .command("restore ") + .description("Restore a backup archive into ~/.arc/ (destructive)") + .option("--force", "Overwrite an existing ~/.arc/config.json") + .action(async (file: string, opts: { force?: boolean }) => { + const mod = await import("./commands/backup.js"); + await mod.handleBackupRestore(file, opts); + }); + + backup + .command("list") + .alias("ls") + .description("List archives in ~/.arc/backups/") + .action(async () => { + const mod = await import("./commands/backup.js"); + await mod.handleBackupList(); + }); + // === Advanced Commands === program diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts index bb0bf53..e8d6299 100644 --- a/packages/cli/src/commands/auth.ts +++ b/packages/cli/src/commands/auth.ts @@ -572,6 +572,11 @@ export async function handleAuthWhoami( const config = loadConfig(); const resolvedName = profileName ?? config.activeProfile; + if (!resolvedName) { + error("No active profile — pass a profile name."); + process.exit(1); + } + const raw = config.profiles[resolvedName]; if (!raw) { error(`Profile "${resolvedName}" not found.`); diff --git a/packages/cli/src/commands/backup.ts b/packages/cli/src/commands/backup.ts new file mode 100644 index 0000000..bedcd79 --- /dev/null +++ b/packages/cli/src/commands/backup.ts @@ -0,0 +1,360 @@ +import fs from "node:fs"; +import path from "node:path"; +import zlib from "node:zlib"; +import { getArcDir, getConfigPath } from "../paths.js"; +import { success, error, info, warn } from "../display.js"; + +/** + * Custom archive format (gzipped): + * + * MAGIC = "ARCBAK01" (8 bytes, fixed) + * repeat per entry: + * pathLen (4 bytes, big-endian uint32) + * path (pathLen bytes, UTF-8, POSIX-style relative path) + * sizeLen (8 bytes, big-endian uint64; actually stored as BigInt) + * contents (sizeLen bytes, raw file bytes) + * + * Plain files only. Directories are implicit (recreated from paths during restore). + * Symlinks are skipped (reported as a warning). + * + * The whole blob is gzipped before being written to disk. + */ + +const MAGIC = Buffer.from("ARCBAK01", "utf-8"); + +interface ArchiveEntry { + relPath: string; + contents: Buffer; +} + +interface WalkOptions { + root: string; + skipDirs: Set; +} + +function walkFiles(opts: WalkOptions): string[] { + const results: string[] = []; + + function recurse(dir: string): void { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + const full = path.join(dir, entry.name); + + if (opts.skipDirs.has(path.resolve(full))) { + continue; + } + + if (entry.isSymbolicLink()) { + // Skip symlinks to avoid escaping the archive root. + continue; + } + + if (entry.isDirectory()) { + recurse(full); + } else if (entry.isFile()) { + results.push(full); + } + // Skip sockets, fifos, block/char devices. + } + } + + recurse(opts.root); + return results; +} + +function toPosixRelative(base: string, full: string): string { + const rel = path.relative(base, full); + return rel.split(path.sep).join("/"); +} + +function writeUint32BE(value: number): Buffer { + const buf = Buffer.alloc(4); + buf.writeUInt32BE(value >>> 0, 0); + return buf; +} + +function writeBigUint64BE(value: bigint): Buffer { + const buf = Buffer.alloc(8); + buf.writeBigUInt64BE(value, 0); + return buf; +} + +function encodeArchive(entries: ArchiveEntry[]): Buffer { + const chunks: Buffer[] = [MAGIC]; + for (const entry of entries) { + const pathBuf = Buffer.from(entry.relPath, "utf-8"); + chunks.push(writeUint32BE(pathBuf.length)); + chunks.push(pathBuf); + chunks.push(writeBigUint64BE(BigInt(entry.contents.length))); + chunks.push(entry.contents); + } + return Buffer.concat(chunks); +} + +function decodeArchive(buf: Buffer): ArchiveEntry[] { + if (buf.length < MAGIC.length || !buf.subarray(0, MAGIC.length).equals(MAGIC)) { + throw new Error("Invalid archive: missing or wrong magic header (expected ARCBAK01)."); + } + const entries: ArchiveEntry[] = []; + let offset = MAGIC.length; + + while (offset < buf.length) { + if (offset + 4 > buf.length) { + throw new Error("Corrupt archive: truncated path length header."); + } + const pathLen = buf.readUInt32BE(offset); + offset += 4; + + if (offset + pathLen > buf.length) { + throw new Error("Corrupt archive: truncated path data."); + } + const relPath = buf.subarray(offset, offset + pathLen).toString("utf-8"); + offset += pathLen; + + if (offset + 8 > buf.length) { + throw new Error("Corrupt archive: truncated size header."); + } + const sizeBig = buf.readBigUInt64BE(offset); + offset += 8; + + if (sizeBig > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error(`Corrupt archive: entry "${relPath}" declares impossibly large size.`); + } + const size = Number(sizeBig); + + if (offset + size > buf.length) { + throw new Error(`Corrupt archive: truncated contents for "${relPath}".`); + } + const contents = Buffer.from(buf.subarray(offset, offset + size)); + offset += size; + + entries.push({ relPath, contents }); + } + + return entries; +} + +function isoTimestamp(): string { + return new Date().toISOString().replace(/[:.]/g, "-"); +} + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; +} + +export async function handleBackupCreate(opts: { + out?: string; + excludeCredentials?: boolean; +}): Promise { + const arcDir = getArcDir(); + + if (!fs.existsSync(arcDir)) { + error(`ARC directory not found at ${arcDir}. Nothing to back up.`); + process.exit(1); + } + + const backupsDir = path.join(arcDir, "backups"); + fs.mkdirSync(backupsDir, { recursive: true }); + + const outPath = + opts.out !== undefined + ? path.resolve(opts.out) + : path.join(backupsDir, `arc-backup-${isoTimestamp()}.tar.gz`); + + // Ensure output parent exists. + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + + const skipDirs = new Set(); + // Never recurse into the backups directory (don't archive our own output). + skipDirs.add(path.resolve(backupsDir)); + // By default exclude credentials (hot-swap snapshots are sensitive). + const excludeCreds = opts.excludeCredentials !== false; + if (excludeCreds) { + skipDirs.add(path.resolve(path.join(arcDir, "credentials"))); + } + + const files = walkFiles({ root: arcDir, skipDirs }); + + if (files.length === 0) { + warn("No files to archive (ARC directory is effectively empty)."); + } + + const entries: ArchiveEntry[] = []; + for (const full of files) { + let contents: Buffer; + try { + contents = fs.readFileSync(full); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + warn(`Skipping unreadable file "${full}": ${msg}`); + continue; + } + entries.push({ relPath: toPosixRelative(arcDir, full), contents }); + } + + const raw = encodeArchive(entries); + const gz = zlib.gzipSync(raw, { level: zlib.constants.Z_BEST_COMPRESSION }); + + try { + fs.writeFileSync(outPath, gz); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + error(`Failed to write archive to "${outPath}": ${msg}`); + process.exit(1); + } + + success(`Backup written: ${outPath}`); + info( + `Archived ${entries.length} file(s), ${formatSize(raw.length)} uncompressed, ${formatSize(gz.length)} compressed.`, + ); + if (excludeCreds) { + info("Note: ~/.arc/credentials/ was excluded. Pass --include-credentials to change (not yet exposed)."); + } +} + +export async function handleBackupRestore( + file: string, + opts: { force?: boolean }, +): Promise { + const archivePath = path.resolve(file); + if (!fs.existsSync(archivePath)) { + error(`Archive file not found: ${archivePath}`); + process.exit(1); + } + + const arcDir = getArcDir(); + const configPath = getConfigPath(); + + if (fs.existsSync(configPath) && !opts.force) { + warn(`Existing ARC config detected at ${configPath}.`); + warn("Restore is destructive — it overwrites files in-place."); + error("Refusing to continue without --force."); + process.exit(1); + } + + let gz: Buffer; + try { + gz = fs.readFileSync(archivePath); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + error(`Failed to read archive: ${msg}`); + process.exit(1); + } + + let raw: Buffer; + try { + raw = zlib.gunzipSync(gz); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + error(`Failed to gunzip archive: ${msg}`); + process.exit(1); + } + + let entries: ArchiveEntry[]; + try { + entries = decodeArchive(raw); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + error(msg); + process.exit(1); + } + + fs.mkdirSync(arcDir, { recursive: true }); + const arcDirResolved = path.resolve(arcDir); + + const restored: string[] = []; + for (const entry of entries) { + // Validate path: must be a relative POSIX-style path with no absolute/traversal parts. + if ( + entry.relPath.length === 0 || + entry.relPath.startsWith("/") || + entry.relPath.includes("\\") || + /^[a-zA-Z]:/.test(entry.relPath) + ) { + warn(`Skipping unsafe absolute path in archive: "${entry.relPath}"`); + continue; + } + + const segments = entry.relPath.split("/"); + if (segments.some((seg) => seg === "..")) { + warn(`Skipping path with traversal components: "${entry.relPath}"`); + continue; + } + + const destFull = path.resolve(arcDirResolved, ...segments); + const relCheck = path.relative(arcDirResolved, destFull); + if (relCheck.startsWith("..") || path.isAbsolute(relCheck)) { + warn(`Skipping path that escapes ARC dir: "${entry.relPath}"`); + continue; + } + + try { + fs.mkdirSync(path.dirname(destFull), { recursive: true }); + fs.writeFileSync(destFull, entry.contents); + restored.push(entry.relPath); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + warn(`Failed to write "${entry.relPath}": ${msg}`); + } + } + + success(`Restored ${restored.length} file(s) to ${arcDir}.`); + const preview = restored.slice(0, 10); + for (const p of preview) { + console.log(` ${p}`); + } + if (restored.length > preview.length) { + console.log(` ... and ${restored.length - preview.length} more`); + } +} + +export async function handleBackupList(): Promise { + const backupsDir = path.join(getArcDir(), "backups"); + + if (!fs.existsSync(backupsDir)) { + info(`No backups directory yet (${backupsDir}).`); + info("Create one with: arc backup create"); + return; + } + + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(backupsDir, { withFileTypes: true }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + error(`Failed to read ${backupsDir}: ${msg}`); + process.exit(1); + } + + const files = entries + .filter((e) => e.isFile()) + .map((e) => { + const full = path.join(backupsDir, e.name); + const stat = fs.statSync(full); + return { name: e.name, full, size: stat.size, mtime: stat.mtime }; + }) + .sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); + + if (files.length === 0) { + info(`No backup files found in ${backupsDir}.`); + return; + } + + console.log(); + for (const f of files) { + const when = f.mtime.toISOString(); + const size = formatSize(f.size).padStart(10); + console.log(` ${size} ${when} ${f.name}`); + } + console.log(); + info(`${files.length} backup(s) in ${backupsDir}`); +} diff --git a/packages/cli/src/commands/chat.ts b/packages/cli/src/commands/chat.ts new file mode 100644 index 0000000..511af85 --- /dev/null +++ b/packages/cli/src/commands/chat.ts @@ -0,0 +1,633 @@ +/** + * `arc chat` — interactive terminal chat REPL that streams an ARC profile's + * agent responses and drives ARC tools via the tool registry. + * + * See docs/plans/ai-and-roundtable.md — Phase 4. + * + * Architecture: + * - `AgentClient` (Phase 1) — spawns the profile's CLI tool one-shot. + * - `ToolRegistry` + `runAgent` (Phase 2) — local dispatch of tool calls. + * - `buildSystemPrompt` (Phase 3) — system-prompt composition. + * - `ChatSession` + store (Phase 4) — multi-turn persistence. + */ + +import readline from "node:readline"; +import pc from "picocolors"; +import { + ChatSession, + saveSession, + loadSession, + listSessions, + getAgentClientForProfile, + ToolRegistry, + registerArcTools, + runAgent, + buildSystemPrompt, + getRecentLaunches, + loadConfig, + resolveProfile, + estimateTokens, + type AgentClient, + type Profile, + type ChatMessage, + type ToolCallRecord, + type PermissionMode, + type ToolContext, + type AgentEvent, +} from "@axiom-labs/arc-core"; +import { getVersion } from "../display.js"; + +// --------------------------------------------------------------------------- +// Options +// --------------------------------------------------------------------------- + +export interface ChatOptions { + profile?: string; + mode?: PermissionMode; + once?: string; + noTools?: boolean; + session?: string; + new?: boolean; +} + +// --------------------------------------------------------------------------- +// ANSI helpers (self-contained — no dependency on TTY-only helpers in display) +// --------------------------------------------------------------------------- + +function writeText(s: string): void { + process.stdout.write(s); +} +function writeLine(s: string): void { + process.stdout.write(s + "\n"); +} +function printAssistantText(s: string): void { + writeText(s); +} +function printThinking(s: string): void { + writeText(pc.dim(s)); +} +function printToolCall(tool: string, input: unknown): void { + let body: string; + try { + body = JSON.stringify(input); + } catch { + body = String(input); + } + writeLine("\n" + pc.cyan(`→ tool:${tool}`) + " " + pc.dim(body)); +} +function printToolResult(tool: string, result: unknown, ok: boolean): void { + const label = ok ? pc.gray("← result") : pc.red("← error"); + let body: string; + try { + body = typeof result === "string" ? result : JSON.stringify(result); + } catch { + body = String(result); + } + if (body.length > 400) body = body.slice(0, 400) + "..."; + writeLine(label + ` ${pc.gray(tool)} ` + pc.dim(body)); +} +function printSystem(s: string): void { + writeLine(pc.blue("\u2139") + " " + s); +} +function printError(s: string): void { + process.stderr.write(pc.red("\u2716") + " " + s + "\n"); +} + +// --------------------------------------------------------------------------- +// Prompt construction +// --------------------------------------------------------------------------- + +/** + * Construct the single-string prompt fed to a one-shot `AgentClient` on every + * turn. + * + * v1 LIMITATION (see docs/plans/ai-and-roundtable.md — Phase 4): + * One-shot agent clients cannot accept additional `tool_result` messages into + * an existing session, so we must replay the full conversation on every turn. + * This produces O(n^2) context growth as the conversation lengthens. A future + * phase will upgrade to persistent TTY sessions (`inputMethod: "sendKeys"`) + * and remove this replay entirely. + */ +export function constructPromptFromSession(session: ChatSession): string { + const MAX_PROMPT_TOKENS = 15_000; + + const parts: string[] = []; + parts.push(""); + + // We iterate sequentially and drop the oldest non-system turns first if + // we're over budget. System messages are always kept at the top. + const systemMsgs: ChatMessage[] = []; + const convMsgs: ChatMessage[] = []; + for (const m of session.messages) { + if (m.role === "system") systemMsgs.push(m); + else convMsgs.push(m); + } + + // Work backwards so we preserve the most recent context when truncating. + const kept: ChatMessage[] = []; + let runningChars = 0; + const softLimitChars = MAX_PROMPT_TOKENS * 4; + for (let i = convMsgs.length - 1; i >= 0; i--) { + const m = convMsgs[i]; + runningChars += m.content.length + 64; + if (runningChars > softLimitChars && kept.length > 0) break; + kept.unshift(m); + } + if (kept.length < convMsgs.length) { + kept.unshift({ + role: "system", + content: `(${convMsgs.length - kept.length} earlier messages omitted to fit context)`, + timestamp: new Date().toISOString(), + }); + } + + for (const m of systemMsgs) { + parts.push(`System: ${m.content}`); + } + for (const m of kept) { + if (m.role === "user") { + parts.push(`User: ${m.content}`); + } else if (m.role === "assistant") { + let line = `Assistant: ${m.content}`; + if (m.toolCalls && m.toolCalls.length > 0) { + for (const tc of m.toolCalls) { + const resultBlob = + tc.error !== undefined + ? `error=${tc.error}` + : tc.result !== undefined + ? `result=${JSON.stringify(tc.result).slice(0, 300)}` + : "(no result)"; + line += `\n (tool_call:${tc.name} ${JSON.stringify(tc.input).slice(0, 200)}) → ${resultBlob}`; + } + } + parts.push(line); + } else if (m.role === "tool") { + parts.push(`tool_result: ${m.content}`); + } else { + parts.push(`System: ${m.content}`); + } + } + + parts.push(""); + parts.push(""); + parts.push("Respond to the last user message."); + const joined = parts.join("\n"); + // Final safety clamp — if we're still over budget, hard-truncate. + if (estimateTokens(joined) > MAX_PROMPT_TOKENS) { + const maxChars = MAX_PROMPT_TOKENS * 4; + return joined.slice(0, maxChars); + } + return joined; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function parseMode(val: string | undefined): PermissionMode { + if (val === "read-only" || val === "supervised" || val === "autonomous") return val; + if (val === undefined) return "supervised"; + throw new Error( + `Unknown --mode "${val}". Expected one of: read-only, supervised, autonomous.`, + ); +} + +function resolveProfileForChat( + profileName?: string, +): { profile: Profile; name: string } | null { + const config = loadConfig(); + const explicit = profileName; + const active = config.activeProfile ?? undefined; + const name = explicit ?? active; + if (!name) { + printError( + "No active profile and --profile not given. Run 'arc profile switch ' or pass --profile.", + ); + return null; + } + let profile: Profile; + try { + profile = resolveProfile(config, name); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + printError(msg); + return null; + } + if (!profile.tool) { + printError( + `Profile "${name}" has no tool set — cannot run chat. Set a tool with 'arc profile create' or edit ~/.arc/config.json.`, + ); + return null; + } + return { profile, name }; +} + +function buildToolCategories(noTools: boolean): string[] { + if (noTools) return []; + return [ + "profiles (list, show, clone, switch, export)", + "state (active profile, recent launches, doctor)", + "logs + memory + tasks (read + search)", + "skills + remote agents (read)", + "shared layer (read)", + ]; +} + +// --------------------------------------------------------------------------- +// Interactive confirmation +// --------------------------------------------------------------------------- + +function makeInteractiveConfirm( + rl: readline.Interface, +): (prompt: string) => Promise { + return (prompt: string): Promise => { + return new Promise((resolve) => { + rl.question(`\n${pc.yellow("?")} ${prompt} [y/N] `, (answer) => { + const a = answer.trim().toLowerCase(); + resolve(a === "y" || a === "yes"); + }); + }); + }; +} + +function makeNonInteractiveConfirm(): (prompt: string) => Promise { + // In `--once` mode we do not have a persistent readline; auto-deny writes + // to avoid blocking on missing stdin. + return async () => false; +} + +// --------------------------------------------------------------------------- +// Core turn execution +// --------------------------------------------------------------------------- + +async function executeTurn(args: { + client: AgentClient; + registry: ToolRegistry; + ctx: ToolContext; + session: ChatSession; + systemPrompt: string; + userMessage: string; +}): Promise { + const { client, registry, ctx, session, systemPrompt, userMessage } = args; + session.append({ role: "user", content: userMessage }); + + // Proxy the session into the one-shot client as a single prompt. + const prompt = constructPromptFromSession(session); + + let assistantText = ""; + const toolCalls: ToolCallRecord[] = []; + const pendingById = new Map(); + + // Wrap the client.send invocation to surface the system prompt via + // instructions — the one-shot adapters prepend it to the prompt. + const wrappedClient: AgentClient = { + send(p) { + return client.send(p, { instructions: systemPrompt }); + }, + shutdown() { + return client.shutdown(); + }, + }; + + let events: AsyncIterable; + try { + events = runAgent({ client: wrappedClient, registry, ctx }, prompt); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + printError(`Agent failed: ${msg}`); + return; + } + + try { + for await (const ev of events) { + switch (ev.type) { + case "text": + assistantText += ev.content; + printAssistantText(ev.content); + break; + case "thinking": + printThinking(ev.content); + break; + case "tool_call": { + printToolCall(ev.tool, ev.input); + const rec: ToolCallRecord = { + id: ev.id, + name: ev.tool, + input: ev.input, + }; + pendingById.set(ev.id, rec); + toolCalls.push(rec); + break; + } + case "tool_result": { + const rec = pendingById.get(ev.id); + const ok = ev.result.ok; + if (ok) { + if (rec) rec.result = ev.result.output; + printToolResult(ev.tool, ev.result.output, true); + } else { + if (rec) { + rec.error = ev.result.error; + if (ev.result.blocked) rec.confirmed = false; + } + printToolResult(ev.tool, ev.result.error, false); + } + break; + } + case "error": + printError(ev.message); + break; + case "done": + if (ev.reason === "max_turns") { + writeLine(pc.yellow("\n(max tool-turns reached)")); + } + break; + } + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + printError(`Stream error: ${msg}`); + } + + // Ensure we terminate the line. + if (assistantText && !assistantText.endsWith("\n")) writeText("\n"); + + session.append({ + role: "assistant", + content: assistantText, + toolCalls: toolCalls.length > 0 ? toolCalls : undefined, + }); + + try { + saveSession(session); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + printError(`Failed to save session: ${msg}`); + } +} + +// --------------------------------------------------------------------------- +// Entrypoint +// --------------------------------------------------------------------------- + +export async function handleChat(opts: ChatOptions): Promise { + const mode = parseMode(opts.mode); + const resolved = resolveProfileForChat(opts.profile); + if (!resolved) { + process.exit(1); + } + const { profile, name: profileName } = resolved; + + // Agent client. + let client: AgentClient; + try { + client = getAgentClientForProfile(profile); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + printError(msg); + process.exit(1); + return; + } + + // Tool registry. + const registry = new ToolRegistry(); + if (!opts.noTools) { + registerArcTools(registry); + } + + // System prompt. + const config = loadConfig(); + const recentLaunches = getRecentLaunches(3).map((l) => ({ + profile: l.profile, + tool: l.tool, + startedAt: l.timestamp, + exitCode: l.exitCode, + })); + const systemPrompt = buildSystemPrompt({ + config, + recentLaunches, + activeProfile: profile, + arcVersion: getVersion(), + doctorIssues: [], + permissionMode: mode, + toolCategories: buildToolCategories(opts.noTools ?? false), + }); + + // ── Resume or create session ────────────────────────── + let session: ChatSession; + if (opts.session) { + try { + session = loadSession(profileName, opts.session); + printSystem(`Resuming session ${session.id} (${session.messages.length} msgs).`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + printError(msg); + process.exit(1); + return; + } + } else { + session = new ChatSession({ profileName, permissionMode: mode }); + // Save baseline system context as a system message (useful for resume). + session.append({ + role: "system", + content: `ARC chat session — profile=${profileName}, mode=${mode}, tool=${profile.tool}`, + }); + } + + // ── One-shot mode ───────────────────────────────────── + if (opts.once !== undefined) { + const ctx: ToolContext = { + mode, + confirm: makeNonInteractiveConfirm(), + log: () => {}, + profile, + }; + await executeTurn({ + client, + registry, + ctx, + session, + systemPrompt, + userMessage: opts.once, + }); + try { + await client.shutdown(); + } catch { + /* ignore */ + } + return; + } + + // ── Interactive REPL ────────────────────────────────── + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: true, + }); + const confirm = makeInteractiveConfirm(rl); + + printSystem( + `ARC chat — profile=${profileName} tool=${profile.tool} mode=${mode}. Type /help for commands, /exit to quit.`, + ); + + let currentMode: PermissionMode = mode; + let currentSession = session; + let currentSystemPrompt = systemPrompt; + + const promptUser = (): Promise => { + return new Promise((resolve) => { + rl.question(pc.bold(pc.cyan("\nyou > ")), (line) => resolve(line)); + }); + }; + + const rebuildSystemPromptForMode = (m: PermissionMode): string => { + return buildSystemPrompt({ + config: loadConfig(), + recentLaunches, + activeProfile: profile, + arcVersion: getVersion(), + doctorIssues: [], + permissionMode: m, + toolCategories: buildToolCategories(opts.noTools ?? false), + }); + }; + + const printHelp = (): void => { + writeLine(""); + writeLine(pc.bold("Commands:")); + writeLine(" /exit, /quit End the session"); + writeLine(" /save Save the session to disk"); + writeLine(" /new Start a new session (forgets current)"); + writeLine(" /mode Switch permission mode (read-only|supervised|autonomous)"); + writeLine(" /clear Clear the in-memory transcript (keeps id)"); + writeLine(" /sessions List saved sessions for this profile"); + writeLine(" /resume Resume a saved session"); + writeLine(" /help Show this help"); + writeLine(""); + }; + + try { + // eslint-disable-next-line no-constant-condition + while (true) { + const input = (await promptUser()).trim(); + if (!input) continue; + + if (input.startsWith("/")) { + const [cmd, ...rest] = input.slice(1).split(/\s+/); + const arg = rest.join(" ").trim(); + + if (cmd === "exit" || cmd === "quit") { + break; + } + if (cmd === "help") { + printHelp(); + continue; + } + if (cmd === "save") { + try { + saveSession(currentSession); + printSystem(`Saved session ${currentSession.id}.`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + printError(msg); + } + continue; + } + if (cmd === "new") { + currentSession = new ChatSession({ + profileName, + permissionMode: currentMode, + }); + currentSession.append({ + role: "system", + content: `ARC chat session — profile=${profileName}, mode=${currentMode}, tool=${profile.tool}`, + }); + printSystem(`Started new session ${currentSession.id}.`); + continue; + } + if (cmd === "mode") { + try { + const newMode = parseMode(arg); + currentMode = newMode; + currentSession.permissionMode = newMode; + currentSystemPrompt = rebuildSystemPromptForMode(newMode); + printSystem(`Permission mode → ${newMode}.`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + printError(msg); + } + continue; + } + if (cmd === "clear") { + currentSession.messages = []; + currentSession.append({ + role: "system", + content: `ARC chat session — profile=${profileName}, mode=${currentMode}, tool=${profile.tool}`, + }); + printSystem("Transcript cleared."); + continue; + } + if (cmd === "sessions") { + const summaries = listSessions(profileName); + if (summaries.length === 0) { + printSystem("No saved sessions."); + } else { + writeLine(""); + for (const s of summaries.slice(0, 20)) { + const marker = s.id === currentSession.id ? pc.green("*") : " "; + writeLine( + ` ${marker} ${pc.cyan(s.id)} ${pc.dim(s.updatedAt)} ${pc.dim(`(${s.messageCount} msgs)`)} ${s.summary}`, + ); + } + writeLine(""); + } + continue; + } + if (cmd === "resume") { + if (!arg) { + printError("/resume — pass a session id from /sessions."); + continue; + } + try { + currentSession = loadSession(profileName, arg); + currentMode = currentSession.permissionMode; + currentSystemPrompt = rebuildSystemPromptForMode(currentMode); + printSystem( + `Resumed ${currentSession.id} (${currentSession.messages.length} msgs, mode=${currentMode}).`, + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + printError(msg); + } + continue; + } + printError(`Unknown command: /${cmd}. Try /help.`); + continue; + } + + const ctx: ToolContext = { + mode: currentMode, + confirm, + log: () => {}, + profile, + }; + await executeTurn({ + client, + registry, + ctx, + session: currentSession, + systemPrompt: currentSystemPrompt, + userMessage: input, + }); + } + } finally { + rl.close(); + try { + await client.shutdown(); + } catch { + /* ignore */ + } + } + + printSystem("Goodbye."); +} diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts new file mode 100644 index 0000000..e04116f --- /dev/null +++ b/packages/cli/src/commands/daemon.ts @@ -0,0 +1,137 @@ +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { loadDaemonConfig, readPid, startDaemon } from "@axiom-labs/arc-daemon"; +import { VERSION } from "../version.js"; +import { info, error, success } from "../display.js"; + +interface StartOpts { + port?: string; + foreground?: boolean; +} + +export async function handleDaemonStart(opts: StartOpts): Promise { + const port = opts.port ? Number.parseInt(opts.port, 10) : undefined; + const cfg = loadDaemonConfig(port !== undefined ? { port } : {}); + const existing = readPid(cfg.pidPath); + if (existing !== null) { + info(`daemon already running (pid ${existing}) on ${cfg.host}:${cfg.port}`); + return; + } + + if (opts.foreground) { + const handle = await startDaemon({ + ...(port !== undefined ? { port } : {}), + version: VERSION, + }); + success(`daemon listening on ${handle.config.host}:${handle.config.port}`); + // Block until a signal triggers stop. `startDaemon` installs SIGINT/SIGTERM + // handlers that call `process.exit(0)` once shutdown completes. + await new Promise(() => {}); + return; + } + + const args = [ + process.argv[1] ?? "", + "daemon", + "start", + "--foreground", + ]; + if (opts.port) args.push("--port", opts.port); + const child = spawn(process.execPath, args, { + detached: true, + stdio: "ignore", + env: process.env, + }); + child.unref(); + await waitForPid(cfg.pidPath, 5000); + success(`daemon started on ${cfg.host}:${cfg.port} (log: ${cfg.logPath})`); +} + +export async function handleDaemonStop(): Promise { + const cfg = loadDaemonConfig(); + const pid = readPid(cfg.pidPath); + if (pid === null) { + info("daemon not running"); + return; + } + try { + process.kill(pid, "SIGTERM"); + } catch (err) { + error(`failed to signal daemon: ${(err as Error).message}`); + return; + } + for (let i = 0; i < 20; i++) { + await sleep(100); + if (readPid(cfg.pidPath) === null) { + success("daemon stopped"); + return; + } + } + error("daemon did not exit within 2s — may still be shutting down"); +} + +export async function handleDaemonStatus(opts: { json?: boolean } = {}): Promise { + const cfg = loadDaemonConfig(); + const pid = readPid(cfg.pidPath); + const running = pid !== null; + if (opts.json) { + process.stdout.write( + JSON.stringify({ running, pid, host: cfg.host, port: cfg.port, logPath: cfg.logPath }, null, 2) + + "\n", + ); + return; + } + if (!running) { + info("daemon: stopped"); + return; + } + success(`daemon: running (pid ${pid}) on ${cfg.host}:${cfg.port}`); +} + +export async function handleDaemonRestart(opts: StartOpts): Promise { + await handleDaemonStop(); + await sleep(200); + await handleDaemonStart(opts); +} + +export async function handleDaemonLogs(opts: { tail?: boolean; n?: string } = {}): Promise { + const cfg = loadDaemonConfig(); + if (!fs.existsSync(cfg.logPath)) { + info(`no log file at ${cfg.logPath}`); + return; + } + const n = opts.n ? Number.parseInt(opts.n, 10) : 50; + const content = fs.readFileSync(cfg.logPath, "utf8"); + const lines = content.split("\n").filter(Boolean); + for (const line of lines.slice(-n)) process.stdout.write(`${line}\n`); + if (opts.tail) { + // naive tail: re-read after size grows + let lastSize = fs.statSync(cfg.logPath).size; + while (true) { + await sleep(500); + const stat = fs.statSync(cfg.logPath); + if (stat.size > lastSize) { + const fd = fs.openSync(cfg.logPath, "r"); + const buf = Buffer.alloc(stat.size - lastSize); + fs.readSync(fd, buf, 0, buf.length, lastSize); + fs.closeSync(fd); + process.stdout.write(buf.toString("utf8")); + lastSize = stat.size; + } + } + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForPid(pidPath: string, timeoutMs: number): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (readPid(pidPath) !== null) return; + await sleep(100); + } + throw new Error(`daemon did not start within ${timeoutMs}ms`); +} diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 7860570..1bc2247 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -158,6 +158,21 @@ async function checkProfiles(): Promise { return; } + // Surface active-profile state first (info row, not a failure). + if (config.activeProfile === null) { + console.log( + ` ${pc.blue("\u2139")} active profile: ${pc.dim("(none)")} \u2014 that's fine; use ${pc.bold(pc.cyan("arc run "))} to launch tools natively` + ); + } else if (!config.profiles[config.activeProfile]) { + console.log( + ` ${pc.yellow("\u26A0")} active profile ${JSON.stringify(config.activeProfile)} references a missing profile` + ); + } else { + console.log( + ` ${pc.green("\u2714")} active profile: ${config.activeProfile}` + ); + } + const names = Object.keys(config.profiles); if (names.length === 0) { console.log(` ${pc.dim("No profiles configured.")}`); @@ -226,6 +241,22 @@ async function checkProfiles(): Promise { // ── Main Handler ──────────────────────────────────── +/** + * Diagnostic: env.deprecated_no_flicker + * Flags the deprecated CLAUDE_CODE_NO_FLICKER env var. v2.1.110+ of Claude Code + * uses `/tui fullscreen` instead; leaving this var set can cause odd rendering. + */ +function checkDeprecatedEnv(): void { + if (process.env["CLAUDE_CODE_NO_FLICKER"] !== undefined) { + warning( + "CLAUDE_CODE_NO_FLICKER is deprecated (v2.1.110+ uses /tui fullscreen)" + ); + console.log( + ` ${pc.dim("Repair: Remove CLAUDE_CODE_NO_FLICKER from your shell profile")}` + ); + } +} + function checkNodeVersion(): void { const version = process.version.replace(/^v/, ""); const major = parseInt(version.split(".")[0], 10); @@ -250,6 +281,7 @@ export async function handleDoctor(): Promise { checkConfigFile(); checkPath(); checkShellIntegration(); + checkDeprecatedEnv(); await checkProfiles(); diff --git a/packages/cli/src/commands/exec.ts b/packages/cli/src/commands/exec.ts index 8a66cf8..252f9a0 100644 --- a/packages/cli/src/commands/exec.ts +++ b/packages/cli/src/commands/exec.ts @@ -17,10 +17,11 @@ export async function handleExec( if (name && config.profiles[name]) { profileName = name; passthrough = rawArgs.slice(1); - } else if (name) { - profileName = config.activeProfile; - passthrough = rawArgs; } else { + if (config.activeProfile === null) { + error("No active profile. Use 'arc profile switch ' or pass a profile argument."); + process.exit(1); + } profileName = config.activeProfile; passthrough = rawArgs; } diff --git a/packages/cli/src/commands/export.ts b/packages/cli/src/commands/export.ts new file mode 100644 index 0000000..5de3b84 --- /dev/null +++ b/packages/cli/src/commands/export.ts @@ -0,0 +1,213 @@ +import fs from "node:fs"; +import path from "node:path"; +import { loadConfig, saveConfig } from "../config.js"; +import { getProfileDir } from "../paths.js"; +import type { Profile } from "@axiom-labs/arc-core"; +import { success, error, info, warn } from "../display.js"; + +/** + * On-disk format for `arc profile export`. + * + * Version 1 inlines the referenced `instructionsFile` (when present) so that + * imports on other machines don't rely on that path existing. + */ +export interface ProfileExportManifest { + manifest: "arc-profile-export"; + manifestVersion: 1; + exportedAt: string; + arcVersion?: string; + name: string; + profile: Profile; + /** Verbatim contents of profile.instructionsFile, if it existed on disk. */ + instructionsContent?: string; +} + +const MANIFEST_KIND = "arc-profile-export"; +const MANIFEST_VERSION: 1 = 1; + +function slugify(name: string): string { + return name.replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "") || "profile"; +} + +export async function handleProfileExport( + name: string, + opts: { out?: string }, +): Promise { + const config = loadConfig(); + const profile = config.profiles[name]; + if (!profile) { + error(`Profile "${name}" not found.`); + process.exit(1); + } + + // Deep-clone so we don't mutate the in-memory config. + const profileCopy: Profile = JSON.parse(JSON.stringify(profile)); + + let instructionsContent: string | undefined; + if (profileCopy.instructionsFile) { + try { + instructionsContent = fs.readFileSync(profileCopy.instructionsFile, "utf-8"); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + warn(`Could not inline instructions file "${profileCopy.instructionsFile}": ${msg}`); + } + } + + let arcVersion: string | undefined; + try { + const mod = await import("../version.js"); + arcVersion = mod.VERSION; + } catch { + // Optional — continue without. + } + + const manifest: ProfileExportManifest = { + manifest: MANIFEST_KIND, + manifestVersion: MANIFEST_VERSION, + exportedAt: new Date().toISOString(), + arcVersion, + name, + profile: profileCopy, + ...(instructionsContent !== undefined ? { instructionsContent } : {}), + }; + + const outPath = + opts.out !== undefined + ? path.resolve(opts.out) + : path.resolve(`./${slugify(name)}.arc-profile.json`); + + try { + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + fs.writeFileSync(outPath, JSON.stringify(manifest, null, 2) + "\n", "utf-8"); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + error(`Failed to write "${outPath}": ${msg}`); + process.exit(1); + } + + success(`Exported profile "${name}" to ${outPath}`); + if (instructionsContent !== undefined) { + info(`Inlined instructions file (${instructionsContent.length} chars).`); + } +} + +function isValidManifest(value: unknown): value is ProfileExportManifest { + if (typeof value !== "object" || value === null) return false; + const m = value as Record; + if (m["manifest"] !== MANIFEST_KIND) return false; + if (typeof m["manifestVersion"] !== "number") return false; + if (typeof m["name"] !== "string") return false; + if (typeof m["profile"] !== "object" || m["profile"] === null) return false; + const p = m["profile"] as Record; + if (typeof p["authType"] !== "string") return false; + if (typeof p["configDir"] !== "string") return false; + if (typeof p["createdAt"] !== "string") return false; + return true; +} + +export async function handleProfileImport( + file: string, + opts: { as?: string; force?: boolean }, +): Promise { + const resolved = path.resolve(file); + if (!fs.existsSync(resolved)) { + error(`File not found: ${resolved}`); + process.exit(1); + } + + let raw: string; + try { + raw = fs.readFileSync(resolved, "utf-8"); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + error(`Failed to read "${resolved}": ${msg}`); + process.exit(1); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + error(`Invalid JSON in "${resolved}": ${msg}`); + process.exit(1); + } + + if (!isValidManifest(parsed)) { + error(`File "${resolved}" is not a valid ARC profile export (manifest check failed).`); + process.exit(1); + } + + const manifest: ProfileExportManifest = parsed; + + if (manifest.manifestVersion !== MANIFEST_VERSION) { + error( + `Unsupported manifest version ${manifest.manifestVersion}. This ARC build supports version ${MANIFEST_VERSION}.`, + ); + process.exit(1); + } + + const targetName = opts.as ?? manifest.name; + if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(targetName)) { + error(`Invalid profile name: "${targetName}".`); + process.exit(1); + } + + const config = loadConfig(); + const exists = Object.prototype.hasOwnProperty.call(config.profiles, targetName); + + if (exists && !opts.force && !opts.as) { + error(`Profile "${targetName}" already exists. Pass --force to overwrite or --as to rename.`); + process.exit(1); + } + if (exists && opts.as) { + // User explicitly renamed but collided with something else. + if (!opts.force) { + error(`Profile "${targetName}" (from --as) already exists. Pass --force to overwrite.`); + process.exit(1); + } + } + + // Clone, then optionally re-target configDir and instructionsFile to the new name. + const imported: Profile = JSON.parse(JSON.stringify(manifest.profile)); + + // If the profile had inline instructions content, write it into the new profile's dir. + if (manifest.instructionsContent !== undefined) { + const profileDir = getProfileDir(targetName); + fs.mkdirSync(profileDir, { recursive: true }); + const instructionsPath = path.join(profileDir, "instructions.md"); + try { + fs.writeFileSync(instructionsPath, manifest.instructionsContent, "utf-8"); + imported.instructionsFile = instructionsPath; + delete imported.instructions; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + warn(`Failed to write instructions to "${instructionsPath}": ${msg}`); + } + } else if (imported.instructionsFile && opts.as) { + // Renamed on import — the old instructionsFile path likely points at a + // foreign profile dir. Keep it as-is but warn. + warn( + `Profile references instructionsFile "${imported.instructionsFile}" — verify it exists on this machine.`, + ); + } + + // Reset configDir to a fresh per-profile dir (do not reuse the source machine's path). + imported.configDir = getProfileDir(targetName); + fs.mkdirSync(imported.configDir, { recursive: true }); + + config.profiles[targetName] = imported; + if (!config.activeProfile) { + config.activeProfile = targetName; + } + saveConfig(config); + + success( + exists + ? `Overwrote profile "${targetName}" from ${resolved}` + : `Imported profile "${targetName}" from ${resolved}`, + ); + if (manifest.instructionsContent !== undefined) { + info(`Wrote instructions to ${imported.instructionsFile}`); + } +} diff --git a/packages/cli/src/commands/instructions.ts b/packages/cli/src/commands/instructions.ts new file mode 100644 index 0000000..3855c00 --- /dev/null +++ b/packages/cli/src/commands/instructions.ts @@ -0,0 +1,145 @@ +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { loadConfig, saveConfig } from "../config.js"; +import { resolveInstructions } from "@axiom-labs/arc-core"; +import { success, error, info, warn } from "../display.js"; + +function getProfile(name?: string) { + const config = loadConfig(); + const profileName = name ?? config.activeProfile; + if (!profileName) { + error("No active profile. Use 'arc profile switch ' or pass a profile name."); + process.exit(1); + } + const profile = config.profiles[profileName]; + if (!profile) { + error(`Profile "${profileName}" not found.`); + process.exit(1); + } + return { config, profileName, profile }; +} + +export async function handleInstructionsShow(name?: string): Promise { + const { profileName, profile } = getProfile(name); + const text = resolveInstructions(profile); + + if (!text) { + info(`No instructions configured for "${profileName}".`); + info("Set with: arc instructions set --from-file ./INSTRUCTIONS.md"); + return; + } + + const source = profile.instructionsFile ? `file: ${profile.instructionsFile}` : "inline"; + info(`Instructions for "${profileName}" (${source}, ${text.length} chars):`); + console.log(); + console.log(text); +} + +export async function handleInstructionsSet( + name: string, + opts: { fromFile?: string; file?: string }, +): Promise { + const { config, profileName, profile } = getProfile(name); + + if (opts.file) { + // Set instructionsFile path (lazy-loaded at launch time) + const resolved = path.resolve(opts.file); + if (!fs.existsSync(resolved)) { + warn(`File "${resolved}" does not exist yet — it will be read at launch time.`); + } + profile.instructionsFile = resolved; + delete profile.instructions; + saveConfig(config); + success(`Instructions file for "${profileName}" set to: ${resolved}`); + return; + } + + if (opts.fromFile) { + // Read file and store as inline instructions + const resolved = path.resolve(opts.fromFile); + try { + const text = fs.readFileSync(resolved, "utf-8"); + profile.instructions = text; + delete profile.instructionsFile; + saveConfig(config); + success(`Inline instructions for "${profileName}" set from: ${resolved} (${text.length} chars)`); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + error(`Failed to read "${resolved}": ${msg}`); + process.exit(1); + } + return; + } + + // Interactive: read from stdin + info("Enter instructions (paste text, then press Ctrl+D when done):"); + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(chunk as Buffer); + } + const text = Buffer.concat(chunks).toString("utf-8").trim(); + + if (!text) { + error("No instructions provided."); + process.exit(1); + } + + profile.instructions = text; + delete profile.instructionsFile; + saveConfig(config); + success(`Inline instructions for "${profileName}" set (${text.length} chars).`); +} + +export async function handleInstructionsEdit(name: string): Promise { + const { config, profileName, profile } = getProfile(name); + + const editor = process.env["EDITOR"] || process.env["VISUAL"] || (process.platform === "win32" ? "notepad" : "vi"); + + // Write current instructions to a temp file + const tmpDir = path.join(profile.configDir, ".arc-tmp"); + fs.mkdirSync(tmpDir, { recursive: true }); + const tmpFile = path.join(tmpDir, "instructions.md"); + + const existing = resolveInstructions(profile) ?? ""; + fs.writeFileSync(tmpFile, existing, "utf-8"); + + info(`Opening ${editor}...`); + const result = spawnSync(editor, [tmpFile], { stdio: "inherit" }); + + if (result.status !== 0) { + error(`Editor exited with code ${result.status}.`); + try { fs.unlinkSync(tmpFile); fs.rmdirSync(tmpDir); } catch { /* ignore */ } + process.exit(1); + } + + const updated = fs.readFileSync(tmpFile, "utf-8").trim(); + try { fs.unlinkSync(tmpFile); fs.rmdirSync(tmpDir); } catch { /* ignore */ } + + if (!updated) { + profile.instructions = undefined; + profile.instructionsFile = undefined; + saveConfig(config); + info(`Instructions cleared for "${profileName}".`); + return; + } + + profile.instructions = updated; + delete profile.instructionsFile; + saveConfig(config); + success(`Instructions for "${profileName}" updated (${updated.length} chars).`); +} + +export async function handleInstructionsClear(name: string): Promise { + const { config, profileName, profile } = getProfile(name); + + if (!profile.instructions && !profile.instructionsFile) { + info(`No instructions configured for "${profileName}".`); + return; + } + + profile.instructions = undefined; + profile.instructionsFile = undefined; + saveConfig(config); + success(`Instructions cleared for "${profileName}".`); +} diff --git a/packages/cli/src/commands/launch.ts b/packages/cli/src/commands/launch.ts index 2001833..c19a82f 100644 --- a/packages/cli/src/commands/launch.ts +++ b/packages/cli/src/commands/launch.ts @@ -10,6 +10,7 @@ import { getAdapter } from "../adapters/index.js"; import { waitForProcessExit } from "@axiom-labs/arc-core"; import { createDefaultHookBus } from "@axiom-labs/arc-core"; import { writeLogEvent, queryLogEvents } from "@axiom-labs/arc-core"; +import { recordLaunch } from "@axiom-labs/arc-core"; import { SessionStore, isResumeIntent } from "@axiom-labs/arc-core"; import { TelemetryProvider, JsonFileExporter, startSessionSpan } from "@axiom-labs/arc-core"; import { CircuitBreaker } from "@axiom-labs/arc-core"; @@ -24,6 +25,35 @@ import type { AgentProcess } from "@axiom-labs/arc-core"; const isWindows = process.platform === "win32"; +/** + * Known native agent tool binaries used for bare-mode inference. + * When the first positional arg to `arc launch` matches one of these AND no + * profile exists by that name, we auto-infer `--bare` and launch the tool + * natively (no profile env injection). + */ +export const KNOWN_AGENT_TOOLS = new Set([ + "claude", + "codex", + "gemini", + "hermes", + "openclaw", +]); + +/** + * Pure helper — decide whether the caller's first positional arg should + * trigger bare-mode inference. Exposed for tests; kept free of I/O. + */ +export function shouldInferBare( + name: string | undefined, + profileNames: readonly string[], + explicitBare: boolean +): boolean { + if (explicitBare) return true; + if (typeof name !== "string") return false; + if (!KNOWN_AGENT_TOOLS.has(name)) return false; + return !profileNames.includes(name); +} + /** Check whether a command binary is available on PATH. */ export function findBinary(name: string): boolean { const result = isWindows @@ -32,6 +62,55 @@ export function findBinary(name: string): boolean { return result.status === 0; } +/** + * Bare launch: spawn a native tool with the ambient environment only. + * No profile resolution, no env injection (CLAUDE_CONFIG_DIR etc.), no + * hook pipeline, no session/telemetry tracking. This is the "arc is + * optional orchestration" path — the user just wants `claude` to run. + */ +export async function handleBareLaunch( + tool: string, + args: string[], + opts?: { beforeSpawn?: () => void | Promise } +): Promise { + if (!findBinary(tool)) { + error(`Binary "${tool}" not found on PATH.`); + warn(getInstallHint(tool)); + process.exit(1); + } + + logAction("launch", `(bare) ${tool}`); + try { + writeLogEvent({ + level: "info", + component: "launch", + action: "bare:launch", + message: `Bare launch of ${tool}`, + data: { profile: null, tool, args }, + }); + } catch { + // Non-fatal + } + + const flagStr = args.length > 0 ? ` [${args.join(" ")}]` : ""; + info(`Launching ${tool} (bare mode)${flagStr}`); + + if (opts?.beforeSpawn) { + await opts.beforeSpawn(); + } + + const result = isWindows + ? spawnSync("cmd", ["/c", tool, ...args], { stdio: "inherit" }) + : spawnSync(tool, args, { stdio: "inherit" }); + + if (result.error) { + error(`Failed to launch ${tool}: ${result.error.message}`); + process.exit(1); + } + + process.exit(result.status ?? 0); +} + /** Suggest an install command for known agent tool binaries. */ function getInstallHint(tool: string): string { switch (tool) { @@ -49,9 +128,54 @@ function getInstallHint(tool: string): string { export async function handleLaunch( name: string | undefined, rawArgs: string[], - opts?: { beforeSpawn?: () => void | Promise; dashboard?: boolean } + opts?: { + beforeSpawn?: () => void | Promise; + dashboard?: boolean; + /** + * Force a specific launch mode regardless of profile setting or CLI flags. + * Used by orchestrators (Phase 5+ roundtable, pipelines) to guarantee + * `worker` mode so stdout can be captured. + */ + launchMode?: "native" | "worker"; + /** + * Bare mode: skip profile resolution and env injection entirely. Just + * spawn the named tool with ambient env. `name` is then treated as the + * tool binary (claude / codex / gemini / …). + */ + bare?: boolean; + /** Override the tool name in bare mode when it differs from `name`. */ + tool?: string; + } ): Promise { const config = loadConfig(); + + // ─── Bare-mode / tool-name inference ──────────────────────────────── + // 1. Explicit opts.bare=true always wins. + // 2. Otherwise, if the first positional arg matches a known native tool + // AND no profile exists by that name, infer bare mode. + const profileNames = Object.keys(config.profiles); + const explicitBare = opts?.bare === true; + const bare = shouldInferBare(name, profileNames, explicitBare); + if (bare && !explicitBare) { + // Emit informational notice (stderr so stdout stays clean for piping). + warn(`no profile named "${name}" \u2014 launching native tool.`); + } + + if (bare) { + const toolName = opts?.tool ?? name; + if (!toolName) { + error("Bare launch requires a tool name (e.g. 'arc run claude')."); + process.exit(1); + } + // In bare mode, if `name` was consumed as the tool, the rest is passthrough. + let barePassthrough = name === toolName ? rawArgs.slice(1) : rawArgs; + if (barePassthrough.length > 0 && barePassthrough[0] === "--") { + barePassthrough = barePassthrough.slice(1); + } + await handleBareLaunch(toolName, barePassthrough, { beforeSpawn: opts?.beforeSpawn }); + return; + } + let profileName: string; let passthrough: string[]; @@ -60,12 +184,21 @@ export async function handleLaunch( profileName = name; passthrough = rawArgs.slice(1); } else if (name) { - // Commander consumed something as name but it's not a valid profile. - // Treat everything (including the consumed "name") as passthrough. + // Commander consumed something as name but it's not a valid profile, + // and it's not a known tool either. Fall back to active profile. + if (config.activeProfile === null) { + error(`No profile named "${name}" and no active profile set.`); + warn("Use 'arc run ' for native launch, or switch to a profile with 'arc profile switch '."); + process.exit(1); + } profileName = config.activeProfile; passthrough = rawArgs; } else { // No name provided — active profile, everything is passthrough + if (config.activeProfile === null) { + error("No active profile. Use 'arc run ' for native launch, or 'arc profile switch '."); + process.exit(1); + } profileName = config.activeProfile; passthrough = rawArgs; } @@ -75,6 +208,20 @@ export async function handleLaunch( passthrough = passthrough.slice(1); } + // Extract launch-mode flags from passthrough so they are not forwarded to the agent + let cliLaunchMode: "native" | "worker" | undefined; + passthrough = passthrough.filter((arg) => { + if (arg === "--native") { + cliLaunchMode = "native"; + return false; + } + if (arg === "--worker") { + cliLaunchMode = "worker"; + return false; + } + return true; + }); + // Resolve profile through workspace-aware pipeline (arc.json > explicit > activeProfile) let profile: Profile; try { @@ -90,6 +237,11 @@ export async function handleLaunch( const tool = profile.tool ?? "claude"; const enforcement = profile.enforcement ?? "log"; + // Resolve effective launch mode: caller override > CLI flag > profile setting > default native. + // Orchestrators (roundtable, pipelines) pass `opts.launchMode = "worker"` to force supervision. + const effectiveLaunchMode: "native" | "worker" = + opts?.launchMode ?? cliLaunchMode ?? profile.launchMode ?? "native"; + // ─── Session auto-resume detection ────────────────────────────────── // Lightweight: detect whether the user's launch args suggest resume intent // and whether a suspended session exists. Informational only for now. @@ -371,25 +523,43 @@ export async function handleLaunch( } } + recordLaunch({ + profile: profileName, + tool, + timestamp: new Date().toISOString(), + outcome: "started", + }); + let agentProcess: AgentProcess | null = null; - try { - agentProcess = await adapter.launch(profile, { - args: allArgs, - env: profileEnv, - cwd: process.cwd(), - beforeSpawn: opts?.beforeSpawn ? async () => { await opts!.beforeSpawn!(); } : undefined, - }); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - if (msg === "not implemented") { - // Adapter still has stub lifecycle — fall back to spawnSync - agentProcess = null; - } else { - // Real error from a real adapter - error(`Failed to launch ${tool}: ${msg}`); - process.exit(1); + if (effectiveLaunchMode === "worker") { + // Worker mode: hand off to the adapter for managed supervision. + try { + agentProcess = await adapter.launch(profile, { + args: allArgs, + env: profileEnv, + cwd: process.cwd(), + beforeSpawn: opts?.beforeSpawn ? async () => { await opts!.beforeSpawn!(); } : undefined, + launchMode: "worker", + }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + if (msg === "not implemented") { + // Adapter still has stub lifecycle — fall back to spawnSync + agentProcess = null; + } else { + // Real error from a real adapter + recordLaunch({ + profile: profileName, + tool, + timestamp: new Date().toISOString(), + outcome: "failed", + }); + error(`Failed to launch ${tool}: ${msg}`); + process.exit(1); + } } } + // Native mode falls straight through to the spawnSync path below for full TTY handoff. // ─── Finalization helper ────────────────────────────────────────── // Completes session tracking and flushes telemetry. Wrapped in try/catch @@ -489,14 +659,22 @@ export async function handleLaunch( // Block until the child process exits await waitForProcessExit(agentProcess.pid); + recordLaunch({ + profile: profileName, + tool, + timestamp: new Date().toISOString(), + outcome: "exited", + exitCode: 0, + }); await finalizeCoreModules(0); process.exit(0); } - // ─── Legacy spawnSync path (stubbed adapters: Claude, Gemini) ──── + // ─── Native TTY handoff path (default mode + adapter-stub fallback) ─ // Use spawnSync with stdio:"inherit" — the parent blocks completely and - // the child process owns the terminal. No stdin competition, no async - // race conditions, no DEP0190 warning. + // the child process owns the terminal, so the tool can paint its own TUI + // (e.g. Claude's statusLine). No stdin competition, no async race + // conditions, no DEP0190 warning. // On Windows, tools are often .cmd shims that need `cmd /c` to resolve. if (opts?.beforeSpawn) { await opts.beforeSpawn(); @@ -513,12 +691,25 @@ export async function handleLaunch( }); if (result.error) { + recordLaunch({ + profile: profileName, + tool, + timestamp: new Date().toISOString(), + outcome: "failed", + }); await finalizeCoreModules(1); error(`Failed to launch ${tool}: ${result.error.message}`); process.exit(1); } const exitCode = result.status ?? 0; + recordLaunch({ + profile: profileName, + tool, + timestamp: new Date().toISOString(), + outcome: exitCode === 0 ? "exited" : "failed", + exitCode, + }); await finalizeCoreModules(exitCode); process.exit(exitCode); } diff --git a/packages/cli/src/commands/profile.ts b/packages/cli/src/commands/profile.ts index c9075ff..5891ef9 100644 --- a/packages/cli/src/commands/profile.ts +++ b/packages/cli/src/commands/profile.ts @@ -2,7 +2,7 @@ import path from "node:path"; import os from "node:os"; import fs from "node:fs"; import type { AuthType } from "../types.js"; -import { loadConfig, saveConfig, resolveProfileName, resolveProfile } from "../config.js"; +import { loadConfig, saveConfig, resolveProfileName, resolveProfile, cloneProfile } from "../config.js"; import { success, error, info, detail, profileTable } from "../display.js"; import { createProfile, importProfile, validateName } from "../tui/createProfile.js"; @@ -101,7 +101,13 @@ export async function handleList(): Promise { export async function handleShow(name?: string): Promise { const config = loadConfig(); - const resolved = resolveProfileName(config, name); + let resolved: string; + try { + resolved = resolveProfileName(config, name); + } catch (err: unknown) { + error(err instanceof Error ? err.message : String(err)); + process.exit(1); + } const rawProfile = config.profiles[resolved]; if (!rawProfile) { error(`Profile "${resolved}" not found.`); @@ -132,6 +138,12 @@ export async function handleShow(name?: string): Promise { } export async function handleSwitch(name: string): Promise { + // Treat "none" / "off" / "" as a request to clear the active profile. + const lowered = (name ?? "").toLowerCase(); + if (!name || lowered === "none" || lowered === "off") { + await handleClearActive(); + return; + } const config = loadConfig(); if (!config.profiles[name]) { error(`Profile "${name}" not found.`); @@ -142,6 +154,19 @@ export async function handleSwitch(name: string): Promise { success(`Switched to profile "${name}".`); } +export async function handleClearActive(): Promise { + const config = loadConfig(); + if (config.activeProfile === null) { + info("No active profile — nothing to clear."); + return; + } + const previous = config.activeProfile; + config.activeProfile = null; + saveConfig(config); + success(`Cleared active profile (was "${previous}").`); + info("Launch tools natively with 'arc run ' or pass --profile to commands."); +} + export async function handleDelete(name: string, opts?: { force?: boolean }): Promise { const config = loadConfig(); if (!config.profiles[name]) { @@ -174,8 +199,13 @@ export async function handleDelete(name: string, opts?: { force?: boolean }): Pr delete config.profiles[name]; if (config.activeProfile === name) { const remaining = Object.keys(config.profiles); - config.activeProfile = remaining[0]!; - info(`Active profile switched to "${remaining[0]}".`); + if (remaining.length > 0) { + config.activeProfile = remaining[0]!; + info(`Active profile switched to "${remaining[0]}".`); + } else { + config.activeProfile = null; + info("No profiles remain — active profile cleared."); + } } saveConfig(config); success(`Profile "${name}" deleted.`); @@ -206,4 +236,43 @@ export async function handleImport( detail(`Config: ${loadConfig().profiles[opts.name]?.configDir ?? "(unknown)"}`); } +export async function handleClone( + src: string, + dst: string, + opts?: { copyDir?: boolean } +): Promise { + const config = loadConfig(); + + if (!config.profiles[src]) { + error(`Source profile "${src}" not found.`); + process.exit(1); + } + + // Validate dst name (reuse the TUI's shared name validator) + const nameError = validateName(dst, Object.keys(config.profiles)); + if (nameError) { + error(nameError); + process.exit(1); + } + + const sourceDir = config.profiles[src].configDir; + const sourceDirExists = sourceDir ? fs.existsSync(sourceDir) : false; + const copyConfigDir = opts?.copyDir !== false; + + try { + const updated = cloneProfile(config, src, dst, { copyConfigDir }); + saveConfig(updated); + } catch (err) { + error(err instanceof Error ? err.message : String(err)); + process.exit(1); + } + + success(`Profile "${dst}" cloned from "${src}".`); + if (copyConfigDir && !sourceDirExists) { + info(`Source config directory was missing — cloned profile record only.`); + } + const newDir = loadConfig().profiles[dst]?.configDir ?? "(unknown)"; + detail(`Config: ${newDir}`); +} + diff --git a/packages/cli/src/commands/provider.ts b/packages/cli/src/commands/provider.ts new file mode 100644 index 0000000..b50f12f --- /dev/null +++ b/packages/cli/src/commands/provider.ts @@ -0,0 +1,192 @@ +import { loadConfig, saveConfig } from "../config.js"; +import { success, error, info, cmd } from "../display.js"; +import type { ProviderConfig } from "@axiom-labs/arc-core"; + +// ─── Known provider presets ───────────────────────────────────────── + +interface ProviderPreset { + id: string; + displayName: string; + baseUrl: string; + apiKeyEnvVar: string; + models: string[]; + notes: string; +} + +const PRESETS: ProviderPreset[] = [ + { + id: "openrouter", + displayName: "OpenRouter", + baseUrl: "https://openrouter.ai/api/v1", + apiKeyEnvVar: "OPENROUTER_API_KEY", + models: ["anthropic/claude-sonnet-4", "openai/gpt-4o", "google/gemini-2.5-pro", "meta-llama/llama-4-maverick"], + notes: "Multi-provider gateway — use any model from any provider", + }, + { + id: "ollama", + displayName: "Ollama", + baseUrl: "http://localhost:11434/v1", + apiKeyEnvVar: "OLLAMA_API_KEY", + models: ["llama3", "codellama", "mistral", "deepseek-coder"], + notes: "Local models — no API key needed (set dummy value)", + }, + { + id: "lm-studio", + displayName: "LM Studio", + baseUrl: "http://localhost:1234/v1", + apiKeyEnvVar: "LM_STUDIO_API_KEY", + models: ["loaded-model"], + notes: "Local inference — no API key needed (set dummy value)", + }, + { + id: "together", + displayName: "Together AI", + baseUrl: "https://api.together.xyz/v1", + apiKeyEnvVar: "TOGETHER_API_KEY", + models: ["meta-llama/Llama-3-70b-chat-hf", "mistralai/Mixtral-8x7B-Instruct-v0.1"], + notes: "Cloud GPU inference for open models", + }, + { + id: "groq", + displayName: "Groq", + baseUrl: "https://api.groq.com/openai/v1", + apiKeyEnvVar: "GROQ_API_KEY", + models: ["llama-3.3-70b-versatile", "mixtral-8x7b-32768"], + notes: "Ultra-fast inference on custom LPU hardware", + }, + { + id: "minimax", + displayName: "MiniMax", + baseUrl: "https://api.minimax.chat/v1", + apiKeyEnvVar: "MINIMAX_API_KEY", + models: ["MiniMax-Text-01", "abab6.5s-chat"], + notes: "MiniMax cloud models", + }, + { + id: "deepseek", + displayName: "DeepSeek", + baseUrl: "https://api.deepseek.com/v1", + apiKeyEnvVar: "DEEPSEEK_API_KEY", + models: ["deepseek-chat", "deepseek-coder"], + notes: "DeepSeek coding-focused models", + }, +]; + +// ─── Helpers ──────────────────────────────────────────────────────── + +function getProfile(name?: string) { + const config = loadConfig(); + const profileName = name ?? config.activeProfile; + if (!profileName) { + error("No active profile. Use 'arc profile switch ' or pass a profile name."); + process.exit(1); + } + const profile = config.profiles[profileName]; + if (!profile) { + error(`Profile "${profileName}" not found.`); + process.exit(1); + } + return { config, profileName, profile }; +} + +// ─── Handlers ─────────────────────────────────────────────────────── + +export async function handleProviderSet( + name: string, + opts: { baseUrl?: string; model?: string; apiKeyVar?: string; displayName?: string }, +): Promise { + const { config, profileName, profile } = getProfile(name); + + if (!opts.baseUrl && !opts.model && !opts.apiKeyVar && !opts.displayName) { + error("Provide at least one option: --base-url, --model, --api-key-var, or --display-name"); + info(`Example: ${cmd("arc provider set " + profileName + " --base-url https://openrouter.ai/api/v1 --model anthropic/claude-sonnet-4")}`); + process.exit(1); + } + + // Check if input matches a preset + const preset = PRESETS.find((p) => + opts.baseUrl === p.baseUrl || opts.displayName?.toLowerCase() === p.id, + ); + + const existing: ProviderConfig = profile.provider ?? { baseUrl: "" }; + + if (opts.baseUrl) existing.baseUrl = opts.baseUrl; + if (opts.model) existing.model = opts.model; + if (opts.apiKeyVar) existing.apiKeyEnvVar = opts.apiKeyVar; + if (opts.displayName) existing.displayName = opts.displayName; + + // Apply preset defaults for fields not explicitly set + if (preset) { + if (!opts.apiKeyVar && !existing.apiKeyEnvVar) existing.apiKeyEnvVar = preset.apiKeyEnvVar; + if (!opts.displayName && !existing.displayName) existing.displayName = preset.displayName; + } + + profile.provider = existing; + + // Auto-set authType to openai-compat if not already + if (profile.authType !== "openai-compat" && profile.authType !== "api-key") { + profile.authType = "openai-compat"; + } + + saveConfig(config); + + const label = existing.displayName ?? "Custom Provider"; + success(`Provider for "${profileName}" configured: ${label}`); + if (existing.baseUrl) info(` Base URL: ${existing.baseUrl}`); + if (existing.model) info(` Model: ${existing.model}`); + if (existing.apiKeyEnvVar) info(` API key env var: ${existing.apiKeyEnvVar}`); + + // Hint about setting the key + const keyVar = existing.apiKeyEnvVar ?? "OPENAI_API_KEY"; + info(`\nSet your API key: ${cmd(`arc set-key ${profileName}`)}`); + info(`Or via env: ${cmd(`export ${keyVar}=sk-...`)}`); +} + +export async function handleProviderShow(name?: string): Promise { + const { profileName, profile } = getProfile(name); + const p = profile.provider; + + if (!p) { + info(`No provider configured for "${profileName}".`); + info(`Set one with: ${cmd(`arc provider set ${profileName} --base-url `)}`); + info(`Or use a preset: ${cmd("arc provider presets")}`); + return; + } + + const label = p.displayName ?? "Custom Provider"; + info(`Provider for "${profileName}": ${label}`); + console.log(` Base URL: ${p.baseUrl || "(not set)"}`); + console.log(` Model: ${p.model || "(not set)"}`); + console.log(` API key var: ${p.apiKeyEnvVar || "OPENAI_API_KEY"}`); +} + +export async function handleProviderClear(name: string): Promise { + const { config, profileName, profile } = getProfile(name); + + if (!profile.provider) { + info(`No provider configured for "${profileName}".`); + return; + } + + profile.provider = undefined; + saveConfig(config); + success(`Provider cleared for "${profileName}".`); +} + +export async function handleProviderPresets(): Promise { + info("Known provider presets:\n"); + + for (const p of PRESETS) { + console.log(` ${p.displayName.padEnd(14)} ${p.baseUrl}`); + console.log(` ${"".padEnd(14)} Key var: ${p.apiKeyEnvVar}`); + console.log(` ${"".padEnd(14)} Models: ${p.models.slice(0, 3).join(", ")}`); + console.log(` ${"".padEnd(14)} ${p.notes}`); + console.log(); + } + + info("Quick setup example:"); + console.log(` ${cmd("arc create openrouter --tool openai-compat --auth-type openai-compat")}`); + console.log(` ${cmd("arc provider set openrouter --base-url https://openrouter.ai/api/v1 --model anthropic/claude-sonnet-4")}`); + console.log(` ${cmd("arc set-key openrouter")}`); + console.log(` ${cmd("arc launch openrouter")}`); +} diff --git a/packages/cli/src/commands/resolve.ts b/packages/cli/src/commands/resolve.ts index 4c91c0c..bbd47f8 100644 --- a/packages/cli/src/commands/resolve.ts +++ b/packages/cli/src/commands/resolve.ts @@ -11,6 +11,9 @@ export async function handleResolveConfigDir(): Promise { } const resolved = profileName ?? config.activeProfile; + if (!resolved) { + process.exit(1); + } const profile = config.profiles[resolved]; if (!profile) { diff --git a/packages/cli/src/commands/roundtable.ts b/packages/cli/src/commands/roundtable.ts new file mode 100644 index 0000000..f3bd8e0 --- /dev/null +++ b/packages/cli/src/commands/roundtable.ts @@ -0,0 +1,299 @@ +/** + * `arc roundtable` — spawn a multi-agent discussion using the Phase 5 + * orchestrator. Streams per-agent turns + synthesis to stdout with colour- + * coded role headers. + * + * See docs/plans/ai-and-roundtable.md — Phase 6. + */ + +import pc from "picocolors"; +import { + RoundtableOrchestrator, + loadConfig, + type AgentChunk, + type Profile, + type RoundtableAgent, + type RoundtableEvent, + type RoundtableResult, + type RoundtableRole, +} from "@axiom-labs/arc-core"; + +// --------------------------------------------------------------------------- +// Options +// --------------------------------------------------------------------------- + +export interface RoundtableCliOptions { + agents?: string; + rounds?: string | number; + synthesizer?: string; + roles?: string; + format?: "plain" | "json"; + pacing?: boolean; +} + +// --------------------------------------------------------------------------- +// IO helpers +// --------------------------------------------------------------------------- + +function writeText(s: string): void { + process.stdout.write(s); +} +function writeLine(s: string): void { + process.stdout.write(s + "\n"); +} +function printError(s: string): void { + process.stderr.write(pc.red("\u2716") + " " + s + "\n"); +} + +const ROLE_COLORS: Record string> = { + advocate: pc.green, + critic: pc.red, + neutral: pc.cyan, + synthesizer: pc.magenta, +}; + +function colorize(role: RoundtableRole, text: string): string { + const c = ROLE_COLORS[role] ?? pc.white; + return c(text); +} + +// --------------------------------------------------------------------------- +// Parsing helpers +// --------------------------------------------------------------------------- + +function parseRole(val: string, agentIndex: number): RoundtableRole { + const v = val.trim().toLowerCase(); + if (v === "advocate" || v === "critic" || v === "neutral" || v === "synthesizer") { + return v; + } + throw new Error( + `Invalid role "${val}" at agent position ${agentIndex + 1}. Expected: advocate | critic | neutral | synthesizer.`, + ); +} + +function defaultRole(index: number): RoundtableRole { + if (index === 0) return "advocate"; + if (index === 1) return "critic"; + return "neutral"; +} + +function parseList(val: string | undefined): string[] { + if (!val) return []; + return val + .split(",") + .map((s) => s.trim()) + .filter(Boolean); +} + +function parseRounds(val: string | number | undefined): number { + if (val === undefined) return 2; + const n = typeof val === "number" ? val : parseInt(val, 10); + if (!Number.isFinite(n) || n < 1) { + throw new Error(`--rounds must be a positive integer (got "${val}")`); + } + return n; +} + +// --------------------------------------------------------------------------- +// Orchestrator option injection — allow tests to override via env hook +// --------------------------------------------------------------------------- + +export interface RoundtableCliDeps { + orchestratorFactory?: () => RoundtableOrchestrator; +} + +// --------------------------------------------------------------------------- +// Entry +// --------------------------------------------------------------------------- + +export async function handleRoundtable( + topic: string, + opts: RoundtableCliOptions, + deps: RoundtableCliDeps = {}, +): Promise { + if (!topic || !topic.trim()) { + printError("A topic is required: arc roundtable --agents a,b,c"); + process.exit(1); + return; + } + + const format = opts.format ?? "plain"; + if (format !== "plain" && format !== "json") { + printError(`--format must be "plain" or "json" (got "${format}")`); + process.exit(1); + return; + } + + // ── Parse agents + roles ────────────────────────────── + const agentNames = parseList(opts.agents); + if (agentNames.length < 2) { + printError( + `Roundtable requires at least 2 agents. Pass them via --agents a,b,c (got ${agentNames.length}).`, + ); + process.exit(1); + return; + } + + const rolesList = parseList(opts.roles); + if (rolesList.length > 0 && rolesList.length !== agentNames.length) { + printError( + `--roles must have the same number of entries as --agents (got ${rolesList.length} roles for ${agentNames.length} agents).`, + ); + process.exit(1); + return; + } + + let rounds: number; + try { + rounds = parseRounds(opts.rounds); + } catch (err) { + printError(err instanceof Error ? err.message : String(err)); + process.exit(1); + return; + } + + // ── Load profiles ───────────────────────────────────── + const config = loadConfig(); + const agents: RoundtableAgent[] = []; + for (let i = 0; i < agentNames.length; i++) { + const name = agentNames[i]; + const profile: Profile | undefined = config.profiles[name]; + if (!profile) { + printError( + `Profile "${name}" not found. Run 'arc list' to see available profiles.`, + ); + process.exit(1); + return; + } + if (!profile.tool) { + printError( + `Profile "${name}" has no tool set — cannot participate in roundtable.`, + ); + process.exit(1); + return; + } + let role: RoundtableRole; + try { + role = rolesList.length > 0 ? parseRole(rolesList[i], i) : defaultRole(i); + } catch (err) { + printError(err instanceof Error ? err.message : String(err)); + process.exit(1); + return; + } + agents.push({ profile, role, displayName: name }); + } + + // ── Resolve synthesizer ─────────────────────────────── + let synthesizer: RoundtableAgent = agents[0]; + if (opts.synthesizer) { + const match = agents.find((a) => a.displayName === opts.synthesizer); + if (!match) { + printError( + `--synthesizer "${opts.synthesizer}" must be one of --agents. Got agents: ${agentNames.join(", ")}.`, + ); + process.exit(1); + return; + } + synthesizer = match; + } + + // ── Build orchestrator ──────────────────────────────── + const orchestrator = deps.orchestratorFactory + ? deps.orchestratorFactory() + : new RoundtableOrchestrator( + opts.pacing === false + ? { sleep: async () => {} } + : {}, + ); + + // ── Event handler ───────────────────────────────────── + const streaming = format === "plain"; + const onEvent = streaming ? makeStreamingHandler() : undefined; + + // ── Run ────────────────────────────────────────────── + let result: RoundtableResult; + try { + result = await orchestrator.run({ + topic, + agents, + rounds, + synthesizer, + onEvent, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + printError(`Roundtable failed: ${msg}`); + process.exit(1); + return; + } + + // ── Final output ───────────────────────────────────── + if (format === "json") { + writeLine(JSON.stringify(result, null, 2)); + return; + } + + writeLine(""); + writeLine(pc.bold(`Consensus: ${result.consensusScore.toFixed(2)}`)); + writeLine(pc.bold("Summary:")); + writeLine(result.synthesis); + if (result.keyPoints.length > 0) { + writeLine(""); + writeLine(pc.bold("Key points:")); + for (const kp of result.keyPoints) writeLine(` - ${kp}`); + } + writeLine(""); + writeLine( + pc.dim( + `(${result.transcript.length} turns in ${(result.durationMs / 1000).toFixed(1)}s, roundtable id ${result.roundtableId})`, + ), + ); +} + +function makeStreamingHandler(): (evt: RoundtableEvent) => void { + return (evt: RoundtableEvent): void => { + switch (evt.type) { + case "turn-start": { + const header = `\n[${evt.agent}] [${evt.role}] round ${evt.round} \u2500\u2500\u2500\u2500\u2500`; + writeLine(colorize(evt.role, header)); + break; + } + case "turn-chunk": { + const chunk: AgentChunk = evt.chunk; + if (chunk.type === "text") { + writeText(chunk.content); + } else if (chunk.type === "error") { + writeLine("\n" + pc.red(` [error] ${chunk.message}`)); + } + break; + } + case "turn-complete": + writeLine(""); + break; + case "synthesis-start": + writeLine("\n" + pc.bold(pc.magenta(`\u2500\u2500 Synthesis (${evt.agent}) \u2500\u2500`))); + break; + case "synthesis-complete": + writeLine(""); + writeLine( + pc.bold( + `Consensus score: ${evt.consensusScore.toFixed(2)}`, + ), + ); + writeLine(pc.bold("Summary:")); + writeLine(evt.summary); + break; + case "phase-change": + // Quiet — users don't want to see every state transition. + break; + case "error": + writeLine( + "\n" + + pc.red( + `[error] ${evt.agent ? `(${evt.agent}) ` : ""}${evt.message}`, + ), + ); + break; + } + }; +} diff --git a/packages/cli/src/commands/run.ts b/packages/cli/src/commands/run.ts new file mode 100644 index 0000000..16fc4a7 --- /dev/null +++ b/packages/cli/src/commands/run.ts @@ -0,0 +1,22 @@ +import { handleLaunch } from "./launch.js"; + +/** + * `arc run [args...]` — thin alias for `arc launch --bare `. + * + * Skips all profile resolution and env injection. The named binary is + * launched with the ambient environment so ARC behaves as optional + * orchestration rather than a required wrapper. + */ +export async function handleRun( + tool: string, + args: string[], + opts?: { beforeSpawn?: () => void | Promise } +): Promise { + // Delegate to handleLaunch in bare mode. The first positional to + // handleLaunch is the "name" slot; bare mode consumes it as the tool. + await handleLaunch(tool, [tool, ...args], { + bare: true, + tool, + beforeSpawn: opts?.beforeSpawn, + }); +} diff --git a/packages/cli/src/display.ts b/packages/cli/src/display.ts index 0605161..51181c6 100644 --- a/packages/cli/src/display.ts +++ b/packages/cli/src/display.ts @@ -131,6 +131,20 @@ export function profileTable( ].join("\n"); } +// ── Active Profile Rendering ─────────────────────── + +/** + * Format the active profile name for display. + * Returns `(none)` (dimmed) when no profile is active — this is the + * canonical "bare-mode" marker used across CLI output and TUI. + */ +export function formatActiveProfile(name: string | null | undefined): string { + if (!name) { + return pc.dim("(none)"); + } + return name; +} + // ── Redaction ────────────────────────────────────── export function redact(value: string): string { diff --git a/packages/cli/src/services/health.ts b/packages/cli/src/services/health.ts index 1fdb16d..21cf2c6 100644 --- a/packages/cli/src/services/health.ts +++ b/packages/cli/src/services/health.ts @@ -120,11 +120,16 @@ export async function getRuntimeHealthReport(): Promise { } checks.push({ id: "profiles-present", label: "Profiles configured", status: Object.keys(config.profiles).length > 0 ? "pass" : "fail", summary: Object.keys(config.profiles).length > 0 ? `${Object.keys(config.profiles).length} profiles configured` : "No profiles configured" }); - checks.push({ id: "active-profile", label: "Active profile", status: config.profiles[config.activeProfile] ? "pass" : "fail", summary: config.profiles[config.activeProfile] ? `Active profile is ${config.activeProfile}` : `Active profile ${config.activeProfile} is missing`, profile: config.activeProfile }); + const activeKey = config.activeProfile; + if (activeKey === null) { + checks.push({ id: "active-profile", label: "Active profile", status: "pass", summary: "No active profile (bare mode) — tools launch natively via 'arc run'" }); + } else { + checks.push({ id: "active-profile", label: "Active profile", status: config.profiles[activeKey] ? "pass" : "fail", summary: config.profiles[activeKey] ? `Active profile is ${activeKey}` : `Active profile ${activeKey} is missing`, profile: activeKey }); + } const logDirWritable = canWriteLogDir(); checks.push({ id: "log-dir", label: "Log directory", status: logDirWritable ? "pass" : "fail", summary: logDirWritable ? `Log directory writable at ${getLogsDir()}` : `Log directory not writable at ${getLogsDir()}` }); for (const [name, profile] of Object.entries(config.profiles)) { checks.push(...(await buildProfileChecks(name, profile))); } - return buildHealthReport(checks, config.activeProfile); + return buildHealthReport(checks, activeKey ?? undefined); } diff --git a/packages/cli/src/tui/Dashboard.tsx b/packages/cli/src/tui/Dashboard.tsx index c86ce7f..69e63f0 100644 --- a/packages/cli/src/tui/Dashboard.tsx +++ b/packages/cli/src/tui/Dashboard.tsx @@ -1,9 +1,16 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Box, Text, useApp, useInput } from "ink"; import { Spinner } from "@inkjs/ui"; import { useScreenSize } from "fullscreen-ink"; import { Layout } from "./components/Layout.js"; -import { Sidebar, type ViewName } from "./components/Sidebar.js"; +import { + Sidebar, + NAV_ITEMS, + SIDEBAR_PROFILES_START, + sidebarSelectableCount, + sidebarProfileCount, + type ViewName, +} from "./components/Sidebar.js"; import { SessionView } from "./views/SessionView.js"; import { ProfilesView } from "./views/ProfilesView.js"; import { SettingsView } from "./views/SettingsView.js"; @@ -26,6 +33,8 @@ import { ProfileInfoOverlay } from "./views/ProfileInfoOverlay.js"; import { OnboardingScreen } from "./views/OnboardingScreen.js"; import { useProfiles } from "./useProfiles.js"; import { useTheme } from "./theme.js"; +import { ToastProvider, useToast } from "./useToast.js"; +import { ToastContainer } from "./components/Toast.js"; import { runSelfUpdate } from "../update.js"; import { handleLaunch } from "../commands/launch.js"; import { markLaunchPending } from "./render.js"; @@ -35,6 +44,15 @@ const MIN_HEIGHT = 18; type OverlayName = "palette" | "help" | "create" | "updating" | "swap" | "about" | "shared-detail" | "profile-info" | null; export function Dashboard() { + return ( + + + + ); +} + +function DashboardInner() { + const { toasts } = useToast(); const { profiles, loading, config, reload } = useProfiles(); const { exit } = useApp(); const { toggleTheme, theme } = useTheme(); @@ -46,6 +64,24 @@ export function Dashboard() { const [workspaceTyping, setWorkspaceTyping] = useState(false); const [activity, setActivity] = useState([]); const [infoProfile, setInfoProfile] = useState(null); + // 0-indexed position in the combined sidebar list [nav..., visible profiles...]. + const [sidebarSelection, setSidebarSelection] = useState(0); + + const selectableCount = sidebarSelectableCount(profiles); + useEffect(() => { + if (selectableCount === 0) return; + if (sidebarSelection >= selectableCount) { + setSidebarSelection(selectableCount - 1); + } + }, [selectableCount, sidebarSelection]); + + useEffect(() => { + const navIdx = NAV_ITEMS.findIndex((item) => item.view === activeView); + if (navIdx >= 0 && sidebarSelection < NAV_ITEMS.length) { + setSidebarSelection(navIdx); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeView]); const paletteItems: PaletteItem[] = [ { id: "dash", label: "Dashboard", description: "Overview and status" }, @@ -183,6 +219,39 @@ export function Dashboard() { handleTriggerUpdate(); return; } + + // --- Sidebar navigation (combined nav + profile queue) --- + const profileCount = sidebarProfileCount(profiles); + const total = NAV_ITEMS.length + profileCount; + + if (key.upArrow) { + if (total === 0) return; + setSidebarSelection((prev) => Math.max(0, prev - 1)); + return; + } + + if (key.downArrow) { + if (total === 0) return; + setSidebarSelection((prev) => Math.min(total - 1, prev + 1)); + return; + } + + if (key.return) { + if (sidebarSelection < NAV_ITEMS.length) { + const item = NAV_ITEMS[sidebarSelection]; + if (item) setActiveView(item.view); + return; + } + const profileIdx = sidebarSelection - SIDEBAR_PROFILES_START; + const profile = profiles.slice(0, profileCount)[profileIdx]; + if (profile) { + markLaunchPending(); + setTimeout(() => { + handleLaunch(profile.name, [], { beforeSpawn: exit }); + }, 0); + } + return; + } }, { isActive: !showOnboarding }); const tooSmall = width < MIN_WIDTH || height < MIN_HEIGHT; @@ -371,13 +440,12 @@ export function Dashboard() { sidebar={ } - content={content} + content={<>{content}} overlay={overlayNode} overlayOpen={overlay !== null} overlayName={overlay} diff --git a/packages/cli/src/tui/components/Sidebar.tsx b/packages/cli/src/tui/components/Sidebar.tsx index cf3015f..21b8216 100644 --- a/packages/cli/src/tui/components/Sidebar.tsx +++ b/packages/cli/src/tui/components/Sidebar.tsx @@ -1,10 +1,10 @@ -import { Box, Text, useInput } from "ink"; +import { Box, Text } from "ink"; import { useTheme } from "../theme.js"; import type { ProfileEntry } from "../useProfiles.js"; export type ViewName = "dash" | "workspace" | "profiles" | "about" | "doctor" | "settings" | "tasks" | "memory" | "skills" | "sync" | "telemetry" | "agents"; -const NAV_ITEMS: { view: ViewName; label: string }[] = [ +export const NAV_ITEMS: { view: ViewName; label: string }[] = [ { view: "dash", label: "Dash" }, { view: "workspace", label: "Work" }, { view: "profiles", label: "Profiles" }, @@ -19,45 +19,51 @@ const NAV_ITEMS: { view: ViewName; label: string }[] = [ { view: "agents", label: "Agents" }, ]; +/** Max number of profile rows rendered in the sidebar queue. */ +export const SIDEBAR_PROFILE_LIMIT = 5; + +/** + * Returns the count of selectable profile rows in the sidebar (capped). + */ +export function sidebarProfileCount(profiles: ProfileEntry[]): number { + return Math.min(profiles.length, SIDEBAR_PROFILE_LIMIT); +} + +/** + * Total selectable rows in the sidebar (nav items + visible profile rows). + */ +export function sidebarSelectableCount(profiles: ProfileEntry[]): number { + return NAV_ITEMS.length + sidebarProfileCount(profiles); +} + +/** + * Index at which profile rows begin in the combined selection list. + */ +export const SIDEBAR_PROFILES_START = NAV_ITEMS.length; + interface SidebarProps { activeView: ViewName; - onViewChange: (view: ViewName) => void; profiles: ProfileEntry[]; focusedPane: "sidebar" | "content"; - inputEnabled: boolean; + /** + * 0-indexed position in the combined [nav..., profiles...] list. + * Drives the visual highlight when sidebar is focused. + */ + selectedIndex: number; } export function Sidebar({ activeView, - onViewChange, profiles, focusedPane, - inputEnabled, + selectedIndex, }: SidebarProps) { const { theme } = useTheme(); const { colors } = theme; const isFocused = focusedPane === "sidebar"; - const navIndex = Math.max(0, NAV_ITEMS.findIndex((item) => item.view === activeView)); const activeProfile = profiles.find((profile) => profile.active); const readyCount = profiles.filter((profile) => profile.credential?.authenticated).length; - - useInput( - (_, key) => { - if (!isFocused || !inputEnabled) return; - - if (key.upArrow) { - const previous = NAV_ITEMS[Math.max(0, navIndex - 1)]; - if (previous) onViewChange(previous.view); - return; - } - - if (key.downArrow) { - const next = NAV_ITEMS[Math.min(NAV_ITEMS.length - 1, navIndex + 1)]; - if (next) onViewChange(next.view); - } - }, - { isActive: isFocused && inputEnabled } - ); + const visibleProfiles = profiles.slice(0, SIDEBAR_PROFILE_LIMIT); return ( @@ -77,7 +83,7 @@ export function Sidebar({ {NAV_ITEMS.map((item, index) => { const isActive = item.view === activeView; - const isHighlighted = isFocused && index === navIndex; + const isHighlighted = isFocused && index === selectedIndex; let textColor = colors.dimmed; if (isActive) textColor = colors.primary; @@ -115,22 +121,38 @@ export function Sidebar({ {/* Queue */} {"─".repeat(14)} - {profiles.length === 0 ? ( + {visibleProfiles.length === 0 ? ( no profiles ) : ( - profiles.slice(0, 5).map((profile) => ( - - - {profile.active ? "● " : "○ "} - - { + const combinedIndex = SIDEBAR_PROFILES_START + index; + const isHighlighted = isFocused && combinedIndex === selectedIndex; + return ( + - {profile.name} - - - )) + + {isHighlighted ? "›" : " "} + + + {profile.active ? "● " : " ○ "} + + + {profile.name} + + + ); + }) )} diff --git a/packages/cli/src/tui/components/Toast.tsx b/packages/cli/src/tui/components/Toast.tsx new file mode 100644 index 0000000..df98122 --- /dev/null +++ b/packages/cli/src/tui/components/Toast.tsx @@ -0,0 +1,70 @@ +import { Box, Text } from "ink"; +import { useTheme } from "../theme.js"; +import type { ToastItem, ToastKind } from "../useToast.js"; + +function colorForKind( + kind: ToastKind, + colors: ReturnType["theme"]["colors"] +): string { + switch (kind) { + case "success": + return colors.success; + case "error": + return colors.error; + case "warn": + return colors.warning; + case "info": + default: + return colors.primary; + } +} + +function iconForKind(kind: ToastKind): string { + switch (kind) { + case "success": + return "\u2714"; + case "error": + return "\u2716"; + case "warn": + return "\u26A0"; + case "info": + default: + return "\u2139"; + } +} + +interface ToastProps { + toast: ToastItem; +} + +export function Toast({ toast }: ToastProps) { + const { theme } = useTheme(); + const color = colorForKind(toast.kind, theme.colors); + + return ( + + {iconForKind(toast.kind)} + {toast.message} + + ); +} + +interface ToastContainerProps { + toasts: ToastItem[]; +} + +export function ToastContainer({ toasts }: ToastContainerProps) { + if (toasts.length === 0) return null; + return ( + + {toasts.map((toast) => ( + + ))} + + ); +} diff --git a/packages/cli/src/tui/render.tsx b/packages/cli/src/tui/render.tsx index 9acd986..e5d1681 100644 --- a/packages/cli/src/tui/render.tsx +++ b/packages/cli/src/tui/render.tsx @@ -13,13 +13,11 @@ export function markLaunchPending(): void { const ALT_BUFFER_ON = "\x1b[?1049h"; const ALT_BUFFER_OFF = "\x1b[?1049l"; -const MOUSE_ON = "\x1b[?1000h\x1b[?1006h"; -const MOUSE_OFF = "\x1b[?1006l\x1b[?1000l"; const CURSOR_SHOW = "\x1b[?25h"; function restoreTerminal(): void { try { - process.stdout.write(MOUSE_OFF + ALT_BUFFER_OFF + CURSOR_SHOW); + process.stdout.write(ALT_BUFFER_OFF + CURSOR_SHOW); } catch { // stdout may already be closed during teardown } @@ -53,7 +51,7 @@ export async function renderDashboard(): Promise { await withLifecycleScope({ component: "tui" }, async (scope) => { scope.registerCleanup(restoreTerminal); - process.stdout.write(ALT_BUFFER_ON + MOUSE_ON); + process.stdout.write(ALT_BUFFER_ON); writeLogEvent({ level: "info", component: "tui", action: "dashboard:start" }); const instance = render( diff --git a/packages/cli/src/tui/useToast.ts b/packages/cli/src/tui/useToast.ts new file mode 100644 index 0000000..6d46707 --- /dev/null +++ b/packages/cli/src/tui/useToast.ts @@ -0,0 +1,94 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, + createElement, + type ReactNode, +} from "react"; + +export type ToastKind = "info" | "success" | "error" | "warn"; + +export interface ToastItem { + id: string; + message: string; + kind: ToastKind; + createdAt: number; +} + +interface ToastContextValue { + toasts: ToastItem[]; + showToast: (message: string, kind?: ToastKind) => void; + dismissToast: (id: string) => void; +} + +const ToastContext = createContext(undefined); + +const TOAST_DURATION_MS = 2500; + +let toastIdCounter = 0; +function nextToastId(): string { + toastIdCounter += 1; + return `toast-${Date.now()}-${toastIdCounter}`; +} + +interface ToastProviderProps { + children: ReactNode; +} + +export function ToastProvider({ children }: ToastProviderProps) { + const [toasts, setToasts] = useState([]); + const timersRef = useRef(new Map>()); + + const dismissToast = useCallback((id: string) => { + const timer = timersRef.current.get(id); + if (timer) { + clearTimeout(timer); + timersRef.current.delete(id); + } + setToasts((current) => current.filter((t) => t.id !== id)); + }, []); + + const showToast = useCallback( + (message: string, kind: ToastKind = "info") => { + const id = nextToastId(); + const toast: ToastItem = { + id, + message, + kind, + createdAt: Date.now(), + }; + setToasts((current) => [...current, toast]); + const timer = setTimeout(() => { + timersRef.current.delete(id); + setToasts((current) => current.filter((t) => t.id !== id)); + }, TOAST_DURATION_MS); + timersRef.current.set(id, timer); + }, + [] + ); + + useEffect(() => { + const timers = timersRef.current; + return () => { + for (const timer of timers.values()) { + clearTimeout(timer); + } + timers.clear(); + }; + }, []); + + const value: ToastContextValue = { toasts, showToast, dismissToast }; + + return createElement(ToastContext.Provider, { value }, children); +} + +export function useToast(): ToastContextValue { + const ctx = useContext(ToastContext); + if (!ctx) { + throw new Error("useToast must be used within a ToastProvider"); + } + return ctx; +} diff --git a/packages/cli/src/tui/views/DashView.tsx b/packages/cli/src/tui/views/DashView.tsx index 20777ea..ebf71aa 100644 --- a/packages/cli/src/tui/views/DashView.tsx +++ b/packages/cli/src/tui/views/DashView.tsx @@ -7,6 +7,12 @@ import { VERSION } from "../../version.js"; import { ImportHint } from "../components/ImportHint.js"; import { detectToolConfigs, type DetectedTool } from "../../detect.js"; import { checkForUpdate, type UpdateInfo } from "../../update.js"; +import { + getRecentLaunches, + queryLogEvents, + type LaunchHistoryEntry, + type LogEvent, +} from "@axiom-labs/arc-core"; import type { ProfileEntry } from "../useProfiles.js"; interface Props { @@ -132,7 +138,7 @@ function LeftColumn({ profiles, colors, isDark }: { )} {activeToolLabel && ( @@ -141,44 +147,119 @@ function LeftColumn({ profiles, colors, isDark }: { {activeProfile?.credential?.accountTier && ( )} + {!activeProfile && profiles.length > 0 && ( + + Press c to create a profile, + or run arc run <tool> for native mode. + + )} ); } -/* ── Right Column: pipeline + status ───────────────────────────────── */ +/* ── Right Column: recent launches + activity ──────────────────────── */ + +function formatTime(iso: string): string { + try { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return "--:--"; + const hh = String(d.getHours()).padStart(2, "0"); + const mm = String(d.getMinutes()).padStart(2, "0"); + return `${hh}:${mm}`; + } catch { + return "--:--"; + } +} + +function outcomeColor(outcome: string, colors: ThemeColors): string { + switch (outcome) { + case "started": + return colors.primary; + case "exited": + return colors.success; + case "failed": + return colors.error; + default: + return colors.dimmed; + } +} function RightColumn({ colors }: { colors: ThemeColors }) { - // TODO: Wire to real hook runner state + data stores - // For now, show idle/empty state — don't fake activity + const [launches, setLaunches] = useState([]); + const [activity, setActivity] = useState([]); + + useEffect(() => { + const refresh = () => { + try { + setLaunches(getRecentLaunches(5)); + } catch { + setLaunches([]); + } + try { + setActivity(queryLogEvents({ limit: 5 })); + } catch { + setActivity([]); + } + }; + refresh(); + const timer = setInterval(refresh, 4000); + return () => clearInterval(timer); + }, []); return ( - - - {"░".repeat(5)} - {"░".repeat(5)} - {"░".repeat(5)} - {"░".repeat(5)} - - - PRE - VAL - POST - DONE - - - - + + + {launches.length === 0 ? ( + No launches yet. + ) : ( + launches.map((entry, idx) => ( + + + {formatTime(entry.timestamp)} + + + {entry.profile} + + + + {entry.tool} + + + {entry.outcome} + + + )) + )} - + - - - - - + {activity.length === 0 ? ( + No activity yet. + ) : ( + activity + .slice() + .reverse() + .map((event, idx) => { + const detail = + event.message ?? event.detail ?? event.action ?? ""; + return ( + + + + {formatTime(event.timestamp)} + + + + {event.action} + + {detail} + + ); + }) + )} ); diff --git a/packages/cli/src/tui/views/ProfilesView.tsx b/packages/cli/src/tui/views/ProfilesView.tsx index 2e76bea..a4539a8 100644 --- a/packages/cli/src/tui/views/ProfilesView.tsx +++ b/packages/cli/src/tui/views/ProfilesView.tsx @@ -4,12 +4,12 @@ import { Box, Text, useInput } from "ink"; import { Spinner } from "@inkjs/ui"; import { useTheme } from "../theme.js"; import { ProfileList } from "../components/ProfileList.js"; -import { saveConfig, loadConfig } from "../../config.js"; +import { saveConfig, loadConfig, cloneProfile } from "../../config.js"; import { syncSharedToProfile, unsyncSharedFromProfile, getSharedManifest, pullProfileToShared } from "../../shared.js"; -import { RENDER_DEFER_MS } from "../createProfile.js"; +import { RENDER_DEFER_MS, validateName } from "../createProfile.js"; import type { ProfileEntry } from "../useProfiles.js"; -type Action = "idle" | "launching" | "confirm-delete" | "edit-flags"; +type Action = "idle" | "launching" | "confirm-delete" | "edit-flags" | "clone"; interface Props { profiles: ProfileEntry[]; @@ -40,6 +40,8 @@ export function ProfilesView({ const [message, setMessage] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); const [flagsInput, setFlagsInput] = useState(""); + const [cloneSource, setCloneSource] = useState(null); + const [cloneInput, setCloneInput] = useState(""); const showMessage = useCallback((msg: string) => { setMessage(msg); @@ -64,7 +66,8 @@ export function ProfilesView({ if (config.activeProfile === deleteTarget) { const remaining = Object.keys(config.profiles); - config.activeProfile = remaining[0] ?? "default"; + // When no profiles remain, clear active entirely. + config.activeProfile = remaining[0] ?? null; } saveConfig(config); @@ -98,6 +101,54 @@ export function ProfilesView({ return; } + // ── Clone mode ── + if (action === "clone") { + if (key.escape) { + setAction("idle"); + setCloneInput(""); + setCloneSource(null); + showMessage("Clone cancelled"); + return; + } + if (key.return) { + const src = cloneSource; + const dst = cloneInput.trim(); + if (!src) { + setAction("idle"); + setCloneInput(""); + setCloneSource(null); + return; + } + try { + const config = loadConfig(); + const nameError = validateName(dst, Object.keys(config.profiles)); + if (nameError) { + showMessage(nameError); + return; + } + const updated = cloneProfile(config, src, dst, { copyConfigDir: true }); + saveConfig(updated); + showMessage(`Cloned ${src} → ${dst}`); + reload(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + showMessage(`Clone failed: ${msg}`); + } + setAction("idle"); + setCloneInput(""); + setCloneSource(null); + return; + } + if (key.backspace || key.delete) { + setCloneInput((v) => v.slice(0, -1)); + return; + } + if (!key.ctrl && !key.meta && input.length === 1) { + setCloneInput((v) => v + input); + } + return; + } + // ── Edit flags mode ── if (action === "edit-flags") { if (key.escape) { @@ -190,6 +241,14 @@ export function ProfilesView({ return; } + // [C] clone profile (uppercase — lowercase `c` still means create) + if (input === "C") { + setCloneSource(selected.name); + setCloneInput(""); + setAction("clone"); + return; + } + if (input === "c") { onCreateProfile?.(); return; @@ -300,6 +359,45 @@ export function ProfilesView({ onShowInfo?.(selected.name); return; } + + // [m] toggle launch mode (native <-> worker) + if (input === "m") { + try { + const config = loadConfig(); + const profile = config.profiles[selected.name]; + if (!profile) return; + const current = profile.launchMode ?? "native"; + const next: "native" | "worker" = current === "native" ? "worker" : "native"; + profile.launchMode = next; + saveConfig(config); + showMessage(`Launch mode: ${next}`); + reload(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + showMessage(`Toggle failed: ${msg}`); + } + return; + } + + // [x] clear active profile (tools launch natively via `arc run`) + if (input === "x") { + try { + const config = loadConfig(); + if (config.activeProfile === null) { + showMessage("No active profile — already cleared"); + } else { + const previous = config.activeProfile; + config.activeProfile = null; + saveConfig(config); + showMessage(`Cleared active profile (was ${previous})`); + reload(); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + showMessage(`Clear failed: ${msg}`); + } + return; + } }, { isActive: isActive && inputEnabled } ); @@ -328,6 +426,10 @@ export function ProfilesView({ {isSyncSource && ( {"\u2605"} sync source — shared layer auto-pulls from this profile )} + {/* Launch mode indicator */} + + launch: [{selectedConfig?.launchMode ?? "native"}] + {/* Shared layer status */} {selectedManifest ? ( @@ -353,6 +455,20 @@ export function ProfilesView({ )} + {/* Clone mode */} + {action === "clone" && ( + + + Clone {cloneSource} as: + {cloneInput} + {"\u258C"} + + + enter save esc cancel + + + )} + {/* Edit flags mode */} {action === "edit-flags" && ( @@ -389,7 +505,7 @@ export function ProfilesView({ {!loading && action === "idle" && ( - {"\u21B5"} launch s switch i info d delete h sync shift+h push shift+s source f flags c create + {"\u21B5"} launch s switch x clear i info m mode d delete h sync shift+h push shift+s source f flags c create shift+c clone )} diff --git a/packages/cli/src/tui/views/SessionView.tsx b/packages/cli/src/tui/views/SessionView.tsx index 577359a..060f3f8 100644 --- a/packages/cli/src/tui/views/SessionView.tsx +++ b/packages/cli/src/tui/views/SessionView.tsx @@ -522,10 +522,17 @@ export function SessionView({ ) : ( - - No active profile. Press - c - to create one. + + + No active profile. Use + arc run claude + for native launch, + + + or switch to a profile (press + c + to create one). + )} diff --git a/packages/cli/src/version.ts b/packages/cli/src/version.ts index edbab61..457da26 100644 --- a/packages/cli/src/version.ts +++ b/packages/cli/src/version.ts @@ -1 +1 @@ -export const VERSION = "0.2.0"; +export const VERSION = "1.0.0-alpha.0"; diff --git a/packages/client/README.md b/packages/client/README.md new file mode 100644 index 0000000..d5c9b64 --- /dev/null +++ b/packages/client/README.md @@ -0,0 +1,21 @@ +# @axiom-labs/arc-client + +Client SDK for the ARC daemon's binary-multiplexed WebSocket protocol. + +Used by ARC's TUI, CLI, dashboard, Electron wrapper, and mobile app. +Exposes: + +- `ArcClient` — connect, reconnect, auth, call/subscribe/attachTerminal +- `Envelope`, `Methods`, `Channel` — protocol schemas shared with the daemon +- `encodeFrame`, `decodeFrame`, `encodeControl`, `decodeControl` — binary mux codec + +```ts +import { ArcClient } from "@axiom-labs/arc-client"; + +const client = new ArcClient({ url: "ws://127.0.0.1:7272", token: "…" }); +await client.connect(); +const health = await client.health(); +const { agents } = await client.agents.list(); +``` + +See `docs/plans/arc-v3-daemon.md` for the full protocol spec. diff --git a/packages/client/package.json b/packages/client/package.json new file mode 100644 index 0000000..6c70f22 --- /dev/null +++ b/packages/client/package.json @@ -0,0 +1,19 @@ +{ + "name": "@axiom-labs/arc-client", + "version": "1.0.0-alpha.0", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "description": "Client SDK for the ARC daemon protocol (binary-mux WebSocket).", + "scripts": { + "build": "tsup src/index.ts --format esm --target node20 --clean", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "ws": "^8.18.0", + "zod": "^3.25.0" + }, + "devDependencies": { + "@types/ws": "^8.5.12" + } +} diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts new file mode 100644 index 0000000..0cbb10b --- /dev/null +++ b/packages/client/src/client.ts @@ -0,0 +1,269 @@ +import WebSocket from "ws"; +import { z } from "zod"; +import { + AgentListResult, + AgentOkResult, + AgentRunParams, + AgentRunResult, + AgentSendParams, + AgentStopParams, + AuthLoginResult, + Channel, + Envelope, + HealthGetResult, + Methods, + ProfileListResult, + type ChannelId, + type Envelope as EnvelopeT, +} from "./protocol.js"; +import { decodeFrame, encodeControl, encodeFrame } from "./frame.js"; + +export interface ArcClientOptions { + /** WebSocket URL, e.g. `ws://127.0.0.1:7272`. */ + url: string; + /** Shared secret or per-client token. */ + token: string; + /** Reconnect backoff base in ms (default 500). */ + reconnectBaseMs?: number; + /** Reconnect backoff cap in ms (default 15000). */ + reconnectCapMs?: number; + /** Disable auto-reconnect (useful for one-shot CLI). */ + noReconnect?: boolean; +} + +export type TopicHandler = (payload: unknown) => void; +export type TerminalHandler = (agentId: string, bytes: Uint8Array) => void; + +interface Pending { + resolve: (value: unknown) => void; + reject: (err: Error) => void; +} + +export class ArcClient { + private ws: WebSocket | null = null; + private opts: Required> & { noReconnect: boolean }; + private pending = new Map(); + private topicHandlers = new Map>(); + private terminalHandler: TerminalHandler | null = null; + private reconnectAttempt = 0; + private closed = false; + private idCounter = 0; + private sessionId: string | null = null; + + constructor(options: ArcClientOptions) { + this.opts = { + url: options.url, + token: options.token, + reconnectBaseMs: options.reconnectBaseMs ?? 500, + reconnectCapMs: options.reconnectCapMs ?? 15000, + noReconnect: options.noReconnect ?? false, + }; + } + + /** Open connection + authenticate. Resolves after the login RPC returns. */ + async connect(): Promise { + await this.openSocket(); + const result = await this.call(Methods.auth_login, { token: this.opts.token }); + const parsed = AuthLoginResult.parse(result); + this.sessionId = parsed.sessionId; + // Resubscribe any topics from before reconnect. + for (const topic of this.topicHandlers.keys()) { + await this.sendSubscribe(topic); + } + } + + async close(): Promise { + this.closed = true; + this.ws?.close(); + this.ws = null; + } + + /** Typed request/response RPC. */ + async call(method: string, params?: unknown): Promise { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + throw new Error("client not connected"); + } + const id = this.nextId(); + const envelope: EnvelopeT = { v: 1, id, type: "request", method, params }; + const frame = encodeControl(envelope); + return new Promise((resolve, reject) => { + this.pending.set(id, { + resolve: (v) => resolve(v as T), + reject, + }); + this.ws!.send(frame); + }); + } + + async subscribe(topic: string, handler: TopicHandler): Promise<() => void> { + let set = this.topicHandlers.get(topic); + const first = !set; + if (!set) { + set = new Set(); + this.topicHandlers.set(topic, set); + } + set.add(handler); + if (first) await this.sendSubscribe(topic); + return async () => { + set!.delete(handler); + if (set!.size === 0) { + this.topicHandlers.delete(topic); + try { + await this.sendUnsubscribe(topic); + } catch { + // connection already closed — nothing to unsubscribe on server side + } + } + }; + } + + /** Set a handler for raw terminal channel bytes. One at a time (last wins). */ + attachTerminal(handler: TerminalHandler | null): void { + this.terminalHandler = handler; + } + + // --- Typed domain wrappers ---------------------------------------------- + + health = (): Promise> => + this.call(Methods.health_get).then((r) => HealthGetResult.parse(r)); + + profiles = { + list: (): Promise> => + this.call(Methods.profile_list).then((r) => ProfileListResult.parse(r)), + get: (name: string): Promise => this.call(Methods.profile_get, { name }), + }; + + agents = { + list: (): Promise> => + this.call(Methods.agent_list).then((r) => AgentListResult.parse(r)), + run: ( + params: z.infer, + ): Promise> => + this.call(Methods.agent_run, AgentRunParams.parse(params)).then((r) => + AgentRunResult.parse(r), + ), + stop: (params: z.infer): Promise> => + this.call(Methods.agent_stop, AgentStopParams.parse(params)).then((r) => + AgentOkResult.parse(r), + ), + send: (params: z.infer): Promise> => + this.call(Methods.agent_send, AgentSendParams.parse(params)).then((r) => + AgentOkResult.parse(r), + ), + }; + + // --- Internals ----------------------------------------------------------- + + private nextId(): string { + this.idCounter = (this.idCounter + 1) & 0xffffff; + return `${Date.now().toString(36)}-${this.idCounter.toString(36)}`; + } + + private openSocket(): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(this.opts.url); + ws.binaryType = "arraybuffer"; + const onOpen = () => { + ws.off("error", onError); + this.ws = ws; + this.reconnectAttempt = 0; + resolve(); + }; + const onError = (err: Error) => { + ws.off("open", onOpen); + reject(err); + }; + ws.once("open", onOpen); + ws.once("error", onError); + ws.on("message", (data: WebSocket.RawData) => this.handleMessage(data)); + ws.on("close", () => this.handleClose()); + }); + } + + private handleMessage(raw: WebSocket.RawData): void { + let buf: Uint8Array; + if (raw instanceof ArrayBuffer) buf = new Uint8Array(raw); + else if (Array.isArray(raw)) buf = Buffer.concat(raw); + else buf = new Uint8Array(raw as Buffer); + let frame: ReturnType; + try { + frame = decodeFrame(buf); + } catch { + return; + } + if (frame.channel === Channel.Control) { + let envelope: EnvelopeT; + try { + envelope = Envelope.parse(JSON.parse(new TextDecoder().decode(frame.payload))); + } catch { + return; + } + this.handleEnvelope(envelope); + return; + } + if (frame.channel === Channel.Terminal && this.terminalHandler) { + // Terminal frames carry agent id as the first UTF-8 line, then raw bytes. + // For Phase 1 there's no producer yet; payload is opaque — handed to the + // consumer verbatim with a placeholder agent id. + this.terminalHandler("", frame.payload); + } + } + + private handleEnvelope(envelope: EnvelopeT): void { + if (envelope.type === "response" || envelope.type === "error") { + const pending = this.pending.get(envelope.id); + if (!pending) return; + this.pending.delete(envelope.id); + if (envelope.type === "error") { + const err = new Error(envelope.message ?? "rpc error") as Error & { code?: string }; + err.code = envelope.code; + pending.reject(err); + } else { + pending.resolve(envelope.result); + } + return; + } + if (envelope.type === "event" && envelope.topic) { + const set = this.topicHandlers.get(envelope.topic); + if (!set) return; + for (const h of set) h(envelope.payload); + } + } + + private handleClose(): void { + this.ws = null; + for (const [id, pending] of this.pending) { + pending.reject(new Error("connection closed")); + this.pending.delete(id); + } + if (this.closed || this.opts.noReconnect) return; + const delay = Math.min( + this.opts.reconnectCapMs, + this.opts.reconnectBaseMs * 2 ** this.reconnectAttempt, + ); + this.reconnectAttempt += 1; + setTimeout(() => { + this.connect().catch(() => { + /* will loop back via handleClose */ + }); + }, delay); + } + + private async sendSubscribe(topic: string): Promise { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; + const id = this.nextId(); + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve: () => resolve(), reject }); + this.ws!.send(encodeControl({ v: 1, id, type: "subscribe", topic })); + }); + } + + private async sendUnsubscribe(topic: string): Promise { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; + const id = this.nextId(); + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve: () => resolve(), reject }); + this.ws!.send(encodeControl({ v: 1, id, type: "unsubscribe", topic })); + }); + } +} diff --git a/packages/client/src/frame.ts b/packages/client/src/frame.ts new file mode 100644 index 0000000..ee6f425 --- /dev/null +++ b/packages/client/src/frame.ts @@ -0,0 +1,54 @@ +import { Channel, type ChannelId } from "./protocol.js"; + +/** + * Binary frame format: + * + * ┌────┬──────┬────────────┬──────────────────┐ + * │ ch │ flag │ len (u32be)│ payload (N bytes)│ + * │ 1B │ 1B │ 4B │ │ + * └────┴──────┴────────────┴──────────────────┘ + * + * We transport this as a single WebSocket binary message — one frame per + * message. WebSocket already does its own length-prefixing, but we keep + * our own `len` so the format is self-describing if we ever move off WS + * (TCP, relay channels, etc.). + */ +export interface Frame { + channel: ChannelId; + flags: number; + payload: Uint8Array; +} + +export function encodeFrame(frame: Frame): Uint8Array { + const len = frame.payload.length; + const out = new Uint8Array(6 + len); + out[0] = frame.channel; + out[1] = frame.flags & 0xff; + // length big-endian + out[2] = (len >>> 24) & 0xff; + out[3] = (len >>> 16) & 0xff; + out[4] = (len >>> 8) & 0xff; + out[5] = len & 0xff; + out.set(frame.payload, 6); + return out; +} + +export function decodeFrame(buf: Uint8Array): Frame { + if (buf.length < 6) throw new Error("frame too short"); + const channel = buf[0] as ChannelId; + const flags = buf[1]!; + const len = (buf[2]! << 24) | (buf[3]! << 16) | (buf[4]! << 8) | buf[5]!; + if (buf.length < 6 + len) throw new Error("frame truncated"); + const payload = buf.subarray(6, 6 + len); + return { channel, flags, payload }; +} + +export function encodeControl(obj: unknown): Uint8Array { + const json = JSON.stringify(obj); + const payload = new TextEncoder().encode(json); + return encodeFrame({ channel: Channel.Control, flags: 0, payload }); +} + +export function decodeControl(payload: Uint8Array): unknown { + return JSON.parse(new TextDecoder().decode(payload)); +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts new file mode 100644 index 0000000..a7e6421 --- /dev/null +++ b/packages/client/src/index.ts @@ -0,0 +1,3 @@ +export * from "./protocol.js"; +export * from "./frame.js"; +export * from "./client.js"; diff --git a/packages/client/src/protocol.ts b/packages/client/src/protocol.ts new file mode 100644 index 0000000..5d9594e --- /dev/null +++ b/packages/client/src/protocol.ts @@ -0,0 +1,127 @@ +import { z } from "zod"; + +export const PROTOCOL_VERSION = 1; + +export const Channel = { + Control: 0x00, + Terminal: 0x01, + File: 0x02, + Audio: 0x03, +} as const; + +export type ChannelId = (typeof Channel)[keyof typeof Channel]; + +export const FLAG_FRAGMENTED = 0x01; + +export const EnvelopeType = z.enum([ + "request", + "response", + "event", + "subscribe", + "unsubscribe", + "error", +]); +export type EnvelopeType = z.infer; + +export const Envelope = z.object({ + v: z.literal(PROTOCOL_VERSION), + id: z.string(), + type: EnvelopeType, + method: z.string().optional(), + params: z.unknown().optional(), + result: z.unknown().optional(), + topic: z.string().optional(), + payload: z.unknown().optional(), + code: z.string().optional(), + message: z.string().optional(), +}); +export type Envelope = z.infer; + +export const ErrorCode = { + Unauthorized: "unauthorized", + BadRequest: "bad_request", + NotFound: "not_found", + Internal: "internal", + Unimplemented: "unimplemented", +} as const; + +// --- Method catalog (v1) ---------------------------------------------------- +// +// Keep this list authoritative. The daemon registers handlers by method name +// and the client SDK exposes typed wrappers keyed off it. + +export const Methods = { + auth_login: "auth.login", + health_get: "health.get", + profile_list: "profile.list", + profile_get: "profile.get", + agent_list: "agent.list", + agent_run: "agent.run", + agent_stop: "agent.stop", + agent_send: "agent.send", +} as const; +export type MethodName = (typeof Methods)[keyof typeof Methods]; + +// --- Method param / result schemas ----------------------------------------- + +export const AuthLoginParams = z.object({ token: z.string().min(16) }); +export const AuthLoginResult = z.object({ + sessionId: z.string(), + clientId: z.string(), + serverVersion: z.string(), + protocol: z.literal(PROTOCOL_VERSION), +}); + +export const HealthGetResult = z.object({ + ok: z.literal(true), + version: z.string(), + protocol: z.literal(PROTOCOL_VERSION), + uptime_ms: z.number(), + pid: z.number(), + host: z.string(), + port: z.number(), +}); + +export const ProfileSummary = z.object({ + name: z.string(), + tool: z.string(), + active: z.boolean().optional(), +}); +export const ProfileListResult = z.object({ profiles: z.array(ProfileSummary) }); + +export const AgentSummary = z.object({ + id: z.string(), + profile: z.string(), + cwd: z.string(), + status: z.string(), + launchMode: z.string(), + createdAt: z.number(), + updatedAt: z.number(), + completedAt: z.number().nullable().optional(), + worktree: z.string().nullable().optional(), +}); +export const AgentListResult = z.object({ agents: z.array(AgentSummary) }); + +export const AgentRunParams = z.object({ + profile: z.string(), + prompt: z.string().optional(), + cwd: z.string().optional(), + worktree: z.string().optional(), + launchMode: z.enum(["native", "worker"]).optional(), +}); +export const AgentRunResult = z.object({ agentId: z.string() }); + +export const AgentStopParams = z.object({ agentId: z.string() }); +export const AgentSendParams = z.object({ agentId: z.string(), text: z.string() }); +export const AgentOkResult = z.object({ ok: z.literal(true) }); + +// --- Topic helpers ---------------------------------------------------------- + +export const Topics = { + agents: "agents", + agent: (id: string) => `agent:${id}`, + profiles: "profiles", + chatRoom: (room: string) => `chat:${room}`, + loop: (id: string) => `loop:${id}`, + daemon: "daemon", +} as const; diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json new file mode 100644 index 0000000..6435d43 --- /dev/null +++ b/packages/client/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src"] +} diff --git a/packages/core/package.json b/packages/core/package.json index 1b49cb9..1ff467a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -8,5 +8,8 @@ "scripts": { "build": "tsup src/index.ts --format esm --target node20 --clean", "typecheck": "tsc --noEmit" + }, + "dependencies": { + "zod": "^3.25.0" } } diff --git a/packages/core/src/adapters/types.ts b/packages/core/src/adapters/types.ts index 0af1464..a2b1ccf 100644 --- a/packages/core/src/adapters/types.ts +++ b/packages/core/src/adapters/types.ts @@ -24,6 +24,12 @@ export interface LaunchOptions { env: Record; cwd?: string; beforeSpawn?: () => Promise; + /** + * Force a specific launch mode regardless of profile/CLI settings. + * Orchestrators (roundtable, pipelines) should pass `"worker"` to ensure + * the tool runs under ARC supervision. + */ + launchMode?: "native" | "worker"; } /** Handle for a running agent process. */ diff --git a/packages/core/src/agent-client/README.md b/packages/core/src/agent-client/README.md new file mode 100644 index 0000000..81946fa --- /dev/null +++ b/packages/core/src/agent-client/README.md @@ -0,0 +1,116 @@ +# agent-client — CLI-spawn Agent Foundation + +Phase 1 of the AI chat / roundtable plan. See `docs/plans/ai-and-roundtable.md` +decision **AD-1** for the why. + +## What it is + +A tiny abstraction for programmatic agent invocation: + +1. Take an ARC `Profile`. +2. Spawn the profile's native CLI tool in **one-shot mode** (no TUI). +3. Stream stdout back as structured `AgentChunk`s. +4. Optionally inject an MCP config at launch so the agent can call our tools. + +We do **not** build a direct HTTP LLM client. We orchestrate the agent tools +that already exist (`claude`, `codex`, `gemini`), letting them handle auth, +retries, streaming, and tool-use negotiation. + +## Usage + +```ts +import { getAgentClientForProfile } from "@axiom-labs/arc-core"; + +const client = getAgentClientForProfile(profile); + +for await (const chunk of client.send("What profiles do I have?")) { + if (chunk.type === "text") process.stdout.write(chunk.content); + if (chunk.type === "done") break; +} + +await client.shutdown(); +``` + +### With MCP injection + +```ts +for await (const chunk of client.send("List my profiles", { + mcpConfig: { + mode: "config-file", // must match profile.tool's mcpMode + servers: { + arc: { + command: "node", + args: ["/path/to/arc-mcp-server.mjs"], + env: { ARC_AUTH_TOKEN: "..." }, + }, + }, + }, +})) { + if (chunk.type === "tool_call") console.log("tool:", chunk.tool, chunk.input); + if (chunk.type === "tool_result") console.log("result:", chunk.result); + if (chunk.type === "text") process.stdout.write(chunk.content); +} +``` + +### With instructions / abort / timeout + +```ts +const ac = new AbortController(); +setTimeout(() => ac.abort(), 30_000); + +for await (const chunk of client.send("Summarize my setup", { + instructions: "You are an ARC operator. Be concise.", + signal: ac.signal, + timeoutMs: 45_000, +})) { /* ... */ } +``` + +## MCP injection modes + +| Tool | `mcpMode` | How | +|--------|----------------|-----| +| claude | `config-file` | Write `{mcpServers:{...}}` to a temp file, pass `--mcp-config `. | +| codex | `config-args` | Emit `-c mcp.servers..=` repeated. | +| gemini | `mcp-add` | Run `gemini mcp add --scope project ...` before launch. | + +All three mirror Agent-Forge's `agents.json` tribal knowledge. + +## Output parsing + +| Tool | stdout format | Parser | +|--------|---------------------|--------| +| claude | line-delimited JSON | `parseClaudeStreamJson` | +| codex | line-delimited JSON | `parseCodexJson` | +| gemini | plain text | `parseGeminiPlain` (text passthrough) | + +Each parser returns `AgentChunk | null` per line. Unknown event shapes return +`null` (we skip instead of crashing). + +## Known gaps / TODOs + +- **System prompt.** Claude's `-p` mode has no dedicated system-prompt flag. + We synthesize one by wrapping: `System: ...\n\nUser: ...`. Same for Codex + (no separate system flag in `exec --json`). Gemini follows the same pattern + for consistency. +- **Codex event shape.** Codex's `exec --json` format has shifted between + versions; the parser accepts both `kind` and `type` discriminators and maps + the recognizable subset. Add real-binary smoke tests in Phase 4. +- **Gemini tool events.** Gemini `-p` prints plain text only; structured tool + events arrive via the MCP side-channel, not stdout. +- **Flags verification.** The exact one-shot flags (`--output-format stream-json + --verbose` for claude, `exec --json` for codex, `-p` for gemini) are drawn + from Agent-Forge + upstream docs. Verify with real binaries in Phase 4 smoke + tests before depending on them in CI. +- **Aider / opencode.** Omitted from Phase 1 — they're TUI-only with no clean + one-shot mode. + +## Files + +- `types.ts` — `AgentClient`, `AgentChunk`, `McpConfigInjection`, `AgentProgram`. +- `registry.ts` — `AGENT_PROGRAMS` ported from Agent-Forge `agents.json`. +- `mcp-injection.ts` — three injection helpers + temp-file cleanup. +- `stream-parsers.ts` — line → `AgentChunk` for each tool's output dialect. +- `spawn-helpers.ts` — internal spawn + stream primitive. +- `claude.ts`, `codex.ts`, `gemini.ts` — per-tool `AgentClient` classes. +- `dispatch.ts` — `getAgentClientForProfile`. +- `index.ts` — barrel export. diff --git a/packages/core/src/agent-client/claude.ts b/packages/core/src/agent-client/claude.ts new file mode 100644 index 0000000..e6f68c6 --- /dev/null +++ b/packages/core/src/agent-client/claude.ts @@ -0,0 +1,83 @@ +/** + * Claude one-shot agent client. + * + * Invocation: + * claude -p "" --output-format stream-json --verbose [--mcp-config ] + * + * Claude's `-p` mode has no dedicated system-prompt flag, so when + * `opts.instructions` is supplied we prepend it to the prompt with a + * "System: ... / User: ..." wrapper — the cleanest way to give it + * role-separated context without a TUI session. + * + * Output is line-delimited JSON parsed by `parseClaudeStreamJson`. The + * client is one-shot: `shutdown()` is a no-op (the child exits on its own). + */ + +import type { AgentClient, AgentChunk, AgentSendOptions } from "./types.js"; +import { AGENT_PROGRAMS } from "./registry.js"; +import { writeMcpConfigFile } from "./mcp-injection.js"; +import { parseClaudeStreamJson } from "./stream-parsers.js"; +import { runAgentProcess, type SpawnFn } from "./spawn-helpers.js"; + +export interface ClaudeAgentClientOptions { + /** Optional working directory override for the child. */ + cwd?: string; + /** Extra environment merged on top of `process.env`. */ + env?: NodeJS.ProcessEnv; + /** Optional spawn override for tests. */ + spawnFn?: SpawnFn; + /** Override the binary (used by tests / Windows shims). */ + command?: string; +} + +export class ClaudeAgentClient implements AgentClient { + constructor(private readonly cfg: ClaudeAgentClientOptions = {}) {} + + send(prompt: string, opts?: AgentSendOptions): AsyncIterable { + const self = this; + return { + [Symbol.asyncIterator](): AsyncIterator { + return self.#iterate(prompt, opts ?? {}); + }, + }; + } + + async shutdown(): Promise { + // One-shot: nothing to tear down. + } + + #iterate(prompt: string, opts: AgentSendOptions): AsyncIterator { + const program = AGENT_PROGRAMS["claude"]!; + const command = this.cfg.command ?? program.command; + + const args = [...program.oneShotFlags]; + + if (opts.mcpConfig) { + if (opts.mcpConfig.mode !== "config-file") { + throw new Error( + `ClaudeAgentClient requires mcpConfig.mode=config-file (got ${opts.mcpConfig.mode})`, + ); + } + const file = writeMcpConfigFile(opts.mcpConfig); + args.push("--mcp-config", file); + } + + const fullPrompt = opts.instructions + ? `System: ${opts.instructions}\n\nUser: ${prompt}` + : prompt; + + // Append prompt after `-p`. Claude accepts the positional prompt last. + // The `-p` flag is already in oneShotFlags[0]. + args.push(fullPrompt); + + return runAgentProcess({ + command, + args, + env: this.cfg.env, + cwd: this.cfg.cwd, + parse: parseClaudeStreamJson, + opts, + spawnFn: this.cfg.spawnFn, + }); + } +} diff --git a/packages/core/src/agent-client/codex.ts b/packages/core/src/agent-client/codex.ts new file mode 100644 index 0000000..48d7e7a --- /dev/null +++ b/packages/core/src/agent-client/codex.ts @@ -0,0 +1,76 @@ +/** + * Codex one-shot agent client. + * + * Invocation: + * codex exec --json [-c mcp.servers..= ...] < prompt-on-stdin + * + * Codex reads the prompt from stdin and emits line-delimited JSON events + * on stdout (see `parseCodexJson`). MCP injection uses `-c` config flags. + * + * Like Claude, `instructions` is folded into the stdin payload as a + * `System: ... / User: ...` block — Codex doesn't have a separate + * system-prompt CLI flag either. + */ + +import type { AgentClient, AgentChunk, AgentSendOptions } from "./types.js"; +import { AGENT_PROGRAMS } from "./registry.js"; +import { buildMcpConfigArgs } from "./mcp-injection.js"; +import { parseCodexJson } from "./stream-parsers.js"; +import { runAgentProcess, type SpawnFn } from "./spawn-helpers.js"; + +export interface CodexAgentClientOptions { + cwd?: string; + env?: NodeJS.ProcessEnv; + spawnFn?: SpawnFn; + command?: string; +} + +export class CodexAgentClient implements AgentClient { + constructor(private readonly cfg: CodexAgentClientOptions = {}) {} + + send(prompt: string, opts?: AgentSendOptions): AsyncIterable { + const self = this; + return { + [Symbol.asyncIterator](): AsyncIterator { + return self.#iterate(prompt, opts ?? {}); + }, + }; + } + + async shutdown(): Promise { + // One-shot: nothing to tear down. + } + + #iterate(prompt: string, opts: AgentSendOptions): AsyncIterator { + const program = AGENT_PROGRAMS["codex"]!; + const command = this.cfg.command ?? program.command; + + const args = [...program.oneShotFlags]; + if (opts.mcpConfig) { + if (opts.mcpConfig.mode !== "config-args") { + throw new Error( + `CodexAgentClient requires mcpConfig.mode=config-args (got ${opts.mcpConfig.mode})`, + ); + } + args.unshift(...buildMcpConfigArgs(opts.mcpConfig)); + // Codex expects `-c ...` flags before the subcommand. But `exec` is + // already in oneShotFlags, which we cloned before unshifting, so the + // order ends up: -c k=v ... exec --json. That matches codex CLI usage. + } + + const stdinPayload = opts.instructions + ? `System: ${opts.instructions}\n\nUser: ${prompt}` + : prompt; + + return runAgentProcess({ + command, + args, + env: this.cfg.env, + cwd: this.cfg.cwd, + stdinPayload, + parse: parseCodexJson, + opts, + spawnFn: this.cfg.spawnFn, + }); + } +} diff --git a/packages/core/src/agent-client/dispatch.ts b/packages/core/src/agent-client/dispatch.ts new file mode 100644 index 0000000..1209121 --- /dev/null +++ b/packages/core/src/agent-client/dispatch.ts @@ -0,0 +1,77 @@ +/** + * Dispatcher — pick the right `AgentClient` for a given profile. + * + * Unsupported tools (`hermes`, `openclaw`, unknown) throw a clear error so + * the caller can decide whether to fall back or surface a user-facing message. + */ + +import type { Profile } from "../types.js"; +import type { AgentClient } from "./types.js"; +import { ClaudeAgentClient } from "./claude.js"; +import { CodexAgentClient } from "./codex.js"; +import { GeminiAgentClient } from "./gemini.js"; +import { AGENT_PROGRAMS } from "./registry.js"; + +export interface DispatchOptions { + /** Working directory override. Defaults to `process.cwd()`. */ + cwd?: string; + /** Extra env on top of `profile.envOverrides`. */ + extraEnv?: NodeJS.ProcessEnv; +} + +/** + * Build the child process environment from a profile. Merges: + * 1. `process.env` + * 2. profile-level env overrides + * 3. `ARC_CONFIG_DIR` set to the profile's isolated config dir + * 4. caller-supplied `extraEnv` + */ +export function buildProfileEnv(profile: Profile, extraEnv?: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env }; + if (profile.envOverrides) { + for (const [k, v] of Object.entries(profile.envOverrides)) { + env[k] = v; + } + } + if (profile.configDir) { + env["ARC_CONFIG_DIR"] = profile.configDir; + } + if (extraEnv) { + for (const [k, v] of Object.entries(extraEnv)) { + if (v !== undefined) env[k] = v; + } + } + return env; +} + +/** + * Return an `AgentClient` bound to the profile's tool, configDir, and env. + * Throws for tools that don't have a CLI-spawn implementation yet. + */ +export function getAgentClientForProfile(profile: Profile, dispatch: DispatchOptions = {}): AgentClient { + const tool = profile.tool; + if (!tool) { + throw new Error(`Profile has no tool set; cannot build an AgentClient`); + } + const program = AGENT_PROGRAMS[tool]; + if (!program) { + throw new Error( + `No AgentClient implementation for tool "${tool}". Supported: ${Object.keys(AGENT_PROGRAMS).join(", ")}.`, + ); + } + + const env = buildProfileEnv(profile, dispatch.extraEnv); + const cwd = dispatch.cwd; + + switch (tool) { + case "claude": + return new ClaudeAgentClient({ cwd, env }); + case "codex": + return new CodexAgentClient({ cwd, env }); + case "gemini": + return new GeminiAgentClient({ cwd, env }); + default: + // Unreachable given the registry lookup above, but keeps TS exhaustive. + throw new Error(`Unsupported tool: ${tool}`); + } +} diff --git a/packages/core/src/agent-client/gemini.ts b/packages/core/src/agent-client/gemini.ts new file mode 100644 index 0000000..1874a1b --- /dev/null +++ b/packages/core/src/agent-client/gemini.ts @@ -0,0 +1,73 @@ +/** + * Gemini one-shot agent client. + * + * Invocation: + * gemini -p "" + * + * Gemini's `-p` mode prints plain text with no structured tool events on + * stdout — tool use flows through the MCP side-channel registered via + * `gemini mcp add ...` before launch. For this phase we just stream text + * lines and emit `{type:"done"}` on process exit. + */ + +import type { AgentClient, AgentChunk, AgentSendOptions } from "./types.js"; +import { AGENT_PROGRAMS } from "./registry.js"; +import { runMcpAdd } from "./mcp-injection.js"; +import { parseGeminiPlain } from "./stream-parsers.js"; +import { runAgentProcess, type SpawnFn } from "./spawn-helpers.js"; + +export interface GeminiAgentClientOptions { + cwd?: string; + env?: NodeJS.ProcessEnv; + spawnFn?: SpawnFn; + command?: string; + /** When false, skip pre-launch `gemini mcp add` (tests). */ + autoRegisterMcp?: boolean; +} + +export class GeminiAgentClient implements AgentClient { + constructor(private readonly cfg: GeminiAgentClientOptions = {}) {} + + send(prompt: string, opts?: AgentSendOptions): AsyncIterable { + const self = this; + return { + [Symbol.asyncIterator](): AsyncIterator { + return self.#iterate(prompt, opts ?? {}); + }, + }; + } + + async shutdown(): Promise { + // One-shot: nothing to tear down. + } + + async *#iterate(prompt: string, opts: AgentSendOptions): AsyncGenerator { + const program = AGENT_PROGRAMS["gemini"]!; + const command = this.cfg.command ?? program.command; + + if (opts.mcpConfig && this.cfg.autoRegisterMcp !== false) { + if (opts.mcpConfig.mode !== "mcp-add") { + throw new Error( + `GeminiAgentClient requires mcpConfig.mode=mcp-add (got ${opts.mcpConfig.mode})`, + ); + } + await runMcpAdd(opts.mcpConfig, command); + } + + const fullPrompt = opts.instructions + ? `System: ${opts.instructions}\n\nUser: ${prompt}` + : prompt; + + const args = [...program.oneShotFlags, fullPrompt]; + + yield* runAgentProcess({ + command, + args, + env: this.cfg.env, + cwd: this.cfg.cwd, + parse: parseGeminiPlain, + opts, + spawnFn: this.cfg.spawnFn, + }); + } +} diff --git a/packages/core/src/agent-client/index.ts b/packages/core/src/agent-client/index.ts new file mode 100644 index 0000000..6207af6 --- /dev/null +++ b/packages/core/src/agent-client/index.ts @@ -0,0 +1,43 @@ +/** + * Agent client — Phase 1 public surface. + * + * Re-exports types, registry helpers, per-agent clients, and the dispatcher. + * See `./README.md` for usage. + */ + +export type { + AgentClient, + AgentChunk, + AgentSendOptions, + AgentProgram, + AgentOutputFormat, + InputMethod, + McpConfigMode, + McpConfigInjection, + McpServerDef, +} from "./types.js"; + +export { AGENT_PROGRAMS, resolveAgentProgram } from "./registry.js"; + +export { + writeMcpConfigFile, + buildMcpConfigArgs, + runMcpAdd, + cleanupMcpTempFiles, +} from "./mcp-injection.js"; + +export { + parseClaudeStreamJson, + parseCodexJson, + parseGeminiPlain, +} from "./stream-parsers.js"; + +export { ClaudeAgentClient } from "./claude.js"; +export { CodexAgentClient } from "./codex.js"; +export { GeminiAgentClient } from "./gemini.js"; + +export { + getAgentClientForProfile, + buildProfileEnv, + type DispatchOptions, +} from "./dispatch.js"; diff --git a/packages/core/src/agent-client/mcp-injection.ts b/packages/core/src/agent-client/mcp-injection.ts new file mode 100644 index 0000000..ff63955 --- /dev/null +++ b/packages/core/src/agent-client/mcp-injection.ts @@ -0,0 +1,174 @@ +/** + * MCP injection helpers for the three supported modes: + * - `config-file` — write `{ mcpServers: {...} }` JSON to a temp file. + * - `config-args` — emit repeated `-c mcp.servers..=` args. + * - `mcp-add` — run ` mcp add --scope [args]` + * before the main launch. + * + * See docs/plans/ai-and-roundtable.md AD-1 / AD-7. + */ + +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import type { McpConfigInjection, McpServerDef } from "./types.js"; + +/** Temp files created this session, cleaned up on demand. */ +const tempFiles: string[] = []; +const tempDirs: string[] = []; + +/** + * Write the MCP injection as a JSON file suitable for Claude's `--mcp-config`. + * Returns the absolute path of the file. The file is tracked for later cleanup + * via `cleanupMcpTempFiles`. + * + * Throws if `injection.mode !== "config-file"` — callers must use the matching + * helper for other modes. + */ +export function writeMcpConfigFile(injection: McpConfigInjection): string { + if (injection.mode !== "config-file") { + throw new Error( + `writeMcpConfigFile requires mode=config-file (got ${injection.mode})`, + ); + } + + const dir = mkdtempSync(path.join(tmpdir(), "arc-mcp-")); + tempDirs.push(dir); + const file = path.join(dir, "mcp-config.json"); + + const payload = { mcpServers: injection.servers }; + writeFileSync(file, JSON.stringify(payload, null, 2), "utf8"); + tempFiles.push(file); + return file; +} + +/** + * Build the `-c mcp.servers..=` flag pairs for Codex. + * Produces flat tokens, e.g.: + * + * ["-c", "mcp.servers.arc.command='node'", + * "-c", "mcp.servers.arc.args=['...']", + * "-c", "mcp.servers.arc.env.FOO='bar'"] + * + * Values are emitted as TOML single-quoted literals (`'...'`) with `'` + * doubled per TOML escape rules. Arrays are serialized as TOML inline arrays. + */ +export function buildMcpConfigArgs(injection: McpConfigInjection): string[] { + if (injection.mode !== "config-args") { + throw new Error( + `buildMcpConfigArgs requires mode=config-args (got ${injection.mode})`, + ); + } + + const out: string[] = []; + for (const [name, def] of Object.entries(injection.servers)) { + const base = `mcp.servers.${name}`; + out.push("-c", `${base}.command=${toTomlLiteral(def.command)}`); + + if (def.args && def.args.length > 0) { + const list = def.args.map(toTomlLiteral).join(", "); + out.push("-c", `${base}.args=[${list}]`); + } + + if (def.env) { + for (const [k, v] of Object.entries(def.env)) { + out.push("-c", `${base}.env.${k}=${toTomlLiteral(v)}`); + } + } + } + return out; +} + +/** + * Execute ` mcp add --scope [args...]` for each + * server in the injection. Used by Gemini (and any other tool that registers + * servers via CLI pre-launch). + */ +export async function runMcpAdd( + injection: McpConfigInjection, + binary: string, + scope: "user" | "project" = "project", +): Promise { + if (injection.mode !== "mcp-add") { + throw new Error( + `runMcpAdd requires mode=mcp-add (got ${injection.mode})`, + ); + } + + for (const [name, def] of Object.entries(injection.servers)) { + const envFlags: string[] = []; + if (def.env) { + for (const [k, v] of Object.entries(def.env)) { + envFlags.push("-e", `${k}=${v}`); + } + } + + const args = [ + "mcp", + "add", + "--scope", + scope, + name, + ...envFlags, + def.command, + ...(def.args ?? []), + ]; + + await runCmd(binary, args); + } +} + +/** + * Remove every temp file / dir created during this process lifetime. + * Safe to call more than once; errors are swallowed (best-effort cleanup). + */ +export function cleanupMcpTempFiles(): void { + for (const f of tempFiles.splice(0)) { + try { + rmSync(f, { force: true }); + } catch { + /* ignore */ + } + } + for (const d of tempDirs.splice(0)) { + try { + rmSync(d, { recursive: true, force: true }); + } catch { + /* ignore */ + } + } +} + +/** Internal: run a child process to completion. Rejects on non-zero exit. */ +function runCmd(cmd: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { + stdio: ["ignore", "pipe", "pipe"], + shell: process.platform === "win32", + windowsHide: true, + }); + let stderr = ""; + child.stderr?.on("data", (b: Buffer) => { + stderr += b.toString("utf8"); + }); + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) resolve(); + else reject(new Error(`${cmd} exited with code ${code}: ${stderr}`)); + }); + }); +} + +/** Render a value as a TOML single-quoted string literal. */ +function toTomlLiteral(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + +// Internal accessor for tests (not exported from the package barrel). +export function __peekTempFiles(): { files: string[]; dirs: string[] } { + return { files: [...tempFiles], dirs: [...tempDirs] }; +} + +// Re-export type for convenience. +export type { McpServerDef }; diff --git a/packages/core/src/agent-client/registry.ts b/packages/core/src/agent-client/registry.ts new file mode 100644 index 0000000..6d9338b --- /dev/null +++ b/packages/core/src/agent-client/registry.ts @@ -0,0 +1,67 @@ +/** + * Agent program registry — ported from Agent-Forge's `agents.json`. + * See docs/plans/ai-and-roundtable.md AD-1 / AD-7. + * + * Each entry describes the one-shot CLI invocation for a known tool. + * The registry is deliberately narrow: only tools for which we can spawn a + * non-interactive process and capture structured output. TUI-only tools + * (aider, opencode) are omitted for Phase 1. + */ + +import type { AgentTool } from "../types.js"; +import type { AgentProgram } from "./types.js"; + +/** + * Typed program table. Keyed by the literal tool name stored on `Profile.tool`. + * + * Claude (`claude -p --output-format stream-json --verbose`): + * - stream-json emits line-delimited JSON per message/content_block. + * - MCP injection via `--mcp-config `. + * + * Codex (`codex exec --json`): + * - Reads prompt from stdin, emits one JSON event per line. + * - MCP injection via repeated `-c mcp.servers..=` args. + * + * Gemini (`gemini -p `): + * - Plain text on stdout; structured tool events flow through MCP side-channel. + * - MCP injection via pre-launch `gemini mcp add` command. + */ +export const AGENT_PROGRAMS: Record = { + claude: { + tool: "claude", + command: "claude", + oneShotFlags: ["-p", "--output-format", "stream-json", "--verbose"], + inputMethod: "direct", + mcpMode: "config-file", + outputFormat: "stream-json", + readyMarker: "\u276F", // ❯ + }, + codex: { + tool: "codex", + command: "codex", + oneShotFlags: ["exec", "--json"], + inputMethod: "direct", + mcpMode: "config-args", + outputFormat: "codex-json", + readyMarker: "\u203A", // › + }, + gemini: { + tool: "gemini", + command: "gemini", + oneShotFlags: ["-p"], + inputMethod: "direct", + mcpMode: "mcp-add", + outputFormat: "plain", + readyMarker: "Type your message", + }, +}; + +/** + * Resolve the registry entry for a given tool name. + * Returns `undefined` for unknown tools so the caller can surface a clear + * error rather than getting a partially-constructed program. + */ +export function resolveAgentProgram(tool: AgentTool | undefined): AgentProgram | undefined { + if (!tool) return undefined; + return AGENT_PROGRAMS[tool]; +} diff --git a/packages/core/src/agent-client/spawn-helpers.ts b/packages/core/src/agent-client/spawn-helpers.ts new file mode 100644 index 0000000..566e30e --- /dev/null +++ b/packages/core/src/agent-client/spawn-helpers.ts @@ -0,0 +1,157 @@ +/** + * Internal spawn + stream helpers shared by claude.ts / codex.ts / gemini.ts. + * + * We intentionally avoid `spawnManagedProcess` from `../process.ts` here — + * that helper is intended for long-lived adapters, logs every spawn, and + * uses process-group semantics. The agent client needs a much lighter + * "spawn, read lines, kill on abort, done" primitive. + */ + +import { spawn, type ChildProcessWithoutNullStreams, type SpawnOptionsWithoutStdio } from "node:child_process"; +import { createInterface } from "node:readline"; +import type { AgentChunk, AgentSendOptions } from "./types.js"; + +/** + * Inject for tests — swap the child_process `spawn` implementation without + * touching global state. Tests pass a fake that returns a stub `ChildProcess` + * with programmable stdout / exit. + */ +export type SpawnFn = typeof spawn; + +/** + * Spawn the agent binary and return an async iterable of parsed chunks. + * + * - `parse(line)` converts each stdout line into `AgentChunk | null`. + * - On child close, emits a terminal `{ type: "done" }` chunk unless the + * parser already emitted one. + * - Honors `opts.signal` and `opts.timeoutMs`. + */ +export async function* runAgentProcess(params: { + command: string; + args: string[]; + env?: NodeJS.ProcessEnv; + cwd?: string; + stdinPayload?: string; // prompt written to stdin (codex) + parse: (line: string) => AgentChunk | null; + opts?: AgentSendOptions; + spawnFn?: SpawnFn; +}): AsyncGenerator { + const spawnImpl = params.spawnFn ?? spawn; + const spawnOpts: SpawnOptionsWithoutStdio = { + cwd: params.cwd, + env: params.env ?? process.env, + windowsHide: true, + // Intentionally no `shell: true` — we're passing args through as-is. + }; + + const child = spawnImpl(params.command, params.args, spawnOpts) as ChildProcessWithoutNullStreams; + + // ── Abort + timeout plumbing ── + const abortHandler = () => { + try { + child.kill("SIGTERM"); + } catch { + /* already dead */ + } + }; + if (params.opts?.signal) { + if (params.opts.signal.aborted) abortHandler(); + else params.opts.signal.addEventListener("abort", abortHandler, { once: true }); + } + + let timeoutHandle: NodeJS.Timeout | undefined; + let timedOut = false; + if (params.opts?.timeoutMs && params.opts.timeoutMs > 0) { + timeoutHandle = setTimeout(() => { + timedOut = true; + abortHandler(); + }, params.opts.timeoutMs); + } + + // ── Deliver prompt via stdin if configured ── + if (params.stdinPayload !== undefined && child.stdin) { + try { + child.stdin.end(params.stdinPayload); + } catch { + /* ignore — child may have died */ + } + } else if (child.stdin) { + // close stdin so the child doesn't hang waiting for input + try { + child.stdin.end(); + } catch { + /* ignore */ + } + } + + // ── Buffer + forward chunks ── + const queue: AgentChunk[] = []; + let resolveWaiter: (() => void) | null = null; + let finished = false; + let spawnError: Error | null = null; + let emittedDone = false; + + const push = (chunk: AgentChunk) => { + if (chunk.type === "done") emittedDone = true; + queue.push(chunk); + resolveWaiter?.(); + resolveWaiter = null; + }; + + const stdoutRl = createInterface({ input: child.stdout }); + stdoutRl.on("line", (line) => { + try { + const chunk = params.parse(line); + if (chunk) push(chunk); + } catch (err) { + push({ type: "error", message: (err as Error).message }); + } + }); + + // Capture stderr as soft errors so callers can surface them. + let stderrBuf = ""; + child.stderr.on("data", (b: Buffer) => { + stderrBuf += b.toString("utf8"); + }); + + child.on("error", (err) => { + spawnError = err; + }); + + child.on("close", (code) => { + if (timeoutHandle) clearTimeout(timeoutHandle); + if (!emittedDone) { + if (spawnError) { + push({ type: "error", message: spawnError.message }); + push({ type: "done", reason: "error" }); + } else if (timedOut) { + push({ type: "error", message: "agent process timed out" }); + push({ type: "done", reason: "error" }); + } else if (code === 0) { + push({ type: "done", reason: "end_turn" }); + } else { + if (stderrBuf.trim()) { + push({ type: "error", message: stderrBuf.trim().slice(0, 2000) }); + } + push({ type: "done", reason: code === null ? "stop" : "error" }); + } + } + finished = true; + resolveWaiter?.(); + resolveWaiter = null; + }); + + // ── Consumer loop ── + while (true) { + if (queue.length > 0) { + const next = queue.shift() as AgentChunk; + yield next; + if (next.type === "done") return; + continue; + } + if (finished) return; + await new Promise((r) => { + resolveWaiter = r; + }); + } +} diff --git a/packages/core/src/agent-client/stream-parsers.ts b/packages/core/src/agent-client/stream-parsers.ts new file mode 100644 index 0000000..6f614bb --- /dev/null +++ b/packages/core/src/agent-client/stream-parsers.ts @@ -0,0 +1,246 @@ +/** + * Stream parsers — convert a single line of agent stdout into an `AgentChunk`. + * + * Each parser is pure: (line) -> AgentChunk | null. The caller (the per-agent + * client) is responsible for buffering partial lines via `readline` and for + * emitting the terminal `{ type: "done" }` chunk when the child process exits. + * + * We tolerate unknown shapes by returning `null` — the client skips silently + * instead of crashing on an event the upstream CLI added in a newer version. + */ + +import type { AgentChunk } from "./types.js"; + +// ─── Claude (Anthropic SDK stream-json) ──────────────────────────────── + +/** + * Parse one line from `claude -p --output-format stream-json --verbose`. + * + * The Anthropic Messages streaming format wraps content blocks in events: + * - `message_start` — we ignore (no chunk to emit). + * - `content_block_start` — opens a text/tool_use/thinking block. + * - `content_block_delta` — text_delta / input_json_delta / thinking_delta. + * - `content_block_stop` — closes a block (ignored). + * - `message_delta` — carries stop_reason. + * - `message_stop` — end of turn. + * + * claude's CLI wraps this with its own envelope: + * { type: "assistant", message: { content: [...] } } (snapshot) + * { type: "assistant", event: { type: "content_block_delta", delta: {...} } } + * + * Both shapes are handled defensively. + */ +export function parseClaudeStreamJson(line: string): AgentChunk | null { + const trimmed = line.trim(); + if (!trimmed) return null; + + let obj: Record; + try { + obj = JSON.parse(trimmed) as Record; + } catch { + // Not JSON — surface as text (claude sometimes emits preamble text). + return { type: "text", content: trimmed }; + } + + // Unwrap claude's outer "event" envelope if present. + const event = (isObject(obj["event"]) ? (obj["event"] as Record) : obj); + const evType = typeof event["type"] === "string" ? (event["type"] as string) : undefined; + + // Top-level result envelope that claude emits on completion. + if (obj["type"] === "result" || evType === "result") { + const reason = coerceStopReason((obj["stop_reason"] ?? event["stop_reason"]) as unknown); + return { type: "done", reason }; + } + + switch (evType) { + case "message_start": + case "content_block_start": + case "content_block_stop": + case "ping": + return null; + + case "content_block_delta": { + const delta = event["delta"] as Record | undefined; + if (!delta) return null; + if (delta["type"] === "text_delta" && typeof delta["text"] === "string") { + return { type: "text", content: delta["text"] }; + } + if (delta["type"] === "thinking_delta" && typeof delta["thinking"] === "string") { + return { type: "thinking", content: delta["thinking"] }; + } + if (delta["type"] === "input_json_delta") { + // Partial tool input — no complete chunk yet. + return null; + } + return null; + } + + case "tool_use": { + const id = typeof event["id"] === "string" ? event["id"] : ""; + const name = typeof event["name"] === "string" ? event["name"] : ""; + return { type: "tool_call", id, tool: name, input: event["input"] ?? {} }; + } + + case "tool_result": { + const id = typeof event["tool_use_id"] === "string" ? event["tool_use_id"] : ""; + const isError = event["is_error"] === true; + return { type: "tool_result", id, result: event["content"] ?? null, isError }; + } + + case "message_delta": { + const delta = event["delta"] as Record | undefined; + const reason = coerceStopReason(delta?.["stop_reason"]); + // Only emit done once we actually have a stop reason. + if (delta && delta["stop_reason"]) return { type: "done", reason }; + return null; + } + + case "message_stop": + return { type: "done", reason: "end_turn" }; + + case "error": { + const msg = typeof event["message"] === "string" ? event["message"] : "unknown error"; + return { type: "error", message: msg }; + } + + default: + return null; + } +} + +// ─── Codex (`codex exec --json`) ─────────────────────────────────────── + +/** + * Parse one line from `codex exec --json`. + * + * Codex emits heterogenous event objects; the shape has drifted between + * versions. We do best-effort mapping based on recognizable keys: + * - `{ kind: "message", role: "assistant", text }` + * - `{ kind: "delta", text }` + * - `{ kind: "tool_call", id, name, arguments }` + * - `{ kind: "tool_result", id, output, is_error }` + * - `{ kind: "done" | "finished" | "complete", reason }` + * + * Older codex builds use `type` instead of `kind`; we accept both. + */ +export function parseCodexJson(line: string): AgentChunk | null { + const trimmed = line.trim(); + if (!trimmed) return null; + + let obj: Record; + try { + obj = JSON.parse(trimmed) as Record; + } catch { + return { type: "text", content: trimmed }; + } + + const kind = (typeof obj["kind"] === "string" + ? obj["kind"] + : typeof obj["type"] === "string" + ? obj["type"] + : "") as string; + + switch (kind) { + case "delta": + case "token": + case "text": { + const text = typeof obj["text"] === "string" + ? (obj["text"] as string) + : typeof obj["content"] === "string" + ? (obj["content"] as string) + : null; + return text !== null ? { type: "text", content: text } : null; + } + + case "message": { + const text = typeof obj["text"] === "string" + ? (obj["text"] as string) + : typeof obj["content"] === "string" + ? (obj["content"] as string) + : null; + if (obj["role"] === "assistant" && text !== null) { + return { type: "text", content: text }; + } + return null; + } + + case "tool_call": + case "function_call": { + const id = typeof obj["id"] === "string" ? obj["id"] : ""; + const name = typeof obj["name"] === "string" + ? (obj["name"] as string) + : typeof obj["tool"] === "string" + ? (obj["tool"] as string) + : ""; + const input = obj["arguments"] ?? obj["input"] ?? {}; + return { type: "tool_call", id, tool: name, input }; + } + + case "tool_result": + case "function_result": { + const id = typeof obj["id"] === "string" ? obj["id"] : ""; + const isError = obj["is_error"] === true || obj["isError"] === true; + return { type: "tool_result", id, result: obj["output"] ?? obj["result"] ?? null, isError }; + } + + case "thinking": + case "reasoning": { + const text = typeof obj["text"] === "string" + ? (obj["text"] as string) + : typeof obj["content"] === "string" + ? (obj["content"] as string) + : ""; + return { type: "thinking", content: text }; + } + + case "done": + case "finished": + case "complete": { + const reason = coerceStopReason(obj["reason"] ?? obj["stop_reason"]); + return { type: "done", reason }; + } + + case "error": { + const msg = typeof obj["message"] === "string" + ? (obj["message"] as string) + : typeof obj["error"] === "string" + ? (obj["error"] as string) + : "codex error"; + return { type: "error", message: msg }; + } + + default: + return null; + } +} + +// ─── Gemini (plain text passthrough) ─────────────────────────────────── + +/** + * Gemini in `-p` mode prints plain text with no structured events. + * Every non-empty line becomes a text chunk. The client emits a terminal + * `{ type: "done", reason: "end_turn" }` on process close; we never emit it + * from inside this parser. + */ +export function parseGeminiPlain(line: string): AgentChunk | null { + // Preserve the line exactly (including leading whitespace) — some tools + // emit significant indentation — but drop fully empty lines. + if (line.length === 0) return null; + return { type: "text", content: line }; +} + +// ─── Helpers ─────────────────────────────────────────────────────────── + +function isObject(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +function coerceStopReason(v: unknown): "end_turn" | "max_turns" | "stop" | "error" { + if (v === "end_turn" || v === "max_turns" || v === "stop" || v === "error") return v; + if (typeof v === "string") { + if (v.includes("max")) return "max_turns"; + if (v.includes("error") || v.includes("fail")) return "error"; + if (v.includes("stop")) return "stop"; + } + return "end_turn"; +} diff --git a/packages/core/src/agent-client/types.ts b/packages/core/src/agent-client/types.ts new file mode 100644 index 0000000..9d4754c --- /dev/null +++ b/packages/core/src/agent-client/types.ts @@ -0,0 +1,117 @@ +/** + * Agent client types — Phase 1 (CLI-spawn foundation). + * + * This module defines the contract for programmatic agent invocation. + * Given an ARC profile, the dispatcher spawns the profile's native CLI tool + * (claude, codex, gemini) with a prompt and optional MCP injection and + * yields structured `AgentChunk`s from its streaming stdout. + * + * Design note (AD-1): we intentionally do NOT build a direct LLM HTTP client. + * We orchestrate the existing agents' own tool use via MCP. Auth, retries, + * tool schemas, and provider negotiation are handled by the native CLI. + */ + +import type { AgentTool } from "../types.js"; + +/** + * How a prompt is delivered to an agent process. + * - `direct` — passed as a CLI argument or written to stdin in one-shot mode. + * This is the only mode used in Phase 1. + * - `sendKeys` — line-by-line stdin write (future TUI mode, persistent session). + * - `pasteFromFile` — write prompt to a temp file, send `/paste ` (future). + */ +export type InputMethod = "direct" | "sendKeys" | "pasteFromFile"; + +/** + * How MCP servers are injected into an agent at launch time. + * Mirrors Agent-Forge's `agents.json` `mcpMode` field. + * + * - `config-file` — write a JSON file, pass via `--mcp-config ` (Claude). + * - `mcp-add` — run ` mcp add --scope project [args]` + * before launch (Gemini). + * - `config-args` — pass `-c mcp.servers..=` CLI args + * per server/field (Codex). + */ +export type McpConfigMode = "config-file" | "mcp-add" | "config-args"; + +/** Definition of a single MCP server to inject into the agent. */ +export interface McpServerDef { + command: string; + args?: string[]; + env?: Record; +} + +/** An MCP injection payload — the mode plus the set of servers to register. */ +export interface McpConfigInjection { + mode: McpConfigMode; + servers: Record; +} + +/** + * Structured events streamed back from an agent. + * + * `text` / `thinking` / `tool_call` / `tool_result` mirror Anthropic's content + * block taxonomy; `error` is a soft failure surfaced to the caller; `done` + * is the terminator carrying the stop reason. + */ +export type AgentChunk = + | { type: "text"; content: string } + | { type: "tool_call"; id: string; tool: string; input: unknown } + | { type: "tool_result"; id: string; result: unknown; isError?: boolean } + | { type: "thinking"; content: string } + | { type: "error"; message: string } + | { type: "done"; reason: "end_turn" | "max_turns" | "stop" | "error" }; + +/** Options accepted by `AgentClient.send`. */ +export interface AgentSendOptions { + /** MCP server(s) to inject at launch. */ + mcpConfig?: McpConfigInjection; + /** + * System instructions prepended to the prompt. Claude's one-shot `-p` mode + * has no dedicated system-prompt flag, so we synthesize one by wrapping: + * + * System: + * + * User: + * + * Gemini and Codex follow the same wrapping for consistency. + */ + instructions?: string; + /** Abort signal — cancellation triggers SIGTERM on the child. */ + signal?: AbortSignal; + /** Optional hard timeout (ms). When elapsed, the child is killed. */ + timeoutMs?: number; +} + +/** + * One-shot agent client. `send` returns an async iterable that yields + * `AgentChunk`s until the child process exits. + */ +export interface AgentClient { + send(prompt: string, opts?: AgentSendOptions): AsyncIterable; + shutdown(): Promise; +} + +/** Output format dialect we know how to parse. */ +export type AgentOutputFormat = "stream-json" | "plain" | "codex-json"; + +/** + * Registry entry describing how to launch one agent in one-shot mode. + * Ported from Agent-Forge's `agents.json`. + */ +export interface AgentProgram { + /** Binary name on PATH. */ + command: string; + /** Flags that put the CLI into one-shot / non-TUI mode. */ + oneShotFlags: string[]; + /** How the prompt is delivered. Phase 1 only uses `direct`. */ + inputMethod: InputMethod; + /** MCP injection dialect. */ + mcpMode: McpConfigMode; + /** Optional TUI readiness marker (unused in one-shot mode; kept for future). */ + readyMarker?: string; + /** Output dialect on stdout. */ + outputFormat: AgentOutputFormat; + /** Tool name for dispatcher lookup. */ + tool: AgentTool; +} diff --git a/packages/core/src/agent/arc-tools.ts b/packages/core/src/agent/arc-tools.ts new file mode 100644 index 0000000..8c5aea5 --- /dev/null +++ b/packages/core/src/agent/arc-tools.ts @@ -0,0 +1,411 @@ +/** + * ARC tool catalog — the concrete set of tools an agent can call against + * ARC's installed state. All handlers delegate to existing core functions; + * no policy logic lives here. + * + * See `docs/plans/ai-and-roundtable.md` AD-3 for the full planned catalog; + * this module ships the Phase 2 baseline (≥15 tools spanning all three + * permission tiers). + */ + +import fs from "node:fs"; +import path from "node:path"; +import { z } from "zod"; +import { loadConfig, saveConfig, cloneProfile } from "../config.js"; +import { getRecentLaunches } from "../history.js"; +import { queryLogEvents, type LogLevel } from "../logging.js"; +import { SkillRegistry } from "../skills/index.js"; +import { PersistentMemory, type MemoryScope } from "../memory/index.js"; +import { TaskStore, type TaskStatus } from "../tasks/index.js"; +import { RemoteAgentRegistry } from "../remote.js"; +import { getSharedSettings } from "../shared.js"; +import type { ToolRegistry } from "./registry.js"; +import type { Tool } from "./types.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const LOG_LEVEL_VALUES = ["debug", "info", "warn", "error"] as const; + +const MEMORY_SCOPE_VALUES = ["session", "persistent", "profile", "team"] as const; + +const TASK_STATUS_VALUES = [ + "created", + "assigned", + "working", + "input-required", + "completed", + "failed", + "cancelled", +] as const; + +/** + * Locate the core package.json at runtime (compiled output lives at + * `packages/core/dist/index.js`, so `../package.json` resolves in both + * source and built form via the `src` → `dist` layout). + */ +function readCorePackageVersion(): string { + const candidates = [ + path.resolve(new URL(".", import.meta.url).pathname, "../../package.json"), + path.resolve(new URL(".", import.meta.url).pathname, "../package.json"), + ]; + for (const candidate of candidates) { + try { + const raw = fs.readFileSync(candidate, "utf-8"); + const parsed = JSON.parse(raw) as { version?: string; name?: string }; + if (parsed.name === "@axiom-labs/arc-core" && typeof parsed.version === "string") { + return parsed.version; + } + } catch { + // try next + } + } + return "unknown"; +} + +// --------------------------------------------------------------------------- +// Read tools +// --------------------------------------------------------------------------- + +const listProfilesTool: Tool = { + name: "list_profiles", + description: "List all ARC profiles with their tool, authType, and description.", + permission: "read", + schema: z.object({}), + handler: async () => { + const cfg = loadConfig(); + return Object.entries(cfg.profiles).map(([name, prof]) => ({ + name, + tool: prof.tool, + authType: prof.authType, + description: prof.description, + createdAt: prof.createdAt, + })); + }, +}; + +const showProfileTool: Tool = { + name: "show_profile", + description: "Return the full record of a single profile by name.", + permission: "read", + schema: z.object({ name: z.string() }), + handler: async (input) => { + const { name } = input as { name: string }; + const cfg = loadConfig(); + const prof = cfg.profiles[name]; + if (!prof) throw new Error(`Profile '${name}' not found`); + return { name, ...prof }; + }, +}; + +const getActiveProfileTool: Tool = { + name: "get_active_profile", + description: "Return the name and record of the currently active profile, or null if none is active.", + permission: "read", + schema: z.object({}), + handler: async () => { + const cfg = loadConfig(); + const name = cfg.activeProfile as string | null; + if (!name) return null; + const prof = cfg.profiles[name]; + if (!prof) return null; + return { name, ...prof }; + }, +}; + +const listLaunchesTool: Tool = { + name: "list_launches", + description: "List recent profile launch history entries (most-recent first).", + permission: "read", + schema: z.object({ limit: z.number().int().positive().optional() }), + handler: async (input) => { + const { limit } = input as { limit?: number }; + return getRecentLaunches(limit ?? 10); + }, +}; + +const queryLogsTool: Tool = { + name: "query_logs", + description: "Query ARC's structured log with optional level/component filters.", + permission: "read", + schema: z.object({ + limit: z.number().int().positive().optional(), + level: z.enum(LOG_LEVEL_VALUES).optional(), + component: z.string().optional(), + }), + handler: async (input) => { + const { limit, level, component } = input as { + limit?: number; + level?: LogLevel; + component?: string; + }; + return queryLogEvents({ limit, level, component }); + }, +}; + +const listSkillsTool: Tool = { + name: "list_skills", + description: "List skills in the in-memory SkillRegistry.", + permission: "read", + schema: z.object({}), + handler: async () => { + // SkillRegistry is ephemeral; Phase 2 returns an empty registry snapshot. + // Callers that keep a shared registry should inject one via a future ctx hook. + const registry = new SkillRegistry(); + return registry.list(); + }, +}; + +const listMemoriesTool: Tool = { + name: "list_memories", + description: "List entries from a PersistentMemory scope (default 'persistent').", + permission: "read", + schema: z.object({ + limit: z.number().int().positive().optional(), + scope: z.enum(MEMORY_SCOPE_VALUES).optional(), + }), + handler: async (input) => { + const { limit, scope } = input as { limit?: number; scope?: MemoryScope }; + const store = new PersistentMemory(scope ?? "persistent"); + const entries = store.list(); + return typeof limit === "number" ? entries.slice(0, limit) : entries; + }, +}; + +const listTasksTool: Tool = { + name: "list_tasks", + description: "List tasks from the TaskStore, optionally filtered by status.", + permission: "read", + schema: z.object({ status: z.enum(TASK_STATUS_VALUES).optional() }), + handler: async (input) => { + const { status } = input as { status?: TaskStatus }; + const store = new TaskStore(); + return store.list(status ? { status } : undefined); + }, +}; + +const listRemoteAgentsTool: Tool = { + name: "list_remote_agents", + description: "List all registered remote agents and their status.", + permission: "read", + schema: z.object({}), + handler: async () => { + const registry = new RemoteAgentRegistry(); + return registry.list(); + }, +}; + +const listMcpServersTool: Tool = { + name: "list_mcp_servers", + description: "List MCP servers configured in the shared layer settings.", + permission: "read", + schema: z.object({}), + handler: async () => { + const settings = getSharedSettings(); + if (!settings) return []; + const servers = settings["mcpServers"]; + if (!servers || typeof servers !== "object") return []; + return Object.entries(servers as Record).map(([name, def]) => ({ + name, + config: def, + })); + }, +}; + +const getArcVersionTool: Tool = { + name: "get_arc_version", + description: "Return the installed ARC core package version string.", + permission: "read", + schema: z.object({}), + handler: async () => { + return { version: readCorePackageVersion() }; + }, +}; + +// --------------------------------------------------------------------------- +// Write tools +// --------------------------------------------------------------------------- + +const cloneProfileTool: Tool = { + name: "clone_profile", + description: "Clone a profile to a new name. Optionally copy its configDir on disk.", + permission: "write", + schema: z.object({ + src: z.string(), + dst: z.string(), + copyConfigDir: z.boolean().optional(), + }), + handler: async (input) => { + const { src, dst, copyConfigDir } = input as { + src: string; + dst: string; + copyConfigDir?: boolean; + }; + const cfg = loadConfig(); + const updated = cloneProfile(cfg, src, dst, { copyConfigDir }); + saveConfig(updated); + return { src, dst, ok: true }; + }, +}; + +const switchActiveProfileTool: Tool = { + name: "switch_active_profile", + description: "Set the active ARC profile. Pass null to clear the active profile.", + permission: "write", + schema: z.object({ name: z.string().nullable() }), + handler: async (input) => { + const { name } = input as { name: string | null }; + const cfg = loadConfig(); + if (name !== null && !cfg.profiles[name]) { + throw new Error(`Profile '${name}' not found`); + } + // Guard: activeProfile may be `string | null` concurrently. + (cfg as { activeProfile: string | null }).activeProfile = name; + saveConfig(cfg as typeof cfg); + return { activeProfile: name, ok: true }; + }, +}; + +const setProfileInstructionsTool: Tool = { + name: "set_profile_instructions", + description: + "Set the `instructions` or `instructionsFile` field of a profile. Omit both to clear.", + permission: "write", + schema: z.object({ + profileName: z.string(), + instructions: z.string().optional(), + instructionsFile: z.string().optional(), + }), + handler: async (input) => { + const { profileName, instructions, instructionsFile } = input as { + profileName: string; + instructions?: string; + instructionsFile?: string; + }; + const cfg = loadConfig(); + const prof = cfg.profiles[profileName]; + if (!prof) throw new Error(`Profile '${profileName}' not found`); + if (instructions === undefined) { + delete prof.instructions; + } else { + prof.instructions = instructions; + } + if (instructionsFile === undefined) { + delete prof.instructionsFile; + } else { + prof.instructionsFile = instructionsFile; + } + saveConfig(cfg); + return { + profileName, + instructions: prof.instructions, + instructionsFile: prof.instructionsFile, + }; + }, +}; + +const setProfileFlagsTool: Tool = { + name: "set_profile_flags", + description: "Replace the launchArgs array on a profile.", + permission: "write", + schema: z.object({ + profileName: z.string(), + flags: z.array(z.string()), + }), + handler: async (input) => { + const { profileName, flags } = input as { profileName: string; flags: string[] }; + const cfg = loadConfig(); + const prof = cfg.profiles[profileName]; + if (!prof) throw new Error(`Profile '${profileName}' not found`); + prof.launchArgs = [...flags]; + saveConfig(cfg); + return { profileName, launchArgs: prof.launchArgs }; + }, +}; + +// --------------------------------------------------------------------------- +// Dangerous tools +// --------------------------------------------------------------------------- + +const deleteProfileTool: Tool = { + name: "delete_profile", + description: + "Delete a profile from ARC config. Does NOT remove the profile's configDir on disk.", + permission: "dangerous", + schema: z.object({ name: z.string() }), + handler: async (input) => { + const { name } = input as { name: string }; + const cfg = loadConfig(); + if (!cfg.profiles[name]) { + throw new Error(`Profile '${name}' not found`); + } + delete cfg.profiles[name]; + const activeProfile = cfg.activeProfile as string | null; + if (activeProfile === name) { + (cfg as { activeProfile: string | null }).activeProfile = null; + } + if (Array.isArray(cfg.profileOrder)) { + cfg.profileOrder = cfg.profileOrder.filter((n) => n !== name); + } + saveConfig(cfg); + return { name, deleted: true }; + }, +}; + +// --------------------------------------------------------------------------- +// Registration +// --------------------------------------------------------------------------- + +/** + * Register ARC's Phase 2 tool catalog on the given registry. + * Safe to call once at startup; attempting to register the same tool twice + * will throw via the registry's duplicate-name guard. + */ +export function registerArcTools(registry: ToolRegistry): void { + const tools = [ + // Read + listProfilesTool, + showProfileTool, + getActiveProfileTool, + listLaunchesTool, + queryLogsTool, + listSkillsTool, + listMemoriesTool, + listTasksTool, + listRemoteAgentsTool, + listMcpServersTool, + getArcVersionTool, + // Write + cloneProfileTool, + switchActiveProfileTool, + setProfileInstructionsTool, + setProfileFlagsTool, + // Dangerous + deleteProfileTool, + ]; + + for (const tool of tools) { + registry.register(tool); + } +} + +/** Exposed for tests and introspection. */ +export const ARC_TOOLS = Object.freeze({ + list_profiles: listProfilesTool, + show_profile: showProfileTool, + get_active_profile: getActiveProfileTool, + list_launches: listLaunchesTool, + query_logs: queryLogsTool, + list_skills: listSkillsTool, + list_memories: listMemoriesTool, + list_tasks: listTasksTool, + list_remote_agents: listRemoteAgentsTool, + list_mcp_servers: listMcpServersTool, + get_arc_version: getArcVersionTool, + clone_profile: cloneProfileTool, + switch_active_profile: switchActiveProfileTool, + set_profile_instructions: setProfileInstructionsTool, + set_profile_flags: setProfileFlagsTool, + delete_profile: deleteProfileTool, +}); diff --git a/packages/core/src/agent/index.ts b/packages/core/src/agent/index.ts new file mode 100644 index 0000000..8e9dd0d --- /dev/null +++ b/packages/core/src/agent/index.ts @@ -0,0 +1,25 @@ +/** + * Agent tool-use public surface (Phase 2). + * + * See `docs/plans/ai-and-roundtable.md` — AD-2, AD-3. + */ + +export type { + PermissionMode, + ToolPermission, + ToolContext, + Tool, + ToolDefinition, + ToolResult, + AgentEvent, + AgentLoopOptions, + ToolRegistryLike, +} from "./types.js"; + +export { canUseTool, needsConfirmation } from "./permissions.js"; + +export { ToolRegistry } from "./registry.js"; + +export { runAgent } from "./loop.js"; + +export { registerArcTools, ARC_TOOLS } from "./arc-tools.js"; diff --git a/packages/core/src/agent/loop.ts b/packages/core/src/agent/loop.ts new file mode 100644 index 0000000..c6c4966 --- /dev/null +++ b/packages/core/src/agent/loop.ts @@ -0,0 +1,117 @@ +/** + * Agent loop — observe `AgentClient` chunks and dispatch tool calls through + * the registry, re-emitting everything as `AgentEvent`s to the caller. + * + * TODO (Phase 4): the one-shot `AgentClient` from Phase 1 cannot accept + * tool_result messages as additional input to the same session. This loop + * therefore only *observes* tool calls an agent makes and dispatches them + * locally; it does not round-trip `tool_result` back to the LLM. True + * multi-turn tool use (model sees result, continues reasoning) requires + * persistent-session support — to be added when we build the interactive + * agent client in Phase 4 and the roundtable orchestrator in Phase 5. + */ + +import type { AgentChunk } from "../agent-client/index.js"; +import type { AgentEvent, AgentLoopOptions, ToolResult } from "./types.js"; + +const DEFAULT_MAX_TURNS = 10; + +/** + * Consume an `AgentClient.send(...)` stream, dispatching tool calls through + * the registry and re-emitting text / thinking / tool_call / tool_result / + * error / done events. + * + * The loop completes when the client emits `{type:"done"}`, when `maxTurns` + * tool-call dispatches are reached, or when the client ends its stream. + */ +export async function* runAgent( + opts: AgentLoopOptions, + userPrompt: string, +): AsyncIterable { + const { client, registry, ctx } = opts; + const maxTurns = opts.maxTurns ?? DEFAULT_MAX_TURNS; + let toolCalls = 0; + + let stream: AsyncIterable; + try { + stream = client.send(userPrompt); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + yield { type: "error", message: `client.send failed: ${msg}` }; + yield { type: "done", reason: "error" }; + return; + } + + try { + for await (const chunk of stream) { + switch (chunk.type) { + case "text": + yield { type: "text", content: chunk.content }; + break; + + case "thinking": + yield { type: "thinking", content: chunk.content }; + break; + + case "tool_call": { + toolCalls += 1; + yield { + type: "tool_call", + id: chunk.id, + tool: chunk.tool, + input: chunk.input, + }; + + let result: ToolResult; + if (toolCalls > maxTurns) { + result = { + ok: false, + blocked: true, + error: `maxTurns (${maxTurns}) exceeded`, + }; + } else { + result = await registry.execute(chunk.tool, chunk.input, ctx); + } + + yield { + type: "tool_result", + id: chunk.id, + tool: chunk.tool, + result, + }; + + if (toolCalls > maxTurns) { + yield { type: "done", reason: "max_turns" }; + return; + } + break; + } + + case "tool_result": + // Client-side tool results (from MCP side-channel) — surface as-is + // but we don't have registry-level structure for them; wrap to ToolResult. + yield { + type: "tool_result", + id: chunk.id, + tool: "", + result: { ok: !chunk.isError, output: chunk.result } as ToolResult, + }; + break; + + case "error": + yield { type: "error", message: chunk.message }; + break; + + case "done": + yield { type: "done", reason: chunk.reason }; + return; + } + } + // Stream ended without an explicit `done` — emit one for callers. + yield { type: "done", reason: "stop" }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + yield { type: "error", message: msg }; + yield { type: "done", reason: "error" }; + } +} diff --git a/packages/core/src/agent/permissions.ts b/packages/core/src/agent/permissions.ts new file mode 100644 index 0000000..a6ed1fb --- /dev/null +++ b/packages/core/src/agent/permissions.ts @@ -0,0 +1,39 @@ +/** + * Permission gating helpers for the agent tool registry. + * + * The matrix: + * + * | mode | read | write | dangerous | + * |-------------|------|------------------------|------------------------| + * | read-only | yes | hidden | hidden | + * | supervised | yes | yes (needs confirm) | yes (needs confirm) | + * | autonomous | yes | yes (no confirm) | yes (no confirm) | + */ + +import type { PermissionMode, Tool } from "./types.js"; + +/** + * Whether a tool may be listed and invoked under the given mode. + * + * In `read-only` mode, only `read` tools are available. Other modes expose + * everything — the separate `needsConfirmation` gate decides whether the + * human must approve. + */ +export function canUseTool(tool: Tool, mode: PermissionMode): boolean { + if (mode === "read-only") { + return tool.permission === "read"; + } + return true; +} + +/** + * Whether a handler call must be preceded by `ctx.confirm(...)`. + * + * Confirmation is only required in `supervised` mode for `write` or + * `dangerous` tools. `read-only` never reaches write/dangerous tools; in + * `autonomous` the operator has pre-authorized everything. + */ +export function needsConfirmation(tool: Tool, mode: PermissionMode): boolean { + if (mode !== "supervised") return false; + return tool.permission === "write" || tool.permission === "dangerous"; +} diff --git a/packages/core/src/agent/registry.ts b/packages/core/src/agent/registry.ts new file mode 100644 index 0000000..b59c1e9 --- /dev/null +++ b/packages/core/src/agent/registry.ts @@ -0,0 +1,226 @@ +/** + * ToolRegistry — central dispatcher for agent tool calls. + * + * Responsibilities: + * 1. Store `Tool` definitions with unique names. + * 2. Expose `ToolDefinition`s to LLMs filtered by permission mode. + * 3. Validate inputs against each tool's zod schema before dispatch. + * 4. Enforce permission gating (`canUseTool`) and confirmation gating + * (`needsConfirmation`) before calling the handler. + * 5. Catch handler errors and return a structured `ToolResult`. + */ + +import type { z } from "zod"; +import { writeLogEvent } from "../logging.js"; +import { canUseTool, needsConfirmation } from "./permissions.js"; +import type { + PermissionMode, + Tool, + ToolContext, + ToolDefinition, + ToolResult, +} from "./types.js"; + +// --------------------------------------------------------------------------- +// Minimal zod → JSON-schema shim +// --------------------------------------------------------------------------- + +/** + * Inline, dependency-free converter covering the zod shapes we actually use + * in ARC tool definitions: objects of primitives, optionals, arrays, enums. + * We intentionally do NOT re-implement the full spec — any exotic shape falls + * through to `{}` which the LLM will treat as "any JSON". + */ +function zodToSchema(schema: z.ZodTypeAny): Record { + const def = (schema as { _def?: { typeName?: string } })._def; + const typeName = def?.typeName; + + switch (typeName) { + case "ZodString": + return { type: "string" }; + case "ZodNumber": + return { type: "number" }; + case "ZodBoolean": + return { type: "boolean" }; + case "ZodEnum": { + const values = (def as { values?: readonly string[] }).values ?? []; + return { type: "string", enum: [...values] }; + } + case "ZodArray": { + const inner = (def as { type?: z.ZodTypeAny }).type; + return { type: "array", items: inner ? zodToSchema(inner) : {} }; + } + case "ZodOptional": + case "ZodNullable": { + const inner = (def as { innerType?: z.ZodTypeAny }).innerType; + return inner ? zodToSchema(inner) : {}; + } + case "ZodObject": { + const shape = (def as { shape?: () => Record }).shape?.() ?? {}; + const properties: Record = {}; + const required: string[] = []; + for (const [key, child] of Object.entries(shape)) { + properties[key] = zodToSchema(child); + const childDef = (child as { _def?: { typeName?: string } })._def; + if (childDef?.typeName !== "ZodOptional" && childDef?.typeName !== "ZodDefault") { + required.push(key); + } + } + const out: Record = { type: "object", properties }; + if (required.length > 0) out["required"] = required; + return out; + } + case "ZodDefault": { + const inner = (def as { innerType?: z.ZodTypeAny }).innerType; + return inner ? zodToSchema(inner) : {}; + } + default: + return {}; + } +} + +// --------------------------------------------------------------------------- +// ToolRegistry +// --------------------------------------------------------------------------- + +// Tool is used internally to avoid variance issues when callers +// register typed Tool values — TypeScript treats `unknown` +// contravariantly on handler input, so `Tool<{...}, ...>` is not assignable +// to `Tool` without `any` on the storage side. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyTool = Tool; + +export class ToolRegistry { + private tools = new Map(); + + /** Register a tool. Throws if a tool with the same name already exists. */ + register(tool: AnyTool): void { + if (this.tools.has(tool.name)) { + throw new Error(`Tool '${tool.name}' is already registered`); + } + this.tools.set(tool.name, tool); + } + + /** Check whether a tool with the given name is registered. */ + has(name: string): boolean { + return this.tools.has(name); + } + + /** Retrieve a tool by name (or undefined if missing). */ + get(name: string): AnyTool | undefined { + return this.tools.get(name); + } + + /** + * List registered tools. If `filter` is provided, only tools matching the + * predicate are returned. + */ + list(filter?: (t: AnyTool) => boolean): AnyTool[] { + const all = [...this.tools.values()]; + return filter ? all.filter(filter) : all; + } + + /** + * Produce the public `ToolDefinition[]` that should be sent to the LLM for + * the given permission mode. + * + * If `modeFilter` is omitted, returns definitions for every registered + * tool regardless of permission tier. + */ + getSchemas(modeFilter?: PermissionMode): ToolDefinition[] { + return this.list((t) => (modeFilter ? canUseTool(t, modeFilter) : true)).map((t) => ({ + name: t.name, + description: t.description, + permission: t.permission, + inputSchema: zodToSchema(t.schema as z.ZodTypeAny), + })); + } + + /** + * Validate + dispatch a tool call. + * + * Returns a `ToolResult` — never throws for ordinary failure paths + * (unknown tool, validation failure, permission denial, handler error). + */ + async execute(name: string, input: unknown, ctx: ToolContext): Promise { + const tool = this.tools.get(name); + if (!tool) { + return { ok: false, error: `Unknown tool: ${name}` }; + } + + // Permission tier vs. current mode. + if (!canUseTool(tool, ctx.mode)) { + writeLogEvent({ + level: "warn", + component: "agent:registry", + action: "tool-blocked", + message: `Tool '${name}' blocked under permission mode '${ctx.mode}'`, + data: { tool: name, permission: tool.permission, mode: ctx.mode }, + }); + return { + ok: false, + blocked: true, + error: `Tool '${name}' is not available in '${ctx.mode}' mode (requires '${tool.permission}')`, + }; + } + + // Input validation via zod. + const parsed = tool.schema.safeParse(input); + if (!parsed.success) { + const issues = parsed.error.issues + .map((i) => `${i.path.join(".") || ""}: ${i.message}`) + .join("; "); + return { ok: false, error: `Invalid input for '${name}': ${issues}` }; + } + + // Supervised write/dangerous → human confirmation. + if (needsConfirmation(tool, ctx.mode)) { + const prompt = + tool.permission === "dangerous" + ? `DANGEROUS: run tool '${name}'? ${tool.description}` + : `Run tool '${name}'? ${tool.description}`; + let approved = false; + try { + approved = await ctx.confirm(prompt); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { ok: false, error: `Confirmation failed: ${msg}` }; + } + if (!approved) { + writeLogEvent({ + level: "info", + component: "agent:registry", + action: "tool-declined", + message: `User declined tool '${name}'`, + data: { tool: name, permission: tool.permission }, + }); + return { ok: false, blocked: true, error: `User declined tool '${name}'` }; + } + } + + // Dispatch. + ctx.log(`tool:${name} dispatching`); + writeLogEvent({ + level: "info", + component: "agent:registry", + action: "tool-dispatch", + message: `Executing tool '${name}'`, + data: { tool: name, permission: tool.permission, mode: ctx.mode }, + }); + + try { + const output = await tool.handler(parsed.data, ctx); + return { ok: true, output }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + writeLogEvent({ + level: "error", + component: "agent:registry", + action: "tool-error", + message: `Tool '${name}' threw: ${msg}`, + data: { tool: name }, + }); + return { ok: false, error: msg }; + } + } +} diff --git a/packages/core/src/agent/types.ts b/packages/core/src/agent/types.ts new file mode 100644 index 0000000..448fcd7 --- /dev/null +++ b/packages/core/src/agent/types.ts @@ -0,0 +1,139 @@ +/** + * Agent tool-use types — Phase 2 (tool registry + agent loop). + * + * These are the core primitives for the generic tool-use infrastructure that + * lets an agent (driven by `AgentClient` from Phase 1) call ARC functionality + * through a permission-gated registry. + * + * See `docs/plans/ai-and-roundtable.md` — AD-2, AD-3. + */ + +import type { z } from "zod"; +import type { Profile } from "../types.js"; +import type { AgentClient } from "../agent-client/index.js"; + +// --------------------------------------------------------------------------- +// Permission model +// --------------------------------------------------------------------------- + +/** + * Permission mode controlling which tools an agent may invoke and whether a + * human-in-the-loop confirmation is required before write/dangerous calls. + * + * - `read-only` — only `read` tools are exposed to the model. + * - `supervised` — all tools exposed; `write` and `dangerous` require + * confirmation via `ToolContext.confirm`. + * - `autonomous` — all tools exposed; no confirmation prompts. Every action + * is still logged. + */ +export type PermissionMode = "read-only" | "supervised" | "autonomous"; + +/** + * Permission tier a tool requires. + * + * - `read` — side-effect-free inspection of local state. + * - `write` — mutates ARC config or local state; reversible. + * - `dangerous` — destructive or otherwise high-risk operation. + */ +export type ToolPermission = "read" | "write" | "dangerous"; + +// --------------------------------------------------------------------------- +// Tool context +// --------------------------------------------------------------------------- + +/** + * Runtime context passed to every tool handler. Wraps permission mode, + * user-confirmation callback, structured logger, and (optional) active profile. + */ +export interface ToolContext { + mode: PermissionMode; + /** + * Prompt the human for confirmation. Returns `true` to proceed, `false` to + * block. Tool handlers should *not* call this directly — the registry calls + * it before dispatching to the handler when the permission tier + mode + * demand it. Handlers may still use it ad-hoc for multi-step confirmations. + */ + confirm: (prompt: string) => Promise; + /** Structured info-level log sink. Non-throwing. */ + log: (msg: string) => void; + /** Active profile, when the caller has resolved one. May be absent. */ + profile?: Profile; +} + +// --------------------------------------------------------------------------- +// Tool definition +// --------------------------------------------------------------------------- + +/** + * A registered tool. `Input` is validated against `schema` before the handler + * is called. `Output` is opaque to the registry — handlers may return any JSON + * value. + */ +export interface Tool { + name: string; + description: string; + permission: ToolPermission; + schema: z.ZodSchema; + handler: (input: Input, ctx: ToolContext) => Promise; +} + +/** + * Public form of a tool sent to LLMs (no handler, no zod object — just + * name + description + a JSON-Schema-ish shape the model can reason about). + * + * We intentionally keep `inputSchema` shape-agnostic (`Record`) + * so that callers can use `zod-to-json-schema` or any other converter without + * this module taking on that dependency. + */ +export interface ToolDefinition { + name: string; + description: string; + permission: ToolPermission; + inputSchema: Record; +} + +// --------------------------------------------------------------------------- +// Tool result +// --------------------------------------------------------------------------- + +/** + * Result of executing a tool via the registry. + * + * `blocked: true` indicates the tool was refused (permission gate or + * confirmation declined) — distinct from a handler-thrown error. + */ +export type ToolResult = + | { ok: true; output: unknown } + | { ok: false; error: string; blocked?: boolean }; + +// --------------------------------------------------------------------------- +// Agent loop +// --------------------------------------------------------------------------- + +/** + * Events emitted by `runAgent` as it observes an agent session. + */ +export type AgentEvent = + | { type: "text"; content: string } + | { type: "thinking"; content: string } + | { type: "tool_call"; id: string; tool: string; input: unknown } + | { type: "tool_result"; id: string; tool: string; result: ToolResult } + | { type: "error"; message: string } + | { type: "done"; reason: "end_turn" | "max_turns" | "stop" | "error" }; + +/** + * Forward declaration — avoids a circular type import. The concrete + * `ToolRegistry` class lives in `./registry.ts`. + */ +export interface ToolRegistryLike { + execute(name: string, input: unknown, ctx: ToolContext): Promise; + has(name: string): boolean; +} + +export interface AgentLoopOptions { + client: AgentClient; + registry: ToolRegistryLike; + ctx: ToolContext; + /** Safety cap on number of tool-call cycles. Default: 10. */ + maxTurns?: number; +} diff --git a/packages/core/src/chat/index.ts b/packages/core/src/chat/index.ts new file mode 100644 index 0000000..1df02fc --- /dev/null +++ b/packages/core/src/chat/index.ts @@ -0,0 +1,22 @@ +/** + * Chat session public surface — Phase 4. + * + * See docs/plans/ai-and-roundtable.md — Phase 4 for the overall design. + */ + +export { + ChatSession, + type ChatMessage, + type ToolCallRecord, + type ChatSessionJson, + type ChatSessionSummary, +} from "./session.js"; + +export { + saveSession, + loadSession, + listSessions, + deleteSession, + getChatSessionsDir, + getChatSessionPath, +} from "./store.js"; diff --git a/packages/core/src/chat/session.ts b/packages/core/src/chat/session.ts new file mode 100644 index 0000000..b0e6521 --- /dev/null +++ b/packages/core/src/chat/session.ts @@ -0,0 +1,184 @@ +/** + * Chat session primitive — a replayable transcript of a single `arc chat` + * conversation. Serialized to JSON on disk for resume support. + * + * See docs/plans/ai-and-roundtable.md — Phase 4. + */ + +import crypto from "node:crypto"; +import type { PermissionMode } from "../agent/types.js"; + +/** A recorded tool invocation within an assistant turn. */ +export interface ToolCallRecord { + id: string; + name: string; + input: unknown; + result?: unknown; + error?: string; + confirmed?: boolean; +} + +/** A single message in the chat transcript. */ +export interface ChatMessage { + role: "user" | "assistant" | "system" | "tool"; + content: string; + toolCalls?: ToolCallRecord[]; + /** Present on `role: "tool"` messages — points back at the originating tool_call id. */ + toolCallId?: string; + timestamp: string; +} + +/** Summary payload for session listings. */ +export interface ChatSessionSummary { + id: string; + summary: string; + profileName: string; + updatedAt: string; + createdAt: string; + messageCount: number; +} + +/** On-disk wire format. */ +export interface ChatSessionJson { + id: string; + profileName: string; + permissionMode: PermissionMode; + messages: ChatMessage[]; + createdAt: string; + updatedAt: string; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +/** + * In-memory chat session. Holds an ordered list of messages and a permission + * mode. Serializes to and from disk via `serialize`/`ChatSession.load`. + */ +export class ChatSession { + readonly id: string; + readonly profileName: string; + readonly createdAt: string; + permissionMode: PermissionMode; + messages: ChatMessage[]; + updatedAt: string; + + constructor(init: { + id?: string; + profileName: string; + permissionMode: PermissionMode; + messages?: ChatMessage[]; + createdAt?: string; + updatedAt?: string; + }) { + this.id = init.id ?? crypto.randomUUID(); + this.profileName = init.profileName; + this.permissionMode = init.permissionMode; + this.messages = init.messages ? [...init.messages] : []; + this.createdAt = init.createdAt ?? nowIso(); + this.updatedAt = init.updatedAt ?? this.createdAt; + } + + /** Append a message and bump `updatedAt`. Returns the stored message. */ + append(msg: Omit & { timestamp?: string }): ChatMessage { + const stored: ChatMessage = { + ...msg, + timestamp: msg.timestamp ?? nowIso(), + }; + this.messages.push(stored); + this.updatedAt = stored.timestamp; + return stored; + } + + /** Short, human-readable summary — first user message truncated to 60 chars. */ + summary(): string { + const firstUser = this.messages.find((m) => m.role === "user"); + if (!firstUser) return "(empty session)"; + const oneLine = firstUser.content.replace(/\s+/g, " ").trim(); + if (oneLine.length <= 60) return oneLine; + return oneLine.slice(0, 57) + "..."; + } + + /** Produce the on-disk representation. */ + serialize(): ChatSessionJson { + return { + id: this.id, + profileName: this.profileName, + permissionMode: this.permissionMode, + messages: this.messages, + createdAt: this.createdAt, + updatedAt: this.updatedAt, + }; + } + + /** + * Rebuild a `ChatSession` from parsed JSON. Throws on structural errors + * (missing id / profileName, malformed messages). + */ + static load(json: unknown): ChatSession { + if (typeof json !== "object" || json === null) { + throw new Error("Invalid ChatSession JSON: not an object"); + } + const obj = json as Record; + if (typeof obj["id"] !== "string" || obj["id"].length === 0) { + throw new Error("Invalid ChatSession JSON: missing id"); + } + if (typeof obj["profileName"] !== "string") { + throw new Error("Invalid ChatSession JSON: missing profileName"); + } + const mode = obj["permissionMode"]; + if ( + mode !== "read-only" && + mode !== "supervised" && + mode !== "autonomous" + ) { + throw new Error(`Invalid ChatSession JSON: bad permissionMode "${String(mode)}"`); + } + if (!Array.isArray(obj["messages"])) { + throw new Error("Invalid ChatSession JSON: messages must be an array"); + } + const messages: ChatMessage[] = obj["messages"].map((m, i) => { + if (typeof m !== "object" || m === null) { + throw new Error(`Invalid ChatSession JSON: message[${i}] not an object`); + } + const mm = m as Record; + const role = mm["role"]; + if ( + role !== "user" && + role !== "assistant" && + role !== "system" && + role !== "tool" + ) { + throw new Error(`Invalid ChatSession JSON: message[${i}].role is invalid`); + } + if (typeof mm["content"] !== "string") { + throw new Error(`Invalid ChatSession JSON: message[${i}].content must be a string`); + } + if (typeof mm["timestamp"] !== "string") { + throw new Error(`Invalid ChatSession JSON: message[${i}].timestamp must be a string`); + } + const out: ChatMessage = { + role, + content: mm["content"], + timestamp: mm["timestamp"], + }; + if (Array.isArray(mm["toolCalls"])) { + out.toolCalls = mm["toolCalls"] as ToolCallRecord[]; + } + if (typeof mm["toolCallId"] === "string") { + out.toolCallId = mm["toolCallId"]; + } + return out; + }); + + return new ChatSession({ + id: obj["id"], + profileName: obj["profileName"], + permissionMode: mode, + messages, + createdAt: typeof obj["createdAt"] === "string" ? obj["createdAt"] : nowIso(), + updatedAt: typeof obj["updatedAt"] === "string" ? obj["updatedAt"] : nowIso(), + }); + } +} diff --git a/packages/core/src/chat/store.ts b/packages/core/src/chat/store.ts new file mode 100644 index 0000000..ed6c5f0 --- /dev/null +++ b/packages/core/src/chat/store.ts @@ -0,0 +1,110 @@ +/** + * Per-profile chat session store. + * + * Sessions live under `/chat-sessions/.json`. We create + * the directory lazily on first save and use an atomic `write → rename` to + * avoid torn files on crash. + */ + +import fs from "node:fs"; +import path from "node:path"; +import crypto from "node:crypto"; +import { getProfileDir } from "../paths.js"; +import { + ChatSession, + type ChatSessionJson, + type ChatSessionSummary, +} from "./session.js"; + +/** Directory holding all chat sessions for a profile. */ +export function getChatSessionsDir(profileName: string): string { + return path.join(getProfileDir(profileName), "chat-sessions"); +} + +/** Full path to one chat session file. */ +export function getChatSessionPath(profileName: string, sessionId: string): string { + // Defensive: block path-traversal via malformed sessionId. + if (!/^[A-Za-z0-9_.-]+$/.test(sessionId)) { + throw new Error(`Invalid chat session id: "${sessionId}"`); + } + return path.join(getChatSessionsDir(profileName), `${sessionId}.json`); +} + +function atomicWrite(target: string, contents: string): void { + const dir = path.dirname(target); + fs.mkdirSync(dir, { recursive: true }); + const tmp = path.join( + dir, + `.${path.basename(target)}.${crypto.randomBytes(4).toString("hex")}.tmp`, + ); + fs.writeFileSync(tmp, contents, "utf-8"); + fs.renameSync(tmp, target); +} + +/** + * Persist a `ChatSession` to disk under its profile's directory. + * Creates `chat-sessions/` lazily. Atomic via temp-file + rename. + */ +export function saveSession(session: ChatSession): void { + const target = getChatSessionPath(session.profileName, session.id); + const json = session.serialize(); + atomicWrite(target, JSON.stringify(json, null, 2)); +} + +/** + * Load a `ChatSession` by id from a profile's sessions dir. Throws if the + * file is missing or malformed. + */ +export function loadSession(profileName: string, id: string): ChatSession { + const p = getChatSessionPath(profileName, id); + if (!fs.existsSync(p)) { + throw new Error(`Chat session not found: ${profileName}/${id}`); + } + const raw = fs.readFileSync(p, "utf-8"); + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error(`Chat session JSON is malformed (${profileName}/${id}): ${msg}`); + } + return ChatSession.load(parsed); +} + +/** + * List all saved sessions for a profile, sorted by `updatedAt` descending. + * Returns summaries (no full message bodies) for display. + */ +export function listSessions(profileName: string): ChatSessionSummary[] { + const dir = getChatSessionsDir(profileName); + if (!fs.existsSync(dir)) return []; + + const out: ChatSessionSummary[] = []; + for (const entry of fs.readdirSync(dir)) { + if (!entry.endsWith(".json")) continue; + const full = path.join(dir, entry); + try { + const parsed = JSON.parse(fs.readFileSync(full, "utf-8")) as ChatSessionJson; + const session = ChatSession.load(parsed); + out.push({ + id: session.id, + summary: session.summary(), + profileName: session.profileName, + updatedAt: session.updatedAt, + createdAt: session.createdAt, + messageCount: session.messages.length, + }); + } catch { + // Skip unreadable files rather than erroring the whole listing. + continue; + } + } + out.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : 0)); + return out; +} + +/** Delete a saved session. No-op if the file does not exist. */ +export function deleteSession(profileName: string, id: string): void { + const p = getChatSessionPath(profileName, id); + if (fs.existsSync(p)) fs.rmSync(p, { force: true }); +} diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 79d73e5..162adfe 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -1,14 +1,16 @@ import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; -import { getArcDir, getConfigPath } from "./paths.js"; +import { getArcDir, getConfigPath, getProfileDir } from "./paths.js"; import { deepMerge } from "./shared-fs.js"; import type { ArcConfig, Profile } from "./types.js"; -const AUTH_TYPES = new Set(["oauth", "api-key", "bedrock", "vertex", "foundry"]); +const AUTH_TYPES = new Set(["oauth", "api-key", "bedrock", "vertex", "foundry", "openai-compat"]); export function defaultConfig(): ArcConfig { - return { version: 1, activeProfile: "default", profiles: {} }; + // New installs start with no active profile — tools launched through + // ARC without an explicit profile default to native (bare) mode. + return { version: 1, activeProfile: null, profiles: {} }; } export function validateConfig(config: unknown): config is ArcConfig { @@ -17,7 +19,11 @@ export function validateConfig(config: unknown): config is ArcConfig { } const obj = config as Record; - if (obj["version"] !== 1 || typeof obj["activeProfile"] !== "string") { + if (obj["version"] !== 1) { + return false; + } + // activeProfile is either a string (named profile) or null (none). + if (obj["activeProfile"] !== null && typeof obj["activeProfile"] !== "string") { return false; } @@ -98,11 +104,22 @@ export function saveConfig(config: ArcConfig): void { } export function getActiveProfile(config: ArcConfig): Profile | undefined { + if (config.activeProfile === null) return undefined; return config.profiles[config.activeProfile]; } +/** + * Resolve a profile name for a command. + * Throws when no `name` is provided and no active profile is set. + */ export function resolveProfileName(config: ArcConfig, name?: string): string { - return name ?? config.activeProfile; + if (name) return name; + if (config.activeProfile === null) { + throw new Error( + "No active profile. Use 'arc profile switch ' or pass --profile." + ); + } + return config.activeProfile; } const MAX_INHERITANCE_DEPTH = 10; @@ -171,3 +188,92 @@ export function resolveProfile(config: ArcConfig, profileName: string): Profile return merged as unknown as Profile; } + +/** + * Resolve the effective instructions text for a profile. + * + * Priority: instructionsFile (read from disk) > inline instructions > undefined. + * Returns undefined if no instructions are configured. + */ +export function resolveInstructions(profile: Profile): string | undefined { + if (profile.instructionsFile) { + const resolved = path.isAbsolute(profile.instructionsFile) + ? profile.instructionsFile + : path.resolve(profile.configDir, profile.instructionsFile); + try { + return fs.readFileSync(resolved, "utf-8"); + } catch { + // File missing or unreadable — fall through to inline + } + } + return profile.instructions; +} + +export interface CloneProfileOptions { + /** When true (default), recursively copy the source configDir to the new profile directory. */ + copyConfigDir?: boolean; +} + +/** + * Deep-copy a profile record from `src` to `dst`, resetting `createdAt` and + * (optionally) copying the on-disk configDir to the new profile's location. + * + * Mutates and returns `config`. Callers are responsible for persisting via saveConfig(). + * + * Throws if `src` does not exist or `dst` already exists. If the source + * configDir has been deleted, the profile record is still cloned but the + * directory copy is silently skipped (warning is left to the caller's UI). + */ +export function cloneProfile( + config: ArcConfig, + src: string, + dst: string, + opts?: CloneProfileOptions +): ArcConfig { + if (!config.profiles[src]) { + throw new Error(`Source profile '${src}' not found`); + } + if (src === dst) { + throw new Error(`Destination name must differ from source ('${src}')`); + } + if (config.profiles[dst]) { + throw new Error(`Profile '${dst}' already exists`); + } + + const source = config.profiles[src]; + + // Deep-copy the profile record. structuredClone is available in Node 17+. + const cloned: Profile = + typeof structuredClone === "function" + ? (structuredClone(source) as Profile) + : (JSON.parse(JSON.stringify(source)) as Profile); + + cloned.createdAt = new Date().toISOString(); + + const copyConfigDir = opts?.copyConfigDir !== false; + if (copyConfigDir) { + const newDir = getProfileDir(dst); + const srcDir = source.configDir; + const srcExists = srcDir && fs.existsSync(srcDir); + + if (srcExists) { + fs.mkdirSync(newDir, { recursive: true }); + fs.cpSync(srcDir, newDir, { + recursive: true, + force: true, + dereference: true, + filter: (s: string) => { + const base = path.basename(s); + return base !== "node_modules" && base !== ".bin"; + }, + }); + } else { + // Source dir missing — still create an empty profile dir so launches don't explode. + fs.mkdirSync(newDir, { recursive: true }); + } + cloned.configDir = newDir; + } + + config.profiles[dst] = cloned; + return config; +} diff --git a/packages/core/src/history.ts b/packages/core/src/history.ts new file mode 100644 index 0000000..2fed270 --- /dev/null +++ b/packages/core/src/history.ts @@ -0,0 +1,86 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { getArcDir } from "./paths.js"; + +export type LaunchOutcome = "started" | "exited" | "failed"; + +export interface LaunchHistoryEntry { + profile: string; + tool: string; + timestamp: string; + outcome: LaunchOutcome; + exitCode?: number; +} + +const MAX_HISTORY_ENTRIES = 200; + +export function getHistoryPath(): string { + return path.join(getArcDir(), "history.json"); +} + +function readHistory(): LaunchHistoryEntry[] { + try { + const historyPath = getHistoryPath(); + if (!fs.existsSync(historyPath)) { + return []; + } + const raw = fs.readFileSync(historyPath, "utf-8"); + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) { + return []; + } + return parsed.filter((entry): entry is LaunchHistoryEntry => { + if (typeof entry !== "object" || entry === null) return false; + const record = entry as Record; + return ( + typeof record["profile"] === "string" && + typeof record["tool"] === "string" && + typeof record["timestamp"] === "string" && + typeof record["outcome"] === "string" + ); + }); + } catch { + return []; + } +} + +function writeHistory(entries: LaunchHistoryEntry[]): void { + try { + const dir = getArcDir(); + fs.mkdirSync(dir, { recursive: true }); + const historyPath = getHistoryPath(); + const tempPath = path.join( + dir, + `history.tmp.${crypto.randomBytes(4).toString("hex")}` + ); + fs.writeFileSync(tempPath, JSON.stringify(entries, null, 2) + "\n", "utf-8"); + if (process.platform !== "win32") { + fs.chmodSync(tempPath, 0o600); + } + fs.renameSync(tempPath, historyPath); + } catch { + // History writes must never crash ARC. + } +} + +export function recordLaunch(entry: LaunchHistoryEntry): void { + try { + const entries = readHistory(); + entries.push(entry); + const trimmed = + entries.length > MAX_HISTORY_ENTRIES + ? entries.slice(entries.length - MAX_HISTORY_ENTRIES) + : entries; + writeHistory(trimmed); + } catch { + // Non-fatal. + } +} + +export function getRecentLaunches(limit = 10): LaunchHistoryEntry[] { + const entries = readHistory(); + const reversed = [...entries].reverse(); + if (limit <= 0) return reversed; + return reversed.slice(0, limit); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 65d919d..3d2d68b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -5,6 +5,12 @@ export * from "./workspace.js"; export * from "./import-utils.js"; export * from "./keyring.js"; export * from "./secrets/index.js"; +export * from "./history.js"; +export * from "./agent-client/index.js"; +export * from "./agent/index.js"; +export * from "./knowledge/index.js"; +export * from "./chat/index.js"; +export * from "./orchestration/index.js"; export * from "./lifecycle.js"; export * from "./logging.js"; export * from "./paths.js"; @@ -37,6 +43,7 @@ export type { AuthType, AgentTool, Profile, + ProviderConfig, ArcSettings, ArcConfig, SharedManifest, diff --git a/packages/core/src/knowledge/feature-index.ts b/packages/core/src/knowledge/feature-index.ts new file mode 100644 index 0000000..a57aa37 --- /dev/null +++ b/packages/core/src/knowledge/feature-index.ts @@ -0,0 +1,316 @@ +/** + * Curated index of ARC features with status and short summaries. Fed into + * the system prompt so the assistant can talk accurately about what ARC + * can and cannot do. Keep roughly in sync with FEATURES.md — this file is + * authoritative for what the AI layer sees. + */ + +export type FeatureStatus = "shipped" | "roadmap" | "deferred"; + +export interface FeatureEntry { + id: string; + name: string; + status: FeatureStatus; + summary: string; + since?: string; + docLink?: string; +} + +export const FEATURES_INDEX: FeatureEntry[] = [ + { + id: "profile-management", + name: "Profile management", + status: "shipped", + summary: + "Create, clone, import, export, switch, and delete isolated profiles with per-profile config dirs, env, and auth.", + since: "0.1.0", + docLink: "/docs/profiles", + }, + { + id: "profile-inheritance", + name: "Profile inheritance", + status: "shipped", + summary: + "A profile may inherit env, hooks, and fields from a parent profile via the `inherits` key.", + since: "1.2.0", + docLink: "/docs/profiles", + }, + { + id: "bare-launch", + name: "Bare launch", + status: "shipped", + summary: + "Launch the tool binary directly with profile env — no hook wrapping, no adapter overhead.", + since: "0.3.0", + docLink: "/docs/advanced", + }, + { + id: "native-launch", + name: "Native adapter launch", + status: "shipped", + summary: + "Launch through a tool's native plugin/adapter when available (Claude Code plugin, OpenClaw plugin).", + since: "1.0.0", + }, + { + id: "worker-mode", + name: "Worker permission mode", + status: "shipped", + summary: + "Restricted permission policy for non-interactive worker launches (explicit allowlist, no approvals).", + since: "1.4.0", + }, + { + id: "doctor", + name: "Doctor diagnostics", + status: "shipped", + summary: + "Environment checks (PATH, auth, writable config dir) with inline repair hints.", + since: "0.2.0", + docLink: "/docs/troubleshooting", + }, + { + id: "shared-layer", + name: "Shared layer", + status: "shipped", + summary: + "~/.arc/shared/ syncs MCP servers, commands, CLAUDE.md, memory, and projects across opted-in profiles.", + since: "1.1.0", + docLink: "/docs/advanced", + }, + { + id: "credential-hotswap", + name: "Credential hot-swap", + status: "shipped", + summary: + "[experimental] Capture and swap tool auth credentials in the canonical tool dir without touching MCPs, settings, or history.", + since: "1.3.0", + }, + { + id: "agent-instructions", + name: "Agent instructions", + status: "shipped", + summary: + "Per-profile prompt text resolved at launch and injected as ARC_AGENT_INSTRUCTIONS env var.", + since: "1.6.0", + }, + { + id: "openai-compat-providers", + name: "OpenAI-compatible providers", + status: "shipped", + summary: + "Custom providers (baseUrl + model + apiKeyEnvVar) with presets for OpenRouter, Ollama, LM Studio, Together, Groq, MiniMax, DeepSeek.", + since: "1.6.0", + }, + { + id: "hook-pipeline", + name: "Hook pipeline", + status: "shipped", + summary: + "Priority-ordered bus of 8 hooks around every agent turn; per-profile enforcement modes (off/log/advise/enforce).", + since: "1.4.0", + }, + { + id: "risk-classification", + name: "Risk classification", + status: "shipped", + summary: + "Hook that classifies agent output into low/medium/high/critical tiers to gate supervision.", + since: "1.4.0", + }, + { + id: "source-classification", + name: "Source classification", + status: "shipped", + summary: "Hook that tags message sources (user, agent, system, tool) for downstream routing.", + since: "1.4.0", + }, + { + id: "interagent-routing", + name: "Interagent routing", + status: "shipped", + summary: "Hook that routes messages between agents in multi-agent sessions.", + since: "1.5.0", + }, + { + id: "supervision-gate", + name: "Supervision gate", + status: "shipped", + summary: + "Hook that pauses high-risk actions for human approval or LLM review depending on permission mode.", + since: "1.5.0", + }, + { + id: "post-verify", + name: "Post-verify hook", + status: "shipped", + summary: "Runs after agent output to verify claims and detect hallucinated state.", + since: "1.5.0", + }, + { + id: "roundtable", + name: "Roundtable", + status: "shipped", + summary: + "Multi-agent discussion protocol — several agents answer the same prompt, synthesized into a single verdict via the hook bus.", + since: "1.5.0", + }, + { + id: "task-delegation", + name: "Task delegation", + status: "shipped", + summary: "Persisted tasks with priority, status, assignee, and interagent message bus.", + since: "1.5.0", + }, + { + id: "sessions", + name: "Session continuity", + status: "shipped", + summary: + "Session threads persisted across launches; resume by id or most-recent detection.", + since: "1.5.0", + }, + { + id: "memory", + name: "Memory system", + status: "shipped", + summary: + "Scoped, typed, decay-scored memory with session and persistent stores and full-text search.", + since: "1.4.0", + }, + { + id: "skills", + name: "Skill registry", + status: "shipped", + summary: + "Load skills from directories, convert MCP tools into skills, detect repeated patterns, auto-generate skills.", + since: "1.5.0", + }, + { + id: "telemetry", + name: "Telemetry (OpenTelemetry)", + status: "shipped", + summary: + "OTel spans around sessions, preflight, hooks, agent execution, tool use, postflight, circuit breakers; exporters for console, JSON file, OTLP.", + since: "1.4.0", + }, + { + id: "sync", + name: "Cloud sync", + status: "shipped", + summary: "Filesystem-based sync provider + manager with delta/change tracking.", + since: "1.4.0", + }, + { + id: "plugins", + name: "Plugin registry", + status: "shipped", + summary: "Install, enable, disable, and list plugins with capability manifests.", + since: "1.5.0", + }, + { + id: "remote-agents", + name: "Remote agents", + status: "shipped", + summary: "Register remote agents over HTTP/MCP transports with health checks.", + since: "1.5.0", + }, + { + id: "factory-mode", + name: "Dark Factory Mode", + status: "shipped", + summary: "Parallel wave execution of agent specs for batch/background work.", + since: "1.5.0", + }, + { + id: "dashboard", + name: "Web dashboard", + status: "shipped", + summary: + "React web UI with 13 views (overview, sessions, traces, risk, tasks, skills, memory, agents, factory, profiles, diagnostics, sync, plugins).", + since: "1.5.0", + }, + { + id: "mcp-integration", + name: "MCP integration", + status: "shipped", + summary: "Per-profile MCP server declarations reachable from the launched tool.", + since: "1.2.0", + }, + { + id: "backup-restore", + name: "Backup and restore", + status: "shipped", + summary: "Snapshot ~/.arc, list snapshots, and restore with confirmation.", + since: "1.3.0", + }, + { + id: "onboarding", + name: "First-run onboarding", + status: "shipped", + summary: + "Fullscreen TUI wizard for multi-select tool import, rename, and batch profile creation.", + since: "1.2.0", + docLink: "/docs/getting-started", + }, + { + id: "workspace-shell", + name: "Workspace shell", + status: "shipped", + summary: + "TUI shell with tokenized input (syntax highlighting + auto-complete) for mixing shell commands and /arc actions.", + since: "1.6.0", + }, + { + id: "ai-assistant", + name: "In-app AI assistant", + status: "roadmap", + summary: + "Embedded assistant with ARC domain knowledge and tool access for profile/task/session operations (AD-6).", + }, + { + id: "knowledge-endowment", + name: "Knowledge endowment layer", + status: "shipped", + summary: + "System-prompt composition layer (static knowledge + live state) powering the in-app assistant (AD-6 Phase 3).", + since: "1.7.0", + }, +]; + +function normalize(s: string): string { + return s.toLowerCase().replace(/[\s_-]+/g, ""); +} + +/** + * Look up a feature by id or name with simple fuzzy matching: + * exact id → exact name → normalized contains. + */ +export function getFeature(idOrName: string): FeatureEntry | undefined { + if (!idOrName) return undefined; + const q = idOrName.trim(); + const qn = normalize(q); + + // exact id + const byId = FEATURES_INDEX.find((f) => f.id === q); + if (byId) return byId; + + // exact name (case-insensitive) + const byName = FEATURES_INDEX.find((f) => f.name.toLowerCase() === q.toLowerCase()); + if (byName) return byName; + + // normalized id contains + const byIdFuzzy = FEATURES_INDEX.find((f) => normalize(f.id).includes(qn)); + if (byIdFuzzy) return byIdFuzzy; + + // normalized name contains + const byNameFuzzy = FEATURES_INDEX.find((f) => normalize(f.name).includes(qn)); + if (byNameFuzzy) return byNameFuzzy; + + return undefined; +} + +/** Filter features by status. */ +export function getFeaturesByStatus(status: FeatureStatus): FeatureEntry[] { + return FEATURES_INDEX.filter((f) => f.status === status); +} diff --git a/packages/core/src/knowledge/index.ts b/packages/core/src/knowledge/index.ts new file mode 100644 index 0000000..a9ba0e2 --- /dev/null +++ b/packages/core/src/knowledge/index.ts @@ -0,0 +1,31 @@ +/** + * ARC knowledge layer — static domain knowledge + runtime prompt composer + * powering the in-app AI assistant (AD-6 Phase 3). + */ + +export { + ARC_KNOWLEDGE, + COMMANDS_CATALOG, + type ArcKnowledge, + type CommandDoc, + type CommandCategory, +} from "./static.js"; + +export { + FEATURES_INDEX, + getFeature, + getFeaturesByStatus, + type FeatureEntry, + type FeatureStatus, +} from "./feature-index.js"; + +// NOTE: estimateTokens is intentionally NOT re-exported here — the +// canonical `estimateTokens` lives in context-manager.ts and is already +// re-exported by packages/core/src/index.ts. +// PermissionMode and LaunchHistoryEntry are also owned by other modules +// (agent/ and history.ts respectively); import from ./runtime.js directly +// if you need the knowledge-layer-specific helpers. +export { + buildSystemPrompt, + type KnowledgeContext, +} from "./runtime.js"; diff --git a/packages/core/src/knowledge/runtime.ts b/packages/core/src/knowledge/runtime.ts new file mode 100644 index 0000000..2295e50 --- /dev/null +++ b/packages/core/src/knowledge/runtime.ts @@ -0,0 +1,195 @@ +/** + * Runtime composition of the ARC assistant system prompt. Blends static + * domain knowledge (architecture, concepts, commands) with a live snapshot + * of user state (active profile, recent launches, doctor issues, version) + * to produce one structured prompt string under ~4000 tokens. + */ + +import type { ArcConfig, Profile } from "../types.js"; +import { ARC_KNOWLEDGE } from "./static.js"; +import { FEATURES_INDEX } from "./feature-index.js"; + +export interface LaunchHistoryEntry { + profile: string; + tool?: string; + mode?: "default" | "bare" | "native" | "worker"; + startedAt: string; + exitCode?: number; + durationMs?: number; +} + +export type PermissionMode = "read-only" | "supervised" | "autonomous"; + +export interface KnowledgeContext { + config: ArcConfig; + recentLaunches: LaunchHistoryEntry[]; + activeProfile: Profile | null; + arcVersion: string; + doctorIssues?: string[]; + permissionMode: PermissionMode; + toolCategories: string[]; +} + +/** Rough token estimator — 4 chars per token. */ +export function estimateTokens(text: string): number { + if (!text) return 0; + return Math.ceil(text.length / 4); +} + +const TOKEN_BUDGET = 4000; +const MAX_RECENT_LAUNCHES = 3; +const MAX_DOCTOR_ISSUES = 8; +const MAX_CONCEPTS = 14; +const MAX_COMMANDS_IN_PROMPT = 32; + +function truncate(text: string, maxChars: number): string { + if (text.length <= maxChars) return text; + return text.slice(0, Math.max(0, maxChars - 3)) + "..."; +} + +function formatLaunch(l: LaunchHistoryEntry): string { + const mode = l.mode ? ` [${l.mode}]` : ""; + const tool = l.tool ? ` (${l.tool})` : ""; + const code = + typeof l.exitCode === "number" ? ` exit=${l.exitCode}` : ""; + const dur = + typeof l.durationMs === "number" ? ` ${Math.round(l.durationMs / 1000)}s` : ""; + return ` - ${l.startedAt} ${l.profile}${tool}${mode}${code}${dur}`; +} + +function section(title: string, body: string): string { + return `## ${title}\n${body}`.trim(); +} + +/** + * Compose the full assistant system prompt from static knowledge + live + * context. Output is structured in 6 sections. Hard-capped to stay within + * a ~4000-token budget; long inputs (recentLaunches, doctorIssues) are + * truncated, and overall output is clamped as a final safety. + */ +export function buildSystemPrompt(ctx: KnowledgeContext): string { + // 1. Identity + const identity = section( + "Identity", + [ + "You are the ARC assistant — an in-app helper embedded in ARC", + "(Agent Runtime Control). You understand ARC's profile model,", + "adapters, hook pipeline, shared layer, and dashboard. You help", + "the user inspect, configure, launch, and orchestrate agent", + "runtimes through ARC's commands and tools.", + ].join(" "), + ); + + // 2. Capabilities + const caps = ctx.toolCategories.length + ? ctx.toolCategories.map((c) => `- ${c}`).join("\n") + : "- (no tools available in this session)"; + const capabilities = section( + "Capabilities", + `You have access to tools in the following categories (schemas provided separately):\n${caps}`, + ); + + // 3. Architecture brief + const arch = truncate(ARC_KNOWLEDGE.architecture, 1400); + const architecture = section("ARC architecture", arch); + + // 4. Concepts glossary (trimmed) + const conceptKeys = Object.keys(ARC_KNOWLEDGE.concepts).slice(0, MAX_CONCEPTS); + const glossary = conceptKeys + .map((k) => `- **${k}**: ${ARC_KNOWLEDGE.concepts[k]}`) + .join("\n"); + const concepts = section("Key concepts", glossary); + + // 5. Live state snapshot + const profileCount = Object.keys(ctx.config.profiles).length; + const activeName = ctx.config.activeProfile || "(none)"; + const activeTool = ctx.activeProfile?.tool ?? "unknown"; + const activeAuth = ctx.activeProfile?.authType ?? "unknown"; + const sharedOn = ctx.activeProfile?.useShared ? "yes" : "no"; + const enforcement = ctx.activeProfile?.enforcement ?? "log"; + + const launches = ctx.recentLaunches + .slice(0, MAX_RECENT_LAUNCHES) + .map(formatLaunch) + .join("\n"); + + const issues = (ctx.doctorIssues ?? []).slice(0, MAX_DOCTOR_ISSUES); + const issuesBlock = issues.length + ? issues.map((i) => ` - ${truncate(i, 160)}`).join("\n") + : " - none"; + + const shippedFeatureCount = FEATURES_INDEX.filter( + (f) => f.status === "shipped", + ).length; + + const liveState = section( + "Live state", + [ + `- ARC version: ${ctx.arcVersion}`, + `- Permission mode: ${ctx.permissionMode}`, + `- Active profile: ${activeName} (tool=${activeTool}, auth=${activeAuth}, shared=${sharedOn}, enforcement=${enforcement})`, + `- Total profiles: ${profileCount}`, + `- Shipped features indexed: ${shippedFeatureCount}`, + `- Recent launches (last ${MAX_RECENT_LAUNCHES}):`, + launches || " - none", + `- Doctor issues:`, + issuesBlock, + ].join("\n"), + ); + + // 6. Behavior rules + const modeRules: Record = { + "read-only": [ + "You are in read-only mode. You may inspect ARC state but you MUST NOT", + "call tools that modify profiles, credentials, or run subprocesses.", + "If the user asks for a destructive action, explain what command", + "they would run instead and stop.", + ].join(" "), + supervised: [ + "You are in supervised mode. For destructive actions (profile delete,", + "credential swap, backup restore, shared push/pull, factory abort) you", + "MUST state your plan and wait for user confirmation before invoking", + "the tool. Non-destructive reads may proceed without confirmation.", + ].join(" "), + autonomous: [ + "You are in autonomous mode. You may invoke tools without per-action", + "confirmation, but still narrate what you are doing, and stop", + "immediately if a tool returns an error or unexpected state.", + ].join(" "), + }; + + const behavior = section( + "Behavior rules", + [ + "- Prefer tools over speculation. If the user asks about their state,", + " read it through the provided tools rather than guessing.", + "- Prefer asking one clarifying question over guessing when intent is", + " ambiguous about which profile or tool is involved.", + "- Cite the exact `arc ...` command when recommending an action, so", + " the user can run it outside the assistant if they prefer.", + "- Treat `swap`, `backup restore`, `profile delete`, `shared push`,", + " `factory abort`, and `uninstall` as destructive.", + "- Never invent commands, flags, or concepts. If unsure, say so.", + "", + modeRules[ctx.permissionMode], + ].join("\n"), + ); + + // Compose + enforce token cap + let prompt = [ + identity, + capabilities, + architecture, + concepts, + liveState, + behavior, + ].join("\n\n"); + + // Final safety clamp. Aim for 2500-3000; hard cap at 4000. + if (estimateTokens(prompt) > TOKEN_BUDGET) { + const maxChars = TOKEN_BUDGET * 4; + prompt = truncate(prompt, maxChars); + } + + return prompt; +} diff --git a/packages/core/src/knowledge/static.ts b/packages/core/src/knowledge/static.ts new file mode 100644 index 0000000..79268ac --- /dev/null +++ b/packages/core/src/knowledge/static.ts @@ -0,0 +1,485 @@ +/** + * Static ARC knowledge: purpose, architecture, concept glossary, and command + * catalog. This payload is composed into the assistant system prompt by + * buildSystemPrompt() at runtime. + */ + +export type CommandCategory = + | "profile" + | "launch" + | "diagnostic" + | "orchestration" + | "data" + | "utility"; + +export interface CommandDoc { + name: string; + description: string; + examples?: string[]; + category: CommandCategory; +} + +export interface ArcKnowledge { + purpose: string; + architecture: string; + concepts: Record; + commands: CommandDoc[]; + docLinks: Record; +} + +/** + * Curated catalog of ARC CLI commands. Not auto-generated; kept in sync with + * packages/cli/src/cli.ts by hand. Covers the most common commands + key + * subcommands. Not exhaustive (~45 entries). + */ +export const COMMANDS_CATALOG: CommandDoc[] = [ + // ─── Profile management ───────────────────────────────────────────── + { + name: "profile list", + description: "List all registered profiles with tool, auth type, and active marker.", + category: "profile", + examples: ["arc profile list", "arc ls"], + }, + { + name: "profile show", + description: "Show full profile record (env overrides, hooks, shared layer state).", + category: "profile", + examples: ["arc profile show", "arc profile show work"], + }, + { + name: "profile switch", + description: "Set the active profile. Subsequent launches use its config dir and env.", + category: "profile", + examples: ["arc profile switch work", "arc use work"], + }, + { + name: "profile create", + description: "Create a new profile. Prompts for tool binary, auth type, and description.", + category: "profile", + examples: [ + "arc profile create work", + "arc create work --tool claude --auth-type oauth", + ], + }, + { + name: "profile clone", + description: "Copy an existing profile's config dir into a new profile with a new name.", + category: "profile", + examples: ["arc profile clone work work-staging"], + }, + { + name: "profile delete", + description: "Delete a profile and its isolated config dir. Prompts for confirmation.", + category: "profile", + examples: ["arc profile delete old-profile"], + }, + { + name: "profile import", + description: "Interactive import of existing tool installs (~/.claude, ~/.codex, etc.).", + category: "profile", + examples: ["arc profile import"], + }, + { + name: "profile export", + description: "Export profile config dir as a tarball for backup or transfer.", + category: "profile", + examples: ["arc profile export work ./work.tgz"], + }, + { + name: "profile import-file", + description: "Restore a profile from a tarball produced by profile export.", + category: "profile", + examples: ["arc profile import-file ./work.tgz"], + }, + { + name: "profile clear-active", + description: "Unset the active profile without deleting it.", + category: "profile", + examples: ["arc profile clear-active"], + }, + + // ─── Launch ───────────────────────────────────────────────────────── + { + name: "launch", + description: + "Launch the active profile's tool (or a named one). Default mode wraps the tool with ARC hooks.", + category: "launch", + examples: ["arc launch", "arc launch work"], + }, + { + name: "launch --bare", + description: + "Launch the tool binary with the profile's env + configDir but no ARC wrapping or hooks.", + category: "launch", + examples: ["arc launch --bare", "arc launch work --bare"], + }, + { + name: "launch --native", + description: + "Use the tool's native plugin/adapter integration when available (e.g. Claude Code plugin).", + category: "launch", + examples: ["arc launch --native"], + }, + { + name: "launch --worker", + description: + "Launch in worker permission mode (restricted allowlist, non-interactive).", + category: "launch", + examples: ["arc launch --worker"], + }, + { + name: "run", + description: "Run a one-shot prompt against the active profile's tool and print the result.", + category: "launch", + examples: ['arc run "summarize this repo"'], + }, + { + name: "exec", + description: "Run a shell command with the profile's env vars applied (configDir, tool env).", + category: "launch", + examples: ["arc exec -- claude --version"], + }, + { + name: "shell", + description: "Open a subshell with the profile's env vars applied.", + category: "launch", + examples: ["arc shell"], + }, + + // ─── Diagnostic ───────────────────────────────────────────────────── + { + name: "doctor", + description: + "Run diagnostic checks (binary on PATH, auth present, config dir writable). Prints repair hints.", + category: "diagnostic", + examples: ["arc doctor"], + }, + { + name: "health", + description: "Structured JSON health report — used by the TUI and dashboard.", + category: "diagnostic", + examples: ["arc health"], + }, + { + name: "logs", + description: "Tail or show the ARC activity log (~/.arc/activity.log).", + category: "diagnostic", + examples: ["arc logs", "arc logs --tail 50"], + }, + { + name: "which", + description: "Print the active profile's resolved config dir and tool binary path.", + category: "diagnostic", + examples: ["arc which"], + }, + + // ─── Orchestration ────────────────────────────────────────────────── + { + name: "tasks list", + description: "List persisted orchestration tasks (delegated, scheduled, completed).", + category: "orchestration", + examples: ["arc tasks list"], + }, + { + name: "tasks create", + description: "Create a new task with description, priority, and optional assignee.", + category: "orchestration", + examples: ['arc tasks create "refactor auth flow"'], + }, + { + name: "tasks stop", + description: "Cancel a running task.", + category: "orchestration", + examples: ["arc tasks stop t-42"], + }, + { + name: "sessions list", + description: "List session threads (running, paused, completed).", + category: "orchestration", + examples: ["arc sessions list"], + }, + { + name: "sessions resume", + description: "Resume the most recent session or a specific session id.", + category: "orchestration", + examples: ["arc sessions resume", "arc sessions resume s-12"], + }, + { + name: "factory status", + description: "Show Dark Factory wave status (parallel agent spec execution).", + category: "orchestration", + examples: ["arc factory status"], + }, + { + name: "remote list", + description: "List registered remote agents (MCP/HTTP bridges).", + category: "orchestration", + examples: ["arc remote list"], + }, + + // ─── Data ─────────────────────────────────────────────────────────── + { + name: "backup create", + description: "Create a compressed snapshot of ~/.arc (config + profile dirs).", + category: "data", + examples: ["arc backup create"], + }, + { + name: "backup list", + description: "List available backup snapshots with timestamps and sizes.", + category: "data", + examples: ["arc backup list"], + }, + { + name: "backup restore", + description: "Restore a backup snapshot. Prompts before overwriting current state.", + category: "data", + examples: ["arc backup restore 2026-04-17T12-00"], + }, + { + name: "shared status", + description: "Show which profiles are subscribed to the shared layer and what it contains.", + category: "data", + examples: ["arc shared status"], + }, + { + name: "shared pull", + description: + "Pull a profile's MCPs/commands/CLAUDE.md/memory/projects into ~/.arc/shared/.", + category: "data", + examples: ["arc shared pull work"], + }, + { + name: "shared push", + description: "Push (enable) the shared layer onto a profile so it sees shared resources.", + category: "data", + examples: ["arc shared enable work"], + }, + { + name: "memory list", + description: "List persistent memory entries (scoped, typed, scored).", + category: "data", + examples: ["arc memory list"], + }, + { + name: "memory search", + description: "Full-text search memory entries.", + category: "data", + examples: ['arc memory search "oauth refresh"'], + }, + { + name: "skills list", + description: "List loaded skills and their contract metadata.", + category: "data", + examples: ["arc skills list"], + }, + + // ─── Utility ──────────────────────────────────────────────────────── + { + name: "mcp connect", + description: "Add an MCP server to the active profile's config.", + category: "utility", + examples: ["arc mcp connect github https://mcp.github.dev"], + }, + { + name: "mcp list", + description: "List MCP servers configured for the active profile.", + category: "utility", + examples: ["arc mcp list"], + }, + { + name: "swap capture", + description: + "[experimental] Capture the current tool's auth credentials into a named snapshot.", + category: "utility", + examples: ["arc swap capture personal"], + }, + { + name: "swap to", + description: + "[experimental] Swap the live tool credentials to a named snapshot without touching MCPs/settings.", + category: "utility", + examples: ["arc swap to work"], + }, + { + name: "instructions show", + description: "Show the agent instructions injected as ARC_AGENT_INSTRUCTIONS at launch.", + category: "utility", + examples: ["arc instructions show"], + }, + { + name: "instructions set", + description: "Set inline agent instructions for the active profile.", + category: "utility", + examples: ['arc instructions set "Always run typecheck before commit"'], + }, + { + name: "instructions edit", + description: "Open the instructions file in $EDITOR.", + category: "utility", + examples: ["arc instructions edit"], + }, + { + name: "instructions clear", + description: "Remove agent instructions from the active profile.", + category: "utility", + examples: ["arc instructions clear"], + }, + { + name: "provider set", + description: + "Configure an OpenAI-compatible provider (baseUrl, model, apiKeyEnvVar) on the active profile.", + category: "utility", + examples: ["arc provider set --preset openrouter"], + }, + { + name: "provider show", + description: "Show the active profile's configured provider.", + category: "utility", + examples: ["arc provider show"], + }, + { + name: "provider clear", + description: "Remove the provider config from the active profile.", + category: "utility", + examples: ["arc provider clear"], + }, + { + name: "provider presets", + description: + "List built-in provider presets (OpenRouter, Ollama, LM Studio, Together, Groq, MiniMax, DeepSeek).", + category: "utility", + examples: ["arc provider presets"], + }, + { + name: "dashboard", + description: "Open the ARC web dashboard in the browser.", + category: "utility", + examples: ["arc dashboard"], + }, + { + name: "shell-init", + description: "Print shell init snippet (completions, PROMPT_COMMAND integration).", + category: "utility", + examples: ["arc shell-init bash"], + }, + { + name: "update", + description: "Self-update ARC via npm install -g.", + category: "utility", + examples: ["arc update"], + }, +]; + +export const ARC_KNOWLEDGE: ArcKnowledge = { + purpose: [ + "ARC (Agent Runtime Control) is a CLI and TUI for managing multiple agent", + "runtimes — Claude Code, Gemini CLI, Codex CLI, OpenClaw, and any OpenAI", + "compatible tool — side by side on a single machine. Each runtime lives", + "in an isolated profile with its own config directory, credentials, MCP", + "servers, hooks, and environment variables. Switching profiles is a", + "single command; launching a profile rewrites the target tool's HOME-", + "equivalent env vars (CLAUDE_CONFIG_DIR, GEMINI_CLI_HOME, CODEX_HOME,", + "HERMES_HOME) so the tool reads only that profile's state.", + "", + "The problem ARC solves: agent CLIs assume a single global install and", + "a single credential set. Users who need work/personal separation,", + "multiple API keys, per-project MCP sets, or reproducible setups across", + "machines hit painful collisions. ARC removes the collisions by", + "scoping every piece of state to a profile and providing operations —", + "clone, import, export, shared layer, credential hot-swap, backup —", + "that treat profiles as first-class artifacts. On top of that base,", + "ARC adds an orchestration layer (hook pipeline, roundtable, task", + "delegation, risk classification) so the same profiles can be used", + "both for interactive sessions and for supervised multi-agent work.", + ].join(" "), + + architecture: [ + "Monorepo laid out as pnpm workspaces under packages/: core (profile", + "model, hooks, memory, sessions, skills, tasks, telemetry, sync,", + "factory, permissions), cli (Commander.js surface + Ink TUI),", + "adapter-claude, adapter-openclaw, mcp, and dashboard (web UI).", + "", + "Profiles are records in ~/.arc/config.json that point at an isolated", + "config directory under ~/.arc/profiles//. At launch time the", + "runtime resolves the profile, rewrites env (HOME-equivalents per", + "tool, plus envOverrides and ARC_AGENT_INSTRUCTIONS), and invokes the", + "right adapter.", + "", + "Adapters implement a common interface (detect, launch, hook into", + "tool output) for each backend: Claude Code (SDK + plugin + hooks),", + "Codex CLI, Gemini CLI, OpenClaw (native plugin), Hermes (MCP bridge),", + "OpenAI-compatible (custom baseUrl/model), Generic (fallback).", + "", + "The hook pipeline is a priority-ordered bus of 8 hooks (source-", + "classify → risk detect → interagent routing → supervision gate →", + "agent execution → post-verify → audit → roundtable). Each profile", + "selects an enforcement mode (off/log/advise/enforce).", + "", + "The shared layer lives under ~/.arc/shared/ and syncs MCP servers,", + "slash commands, CLAUDE.md, memory, and projects across profiles", + "that opt in. Pull copies from a profile into shared; enable links", + "shared into a profile via directory links or merged files.", + "", + "MCP integration: MCP servers are declared per profile and reachable", + "from whichever tool is launched. The dashboard is a React app that", + "reads ~/.arc via a local HTTP bridge and surfaces sessions, traces,", + "risk, tasks, skills, memory, agents, factory, profiles, diagnostics,", + "sync, and plugins.", + "", + "The roundtable module runs multi-agent discussions for a single", + "request, collecting agent outputs and synthesizing a verdict through", + "the hook bus so the orchestration and adapter layers share one path.", + ].join(" "), + + concepts: { + profile: + "A named runtime record (tool, auth type, config dir, env, hooks) stored in ~/.arc/config.json.", + "active profile": + "The profile ARC uses by default for launch/run/exec. Set via `arc use` or `arc profile switch`.", + "config dir": + "The isolated directory under ~/.arc/profiles// that holds tool-native config, credentials, and history.", + "bare launch": + "Launch mode that sets the profile env and invokes the tool binary directly — no hooks, no wrapping.", + "launch mode": + "One of: default (hook-wrapped), --bare (no wrapping), --native (tool-native plugin integration), --worker (restricted permissions).", + adapter: + "Per-tool integration module (claude, codex, gemini, openclaw, hermes, openai-compat, generic) implementing detect + launch + hook.", + "shared layer": + "~/.arc/shared/ holding MCP servers, commands, CLAUDE.md, memory, and projects that multiple profiles can opt into.", + "hook pipeline": + "Priority-ordered bus of 8 hooks (source-classify, risk, routing, supervision, exec, post-verify, audit, roundtable) run around every agent turn.", + "enforcement mode": + "Per-profile hook behavior: off (skip), log (observe), advise (inject suggestions), enforce (block on failures).", + roundtable: + "Multi-agent discussion protocol: several agents answer the same prompt, results are synthesized into a single verdict.", + "permission mode": + "read-only | supervised | autonomous — governs whether the assistant may call tools and whether destructive ops need confirmation.", + "risk tier": + "Output of the risk-detection hook: low | medium | high | critical. Higher tiers raise supervision requirements.", + "credential hot-swap": + "[experimental] `arc swap` — capture/restore tool auth credentials without touching MCPs, settings, or history.", + "agent instructions": + "Per-profile prompt text resolved at launch and injected as ARC_AGENT_INSTRUCTIONS env var.", + telemetry: + "OpenTelemetry spans around sessions, preflight, hooks, agent execution, tool use, postflight, and circuit breakers.", + factory: + "Dark Factory Mode — parallel wave execution of agent specs for batch/background work.", + "profile inheritance": + "A profile may set `inherits: ` to reuse env, hooks, and config fields unless overridden.", + }, + + commands: COMMANDS_CATALOG, + + docLinks: { + "getting started": "/docs/getting-started", + profiles: "/docs/profiles", + authentication: "/docs/authentication", + configuration: "/docs/configuration", + advanced: "/docs/advanced", + "shell integration": "/docs/shell-integration", + troubleshooting: "/docs/troubleshooting", + development: "/docs/development", + spec: "/docs/spec/SPEC", + }, +}; diff --git a/packages/core/src/orchestration/delivery-policy.ts b/packages/core/src/orchestration/delivery-policy.ts new file mode 100644 index 0000000..4d48870 --- /dev/null +++ b/packages/core/src/orchestration/delivery-policy.ts @@ -0,0 +1,220 @@ +// Ported from agent-forge/lib/agent-delivery.ts — see docs/plans/ai-and-roundtable.md AD-7 +/** + * Adaptive delivery pacing for the roundtable orchestrator. + * + * Each model family (Gemini, Claude, Codex, default) has a built-in profile + * with min/max grace periods and an adaptive multiplier. The orchestrator + * tracks a rolling EMA of observed reply latency per agent and uses it to + * compute the next "grace" pause between turns — faster agents breathe less, + * slower ones get a longer runway so the group stays in rhythm. + * + * Coalescing helpers and a tiny priority queue are exposed for message + * batching when multiple pending messages target the same agent. + */ + +import type { AgentTool } from "../types.js"; + +// ─── Constants ─────────────────────────────────────────────────────── + +const DEFAULT_MIN_REPLY_GRACE_MS = 10_000; +const DEFAULT_MAX_REPLY_GRACE_MS = 90_000; +const DEFAULT_ADAPTIVE_REPLY_MULTIPLIER = 1.3; +const DEFAULT_BROADCAST_COALESCE_WINDOW_MS = 12_000; + +// EMA weights — 70% weight on previous observation, 30% on new sample. +const EMA_PREVIOUS_WEIGHT = 0.7; +const EMA_SAMPLE_WEIGHT = 0.3; + +// ─── Types ─────────────────────────────────────────────────────────── + +/** + * Delivery policy governing how aggressively the orchestrator paces turns + * between agents. All durations are in milliseconds. + */ +export interface AgentDeliveryPolicy { + /** Lower bound on between-turn grace. Fast agents still wait this long. */ + minReplyGraceMs: number; + /** Upper bound on between-turn grace. Slow agents never block longer. */ + maxReplyGraceMs: number; + /** Multiplier applied to the rolling EMA latency when computing grace. */ + adaptiveReplyMultiplier: number; + /** Window within which adjacent broadcasts are candidates to coalesce. */ + broadcastCoalesceWindowMs: number; + /** When false, pacing is disabled entirely (grace = 0). */ + respectAgentPace: boolean; + /** When true, direct (non-broadcast) messages bypass pacing. */ + directMessageBypass: boolean; +} + +/** EMA-tracked latency state, per agent. */ +export interface DeliveryState { + /** Rolling latency average — milliseconds. 0 means no samples yet. */ + observedLatencyMs: number; + /** Number of samples folded into the EMA. */ + sampleCount: number; +} + +export type MessagePriority = "urgent" | "normal" | "low"; + +/** Simple priority-queue entry for coalescing pending messages. */ +export interface PriorityQueueItem { + text: string; + priority: MessagePriority; + createdAt: number; +} + +// ─── Built-in model profiles ───────────────────────────────────────── + +const GEMINI_PROFILE: AgentDeliveryPolicy = { + minReplyGraceMs: 18_000, + maxReplyGraceMs: 120_000, + adaptiveReplyMultiplier: 1.6, + broadcastCoalesceWindowMs: 20_000, + respectAgentPace: true, + directMessageBypass: true, +}; + +const CLAUDE_PROFILE: AgentDeliveryPolicy = { + minReplyGraceMs: 12_000, + maxReplyGraceMs: 90_000, + adaptiveReplyMultiplier: 1.45, + broadcastCoalesceWindowMs: 14_000, + respectAgentPace: true, + directMessageBypass: true, +}; + +const CODEX_PROFILE: AgentDeliveryPolicy = { + minReplyGraceMs: 8_000, + maxReplyGraceMs: 60_000, + adaptiveReplyMultiplier: 1.2, + broadcastCoalesceWindowMs: 8_000, + respectAgentPace: true, + directMessageBypass: true, +}; + +const DEFAULT_PROFILE: AgentDeliveryPolicy = { + minReplyGraceMs: DEFAULT_MIN_REPLY_GRACE_MS, + maxReplyGraceMs: DEFAULT_MAX_REPLY_GRACE_MS, + adaptiveReplyMultiplier: DEFAULT_ADAPTIVE_REPLY_MULTIPLIER, + broadcastCoalesceWindowMs: DEFAULT_BROADCAST_COALESCE_WINDOW_MS, + respectAgentPace: true, + directMessageBypass: true, +}; + +// ─── Helpers ───────────────────────────────────────────────────────── + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +// ─── Public API ────────────────────────────────────────────────────── + +/** Fresh EMA state, no samples observed yet. */ +export function createDeliveryState(): DeliveryState { + return { observedLatencyMs: 0, sampleCount: 0 }; +} + +/** + * Compute the next between-turn grace period given the current EMA state + * and the active policy. Pure function — no side effects. + * + * Semantics: + * - policy.respectAgentPace === false → returns 0 + * - no samples yet → returns policy.minReplyGraceMs + * - otherwise → clamp(observed * multiplier, min, max) + */ +export function computeAdaptiveGraceMs( + policy: AgentDeliveryPolicy, + state: DeliveryState, +): number { + if (!policy.respectAgentPace) return 0; + if (state.sampleCount <= 0 || !(state.observedLatencyMs > 0)) { + return policy.minReplyGraceMs; + } + const projected = state.observedLatencyMs * policy.adaptiveReplyMultiplier; + return clamp(projected, policy.minReplyGraceMs, policy.maxReplyGraceMs); +} + +/** + * Fold a new latency sample into the EMA state. + * + * First sample seeds the EMA; subsequent samples decay prior state 70% + * and blend the new sample at 30%. Invalid samples (≤0 or non-finite) + * are ignored. + */ +export function updateReplyLatencyAverage( + state: DeliveryState, + newLatencyMs: number, +): DeliveryState { + if (!Number.isFinite(newLatencyMs) || newLatencyMs <= 0) return state; + if (state.sampleCount <= 0 || state.observedLatencyMs <= 0) { + return { observedLatencyMs: Math.round(newLatencyMs), sampleCount: 1 }; + } + const blended = + state.observedLatencyMs * EMA_PREVIOUS_WEIGHT + + newLatencyMs * EMA_SAMPLE_WEIGHT; + return { + observedLatencyMs: Math.round(blended), + sampleCount: state.sampleCount + 1, + }; +} + +/** Dispatch the right built-in policy for a tool identifier. */ +export function resolveDeliveryPolicyForTool( + tool: AgentTool | undefined, +): AgentDeliveryPolicy { + if (!tool) return { ...DEFAULT_PROFILE }; + const key = tool.toLowerCase(); + if (key.includes("gemini")) return { ...GEMINI_PROFILE }; + if (key.includes("claude")) return { ...CLAUDE_PROFILE }; + if (key.includes("codex")) return { ...CODEX_PROFILE }; + return { ...DEFAULT_PROFILE }; +} + +// ─── Priority queue (coalescing) ───────────────────────────────────── + +const PRIORITY_ORDER: Record = { + urgent: 0, + normal: 1, + low: 2, +}; + +/** + * Minimal priority queue for message coalescing. Stable FIFO within a + * priority level; higher-priority items drain first. + * + * Not thread-safe — intended for use from a single orchestrator loop. + */ +export class MessagePriorityQueue { + private readonly items: PriorityQueueItem[] = []; + + enqueue(item: PriorityQueueItem): void { + this.items.push(item); + this.items.sort((a, b) => { + const dp = PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]; + if (dp !== 0) return dp; + return a.createdAt - b.createdAt; + }); + } + + dequeue(): PriorityQueueItem | undefined { + return this.items.shift(); + } + + peek(): PriorityQueueItem | undefined { + return this.items[0]; + } + + get size(): number { + return this.items.length; + } + + clear(): void { + this.items.length = 0; + } + + /** Snapshot (defensive copy) of all pending items. */ + snapshot(): PriorityQueueItem[] { + return [...this.items]; + } +} diff --git a/packages/core/src/orchestration/index.ts b/packages/core/src/orchestration/index.ts new file mode 100644 index 0000000..a4f00ff --- /dev/null +++ b/packages/core/src/orchestration/index.ts @@ -0,0 +1,62 @@ +/** + * Orchestration layer — drives the hook pipeline proactively. + * + * Public exports: + * - Adaptive delivery policy + EMA latency tracking (`delivery-policy.ts`) + * - Staged PLAN/EXEC/VERIFY workflow (`staged-workflow.ts`) + * - Stall watchdog (`watchdog.ts`) + * - Roundtable orchestrator (`roundtable.ts`) + */ + +// Delivery policy +export { + computeAdaptiveGraceMs, + createDeliveryState, + resolveDeliveryPolicyForTool, + updateReplyLatencyAverage, + MessagePriorityQueue, + type AgentDeliveryPolicy, + type DeliveryState, + type MessagePriority, + type PriorityQueueItem, +} from "./delivery-policy.js"; + +// Staged workflow +export { + DEFAULT_COMPLETION_PATTERNS, + InMemoryMessageBus, + StagedWorkflowManager, + type MessageBusReadResult, + type StagedMessage, + type StagedMessageBus, + type StagedPhase, + type StagedWorkflowConfig, + type StagedWorkflowManagerDeps, + type StagedWorkflowResult, + type StagedWorkflowTerminal, +} from "./staged-workflow.js"; + +// Watchdog +export { + AgentWatchdog, + isLowSignalWatchdogReply, + WATCHDOG_NUDGE_AFTER_MS, + WATCHDOG_NUDGE_TEXT, + WATCHDOG_STALL_AFTER_MS, + WATCHDOG_STALL_OPTIONS, + type AgentWatchdogDeps, + type WatchdogEvent, + type WatchdogEventType, +} from "./watchdog.js"; + +// Roundtable +export { + RoundtableOrchestrator, + type RoundtableAgent, + type RoundtableEvent, + type RoundtableMessage, + type RoundtableOrchestratorOptions, + type RoundtableResult, + type RoundtableRole, + type RoundtableRunOptions, +} from "./roundtable.js"; diff --git a/packages/core/src/orchestration/roundtable.ts b/packages/core/src/orchestration/roundtable.ts new file mode 100644 index 0000000..73a5864 --- /dev/null +++ b/packages/core/src/orchestration/roundtable.ts @@ -0,0 +1,559 @@ +/** + * Roundtable orchestrator — drives the existing `roundtable` hook from + * outside. The hook is reactive (it reacts to messages entering the + * pipeline). This orchestrator is proactive: it iterates turns, calls each + * agent's AgentClient, feeds the response back through HookBus.runPost() so + * the hook's state machine advances normally. + * + * Design notes: + * - Virtual agents (multiple roles sharing one profile) are deferred to + * Phase 5.1. The type surface accepts `virtualRole`, but the runner + * throws if one is supplied without a dedicated profile. + * - `launchMode` is forced to "worker" for every agent profile: the + * orchestrator requires captured stdout streams, not TTY handoff. + * - Synthesizer JSON parsing is tolerant: if the LLM returns prose, we + * return `consensus = 0.5` and `summary = raw text`. + */ + +import { writeLogEvent } from "../logging.js"; +import type { Profile } from "../types.js"; +import { HookBus } from "../hooks/hook-bus.js"; +import { HookStateStore } from "../hooks/hook-state.js"; +import { + createRoundtableHook, + type RoundtableState, +} from "../hooks/roundtable.js"; +import type { + AgentResponse, + HookContext, +} from "../hooks/types.js"; +import type { + AgentChunk, + AgentClient, + AgentSendOptions, +} from "../agent-client/types.js"; +import { getAgentClientForProfile } from "../agent-client/dispatch.js"; +import { + computeAdaptiveGraceMs, + createDeliveryState, + resolveDeliveryPolicyForTool, + updateReplyLatencyAverage, + type AgentDeliveryPolicy, + type DeliveryState, +} from "./delivery-policy.js"; + +// ─── Public types ──────────────────────────────────────────────────── + +export type RoundtableRole = + | "advocate" + | "critic" + | "neutral" + | "synthesizer"; + +/** + * One participant in a roundtable. + * + * v1 requires `profile`. `virtualRole` is reserved for Phase 5.1, where + * multiple logical roles can share a single profile. + */ +export interface RoundtableAgent { + profile?: Profile; + virtualRole?: string; + role: RoundtableRole; + displayName?: string; +} + +export interface RoundtableRunOptions { + topic: string; + agents: RoundtableAgent[]; + /** Number of discussion rounds. Defaults to 2. */ + rounds?: number; + /** Agent that writes the final summary. Defaults to first entry in `agents`. */ + synthesizer?: RoundtableAgent; + /** Session id to scope hook state. Auto-generated if omitted. */ + sessionId?: string; + /** Abort signal — propagated into each agent call. */ + signal?: AbortSignal; + /** Live progress observer. */ + onEvent?: (evt: RoundtableEvent) => void; +} + +export interface RoundtableMessage { + agent: string; + role: RoundtableRole; + round: number; + content: string; + createdAt: number; + latencyMs: number; +} + +export type RoundtableEvent = + | { + type: "turn-start"; + agent: string; + role: RoundtableRole; + round: number; + turnIndex: number; + } + | { type: "turn-chunk"; agent: string; chunk: AgentChunk } + | { + type: "turn-complete"; + agent: string; + round: number; + latencyMs: number; + content: string; + } + | { + type: "phase-change"; + status: RoundtableState["status"]; + } + | { type: "synthesis-start"; agent: string } + | { + type: "synthesis-complete"; + consensusScore: number; + summary: string; + } + | { type: "error"; message: string; agent?: string }; + +export interface RoundtableResult { + transcript: RoundtableMessage[]; + synthesis: string; + consensusScore: number; + keyPoints: string[]; + durationMs: number; + roundtableId: string; +} + +// ─── Internals ─────────────────────────────────────────────────────── + +const COMPONENT = "orchestration:roundtable"; + +interface PreparedAgent { + id: string; + role: RoundtableRole; + profile: Profile; + client: AgentClient; + policy: AgentDeliveryPolicy; + delivery: DeliveryState; +} + +function generateSessionId(): string { + return `rt-session-${Date.now().toString(36)}-${Math.random() + .toString(36) + .slice(2, 8)}`; +} + +function agentIdentifier(agent: RoundtableAgent, index: number): string { + if (agent.displayName) return agent.displayName; + if (agent.profile?.tool) { + return `${agent.profile.tool}-${index}`; + } + return `agent-${index}`; +} + +function roleInstruction(role: RoundtableRole, topic: string): string { + switch (role) { + case "advocate": + return `You are the ADVOCATE in a roundtable discussion on: "${topic}". Argue the strongest case for taking action. Be concrete and cite evidence.`; + case "critic": + return `You are the CRITIC in a roundtable discussion on: "${topic}". Challenge weak claims. Name the biggest risks the group is glossing over.`; + case "neutral": + return `You are a NEUTRAL participant in a roundtable discussion on: "${topic}". Weigh tradeoffs honestly without advocating a side.`; + case "synthesizer": + return `You are the SYNTHESIZER in a roundtable discussion on: "${topic}". Your job is to summarize the conversation fairly and score consensus.`; + } +} + +function renderTranscript(transcript: RoundtableMessage[]): string { + if (transcript.length === 0) return "(no prior messages)"; + return transcript + .map( + (m) => + `[Round ${m.round}] ${m.agent} (${m.role}):\n${m.content.trim()}`, + ) + .join("\n\n"); +} + +function buildTurnPrompt( + topic: string, + role: RoundtableRole, + round: number, + totalRounds: number, + transcript: RoundtableMessage[], +): string { + return [ + roleInstruction(role, topic), + `This is round ${round} of ${totalRounds}.`, + "Transcript so far:", + renderTranscript(transcript), + "", + "Write your response now. Be focused — 3-6 sentences.", + ].join("\n"); +} + +function buildSynthesisPrompt( + topic: string, + transcript: RoundtableMessage[], +): string { + return [ + roleInstruction("synthesizer", topic), + "Summarize the discussion and output ONLY a JSON object with this shape:", + '{ "consensus": , "summary": , "keyPoints": }', + "", + "Transcript:", + renderTranscript(transcript), + "", + "Output JSON now. No prose, no markdown fences.", + ].join("\n"); +} + +async function collectAgentResponse( + client: AgentClient, + prompt: string, + opts: AgentSendOptions, + agentId: string, + onEvent?: (evt: RoundtableEvent) => void, +): Promise<{ content: string; error?: string }> { + const parts: string[] = []; + let error: string | undefined; + try { + for await (const chunk of client.send(prompt, opts)) { + if (onEvent) { + try { + onEvent({ type: "turn-chunk", agent: agentId, chunk }); + } catch { + /* observer errors non-fatal */ + } + } + if (chunk.type === "text") { + parts.push(chunk.content); + } else if (chunk.type === "error") { + error = chunk.message; + } + } + } catch (err) { + error = err instanceof Error ? err.message : String(err); + } + return { content: parts.join(""), error }; +} + +/** + * Tolerant synthesizer parser. + * + * - Finds the first balanced JSON object in the response (strips markdown + * fences, prose prefix/suffix). + * - On failure, returns a neutral 0.5 consensus and the raw text as summary. + */ +function parseSynthesis(raw: string): { + consensus: number; + summary: string; + keyPoints: string[]; +} { + const fallback = { + consensus: 0.5, + summary: raw.trim() || "(no synthesizer output)", + keyPoints: [] as string[], + }; + if (!raw.trim()) return fallback; + + const start = raw.indexOf("{"); + const end = raw.lastIndexOf("}"); + if (start < 0 || end <= start) return fallback; + const candidate = raw.slice(start, end + 1); + + try { + const parsed = JSON.parse(candidate) as { + consensus?: unknown; + summary?: unknown; + keyPoints?: unknown; + }; + const consensus = + typeof parsed.consensus === "number" && + Number.isFinite(parsed.consensus) + ? Math.max(0, Math.min(1, parsed.consensus)) + : 0.5; + const summary = + typeof parsed.summary === "string" && parsed.summary.trim() + ? parsed.summary + : raw.trim(); + const keyPoints = Array.isArray(parsed.keyPoints) + ? parsed.keyPoints + .filter((kp): kp is string => typeof kp === "string") + .map((kp) => kp.trim()) + .filter(Boolean) + : []; + return { consensus, summary, keyPoints }; + } catch { + return fallback; + } +} + +// ─── Orchestrator ──────────────────────────────────────────────────── + +export interface RoundtableOrchestratorOptions { + /** Optional custom client factory — lets tests inject mocks. */ + clientFactory?: (profile: Profile) => AgentClient; + /** Sleep function — swap for deterministic tests. */ + sleep?: (ms: number) => Promise; + /** Clock override. */ + now?: () => number; +} + +export class RoundtableOrchestrator { + private readonly clientFactory: (profile: Profile) => AgentClient; + private readonly sleep: (ms: number) => Promise; + private readonly now: () => number; + + constructor(options: RoundtableOrchestratorOptions = {}) { + this.clientFactory = + options.clientFactory ?? + ((profile: Profile) => getAgentClientForProfile(profile)); + this.sleep = + options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))); + this.now = options.now ?? Date.now; + } + + async run(opts: RoundtableRunOptions): Promise { + const start = this.now(); + const rounds = opts.rounds ?? 2; + const sessionId = opts.sessionId ?? generateSessionId(); + + if (opts.agents.length < 2) { + throw new Error( + `Roundtable requires at least 2 agents — got ${opts.agents.length}`, + ); + } + + // Prepare agents (forcing worker launchMode). + const prepared: PreparedAgent[] = opts.agents.map((agent, i) => { + if (agent.virtualRole && !agent.profile) { + throw new Error( + `Virtual agents not yet supported — assign a profile (agent index ${i}, role "${agent.virtualRole}"). ` + + `TODO Phase 5.1: allow role-sharing on one profile.`, + ); + } + if (!agent.profile) { + throw new Error( + `Agent at index ${i} is missing a profile. All v1 roundtable agents need an ARC profile.`, + ); + } + const forcedProfile: Profile = { ...agent.profile, launchMode: "worker" }; + const client = this.clientFactory(forcedProfile); + const policy = resolveDeliveryPolicyForTool(forcedProfile.tool); + return { + id: agentIdentifier(agent, i), + role: agent.role, + profile: forcedProfile, + client, + policy, + delivery: createDeliveryState(), + }; + }); + + const synthesizerChoice = opts.synthesizer ?? opts.agents[0]; + const synthesizer = + prepared.find( + (p, i) => p.id === agentIdentifier(synthesizerChoice, i), + ) ?? + prepared.find((p) => p.role === "synthesizer") ?? + prepared[0]; + + // Build a dedicated HookBus + state store so the orchestrator doesn't + // clobber any externally-managed pipeline. + const stateStore = new HookStateStore(); + const hookBus = new HookBus(); + const hook = createRoundtableHook(stateStore, { defaultRounds: rounds }); + hookBus.register(hook, { enabled: true }); + + // Fire the trigger to initialize hook state. We use the first agent's + // id as the "adapter" value for the trigger message. + // Compose the trigger so agents: stays on its own line (the hook's + // parser greedy-matches to end-of-line, so we terminate with \n). + const triggerMessage = [ + `@roundtable rounds: ${rounds}`, + `agents: ${prepared.map((p) => p.id).join(", ")}`, + opts.topic, + ].join("\n"); + + const baseCtx = (adapter: string, message: string): HookContext => ({ + message, + sessionId, + profile: prepared[0].profile, + adapter, + hookMetadata: {}, + }); + + await hookBus.runPre( + baseCtx(prepared[0].id, triggerMessage), + "log", + "pre-message", + ); + + const state = stateStore.get( + sessionId, + "roundtable", + "roundtableState", + ); + if (!state) { + throw new Error( + "Roundtable hook failed to initialize — no state after trigger", + ); + } + + const transcript: RoundtableMessage[] = []; + const totalTurns = rounds * prepared.length; + let turnsCompleted = 0; + + opts.onEvent?.({ type: "phase-change", status: state.status }); + + // Main loop — drive the hook's state machine. + while (turnsCompleted < totalTurns) { + if (opts.signal?.aborted) { + throw new Error("Roundtable aborted"); + } + + const live = stateStore.get( + sessionId, + "roundtable", + "roundtableState", + ); + if (!live || live.status !== "active") break; + + const turnIndex = live.currentTurnIndex; + const round = live.currentRound; + const agent = prepared[turnIndex]; + if (!agent) { + throw new Error( + `Turn index ${turnIndex} out of range (agents=${prepared.length})`, + ); + } + + opts.onEvent?.({ + type: "turn-start", + agent: agent.id, + role: agent.role, + round, + turnIndex, + }); + + const prompt = buildTurnPrompt( + opts.topic, + agent.role, + round, + rounds, + transcript, + ); + + const turnStart = this.now(); + const { content, error } = await collectAgentResponse( + agent.client, + prompt, + { signal: opts.signal }, + agent.id, + opts.onEvent, + ); + const latencyMs = this.now() - turnStart; + + if (error) { + opts.onEvent?.({ type: "error", message: error, agent: agent.id }); + writeLogEvent({ + level: "warn", + component: COMPONENT, + action: "turn-error", + message: `Agent '${agent.id}' errored during turn: ${error}`, + data: { sessionId, round, turnIndex }, + }); + } + + transcript.push({ + agent: agent.id, + role: agent.role, + round, + content, + createdAt: this.now(), + latencyMs, + }); + + // Feed response back into the hook so state advances. + const response: AgentResponse = { content }; + await hookBus.runPost( + baseCtx(agent.id, prompt), + response, + "log", + "post-message", + ); + + opts.onEvent?.({ + type: "turn-complete", + agent: agent.id, + round, + latencyMs, + content, + }); + + // Update adaptive pacing and sleep. + agent.delivery = updateReplyLatencyAverage(agent.delivery, latencyMs); + const graceMs = computeAdaptiveGraceMs(agent.policy, agent.delivery); + + turnsCompleted++; + const stateAfter = stateStore.get( + sessionId, + "roundtable", + "roundtableState", + ); + if (stateAfter && stateAfter.status !== "active") { + opts.onEvent?.({ type: "phase-change", status: stateAfter.status }); + } + + if (turnsCompleted < totalTurns && graceMs > 0) { + await this.sleep(graceMs); + } + } + + // Synthesis. + opts.onEvent?.({ type: "synthesis-start", agent: synthesizer.id }); + + const synthesisPrompt = buildSynthesisPrompt(opts.topic, transcript); + const { content: rawSynthesis, error: synthesisError } = + await collectAgentResponse( + synthesizer.client, + synthesisPrompt, + { signal: opts.signal }, + synthesizer.id, + opts.onEvent, + ); + if (synthesisError) { + opts.onEvent?.({ + type: "error", + message: synthesisError, + agent: synthesizer.id, + }); + } + + const parsed = parseSynthesis(rawSynthesis); + + // Feed synthesis back through the hook so status flips to "complete". + await hookBus.runPost( + baseCtx(synthesizer.id, synthesisPrompt), + { content: rawSynthesis }, + "log", + "post-message", + ); + + opts.onEvent?.({ + type: "synthesis-complete", + consensusScore: parsed.consensus, + summary: parsed.summary, + }); + opts.onEvent?.({ type: "phase-change", status: "complete" }); + + return { + transcript, + synthesis: parsed.summary, + consensusScore: parsed.consensus, + keyPoints: parsed.keyPoints, + durationMs: this.now() - start, + roundtableId: state.id, + }; + } +} diff --git a/packages/core/src/orchestration/staged-workflow.ts b/packages/core/src/orchestration/staged-workflow.ts new file mode 100644 index 0000000..7b62116 --- /dev/null +++ b/packages/core/src/orchestration/staged-workflow.ts @@ -0,0 +1,265 @@ +// Ported from agent-forge/lib/staged-workflow.ts — see docs/plans/ai-and-roundtable.md AD-7 +/** + * Staged workflow — PLAN → EXEC → VERIFY state machine. + * + * The manager advances one phase at a time. To leave a phase, every + * participating agent must have posted a message matching that phase's + * completion regex set. If the phase timeout expires first, the manager + * advances anyway and records the timeout in the transcript. + * + * The message bus is dependency-injected so the same manager can drive an + * in-memory test stub or a real bus implementation in production. + */ + +// ─── Phase types ───────────────────────────────────────────────────── + +export type StagedPhase = "plan" | "exec" | "verify"; + +export type StagedWorkflowTerminal = "complete" | "aborted"; + +// ─── Defaults ──────────────────────────────────────────────────────── + +const DEFAULT_PLAN_TIMEOUT_MS = 120_000; +const DEFAULT_EXEC_TIMEOUT_MS = 300_000; +const DEFAULT_VERIFY_TIMEOUT_MS = 120_000; +const DEFAULT_POLL_INTERVAL_MS = 50; + +/** + * Default completion regexes — ported from Agent-Forge. Each phase + * requires at least one match per agent to advance. + */ +export const DEFAULT_COMPLETION_PATTERNS: Record = { + plan: [ + /\bplan\b/i, + /\bstrateg/i, + /\bapproach\b/i, + /\bready\b/i, + /\bplan\s+shared\b/i, + ], + exec: [ + /\bdone\b/i, + /\bcomplete(?:d)?\b/i, + /\bfinished\b/i, + /\bimplemented\b/i, + /\bexec_done\b/i, + ], + verify: [ + /\bverify(?:_ok)?\b/i, + /\bverified\b/i, + /\bapproved\b/i, + /\breview\s+complete\b/i, + ], +}; + +// ─── Public config ─────────────────────────────────────────────────── + +export interface StagedWorkflowConfig { + /** Ordered phases to run. Defaults to the full PLAN → EXEC → VERIFY cycle. */ + phases?: StagedPhase[]; + /** Per-phase timeout. Missing entries fall back to per-phase defaults. */ + phaseTimeoutMs?: Partial>; + /** Per-phase completion patterns. Overrides defaults for that phase. */ + completionPatterns?: Partial>; + /** How often to poll the message bus while waiting for completion. */ + pollIntervalMs?: number; + /** Called whenever the phase changes. Purely observational. */ + onPhaseChange?: (phase: StagedPhase | StagedWorkflowTerminal) => void; +} + +// ─── Messages & bus ────────────────────────────────────────────────── + +/** Minimal message shape understood by StagedWorkflowManager. */ +export interface StagedMessage { + id: string; + from: string; + content: string; + phase?: StagedPhase; + createdAt: number; +} + +export interface MessageBusReadResult { + messages: StagedMessage[]; + cursor: number; +} + +/** Cursor-based message bus contract the manager depends on. */ +export interface StagedMessageBus { + getMessages(cursor: number): Promise; + post(msg: StagedMessage): Promise; +} + +/** Trivial in-memory bus for tests + default runs. */ +export class InMemoryMessageBus implements StagedMessageBus { + private readonly messages: StagedMessage[] = []; + + async getMessages(cursor: number): Promise { + const messages = this.messages.slice(cursor); + return { messages, cursor: this.messages.length }; + } + + async post(msg: StagedMessage): Promise { + this.messages.push(msg); + } + + /** Test-only snapshot. */ + all(): StagedMessage[] { + return [...this.messages]; + } +} + +// ─── Manager ───────────────────────────────────────────────────────── + +export interface StagedWorkflowManagerDeps { + messageBus: StagedMessageBus; + allAgents: string[]; + /** Override Date.now() for deterministic tests. */ + now?: () => number; + /** Override setTimeout-based sleep for deterministic tests. */ + sleep?: (ms: number) => Promise; +} + +export interface StagedWorkflowResult { + phase: StagedWorkflowTerminal; + transcript: StagedMessage[]; + durationMs: number; + phasesCompleted: StagedPhase[]; + phasesTimedOut: StagedPhase[]; +} + +export class StagedWorkflowManager { + private readonly phases: StagedPhase[]; + private readonly phaseTimeoutMs: Record; + private readonly completionPatterns: Record; + private readonly pollIntervalMs: number; + private readonly onPhaseChange?: ( + phase: StagedPhase | StagedWorkflowTerminal, + ) => void; + private readonly bus: StagedMessageBus; + private readonly allAgents: string[]; + private readonly now: () => number; + private readonly sleep: (ms: number) => Promise; + + private cursor = 0; + private transcript: StagedMessage[] = []; + private currentPhase: StagedPhase | StagedWorkflowTerminal | null = null; + + constructor(config: StagedWorkflowConfig, deps: StagedWorkflowManagerDeps) { + this.phases = + config.phases && config.phases.length > 0 + ? [...config.phases] + : (["plan", "exec", "verify"] as StagedPhase[]); + + const timeoutDefaults: Record = { + plan: DEFAULT_PLAN_TIMEOUT_MS, + exec: DEFAULT_EXEC_TIMEOUT_MS, + verify: DEFAULT_VERIFY_TIMEOUT_MS, + }; + this.phaseTimeoutMs = { + plan: config.phaseTimeoutMs?.plan ?? timeoutDefaults.plan, + exec: config.phaseTimeoutMs?.exec ?? timeoutDefaults.exec, + verify: config.phaseTimeoutMs?.verify ?? timeoutDefaults.verify, + }; + + this.completionPatterns = { + plan: + config.completionPatterns?.plan ?? DEFAULT_COMPLETION_PATTERNS.plan, + exec: + config.completionPatterns?.exec ?? DEFAULT_COMPLETION_PATTERNS.exec, + verify: + config.completionPatterns?.verify ?? DEFAULT_COMPLETION_PATTERNS.verify, + }; + + this.pollIntervalMs = config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + this.onPhaseChange = config.onPhaseChange; + this.bus = deps.messageBus; + this.allAgents = [...deps.allAgents]; + this.now = deps.now ?? Date.now; + this.sleep = + deps.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))); + } + + /** Current phase (or `null` before `run()` starts). */ + getCurrentPhase(): StagedPhase | StagedWorkflowTerminal | null { + return this.currentPhase; + } + + async run(): Promise { + const start = this.now(); + const phasesCompleted: StagedPhase[] = []; + const phasesTimedOut: StagedPhase[] = []; + + for (const phase of this.phases) { + this.setPhase(phase); + this.resetCursor(); + + const phaseStart = this.now(); + const timeoutMs = this.phaseTimeoutMs[phase]; + const patterns = this.completionPatterns[phase]; + + let advanced = false; + while (this.now() - phaseStart < timeoutMs) { + const read = await this.bus.getMessages(this.cursor); + if (read.messages.length > 0) { + this.transcript.push(...read.messages); + this.cursor = read.cursor; + } + if (this.allAgentsMatched(patterns)) { + advanced = true; + break; + } + await this.sleep(this.pollIntervalMs); + } + + if (advanced) { + phasesCompleted.push(phase); + } else { + phasesTimedOut.push(phase); + } + } + + this.setPhase("complete"); + return { + phase: "complete", + transcript: [...this.transcript], + durationMs: this.now() - start, + phasesCompleted, + phasesTimedOut, + }; + } + + /** Abort the workflow (future: signal-based cancellation). */ + abort(): void { + this.setPhase("aborted"); + } + + // ── Internal ───────────────────────────────────────────────────── + + private resetCursor(): void { + // Keep the transcript, but future completion checks only look at the + // messages accumulated *within* the current phase. Callers build their + // own match set against `this.transcript` filtered by phase. + this.cursor = 0; + this.transcript = []; + } + + private setPhase(next: StagedPhase | StagedWorkflowTerminal): void { + if (this.currentPhase === next) return; + this.currentPhase = next; + try { + this.onPhaseChange?.(next); + } catch { + // Observer errors must never derail the workflow. + } + } + + private allAgentsMatched(patterns: RegExp[]): boolean { + if (this.allAgents.length === 0) return false; + return this.allAgents.every((agent) => + this.transcript.some( + (msg) => + msg.from === agent && + patterns.some((re) => re.test(msg.content)), + ), + ); + } +} diff --git a/packages/core/src/orchestration/watchdog.ts b/packages/core/src/orchestration/watchdog.ts new file mode 100644 index 0000000..2e9cb6b --- /dev/null +++ b/packages/core/src/orchestration/watchdog.ts @@ -0,0 +1,222 @@ +// Ported from agent-forge/lib/agent-watchdog.ts — see docs/plans/ai-and-roundtable.md AD-7 +/** + * Stall detection for orchestrated agents. + * + * Protocol (per Agent-Forge): + * - 3 min of no progress → NUDGE (ask the agent to post concrete progress). + * - 5 min after nudge → STALL (emit a decision with kill / wait options). + * + * Unlike Agent-Forge's watchdog, this port is pure: it takes injected + * dependencies in the constructor and exposes a `tick()` method callers + * invoke on their own schedule. No timers, no I/O — friendly to tests. + */ + +// ─── Constants ─────────────────────────────────────────────────────── + +export const WATCHDOG_NUDGE_AFTER_MS = 180_000; +export const WATCHDOG_STALL_AFTER_MS = 300_000; +export const WATCHDOG_NUDGE_TEXT = + "Are you still working? Share concrete progress. If no active task remains, mark yourself done instead of repeating an idle status update."; + +export const WATCHDOG_STALL_OPTIONS = [ + "Kill agent", + "Wait 5 min", + "Wait 15 min", +] as const; + +// ─── Low-signal detection (ported verbatim) ────────────────────────── + +const LOW_SIGNAL_STATUS_INDICATORS = [ + /\bno active\b.*\b(?:implementation|coding|debugging|investigation|review|task|work)\b.*\b(?:in progress|underway)\b/iu, + /\b(?:conversation|thread)\s+remains\s+idle\b/iu, + /\b(?:awaiting|waiting for)\s+(?:a\s+)?(?:concrete|specific)\s+(?:workspace\s+)?task\b/iu, + /\buser-requested status relays?\b/iu, + /\bno active implementation\b/iu, + /\bno active coding\b/iu, +]; + +const LOW_SIGNAL_SHORT_STATUS_PATTERNS = [ + /^still working[.!]?$/iu, + /^working on it[.!]?$/iu, + /^investigating[.!]?$/iu, + /^looking into it[.!]?$/iu, + /^status update[.:!]?$/iu, + /^progress update[.:!]?$/iu, +]; + +function normalize(content: string): string { + return content.replace(/\s+/g, " ").trim().toLowerCase(); +} + +/** + * Heuristic — returns true if the given message is a "I'm idle, nothing to + * report" reply. The watchdog treats such replies as non-progress so it keeps + * nudging/stalling instead of resetting the timer. + */ +export function isLowSignalWatchdogReply(content: string): boolean { + const normalized = normalize(content); + if (!normalized) return true; + if (LOW_SIGNAL_SHORT_STATUS_PATTERNS.some((p) => p.test(normalized))) { + return true; + } + const indicatorCount = LOW_SIGNAL_STATUS_INDICATORS.filter((p) => + p.test(normalized), + ).length; + if (indicatorCount >= 2) return true; + return ( + /^(?:current|latest|thread)\s+(?:status|progress)/iu.test(normalized) && + indicatorCount >= 1 + ); +} + +// ─── Event + dep types ─────────────────────────────────────────────── + +export type WatchdogEventType = "nudge" | "stall" | "decision"; + +export interface WatchdogEvent { + type: WatchdogEventType; + agentId: string; + timestamp: number; + text?: string; + options?: readonly string[]; +} + +export interface AgentWatchdogDeps { + /** True iff the agent is currently considered active (not stalled/exited). */ + isAgentActive: (agentId: string) => boolean; + /** Epoch ms of the agent's last substantive message. */ + getLastMessageAt: (agentId: string) => number; + /** Deliver a decision prompt to the operator (kill / wait). */ + postDecision: (agentId: string, options: readonly string[]) => void; + /** Deliver a nudge prompt to the agent. */ + postNudge?: (agentId: string, text: string) => void; + /** Observer for all lifecycle events. */ + onEvent?: (evt: WatchdogEvent) => void; + /** Override for tests. */ + now?: () => number; + /** Override default thresholds. */ + nudgeAfterMs?: number; + stallAfterMs?: number; +} + +interface AgentTrackedState { + nudgedAt?: number; + stalledAt?: number; +} + +// ─── Watchdog ──────────────────────────────────────────────────────── + +export class AgentWatchdog { + private readonly tracked = new Map(); + private readonly now: () => number; + private readonly nudgeAfterMs: number; + private readonly stallAfterMs: number; + + constructor(private readonly deps: AgentWatchdogDeps) { + this.now = deps.now ?? Date.now; + this.nudgeAfterMs = deps.nudgeAfterMs ?? WATCHDOG_NUDGE_AFTER_MS; + this.stallAfterMs = deps.stallAfterMs ?? WATCHDOG_STALL_AFTER_MS; + } + + /** Start tracking an agent. Idempotent. */ + track(agentId: string): void { + if (!this.tracked.has(agentId)) { + this.tracked.set(agentId, {}); + } + } + + /** Stop tracking an agent entirely. */ + forget(agentId: string): void { + this.tracked.delete(agentId); + } + + /** Clear nudge/stall markers — e.g., on substantive progress. */ + reset(agentId: string): void { + if (this.tracked.has(agentId)) { + this.tracked.set(agentId, {}); + } + } + + /** + * Run one evaluation pass across all tracked agents. Emits nudges and + * stalls via the injected callbacks. + */ + tick(): WatchdogEvent[] { + const emitted: WatchdogEvent[] = []; + const now = this.now(); + + for (const [agentId, state] of this.tracked.entries()) { + if (!this.deps.isAgentActive(agentId)) continue; + + const lastAt = this.deps.getLastMessageAt(agentId); + if (!Number.isFinite(lastAt)) continue; + + const idleMs = now - lastAt; + + // Nudge at threshold (once). + if (!state.nudgedAt && idleMs >= this.nudgeAfterMs) { + state.nudgedAt = now; + try { + this.deps.postNudge?.(agentId, WATCHDOG_NUDGE_TEXT); + } catch { + // Nudge delivery failure must not halt the watchdog. + } + const evt: WatchdogEvent = { + type: "nudge", + agentId, + timestamp: now, + text: WATCHDOG_NUDGE_TEXT, + }; + this.safeEmit(evt); + emitted.push(evt); + continue; + } + + // Stall + decision, after nudge, once the stall threshold passes. + if (state.nudgedAt && !state.stalledAt) { + const sinceNudge = now - state.nudgedAt; + if (sinceNudge >= this.stallAfterMs) { + state.stalledAt = now; + const stallEvt: WatchdogEvent = { + type: "stall", + agentId, + timestamp: now, + }; + const decisionEvt: WatchdogEvent = { + type: "decision", + agentId, + timestamp: now, + options: WATCHDOG_STALL_OPTIONS, + }; + try { + this.deps.postDecision(agentId, WATCHDOG_STALL_OPTIONS); + } catch { + // Decision delivery failure must not halt the watchdog. + } + this.safeEmit(stallEvt); + this.safeEmit(decisionEvt); + emitted.push(stallEvt, decisionEvt); + } + } + } + + return emitted; + } + + /** Snapshot of internal state — mostly for tests. */ + snapshot(): Record { + const out: Record = {}; + for (const [k, v] of this.tracked.entries()) { + out[k] = { ...v }; + } + return out; + } + + private safeEmit(evt: WatchdogEvent): void { + try { + this.deps.onEvent?.(evt); + } catch { + // Observer errors must not halt the watchdog. + } + } +} diff --git a/packages/core/src/paths.ts b/packages/core/src/paths.ts index e3e9551..d11557d 100644 --- a/packages/core/src/paths.ts +++ b/packages/core/src/paths.ts @@ -53,4 +53,14 @@ export function getClaudeDefaultDir(): string { return path.join(os.homedir(), ".claude"); } +/** Directory where persisted roundtable results live (`~/.arc/roundtables/`). */ +export function getRoundtablesDir(): string { + return path.join(getArcDir(), "roundtables"); +} + +/** Directory where persisted pipeline (staged workflow) results live. */ +export function getPipelinesDir(): string { + return path.join(getArcDir(), "pipelines"); +} + export const getMulticcDir = getArcDir; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 20833c3..1708d19 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,4 +1,4 @@ -export type AuthType = "oauth" | "api-key" | "bedrock" | "vertex" | "foundry"; +export type AuthType = "oauth" | "api-key" | "bedrock" | "vertex" | "foundry" | "openai-compat"; export type AgentTool = string; @@ -20,6 +20,18 @@ export interface HookConfig { options?: Record; } +/** OpenAI-compatible provider configuration. */ +export interface ProviderConfig { + /** API base URL (e.g. https://openrouter.ai/api/v1, http://localhost:11434/v1) */ + baseUrl: string; + /** Model identifier (e.g. anthropic/claude-3.5-sonnet, llama3) */ + model?: string; + /** Env var name that holds the API key. Defaults to OPENAI_API_KEY. */ + apiKeyEnvVar?: string; + /** Provider display name for UI (e.g. "OpenRouter", "Ollama", "LM Studio") */ + displayName?: string; +} + export interface Profile { authType: AuthType; tool?: AgentTool; @@ -38,6 +50,20 @@ export interface Profile { enforcement?: EnforcementMode; /** Per-hook configuration overrides. Keys are hook names. */ hooks?: Record; + /** Custom instructions / system prompt injected into agent context. */ + instructions?: string; + /** Path to an instructions file (read at launch time). Takes precedence over inline instructions. */ + instructionsFile?: string; + /** OpenAI-compatible provider configuration for custom endpoints. */ + provider?: ProviderConfig; + /** + * Launch mode for this profile. + * - `native` (default): full TTY handoff via spawnSync with inherited stdio. ARC exits, + * the tool paints its own TUI (e.g. Claude's statusLine). Use for daily interactive work. + * - `worker`: run under ARC supervision via the adapter's managed lifecycle — stdout is + * captured for monitoring. Use for orchestration, roundtable, and programmatic flows. + */ + launchMode?: "native" | "worker"; } export interface ArcSettings { @@ -46,7 +72,14 @@ export interface ArcSettings { export interface ArcConfig { version: 1; - activeProfile: string; + /** + * Name of the active profile, or null when no profile is active. + * A null active profile means `arc launch` / tool commands with no explicit + * profile argument will fall back to bare mode (native tool launch, no env + * injection). Use `arc profile switch ` or `arc profile clear-active` + * to change this. + */ + activeProfile: string | null; profiles: Record; profileOrder?: string[]; theme?: "dark" | "light"; diff --git a/packages/daemon/package.json b/packages/daemon/package.json new file mode 100644 index 0000000..c9a065c --- /dev/null +++ b/packages/daemon/package.json @@ -0,0 +1,20 @@ +{ + "name": "@axiom-labs/arc-daemon", + "version": "1.0.0-alpha.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsup src/index.ts --format esm --target node20 --clean", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@axiom-labs/arc-core": "workspace:*", + "better-sqlite3": "^11.3.0", + "zod": "^3.25.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.11" + } +} diff --git a/packages/daemon/src/auth.ts b/packages/daemon/src/auth.ts new file mode 100644 index 0000000..6d1a174 --- /dev/null +++ b/packages/daemon/src/auth.ts @@ -0,0 +1,72 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import type { DB } from "./db.js"; + +export interface AuthFile { + /** Root shared secret for locally-issued clients (legacy / bootstrap). */ + rootToken: string; + /** Version of the auth file format. */ + v: 1; +} + +/** + * Ensure `auth.json` exists. Returns the parsed file. + * Creates a fresh root token on first run. + */ +export function ensureAuthFile(authPath: string): AuthFile { + try { + const raw = fs.readFileSync(authPath, "utf8"); + const parsed = JSON.parse(raw) as AuthFile; + if (parsed.v === 1 && typeof parsed.rootToken === "string") return parsed; + } catch { + // fall through and create + } + const fresh: AuthFile = { v: 1, rootToken: crypto.randomBytes(32).toString("hex") }; + fs.mkdirSync(path.dirname(authPath), { recursive: true }); + fs.writeFileSync(authPath, JSON.stringify(fresh, null, 2), { mode: 0o600 }); + return fresh; +} + +export interface PairResult { + clientId: string; + token: string; +} + +export function pairClient(db: DB, label: string | null): PairResult { + const token = crypto.randomBytes(32).toString("hex"); + const clientId = crypto.randomUUID(); + const tokenHash = hashToken(token); + db.prepare( + "INSERT INTO clients (id, label, token_hash, paired_at, source) VALUES (?, ?, ?, ?, ?)", + ).run(clientId, label, tokenHash, Date.now(), "local"); + return { clientId, token }; +} + +export function verifyToken( + db: DB, + rootToken: string, + presented: string, +): { clientId: string; label: string | null } | null { + if (timingSafeEquals(presented, rootToken)) { + return { clientId: "root", label: "root" }; + } + const hash = hashToken(presented); + const row = db + .prepare("SELECT id, label FROM clients WHERE token_hash = ?") + .get(hash) as { id: string; label: string | null } | undefined; + if (!row) return null; + db.prepare("UPDATE clients SET last_seen = ? WHERE id = ?").run(Date.now(), row.id); + return { clientId: row.id, label: row.label }; +} + +export function hashToken(token: string): string { + return crypto.createHash("sha256").update(token).digest("hex"); +} + +function timingSafeEquals(a: string, b: string): boolean { + const ab = Buffer.from(a); + const bb = Buffer.from(b); + if (ab.length !== bb.length) return false; + return crypto.timingSafeEqual(ab, bb); +} diff --git a/packages/daemon/src/bootstrap.ts b/packages/daemon/src/bootstrap.ts new file mode 100644 index 0000000..4e54625 --- /dev/null +++ b/packages/daemon/src/bootstrap.ts @@ -0,0 +1,118 @@ +import fs from "node:fs"; +import path from "node:path"; +import { loadConfig as loadDaemonConfig, type DaemonConfig } from "./config.js"; +import { openDb } from "./db.js"; +import { createLogger } from "./logger.js"; +import { ensureAuthFile } from "./auth.js"; +import { Hub } from "./hub.js"; +import { startServer, type ServerHandle } from "./server.js"; + +export interface DaemonOptions { + port?: number; + host?: string; + arcDir?: string; + version?: string; +} + +export interface DaemonHandle { + config: DaemonConfig; + stop: () => Promise; +} + +/** Read running-daemon PID, or null if none/stale. */ +export function readPid(pidPath: string): number | null { + try { + const raw = fs.readFileSync(pidPath, "utf8").trim(); + const pid = Number.parseInt(raw, 10); + if (!Number.isFinite(pid) || pid <= 0) return null; + // signal 0 → liveness probe + try { + process.kill(pid, 0); + return pid; + } catch { + return null; + } + } catch { + return null; + } +} + +export async function startDaemon( + opts: DaemonOptions = {}, +): Promise { + const config = loadDaemonConfig({ + ...(opts.port !== undefined ? { port: opts.port } : {}), + ...(opts.host !== undefined ? { host: opts.host } : {}), + ...(opts.arcDir !== undefined ? { arcDir: opts.arcDir } : {}), + }); + fs.mkdirSync(config.arcDir, { recursive: true }); + + const existing = readPid(config.pidPath); + if (existing !== null) { + throw new Error(`daemon already running (pid ${existing})`); + } + + const logger = createLogger(config.logPath); + const db = openDb(config.dbPath); + const auth = ensureAuthFile(config.authPath); + const hub = new Hub(); + const version = opts.version ?? readDaemonVersion(); + const startedAt = Date.now(); + + const server = startServer({ config, db, logger, hub, auth, version, startedAt }); + fs.writeFileSync(config.pidPath, String(process.pid)); + logger.info("daemon.started", { pid: process.pid, version, port: config.port }); + + const stop = async () => { + logger.info("daemon.stopping"); + await server.close(); + try { + db.close(); + } catch { + // ignore + } + try { + fs.unlinkSync(config.pidPath); + } catch { + // ignore + } + logger.close(); + }; + + installSignalHandlers(stop); + + return { config, server, stop }; +} + +function installSignalHandlers(stop: () => Promise): void { + let stopping = false; + const handler = async () => { + if (stopping) return; + stopping = true; + try { + await stop(); + } finally { + process.exit(0); + } + }; + process.once("SIGINT", handler); + process.once("SIGTERM", handler); + if (process.platform === "win32") { + process.once("SIGHUP", handler); + } +} + +function readDaemonVersion(): string { + try { + const pkgPath = path.join( + path.dirname(new URL(import.meta.url).pathname.replace(/^\/(?=[A-Za-z]:)/, "")), + "..", + "package.json", + ); + const raw = fs.readFileSync(pkgPath, "utf8"); + const parsed = JSON.parse(raw) as { version?: string }; + return parsed.version ?? "0.0.0"; + } catch { + return "0.0.0"; + } +} diff --git a/packages/daemon/src/config.ts b/packages/daemon/src/config.ts new file mode 100644 index 0000000..2f46760 --- /dev/null +++ b/packages/daemon/src/config.ts @@ -0,0 +1,30 @@ +import path from "node:path"; +import { getArcDir } from "@axiom-labs/arc-core"; + +export const DEFAULT_PORT = 7272; +export const PROTOCOL_VERSION = 1; + +export interface DaemonConfig { + host: string; + port: number; + arcDir: string; + logPath: string; + dbPath: string; + pidPath: string; + authPath: string; +} + +export function loadConfig(overrides: Partial = {}): DaemonConfig { + const arcDir = overrides.arcDir ?? getArcDir(); + const envPort = process.env["ARC_PORT"]; + const port = overrides.port ?? (envPort ? Number.parseInt(envPort, 10) : DEFAULT_PORT); + return { + host: overrides.host ?? process.env["ARC_HOST"] ?? "127.0.0.1", + port, + arcDir, + logPath: overrides.logPath ?? path.join(arcDir, "daemon.log"), + dbPath: overrides.dbPath ?? path.join(arcDir, "arc.db"), + pidPath: overrides.pidPath ?? path.join(arcDir, "daemon.pid"), + authPath: overrides.authPath ?? path.join(arcDir, "auth.json"), + }; +} diff --git a/packages/daemon/src/db.ts b/packages/daemon/src/db.ts new file mode 100644 index 0000000..9c28d5a --- /dev/null +++ b/packages/daemon/src/db.ts @@ -0,0 +1,52 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import Database from "better-sqlite3"; + +export type DB = Database.Database; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const MIGRATIONS = [ + { id: 1, file: "001_init.sql" }, +]; + +export function openDb(dbPath: string): DB { + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + const db = new Database(dbPath); + db.pragma("journal_mode = WAL"); + db.pragma("foreign_keys = ON"); + runMigrations(db); + return db; +} + +function runMigrations(db: DB): void { + db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( + id INTEGER PRIMARY KEY, + applied_at INTEGER NOT NULL + );`); + const applied = new Set( + db.prepare("SELECT id FROM schema_migrations").all().map((r) => (r as { id: number }).id), + ); + for (const { id, file } of MIGRATIONS) { + if (applied.has(id)) continue; + const sql = loadMigrationSql(file); + const tx = db.transaction(() => { + db.exec(sql); + db.prepare("INSERT INTO schema_migrations (id, applied_at) VALUES (?, ?)").run(id, Date.now()); + }); + tx(); + } +} + +function loadMigrationSql(file: string): string { + const candidates = [ + path.join(__dirname, "db", "migrations", file), + path.join(__dirname, "..", "src", "db", "migrations", file), + ]; + for (const p of candidates) { + if (fs.existsSync(p)) return fs.readFileSync(p, "utf8"); + } + throw new Error(`Migration file not found: ${file}`); +} diff --git a/packages/daemon/src/db/migrations/001_init.sql b/packages/daemon/src/db/migrations/001_init.sql new file mode 100644 index 0000000..27917fc --- /dev/null +++ b/packages/daemon/src/db/migrations/001_init.sql @@ -0,0 +1,83 @@ +-- v3 initial schema. Additive-only migrations from here on in minors. + +CREATE TABLE IF NOT EXISTS agents ( + id TEXT PRIMARY KEY, + profile TEXT NOT NULL, + cwd TEXT NOT NULL, + status TEXT NOT NULL, + launch_mode TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + completed_at INTEGER, + worktree TEXT, + metadata TEXT +); +CREATE INDEX IF NOT EXISTS idx_agents_status ON agents(status); +CREATE INDEX IF NOT EXISTS idx_agents_profile ON agents(profile); + +CREATE TABLE IF NOT EXISTS agent_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + epoch INTEGER NOT NULL, + seq INTEGER NOT NULL, + ts INTEGER NOT NULL, + kind TEXT NOT NULL, + payload TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_events_agent_epoch ON agent_events(agent_id, epoch, seq); + +CREATE TABLE IF NOT EXISTS chat_rooms ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + metadata TEXT +); + +CREATE TABLE IF NOT EXISTS chat_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + room_id TEXT NOT NULL REFERENCES chat_rooms(id) ON DELETE CASCADE, + author TEXT NOT NULL, + reply_to INTEGER REFERENCES chat_messages(id), + mentions TEXT, + body TEXT NOT NULL, + ts INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_chat_room_ts ON chat_messages(room_id, ts); + +CREATE TABLE IF NOT EXISTS loops ( + id TEXT PRIMARY KEY, + worker_profile TEXT NOT NULL, + verify_profile TEXT, + verify_check TEXT, + status TEXT NOT NULL, + iteration INTEGER DEFAULT 0, + max_iterations INTEGER, + max_time_ms INTEGER, + started_at INTEGER, + completed_at INTEGER, + archive_path TEXT, + metadata TEXT +); + +CREATE TABLE IF NOT EXISTS handoffs ( + id TEXT PRIMARY KEY, + from_agent TEXT, + to_profile TEXT NOT NULL, + template_path TEXT NOT NULL, + created_at INTEGER NOT NULL, + consumed_at INTEGER +); + +CREATE TABLE IF NOT EXISTS clients ( + id TEXT PRIMARY KEY, + label TEXT, + token_hash TEXT NOT NULL, + paired_at INTEGER NOT NULL, + last_seen INTEGER, + source TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); diff --git a/packages/daemon/src/health.ts b/packages/daemon/src/health.ts new file mode 100644 index 0000000..bf9cc6c --- /dev/null +++ b/packages/daemon/src/health.ts @@ -0,0 +1,28 @@ +import { PROTOCOL_VERSION } from "./config.js"; + +export interface DaemonHealth { + ok: true; + version: string; + protocol: number; + uptime_ms: number; + pid: number; + host: string; + port: number; +} + +export function buildHealth( + version: string, + startedAt: number, + host: string, + port: number, +): DaemonHealth { + return { + ok: true, + version, + protocol: PROTOCOL_VERSION, + uptime_ms: Date.now() - startedAt, + pid: process.pid, + host, + port, + }; +} diff --git a/packages/daemon/src/hub.ts b/packages/daemon/src/hub.ts new file mode 100644 index 0000000..5b9bbef --- /dev/null +++ b/packages/daemon/src/hub.ts @@ -0,0 +1,64 @@ +import type { Session } from "./ws/session.js"; +import type { Envelope } from "@axiom-labs/arc-client"; + +/** + * Subscription fan-out. Keeps a set of sessions per topic; `publish` pushes + * an event envelope to every subscribed session. + */ +export class Hub { + private byTopic = new Map>(); + private bySession = new WeakMap>(); + + subscribe(session: Session, topic: string): void { + let set = this.byTopic.get(topic); + if (!set) { + set = new Set(); + this.byTopic.set(topic, set); + } + set.add(session); + session.subs.add(topic); + let sessSet = this.bySession.get(session); + if (!sessSet) { + sessSet = new Set(); + this.bySession.set(session, sessSet); + } + sessSet.add(topic); + } + + unsubscribe(session: Session, topic: string): void { + this.byTopic.get(topic)?.delete(session); + session.subs.delete(topic); + this.bySession.get(session)?.delete(topic); + } + + detach(session: Session): void { + const topics = this.bySession.get(session); + if (!topics) return; + for (const topic of topics) { + this.byTopic.get(topic)?.delete(session); + } + this.bySession.delete(session); + } + + publish(topic: string, payload: unknown): void { + const set = this.byTopic.get(topic); + if (!set) return; + const envelope: Envelope = { + v: 1, + id: randomEventId(), + type: "event", + topic, + payload, + }; + for (const session of set) { + if (!session.conn.alive) continue; + session.sendControl(envelope); + } + } +} + +let counter = 0; +function randomEventId(): string { + counter = (counter + 1) & 0xffffff; + return `evt-${Date.now().toString(36)}-${counter.toString(36)}`; +} diff --git a/packages/daemon/src/index.ts b/packages/daemon/src/index.ts new file mode 100644 index 0000000..b62051b --- /dev/null +++ b/packages/daemon/src/index.ts @@ -0,0 +1,4 @@ +export { startDaemon, readPid, type DaemonHandle, type DaemonOptions } from "./bootstrap.js"; +export { loadConfig as loadDaemonConfig, DEFAULT_PORT, PROTOCOL_VERSION, type DaemonConfig } from "./config.js"; +export { ensureAuthFile, pairClient, type AuthFile, type PairResult } from "./auth.js"; +export { buildHealth, type DaemonHealth } from "./health.js"; diff --git a/packages/daemon/src/logger.ts b/packages/daemon/src/logger.ts new file mode 100644 index 0000000..3912e91 --- /dev/null +++ b/packages/daemon/src/logger.ts @@ -0,0 +1,49 @@ +import fs from "node:fs"; +import path from "node:path"; + +type Level = "debug" | "info" | "warn" | "error"; + +const ROTATE_BYTES = 50 * 1024 * 1024; + +export interface Logger { + debug: (msg: string, ctx?: Record) => void; + info: (msg: string, ctx?: Record) => void; + warn: (msg: string, ctx?: Record) => void; + error: (msg: string, ctx?: Record) => void; + close: () => void; +} + +export function createLogger(logPath: string): Logger { + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + rotateIfNeeded(logPath); + const stream = fs.createWriteStream(logPath, { flags: "a" }); + let closed = false; + + const write = (level: Level, msg: string, ctx?: Record) => { + if (closed) return; + const entry = { ts: new Date().toISOString(), level, msg, ...(ctx ?? {}) }; + stream.write(`${JSON.stringify(entry)}\n`); + }; + + return { + debug: (m, c) => write("debug", m, c), + info: (m, c) => write("info", m, c), + warn: (m, c) => write("warn", m, c), + error: (m, c) => write("error", m, c), + close: () => { + closed = true; + stream.end(); + }, + }; +} + +function rotateIfNeeded(logPath: string): void { + try { + const stat = fs.statSync(logPath); + if (stat.size >= ROTATE_BYTES) { + fs.renameSync(logPath, `${logPath}.1`); + } + } catch { + // file does not exist yet — nothing to rotate + } +} diff --git a/packages/daemon/src/router.ts b/packages/daemon/src/router.ts new file mode 100644 index 0000000..2534409 --- /dev/null +++ b/packages/daemon/src/router.ts @@ -0,0 +1,90 @@ +import { ErrorCode, type Envelope } from "@axiom-labs/arc-client"; +import { PUBLIC_METHODS, handlers } from "./rpc/index.js"; +import type { RpcContext } from "./rpc/types.js"; +import type { Session } from "./ws/session.js"; + +export async function dispatchEnvelope( + session: Session, + envelope: Envelope, + baseCtx: Omit, +): Promise { + const ctx: RpcContext = { ...baseCtx, session }; + + switch (envelope.type) { + case "request": + await handleRequest(session, envelope, ctx); + return; + case "subscribe": + handleSubscribe(session, envelope, ctx); + return; + case "unsubscribe": + handleUnsubscribe(session, envelope, ctx); + return; + default: + // Clients don't send response/event/error to the server. Ignore cleanly. + return; + } +} + +async function handleRequest(session: Session, envelope: Envelope, ctx: RpcContext): Promise { + const method = envelope.method; + if (!method) { + replyError(session, envelope.id, ErrorCode.BadRequest, "missing method"); + return; + } + const handler = handlers[method]; + if (!handler) { + replyError(session, envelope.id, ErrorCode.Unimplemented, `unknown method: ${method}`); + return; + } + if (!session.authenticated && !PUBLIC_METHODS.has(method)) { + replyError(session, envelope.id, ErrorCode.Unauthorized, "authenticate first"); + return; + } + try { + const result = await handler(envelope.params, ctx); + session.sendControl({ + v: 1, + id: envelope.id, + type: "response", + result, + }); + } catch (err: unknown) { + const { code, message } = normalizeError(err); + replyError(session, envelope.id, code, message); + ctx.logger.warn("rpc.error", { method, code, message }); + } +} + +function handleSubscribe(session: Session, envelope: Envelope, ctx: RpcContext): void { + if (!session.authenticated) { + replyError(session, envelope.id, ErrorCode.Unauthorized, "authenticate first"); + return; + } + const topic = envelope.topic; + if (!topic) { + replyError(session, envelope.id, ErrorCode.BadRequest, "missing topic"); + return; + } + ctx.hub.subscribe(session, topic); + session.sendControl({ v: 1, id: envelope.id, type: "response", result: { ok: true, topic } }); +} + +function handleUnsubscribe(session: Session, envelope: Envelope, ctx: RpcContext): void { + const topic = envelope.topic; + if (!topic) return; + ctx.hub.unsubscribe(session, topic); + session.sendControl({ v: 1, id: envelope.id, type: "response", result: { ok: true, topic } }); +} + +function replyError(session: Session, id: string, code: string, message: string): void { + session.sendControl({ v: 1, id, type: "error", code, message }); +} + +function normalizeError(err: unknown): { code: string; message: string } { + if (err && typeof err === "object" && "code" in err && "message" in err) { + return { code: String((err as { code: unknown }).code), message: String((err as { message: unknown }).message) }; + } + if (err instanceof Error) return { code: ErrorCode.Internal, message: err.message }; + return { code: ErrorCode.Internal, message: String(err) }; +} diff --git a/packages/daemon/src/rpc/agent.ts b/packages/daemon/src/rpc/agent.ts new file mode 100644 index 0000000..789db11 --- /dev/null +++ b/packages/daemon/src/rpc/agent.ts @@ -0,0 +1,49 @@ +import type { RpcHandler } from "./types.js"; + +/** + * Agent RPCs — Phase 1 ships `agent.list` (reads from SQLite) and stubs + * `agent.run/stop/send` with unimplemented errors. Full lifecycle wiring + * lands in Phase 4 when adapters move behind the daemon. + */ + +export const agentList: RpcHandler = (_params, ctx) => { + const rows = ctx.db + .prepare( + `SELECT id, profile, cwd, status, launch_mode, created_at, updated_at, completed_at, worktree + FROM agents ORDER BY updated_at DESC LIMIT 200`, + ) + .all() as Array<{ + id: string; + profile: string; + cwd: string; + status: string; + launch_mode: string; + created_at: number; + updated_at: number; + completed_at: number | null; + worktree: string | null; + }>; + return { + agents: rows.map((r) => ({ + id: r.id, + profile: r.profile, + cwd: r.cwd, + status: r.status, + launchMode: r.launch_mode, + createdAt: r.created_at, + updatedAt: r.updated_at, + completedAt: r.completed_at, + worktree: r.worktree, + })), + }; +}; + +const unimplemented: RpcHandler = () => { + const err = new Error("agent lifecycle lands in Phase 4") as Error & { code: string }; + err.code = "unimplemented"; + throw err; +}; + +export const agentRun = unimplemented; +export const agentStop = unimplemented; +export const agentSend = unimplemented; diff --git a/packages/daemon/src/rpc/auth.ts b/packages/daemon/src/rpc/auth.ts new file mode 100644 index 0000000..3086d40 --- /dev/null +++ b/packages/daemon/src/rpc/auth.ts @@ -0,0 +1,28 @@ +import { AuthLoginParams, type AuthLoginResult } from "@axiom-labs/arc-client"; +import { verifyToken } from "../auth.js"; +import type { RpcHandler } from "./types.js"; + +export const authLogin: RpcHandler = (raw, ctx) => { + const { token } = AuthLoginParams.parse(raw); + const who = verifyToken(ctx.db, ctx.auth.rootToken, token); + if (!who) { + const err = new Error("invalid token") as Error & { code: string }; + err.code = "unauthorized"; + throw err; + } + ctx.session.authenticated = true; + ctx.session.clientId = who.clientId; + ctx.session.clientLabel = who.label; + ctx.logger.info("session.authenticated", { + sessionId: ctx.session.id, + clientId: who.clientId, + label: who.label, + }); + const result: typeof AuthLoginResult._type = { + sessionId: ctx.session.id, + clientId: who.clientId, + serverVersion: ctx.version, + protocol: 1, + }; + return result; +}; diff --git a/packages/daemon/src/rpc/health.ts b/packages/daemon/src/rpc/health.ts new file mode 100644 index 0000000..fda96d6 --- /dev/null +++ b/packages/daemon/src/rpc/health.ts @@ -0,0 +1,6 @@ +import { buildHealth } from "../health.js"; +import type { RpcHandler } from "./types.js"; + +export const healthGet: RpcHandler = (_params, ctx) => { + return buildHealth(ctx.version, ctx.startedAt, ctx.host, ctx.port); +}; diff --git a/packages/daemon/src/rpc/index.ts b/packages/daemon/src/rpc/index.ts new file mode 100644 index 0000000..5341c04 --- /dev/null +++ b/packages/daemon/src/rpc/index.ts @@ -0,0 +1,22 @@ +import { Methods } from "@axiom-labs/arc-client"; +import { authLogin } from "./auth.js"; +import { healthGet } from "./health.js"; +import { profileGet, profileList } from "./profile.js"; +import { agentList, agentRun, agentSend, agentStop } from "./agent.js"; +import type { RpcHandler } from "./types.js"; + +/** Methods that do NOT require an authenticated session. */ +export const PUBLIC_METHODS = new Set([Methods.auth_login, Methods.health_get]); + +export const handlers: Record = { + [Methods.auth_login]: authLogin, + [Methods.health_get]: healthGet, + [Methods.profile_list]: profileList, + [Methods.profile_get]: profileGet, + [Methods.agent_list]: agentList, + [Methods.agent_run]: agentRun, + [Methods.agent_stop]: agentStop, + [Methods.agent_send]: agentSend, +}; + +export type { RpcContext } from "./types.js"; diff --git a/packages/daemon/src/rpc/profile.ts b/packages/daemon/src/rpc/profile.ts new file mode 100644 index 0000000..c21c742 --- /dev/null +++ b/packages/daemon/src/rpc/profile.ts @@ -0,0 +1,40 @@ +import { loadConfig } from "@axiom-labs/arc-core"; +import type { RpcHandler } from "./types.js"; + +/** + * Minimal profile read-only surface for Phase 1. Write ops + * (create/update/delete/switch) land in Phase 4 when the CLI + * is ported off direct config.json mutation. + */ + +export const profileList: RpcHandler = () => { + const cfg = loadConfig(); + const active = cfg.activeProfile ?? null; + const names = cfg.profileOrder ?? Object.keys(cfg.profiles); + return { + profiles: names + .filter((name) => cfg.profiles[name]) + .map((name) => { + const p = cfg.profiles[name]!; + return { name, tool: p.tool, active: active === name }; + }), + }; +}; + +export const profileGet: RpcHandler = (raw) => { + const params = raw as { name?: string }; + const name = params.name; + if (!name) { + const err = new Error("name required") as Error & { code: string }; + err.code = "bad_request"; + throw err; + } + const cfg = loadConfig(); + const profile = cfg.profiles[name]; + if (!profile) { + const err = new Error(`profile not found: ${name}`) as Error & { code: string }; + err.code = "not_found"; + throw err; + } + return { profile }; +}; diff --git a/packages/daemon/src/rpc/types.ts b/packages/daemon/src/rpc/types.ts new file mode 100644 index 0000000..419d8ea --- /dev/null +++ b/packages/daemon/src/rpc/types.ts @@ -0,0 +1,23 @@ +import type { DB } from "../db.js"; +import type { Logger } from "../logger.js"; +import type { Session } from "../ws/session.js"; +import type { Hub } from "../hub.js"; +import type { AuthFile } from "../auth.js"; + +export interface RpcContext { + db: DB; + logger: Logger; + hub: Hub; + auth: AuthFile; + session: Session; + /** Daemon start time — for uptime reporting. */ + startedAt: number; + /** Daemon version string. */ + version: string; + /** Bind host. */ + host: string; + /** Bind port. */ + port: number; +} + +export type RpcHandler = (params: unknown, ctx: RpcContext) => Promise | unknown; diff --git a/packages/daemon/src/server.ts b/packages/daemon/src/server.ts new file mode 100644 index 0000000..8b5f40d --- /dev/null +++ b/packages/daemon/src/server.ts @@ -0,0 +1,161 @@ +import http from "node:http"; +import type { IncomingMessage } from "node:http"; +import type { Duplex } from "node:stream"; +import { ZodError } from "zod"; +import { buildHealth } from "./health.js"; +import type { DaemonConfig } from "./config.js"; +import type { DB } from "./db.js"; +import type { Logger } from "./logger.js"; +import type { Hub } from "./hub.js"; +import type { AuthFile } from "./auth.js"; +import { createWsConnection } from "./ws/connection.js"; +import { Session, receiveBinary } from "./ws/session.js"; +import { dispatchEnvelope } from "./router.js"; +import { acceptKey } from "./ws/frame.js"; + +export interface ServerDeps { + config: DaemonConfig; + db: DB; + logger: Logger; + hub: Hub; + auth: AuthFile; + version: string; + startedAt: number; +} + +export interface ServerHandle { + httpServer: http.Server; + close: () => Promise; + sessions: Set; +} + +const ALLOWED_HOSTS = new Set(); +function isAllowedHost(host: string | undefined, cfgPort: number): boolean { + if (!host) return false; + if (ALLOWED_HOSTS.has(host)) return true; + // accept loopback bindings with the configured port (or default 80/443 stripped) + const [name, port] = host.split(":"); + if (!name) return false; + const isLoopback = name === "127.0.0.1" || name === "localhost" || name === "[::1]" || name === "::1"; + if (!isLoopback) return false; + if (!port) return true; + return Number.parseInt(port, 10) === cfgPort; +} + +export function startServer(deps: ServerDeps): ServerHandle { + const { config, logger, hub, version, startedAt } = deps; + const sessions = new Set(); + + const httpServer = http.createServer((req, res) => { + if (!isAllowedHost(req.headers.host, config.port)) { + res.statusCode = 403; + res.end("forbidden host"); + return; + } + const url = req.url ?? "/"; + if (req.method === "GET" && (url === "/health" || url === "/health/")) { + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(buildHealth(version, startedAt, config.host, config.port))); + return; + } + res.statusCode = 404; + res.end("not found"); + }); + + httpServer.on("upgrade", (req, socket, head) => { + handleUpgrade(req, socket, head, deps, sessions); + }); + + httpServer.listen(config.port, config.host, () => { + logger.info("server.listening", { host: config.host, port: config.port }); + }); + + httpServer.on("error", (err) => { + logger.error("server.error", { message: (err as Error).message }); + }); + + return { + httpServer, + sessions, + close: async () => { + for (const s of sessions) s.close(1001); + await new Promise((resolve) => httpServer.close(() => resolve())); + }, + }; +} + +function handleUpgrade( + req: IncomingMessage, + socket: Duplex, + _head: Buffer, + deps: ServerDeps, + sessions: Set, +): void { + const { config, logger, db, hub, auth, version, startedAt } = deps; + + if (!isAllowedHost(req.headers.host, config.port)) { + socket.write("HTTP/1.1 403 Forbidden\r\n\r\n"); + socket.destroy(); + return; + } + const key = req.headers["sec-websocket-key"]; + if (typeof key !== "string") { + socket.write("HTTP/1.1 400 Bad Request\r\n\r\n"); + socket.destroy(); + return; + } + const accept = acceptKey(key); + socket.write( + [ + "HTTP/1.1 101 Switching Protocols", + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Accept: ${accept}`, + "\r\n", + ].join("\r\n"), + ); + + const conn = createWsConnection(socket); + const session = new Session(conn); + sessions.add(session); + logger.info("session.open", { sessionId: session.id }); + + conn.onMessage = (kind, data) => { + if (kind !== "binary") return; // text frames ignored; protocol is binary-only + let decoded: ReturnType; + try { + decoded = receiveBinary(data); + } catch (err) { + if (err instanceof ZodError) { + logger.warn("frame.invalid-envelope", { sessionId: session.id, issues: err.issues }); + } else { + logger.warn("frame.decode-error", { + sessionId: session.id, + message: (err as Error).message, + }); + } + return; + } + if (decoded.channel === 0 && "envelope" in decoded) { + dispatchEnvelope(session, decoded.envelope, { + db, + logger, + hub, + auth, + version, + startedAt, + host: config.host, + port: config.port, + }).catch((err: Error) => { + logger.error("dispatch.unhandled", { message: err.message }); + }); + } + // Other channels (terminal/file/audio) land in later phases. + }; + + conn.onClose = () => { + hub.detach(session); + sessions.delete(session); + logger.info("session.close", { sessionId: session.id }); + }; +} diff --git a/packages/daemon/src/ws/connection.ts b/packages/daemon/src/ws/connection.ts new file mode 100644 index 0000000..126d831 --- /dev/null +++ b/packages/daemon/src/ws/connection.ts @@ -0,0 +1,121 @@ +import type { Duplex } from "node:stream"; +import { + OPCODE_BINARY, + OPCODE_CLOSE, + OPCODE_CONT, + OPCODE_PING, + OPCODE_PONG, + OPCODE_TEXT, + encodeFrame as encodeWsFrame, + parseFrames, +} from "./frame.js"; + +export type MessageKind = "binary" | "text"; + +export interface WsConnection { + send(kind: MessageKind, payload: Uint8Array | string): void; + close(code?: number): void; + onMessage: ((kind: MessageKind, payload: Buffer) => void) | null; + onClose: (() => void) | null; + alive: boolean; +} + +export function createWsConnection(socket: Duplex): WsConnection { + let buf: Buffer = Buffer.alloc(0); + let fragments: Buffer[] = []; + let fragmentOpcode: number | null = null; + + const conn: WsConnection = { + alive: true, + onMessage: null, + onClose: null, + send(kind, payload) { + const bytes = + typeof payload === "string" ? Buffer.from(payload, "utf8") : Buffer.from(payload); + const opcode = kind === "binary" ? OPCODE_BINARY : OPCODE_TEXT; + try { + socket.write(encodeWsFrame(opcode, bytes)); + } catch { + conn.alive = false; + } + }, + close(code = 1000) { + if (!conn.alive) return; + conn.alive = false; + const payload = Buffer.alloc(2); + payload.writeUInt16BE(code, 0); + try { + socket.write(encodeWsFrame(OPCODE_CLOSE, payload)); + } catch { + // ignore + } + socket.end(); + }, + }; + + socket.on("data", (chunk: Buffer) => { + buf = Buffer.concat([buf, chunk]); + let parsed: ReturnType; + try { + parsed = parseFrames(buf); + } catch { + conn.close(1002); + return; + } + buf = Buffer.from(parsed.rest); + for (const frame of parsed.frames) { + handleFrame(frame.opcode, frame.fin, frame.payload); + } + }); + + socket.on("close", () => { + conn.alive = false; + conn.onClose?.(); + }); + socket.on("error", () => { + conn.alive = false; + }); + + function handleFrame(opcode: number, fin: boolean, payload: Buffer): void { + if (opcode === OPCODE_CLOSE) { + conn.close(); + return; + } + if (opcode === OPCODE_PING) { + try { + socket.write(encodeWsFrame(OPCODE_PONG, payload)); + } catch { + conn.alive = false; + } + return; + } + if (opcode === OPCODE_PONG) return; + + if (opcode === OPCODE_TEXT || opcode === OPCODE_BINARY) { + if (fin) { + deliver(opcode, payload); + } else { + fragments = [payload]; + fragmentOpcode = opcode; + } + return; + } + if (opcode === OPCODE_CONT) { + fragments.push(payload); + if (fin && fragmentOpcode !== null) { + const full = Buffer.concat(fragments); + const op = fragmentOpcode; + fragments = []; + fragmentOpcode = null; + deliver(op, full); + } + } + } + + function deliver(opcode: number, payload: Buffer): void { + const kind: MessageKind = opcode === OPCODE_BINARY ? "binary" : "text"; + conn.onMessage?.(kind, payload); + } + + return conn; +} diff --git a/packages/daemon/src/ws/frame.ts b/packages/daemon/src/ws/frame.ts new file mode 100644 index 0000000..ce19777 --- /dev/null +++ b/packages/daemon/src/ws/frame.ts @@ -0,0 +1,102 @@ +import crypto from "node:crypto"; +import type { Duplex } from "node:stream"; + +export const WS_MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + +export const OPCODE_CONT = 0x00; +export const OPCODE_TEXT = 0x01; +export const OPCODE_BINARY = 0x02; +export const OPCODE_CLOSE = 0x08; +export const OPCODE_PING = 0x09; +export const OPCODE_PONG = 0x0a; + +export function acceptKey(clientKey: string): string { + return crypto.createHash("sha1").update(clientKey + WS_MAGIC).digest("base64"); +} + +export function encodeFrame(opcode: number, payload: Uint8Array): Buffer { + const len = payload.length; + let header: Buffer; + if (len < 126) { + header = Buffer.alloc(2); + header[0] = 0x80 | opcode; + header[1] = len; + } else if (len < 65536) { + header = Buffer.alloc(4); + header[0] = 0x80 | opcode; + header[1] = 126; + header.writeUInt16BE(len, 2); + } else { + header = Buffer.alloc(10); + header[0] = 0x80 | opcode; + header[1] = 127; + header.writeBigUInt64BE(BigInt(len), 2); + } + return Buffer.concat([header, Buffer.from(payload)]); +} + +export interface DecodedFrame { + fin: boolean; + opcode: number; + payload: Buffer; +} + +/** + * Simple streaming frame parser. Maintains a rolling buffer and pulls out + * complete frames. Returns the list of fully-decoded frames and the leftover + * bytes that should be carried into the next call. + */ +export function parseFrames(buf: Buffer): { frames: DecodedFrame[]; rest: Buffer } { + const frames: DecodedFrame[] = []; + let cursor = 0; + while (buf.length - cursor >= 2) { + const b0 = buf[cursor]!; + const b1 = buf[cursor + 1]!; + const fin = (b0 & 0x80) !== 0; + const opcode = b0 & 0x0f; + const masked = (b1 & 0x80) !== 0; + let len = b1 & 0x7f; + let headerLen = 2; + if (len === 126) { + if (buf.length - cursor < 4) break; + len = buf.readUInt16BE(cursor + 2); + headerLen = 4; + } else if (len === 127) { + if (buf.length - cursor < 10) break; + const bigLen = buf.readBigUInt64BE(cursor + 2); + if (bigLen > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error("frame too large"); + } + len = Number(bigLen); + headerLen = 10; + } + const maskLen = masked ? 4 : 0; + if (buf.length - cursor < headerLen + maskLen + len) break; + let payload: Buffer; + if (masked) { + const mask = buf.subarray(cursor + headerLen, cursor + headerLen + 4); + const data = Buffer.from(buf.subarray(cursor + headerLen + 4, cursor + headerLen + 4 + len)); + for (let i = 0; i < data.length; i++) { + data[i] ^= mask[i & 3]!; + } + payload = data; + } else { + payload = Buffer.from(buf.subarray(cursor + headerLen, cursor + headerLen + len)); + } + frames.push({ fin, opcode, payload }); + cursor += headerLen + maskLen + len; + } + return { frames, rest: buf.subarray(cursor) }; +} + +export function writeHandshakeResponse(socket: Duplex, key: string): void { + const accept = acceptKey(key); + const response = [ + "HTTP/1.1 101 Switching Protocols", + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Accept: ${accept}`, + "\r\n", + ].join("\r\n"); + socket.write(response); +} diff --git a/packages/daemon/src/ws/session.ts b/packages/daemon/src/ws/session.ts new file mode 100644 index 0000000..1383f98 --- /dev/null +++ b/packages/daemon/src/ws/session.ts @@ -0,0 +1,58 @@ +import crypto from "node:crypto"; +import type { WsConnection } from "./connection.js"; +import { + Channel, + Envelope, + decodeControl, + decodeFrame, + encodeControl, + encodeFrame, + type ChannelId, + type Envelope as EnvelopeT, +} from "@axiom-labs/arc-client"; + +export type EventSink = (envelope: EnvelopeT) => void; + +export class Session { + readonly id: string; + readonly subs = new Set(); + authenticated = false; + clientId = ""; + clientLabel: string | null = null; + + constructor(public readonly conn: WsConnection) { + this.id = crypto.randomUUID(); + } + + sendControl(envelope: EnvelopeT): void { + this.conn.send("binary", encodeControl(envelope)); + } + + sendTerminal(bytes: Uint8Array): void { + this.conn.send("binary", encodeFrame({ channel: Channel.Terminal, flags: 0, payload: bytes })); + } + + close(code = 1000): void { + this.conn.close(code); + } +} + +export interface DecodedControl { + channel: typeof Channel.Control; + envelope: EnvelopeT; + payload: Uint8Array; +} +export interface DecodedNonControl { + channel: ChannelId; + payload: Uint8Array; +} + +export function receiveBinary(data: Buffer): DecodedControl | DecodedNonControl { + const frame = decodeFrame(data); + if (frame.channel === Channel.Control) { + const raw = decodeControl(frame.payload); + const envelope = Envelope.parse(raw); + return { channel: Channel.Control, envelope, payload: frame.payload }; + } + return { channel: frame.channel, payload: frame.payload }; +} diff --git a/packages/daemon/tests/daemon.test.ts b/packages/daemon/tests/daemon.test.ts new file mode 100644 index 0000000..5a18648 --- /dev/null +++ b/packages/daemon/tests/daemon.test.ts @@ -0,0 +1,78 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { startDaemon, type DaemonHandle } from "../src/index.js"; +import { ArcClient } from "@axiom-labs/arc-client"; + +interface TestCtx { + tmp: string; + port: number; + handle: DaemonHandle & { stop: () => Promise }; + client: ArcClient; + token: string; +} + +// Windows CI can be slow; bump per-test timeouts. +describe("daemon + client end-to-end", () => { + let ctx: TestCtx | null = null; + + beforeEach(async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "arc-daemon-test-")); + process.env["ARC_DIR"] = tmp; + const port = 17200 + Math.floor(Math.random() * 800); + const handle = await startDaemon({ port, version: "1.0.0-alpha.0-test" }); + const authFile = JSON.parse( + fs.readFileSync(path.join(tmp, "auth.json"), "utf8"), + ) as { rootToken: string }; + const client = new ArcClient({ + url: `ws://127.0.0.1:${port}`, + token: authFile.rootToken, + noReconnect: true, + }); + await client.connect(); + ctx = { tmp, port, handle, client, token: authFile.rootToken }; + }); + + afterEach(async () => { + if (!ctx) return; + await ctx.client.close(); + await ctx.handle.stop(); + // Windows holds SQLite WAL/SHM + log-stream handles longer than our retry + // budget. Cleanup is best-effort — the OS reclaims tmp on its own. + try { + fs.rmSync(ctx.tmp, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } catch { + // ignore — don't fail tests on cleanup race + } + ctx = null; + }); + + it("exposes a healthy endpoint over RPC", async () => { + const h = await ctx!.client.health(); + expect(h.ok).toBe(true); + expect(h.protocol).toBe(1); + expect(h.port).toBe(ctx!.port); + }); + + it("lists an empty agent set initially", async () => { + const res = await ctx!.client.agents.list(); + expect(res.agents).toEqual([]); + }); + + it("rejects RPC before auth", async () => { + const unauthed = new ArcClient({ + url: `ws://127.0.0.1:${ctx!.port}`, + token: "not-a-real-token-00000000000000000000", + noReconnect: true, + }); + await expect(unauthed.connect()).rejects.toThrow(); + }); + + it("supports subscribe + unsubscribe round-trip", async () => { + const received: unknown[] = []; + const unsub = await ctx!.client.subscribe("daemon", (p) => received.push(p)); + expect(typeof unsub).toBe("function"); + await unsub(); + }); +}); diff --git a/packages/daemon/tests/smoke.ts b/packages/daemon/tests/smoke.ts new file mode 100644 index 0000000..525a8af --- /dev/null +++ b/packages/daemon/tests/smoke.ts @@ -0,0 +1,58 @@ +/** + * End-to-end smoke test for Phases 1–3. Runs against a temp $ARC_DIR so it + * never touches the user's ~/.arc. Exits 0 on success, non-zero on failure. + */ + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { startDaemon } from "../src/index.js"; +import { ArcClient } from "@axiom-labs/arc-client"; + +async function main() { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "arc-smoke-")); + process.env["ARC_DIR"] = tmp; + const port = 17272 + Math.floor(Math.random() * 1000); + console.log(`smoke: ARC_DIR=${tmp} port=${port}`); + + const handle = await startDaemon({ port, version: "1.0.0-alpha.0-smoke" }); + + const authFile = JSON.parse(fs.readFileSync(path.join(tmp, "auth.json"), "utf8")) as { + rootToken: string; + }; + + const client = new ArcClient({ + url: `ws://127.0.0.1:${port}`, + token: authFile.rootToken, + noReconnect: true, + }); + + try { + await client.connect(); + console.log("smoke: connected + authed"); + + const health = await client.health(); + console.log("smoke: health", JSON.stringify(health)); + if (!health.ok) throw new Error("health not ok"); + if (health.protocol !== 1) throw new Error(`protocol mismatch: ${health.protocol}`); + + const agents = await client.agents.list(); + console.log(`smoke: agents=${agents.agents.length} (expected 0)`); + if (agents.agents.length !== 0) throw new Error("expected empty agent list"); + + const subs: unknown[] = []; + const unsub = await client.subscribe("daemon", (p) => subs.push(p)); + await unsub(); + + console.log("smoke: OK"); + } finally { + await client.close(); + await handle.stop(); + fs.rmSync(tmp, { recursive: true, force: true }); + } +} + +main().catch((err) => { + console.error("smoke: FAILED", err); + process.exit(1); +}); diff --git a/packages/daemon/tsconfig.json b/packages/daemon/tsconfig.json new file mode 100644 index 0000000..6435d43 --- /dev/null +++ b/packages/daemon/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src"] +} diff --git a/packages/dashboard/public/components/chat.js b/packages/dashboard/public/components/chat.js new file mode 100644 index 0000000..8f8da1d --- /dev/null +++ b/packages/dashboard/public/components/chat.js @@ -0,0 +1,581 @@ +// ARC Dashboard — Chat View (Phase 7) +// +// Streams chat completions from POST /api/chat/message via per-session +// WebSocket routing. Renders tool calls as collapsible cards, supports +// supervised-mode confirmation modals, and manages a session list sidebar. +// +// See docs/plans/ai-and-roundtable.md — Phase 7 + AD-5. + +import { api } from '../scripts/api.js'; +import { ws } from '../scripts/ws.js'; +import { registerView } from '../scripts/router.js'; +import { escapeHtml } from '../scripts/utils.js'; + +// --------------------------------------------------------------------------- +// Per-tab WebSocket session id +// --------------------------------------------------------------------------- +// +// One uuid per page load; persisted on `window` so the WS negotiation state +// survives view switches. The dashboard-wide `ws` connection is shared, so +// we only need to send `hello` once per tab. + +function uuid() { + if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID(); + // Lightweight fallback — good enough for an ephemeral session id. + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16); + }); +} + +function getSessionId() { + if (!window.__arcDashboardSessionId) { + window.__arcDashboardSessionId = uuid(); + } + return window.__arcDashboardSessionId; +} + +/** + * Send the WS `hello` message so the server can route `chat-chunk` / + * `chat-confirm-needed` / `chat-done` events back to just this tab. + * Idempotent and re-runs after reconnect. + */ +function ensureHello() { + const sid = getSessionId(); + if (window.__arcDashboardHelloSent) return; + const socket = ws._ws; + if (socket && socket.readyState === 1) { + socket.send(JSON.stringify({ type: 'hello', sessionId: sid })); + window.__arcDashboardHelloSent = true; + } +} + +/** + * Wait (up to `timeoutMs`) until the WS is open AND the hello frame has been + * sent. Callers that POST to an endpoint which streams back via + * `broadcastTo(sessionId, ...)` must await this first — otherwise the + * server's first chunks race the hello registration and get dropped. + * + * This is the client-side half of the hello-race fix; the server-side half + * (`packages/dashboard/src/ws.ts`) also falls back to `broadcast` as a + * last-resort safety net. + */ +function waitForHello(timeoutMs = 5000) { + ensureHello(); + if (window.__arcDashboardHelloSent) return Promise.resolve(); + return new Promise((resolve) => { + const started = Date.now(); + const check = () => { + ensureHello(); + if (window.__arcDashboardHelloSent) return resolve(); + if (Date.now() - started >= timeoutMs) return resolve(); // give up; server will fall back to broadcast + setTimeout(check, 50); + }; + check(); + }); +} + +// Re-send hello whenever the WS (re)connects. +ws.on('connected', () => { + window.__arcDashboardHelloSent = false; + ensureHello(); +}); +ws.on('disconnected', () => { + window.__arcDashboardHelloSent = false; +}); + +// --------------------------------------------------------------------------- +// Bearer token lookup — mutation endpoints require Authorization. +// --------------------------------------------------------------------------- + +let cachedToken = null; +async function getToken() { + if (cachedToken) return cachedToken; + try { + const res = await fetch('/api/auth/token'); + if (!res.ok) return null; + const body = await res.json(); + cachedToken = body.token || null; + return cachedToken; + } catch { + return null; + } +} + +async function authFetch(path, init = {}) { + const token = await getToken(); + const headers = new Headers(init.headers || {}); + if (token) headers.set('Authorization', `Bearer ${token}`); + if (init.body && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + return fetch(path, { ...init, headers }); +} + +// --------------------------------------------------------------------------- +// Chat state (per render cycle) +// --------------------------------------------------------------------------- + +const state = { + profile: null, + mode: 'supervised', + chatSessionId: null, // current loaded chat session (null = new) + messages: [], // rendered messages: { role, content, toolCalls } + streaming: null, // in-flight assistant message being appended + listeners: [], // registered ws handlers, cleared on re-render +}; + +function resetState() { + // Detach any prior ws listeners so we don't duplicate on view switches. + for (const [event, fn] of state.listeners) ws.off(event, fn); + state.listeners = []; + state.streaming = null; + state.messages = []; + state.chatSessionId = null; +} + +function listen(event, handler) { + ws.on(event, handler); + state.listeners.push([event, handler]); +} + +// --------------------------------------------------------------------------- +// Rendering helpers +// --------------------------------------------------------------------------- + +function toolCallCard(tc) { + const summary = typeof tc.input === 'object' + ? JSON.stringify(tc.input).slice(0, 80) + : String(tc.input ?? '').slice(0, 80); + const hasResult = tc.result !== undefined || tc.error !== undefined; + const status = tc.error ? 'error' : hasResult ? 'ok' : 'pending'; + const statusLabel = tc.error ? 'ERROR' : hasResult ? 'DONE' : 'RUNNING'; + + const body = tc.error + ? `
${escapeHtml(tc.error)}
` + : `
${escapeHtml(JSON.stringify({ input: tc.input, result: tc.result }, null, 2))}
`; + + return ` +
+
+ +
+ +
`; +} + +function messageRow(msg) { + const roleLabel = msg.role.toUpperCase(); + const tools = (msg.toolCalls || []).map(toolCallCard).join(''); + const content = msg.content + ? `
${escapeHtml(msg.content)}
` + : ''; + return ` +
+
${escapeHtml(roleLabel)}
+ ${content} + ${tools} +
`; +} + +function sessionItem(s, activeId) { + const cls = s.id === activeId ? 'chat-session chat-session--active' : 'chat-session'; + return ` +
+
${escapeHtml(s.summary)}
+
${escapeHtml(String(s.messageCount))} msgs
+ +
`; +} + +function renderMessages() { + const list = document.getElementById('chat-messages'); + if (!list) return; + if (state.messages.length === 0) { + list.innerHTML = ` +
+ +
Ready when you are
+
Ask a question about ARC — press Enter to send
+
`; + return; + } + list.innerHTML = state.messages.map(messageRow).join(''); + list.scrollTop = list.scrollHeight; +} + +function renderSessionList(sessions) { + const el = document.getElementById('chat-sessions'); + if (!el) return; + const items = sessions.map((s) => sessionItem(s, state.chatSessionId)).join(''); + const emptyBlock = ` +
+ +
No saved sessions
+
Start a new chat to see your history here
+
`; + el.innerHTML = items || emptyBlock; + el.querySelectorAll('.chat-session').forEach((row) => { + row.addEventListener('click', async (e) => { + if (e.target.closest('[data-action="delete-session"]')) return; + const id = row.getAttribute('data-session-id'); + await loadSession(id); + }); + }); + el.querySelectorAll('[data-action="delete-session"]').forEach((btn) => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const id = btn.getAttribute('data-id'); + await deleteSession(id); + }); + }); +} + +// --------------------------------------------------------------------------- +// Chat operations +// --------------------------------------------------------------------------- + +async function refreshSessionList() { + if (!state.profile) return; + try { + const res = await fetch(`/api/chat/sessions?profile=${encodeURIComponent(state.profile)}`); + if (!res.ok) return; + const list = await res.json(); + renderSessionList(Array.isArray(list) ? list : []); + } catch { + /* ignore */ + } +} + +async function loadSession(id) { + if (!state.profile) return; + try { + const res = await fetch(`/api/chat/sessions/${encodeURIComponent(id)}?profile=${encodeURIComponent(state.profile)}`); + if (!res.ok) return; + const body = await res.json(); + state.chatSessionId = body.id; + state.messages = (body.messages || []).map((m) => ({ + role: m.role, + content: m.content, + toolCalls: m.toolCalls, + })); + renderMessages(); + await refreshSessionList(); + } catch { + /* ignore */ + } +} + +async function deleteSession(id) { + if (!state.profile) return; + try { + await authFetch(`/api/chat/sessions/${encodeURIComponent(id)}?profile=${encodeURIComponent(state.profile)}`, { + method: 'DELETE', + }); + if (state.chatSessionId === id) { + state.chatSessionId = null; + state.messages = []; + renderMessages(); + } + await refreshSessionList(); + } catch { + /* ignore */ + } +} + +function newSession() { + state.chatSessionId = null; + state.messages = []; + renderMessages(); + refreshSessionList(); +} + +async function sendMessage(text) { + if (!text.trim()) return; + if (!state.profile) { + alert('Pick a profile first'); + return; + } + + state.messages.push({ role: 'user', content: text }); + state.streaming = { role: 'assistant', content: '', toolCalls: [] }; + state.messages.push(state.streaming); + renderMessages(); + + // Must resolve the hello handshake *before* POSTing, otherwise the server + // starts streaming chunks via broadcastTo(sessionId, ...) and they get + // dropped because our session isn't registered yet. + await waitForHello(); + + try { + const res = await authFetch('/api/chat/message', { + method: 'POST', + body: JSON.stringify({ + sessionId: getSessionId(), + profile: state.profile, + message: text, + mode: state.mode, + chatSessionId: state.chatSessionId || undefined, + }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + state.streaming.content = `[error] ${err.error || res.statusText}`; + state.streaming = null; + renderMessages(); + return; + } + const body = await res.json(); + if (body.chatSessionId) { + state.chatSessionId = body.chatSessionId; + await refreshSessionList(); + } + } catch (err) { + state.streaming.content = `[error] ${err.message}`; + state.streaming = null; + renderMessages(); + } +} + +// --------------------------------------------------------------------------- +// WS event handlers +// --------------------------------------------------------------------------- + +function onChunk(data) { + if (!state.streaming || !data) return; + + if (data.type === 'text') { + state.streaming.content += data.content || ''; + renderMessages(); + } else if (data.type === 'thinking') { + // Ignore in the visible transcript for now — keep chat focused. + } else if (data.type === 'tool_call') { + state.streaming.toolCalls.push({ + id: data.id, + name: data.tool, + input: data.input, + }); + renderMessages(); + } else if (data.type === 'tool_result') { + const tc = state.streaming.toolCalls.find((t) => t.id === data.id); + if (tc) { + const r = data.result || {}; + if (r.ok === false) { + tc.error = r.error || 'tool error'; + } else { + tc.result = r.output; + } + renderMessages(); + } + } +} + +function onDone() { + state.streaming = null; + refreshSessionList(); +} + +function onError(data) { + if (state.streaming) { + state.streaming.content += `\n[error] ${data?.message || 'unknown'}`; + renderMessages(); + } +} + +function onConfirmNeeded(data) { + if (!data || !data.tokenId) return; + showConfirmModal(data.tokenId, data.prompt || 'Run tool?'); +} + +// --------------------------------------------------------------------------- +// Confirmation modal +// --------------------------------------------------------------------------- + +function showConfirmModal(tokenId, prompt) { + const existing = document.getElementById('chat-confirm-modal'); + if (existing) existing.remove(); + + const overlay = document.createElement('div'); + overlay.id = 'chat-confirm-modal'; + overlay.className = 'modal-overlay chat-confirm'; + overlay.innerHTML = ` + `; + document.body.appendChild(overlay); + + const respond = async (allow) => { + overlay.remove(); + try { + await authFetch('/api/chat/confirm', { + method: 'POST', + body: JSON.stringify({ sessionId: getSessionId(), tokenId, allow }), + }); + } catch { + /* ignore — server will auto-deny after timeout */ + } + }; + + overlay.querySelector('[data-confirm="allow"]').addEventListener('click', () => respond(true)); + overlay.querySelector('[data-confirm="deny"]').addEventListener('click', () => respond(false)); +} + +// --------------------------------------------------------------------------- +// Event wiring for interactive UI +// --------------------------------------------------------------------------- + +function wireUi() { + const sendBtn = document.getElementById('chat-send'); + const input = document.getElementById('chat-input'); + const profileSelect = document.getElementById('chat-profile'); + const modeSelect = document.getElementById('chat-mode'); + const newBtn = document.getElementById('chat-new'); + + sendBtn?.addEventListener('click', () => { + const text = input.value; + input.value = ''; + sendMessage(text); + }); + + input?.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + const text = input.value; + input.value = ''; + sendMessage(text); + } + }); + + profileSelect?.addEventListener('change', () => { + state.profile = profileSelect.value; + refreshSessionList(); + }); + + modeSelect?.addEventListener('change', () => { + state.mode = modeSelect.value; + }); + + newBtn?.addEventListener('click', () => { + newSession(); + }); + + // Tool-call expand/collapse via event delegation. + document.getElementById('chat-messages')?.addEventListener('click', (e) => { + const toggle = e.target.closest('[data-action="toggle-tool"]'); + if (!toggle) return; + const tool = toggle.closest('.chat-tool'); + if (!tool) return; + const detail = tool.querySelector('.chat-tool__detail'); + const caret = tool.querySelector('.chat-tool__caret'); + const isHidden = detail.hasAttribute('hidden'); + if (isHidden) { + detail.removeAttribute('hidden'); + caret.textContent = '▾'; + } else { + detail.setAttribute('hidden', ''); + caret.textContent = '▸'; + } + }); +} + +// --------------------------------------------------------------------------- +// View render +// --------------------------------------------------------------------------- + +async function render() { + resetState(); + ensureHello(); + + // Listen for per-session chat events. These are filtered server-side via + // broadcastTo() so only this tab's chunks arrive. + listen('chat-chunk', onChunk); + listen('chat-done', onDone); + listen('chat-error', onError); + listen('chat-confirm-needed', onConfirmNeeded); + + // Load profile list for the dropdown. + let profiles = []; + try { + profiles = await api.profiles(); + } catch { + profiles = []; + } + if (profiles.length > 0 && !state.profile) { + const active = profiles.find((p) => p.active); + state.profile = active ? active.name : profiles[0].name; + } + + const profileOptions = profiles + .map((p) => ``) + .join(''); + + // Kick off a session-list refresh after render commits. + setTimeout(() => { + wireUi(); + refreshSessionList(); + }, 0); + + const skeletonRows = Array.from({ length: 3 }).map(() => ` +
+
+
+
`).join(''); + + const modeHint = 'read-only: safe tools only. supervised: ask before write/exec. autonomous: no prompts.'; + + return ` +
+

Chat

+ IN-APP ASSISTANT +
+ +
+ +
+
+ + +
+
+
+ + +
+
+
`; +} + +registerView('chat', render); diff --git a/packages/dashboard/public/components/pipelines.js b/packages/dashboard/public/components/pipelines.js new file mode 100644 index 0000000..a0ef531 --- /dev/null +++ b/packages/dashboard/public/components/pipelines.js @@ -0,0 +1,411 @@ +// ARC Dashboard — Pipelines View (Phase 8) +// +// Configure a staged PLAN → EXEC → VERIFY workflow, POST /api/pipeline/run, +// and track phase transitions live via `pipeline-event` WebSocket frames. +// History sidebar reads from ~/.arc/pipelines/. + +import { api } from '../scripts/api.js'; +import { ws } from '../scripts/ws.js'; +import { registerView } from '../scripts/router.js'; +import { escapeHtml } from '../scripts/utils.js'; + +// --------------------------------------------------------------------------- +// Auth +// --------------------------------------------------------------------------- + +let cachedToken = null; +async function getToken() { + if (cachedToken) return cachedToken; + try { + const res = await fetch('/api/auth/token'); + if (!res.ok) return null; + const body = await res.json(); + cachedToken = body.token || null; + return cachedToken; + } catch { + return null; + } +} + +async function authFetch(path, init = {}) { + const token = await getToken(); + const headers = new Headers(init.headers || {}); + if (token) headers.set('Authorization', `Bearer ${token}`); + if (init.body && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + return fetch(path, { ...init, headers }); +} + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +const PHASES = ['plan', 'exec', 'verify']; +const DEFAULT_TIMEOUTS = { plan: 120000, exec: 300000, verify: 120000 }; + +const state = { + profiles: [], + agents: [], + phaseEnabled: { plan: true, exec: true, verify: true }, + phaseTimeoutMs: { ...DEFAULT_TIMEOUTS }, + runningId: null, + currentPhase: null, + phaseLog: [], // { phase, durationMs?, at } + listeners: [], + selectedHistoryId: null, +}; + +function resetState() { + for (const [evt, fn] of state.listeners) ws.off(evt, fn); + state.listeners = []; + state.runningId = null; + state.currentPhase = null; + state.phaseLog = []; + state.agents = []; + state.selectedHistoryId = null; +} + +function listen(event, handler) { + ws.on(event, handler); + state.listeners.push([event, handler]); +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +function phaseTracker() { + return PHASES.map((phase) => { + const enabled = state.phaseEnabled[phase]; + const status = !enabled + ? 'disabled' + : state.currentPhase === phase + ? 'active' + : state.phaseLog.some((e) => e.phase === phase && e.durationMs !== undefined) + ? 'done' + : 'pending'; + return ` +
+
${phase.toUpperCase()}
+
${status.toUpperCase()}
+
`; + }).join('
'); +} + +function agentRow(agent, idx) { + const options = state.profiles + .map( + (p) => + ``, + ) + .join(''); + return ` +
+ + +
`; +} + +function phaseToggle(phase) { + const checked = state.phaseEnabled[phase] ? 'checked' : ''; + return ` + `; +} + +function transcriptRow(entry) { + const dur = + typeof entry.durationMs === 'number' + ? `${entry.durationMs}ms` + : entry.at + ? new Date(entry.at).toLocaleTimeString() + : ''; + return ` +
+ ${escapeHtml(String(entry.phase).toUpperCase())} + ${escapeHtml(dur)} +
`; +} + +function renderTracker() { + const el = document.getElementById('pipe-tracker'); + if (el) el.innerHTML = phaseTracker(); +} + +function renderAgents() { + const el = document.getElementById('pipe-agents'); + if (!el) return; + el.innerHTML = state.agents.map((a, i) => agentRow(a, i)).join('') || + '
No agents — press + Add agent above.
'; + wireAgentHandlers(); +} + +function renderLog() { + const el = document.getElementById('pipe-log'); + if (!el) return; + const emptyBlock = ` +
+ +
Awaiting run
+
Phase timings appear here once the pipeline starts
+
`; + el.innerHTML = state.phaseLog.map(transcriptRow).join('') || emptyBlock; + el.scrollTop = el.scrollHeight; +} + +function historyRow(h) { + const cls = + h.id === state.selectedHistoryId + ? 'rt-history__row rt-history__row--active' + : 'rt-history__row'; + const phases = Array.isArray(h.phases) ? h.phases.join(' → ') : ''; + return ` +
+
${escapeHtml(phases || '(pipeline)')}
+
+ ${escapeHtml(new Date(h.createdAt || 0).toLocaleString())} +
+
`; +} + +async function refreshHistory() { + const el = document.getElementById('pipe-history'); + if (!el) return; + try { + const res = await fetch('/api/pipeline/history'); + if (!res.ok) return; + const list = await res.json(); + const emptyBlock = ` +
+ +
No past pipelines
+
PLAN → EXEC → VERIFY flows appear here
+
`; + el.innerHTML = Array.isArray(list) + ? list.map(historyRow).join('') || emptyBlock + : ''; + el.querySelectorAll('[data-history-id]').forEach((row) => { + row.addEventListener('click', () => loadHistory(row.getAttribute('data-history-id'))); + }); + } catch { + /* ignore */ + } +} + +async function loadHistory(id) { + try { + const res = await fetch(`/api/pipeline/${encodeURIComponent(id)}`); + if (!res.ok) return; + const record = await res.json(); + state.selectedHistoryId = id; + const result = record.result || {}; + state.phaseLog = (result.phasesCompleted || []) + .map((p) => ({ phase: p, at: record.createdAt })) + .concat( + (result.phasesTimedOut || []).map((p) => ({ phase: `${p} (timeout)`, at: record.createdAt })), + ); + state.currentPhase = null; + renderTracker(); + renderLog(); + refreshHistory(); + } catch { + /* ignore */ + } +} + +// --------------------------------------------------------------------------- +// Wiring +// --------------------------------------------------------------------------- + +function wireAgentHandlers() { + document.querySelectorAll('#pipe-agents select').forEach((sel) => { + sel.addEventListener('change', () => { + const idx = parseInt(sel.getAttribute('data-idx'), 10); + if (!Number.isNaN(idx) && state.agents[idx]) { + state.agents[idx].profileName = sel.value; + } + }); + }); + document.querySelectorAll('#pipe-agents [data-action="remove-agent"]').forEach((btn) => { + btn.addEventListener('click', () => { + const idx = parseInt(btn.getAttribute('data-idx'), 10); + if (!Number.isNaN(idx)) { + state.agents.splice(idx, 1); + renderAgents(); + } + }); + }); +} + +function wireUi() { + document.getElementById('pipe-add-agent')?.addEventListener('click', () => { + const p = state.profiles[0]?.name || ''; + state.agents.push({ profileName: p }); + renderAgents(); + }); + + document.querySelectorAll('[data-phase-toggle]').forEach((cb) => { + cb.addEventListener('change', () => { + const phase = cb.getAttribute('data-phase-toggle'); + state.phaseEnabled[phase] = cb.checked; + renderTracker(); + }); + }); + + document.querySelectorAll('[data-phase-timeout]').forEach((input) => { + input.addEventListener('input', () => { + const phase = input.getAttribute('data-phase-timeout'); + const n = parseInt(input.value, 10); + if (!Number.isNaN(n) && n > 0) state.phaseTimeoutMs[phase] = n; + }); + }); + + document.getElementById('pipe-start')?.addEventListener('click', startRun); +} + +// --------------------------------------------------------------------------- +// Orchestration +// --------------------------------------------------------------------------- + +async function startRun() { + if (state.agents.length === 0) { + alert('Add at least one agent'); + return; + } + const phases = PHASES.filter((p) => state.phaseEnabled[p]); + if (phases.length === 0) { + alert('Enable at least one phase'); + return; + } + + state.phaseLog = []; + state.currentPhase = null; + state.runningId = null; + state.selectedHistoryId = null; + renderTracker(); + renderLog(); + + try { + const res = await authFetch('/api/pipeline/run', { + method: 'POST', + body: JSON.stringify({ + phases, + phaseTimeoutMs: phases.reduce((acc, p) => { + acc[p] = state.phaseTimeoutMs[p]; + return acc; + }, {}), + agents: state.agents.map((a) => ({ profileName: a.profileName })), + }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + alert(`Failed: ${err.error || res.statusText}`); + return; + } + const body = await res.json(); + state.runningId = body.pipelineId; + } catch (err) { + alert(`Failed: ${err.message}`); + } +} + +function onPipelineEvent(data) { + if (!data) return; + if (state.runningId && data.pipelineId !== state.runningId) return; + const phase = data.phase; + if (!phase) return; + state.phaseLog.push({ + phase, + durationMs: data.durationMs, + at: Date.now(), + }); + if (PHASES.includes(phase)) state.currentPhase = phase; + if (phase === 'complete' || phase === 'aborted' || phase === 'persisted') { + state.currentPhase = null; + if (phase === 'persisted') refreshHistory(); + } + renderTracker(); + renderLog(); +} + +function onPipelineError(data) { + if (!data) return; + if (state.runningId && data.pipelineId !== state.runningId) return; + state.phaseLog.push({ phase: `error: ${data.error || 'unknown'}`, at: Date.now() }); + state.currentPhase = null; + renderTracker(); + renderLog(); +} + +// --------------------------------------------------------------------------- +// Render +// --------------------------------------------------------------------------- + +async function render() { + resetState(); + + listen('pipeline-event', onPipelineEvent); + listen('pipeline-error', onPipelineError); + + let profiles = []; + try { + profiles = await api.profiles(); + } catch { + profiles = []; + } + state.profiles = profiles; + + if (state.agents.length === 0 && profiles.length > 0) { + state.agents = [{ profileName: profiles[0].name }]; + } + + setTimeout(() => { + wireUi(); + renderTracker(); + renderAgents(); + renderLog(); + refreshHistory(); + }, 0); + + const phaseToggles = PHASES.map(phaseToggle).join(''); + + const sidebarSkeleton = Array.from({ length: 3 }).map(() => ` +
+
+
+
`).join(''); + + return ` +
+

Pipelines

+ PLAN → EXEC → VERIFY +
+ +
+ +
+
+
${phaseToggles}
+
+ + +
+
+
+
+
+
+
`; +} + +registerView('pipelines', render); diff --git a/packages/dashboard/public/components/roundtable.js b/packages/dashboard/public/components/roundtable.js new file mode 100644 index 0000000..7f4c426 --- /dev/null +++ b/packages/dashboard/public/components/roundtable.js @@ -0,0 +1,465 @@ +// ARC Dashboard — Roundtable View (Phase 8) +// +// Configure a multi-agent roundtable, POST /api/roundtable/run, then stream +// `roundtable-event` frames over the shared WebSocket to paint per-turn +// blocks + a synthesis card at the end. The history sidebar reflects +// persisted runs from ~/.arc/roundtables/. + +import { api } from '../scripts/api.js'; +import { ws } from '../scripts/ws.js'; +import { registerView } from '../scripts/router.js'; +import { escapeHtml } from '../scripts/utils.js'; + +// --------------------------------------------------------------------------- +// Bearer token lookup (mirrors chat.js — endpoint is mutation-guarded). +// --------------------------------------------------------------------------- + +let cachedToken = null; +async function getToken() { + if (cachedToken) return cachedToken; + try { + const res = await fetch('/api/auth/token'); + if (!res.ok) return null; + const body = await res.json(); + cachedToken = body.token || null; + return cachedToken; + } catch { + return null; + } +} + +async function authFetch(path, init = {}) { + const token = await getToken(); + const headers = new Headers(init.headers || {}); + if (token) headers.set('Authorization', `Bearer ${token}`); + if (init.body && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + return fetch(path, { ...init, headers }); +} + +// --------------------------------------------------------------------------- +// View state +// --------------------------------------------------------------------------- + +const ROLES = ['advocate', 'critic', 'neutral']; + +const state = { + profiles: [], + agents: [], // editable { profileName, role } entries + rounds: 2, + topic: '', + synthesizer: '', // profileName string or '' + runningId: null, + events: [], // { type, ... } + synthesis: null, // { consensusScore, summary } + selectedHistoryId: null, + listeners: [], +}; + +function resetState() { + for (const [evt, fn] of state.listeners) ws.off(evt, fn); + state.listeners = []; + state.agents = []; + state.events = []; + state.synthesis = null; + state.runningId = null; + state.selectedHistoryId = null; +} + +function listen(event, handler) { + ws.on(event, handler); + state.listeners.push([event, handler]); +} + +// --------------------------------------------------------------------------- +// Rendering helpers +// --------------------------------------------------------------------------- + +function agentRow(agent, idx) { + const profileOptions = state.profiles + .map( + (p) => + ``, + ) + .join(''); + const roleOptions = ROLES.map( + (r) => + ``, + ).join(''); + return ` +
+ + + +
`; +} + +function turnBlock(evt) { + const role = evt.role || ''; + const rolePillClass = ROLES.includes(role) ? `role-pill--${role}` : 'role-pill--neutral'; + return ` +
+
+ ${escapeHtml(evt.agent || '')} + ${escapeHtml(role.toUpperCase() || 'AGENT')} + ROUND ${escapeHtml(String(evt.round ?? '?'))} + ${escapeHtml(String(Math.round((evt.latencyMs ?? 0))))}ms +
+
${escapeHtml(evt.content || '')}
+
`; +} + +function synthesisBlock(s) { + const pct = Math.round((s.consensusScore ?? 0) * 100); + return ` +
+
+ + SYNTHESIS + + CONSENSUS ${pct}% +
+
+
+
+
${escapeHtml(s.summary || '')}
+
`; +} + +function renderTranscript() { + const el = document.getElementById('rt-transcript'); + if (!el) return; + const parts = []; + for (const evt of state.events) { + if (evt.type === 'turn-complete') parts.push(turnBlock(evt)); + } + if (state.synthesis) parts.push(synthesisBlock(state.synthesis)); + const emptyBlock = ` +
+ +
Ready to deliberate
+
Configure agents above, set a topic, then press Start roundtable
+
`; + el.innerHTML = parts.join('') || emptyBlock; + el.scrollTop = el.scrollHeight; +} + +function renderAgents() { + const el = document.getElementById('rt-agents'); + if (!el) return; + el.innerHTML = state.agents.map((a, i) => agentRow(a, i)).join('') || + '
No agents yet — press + Add agent above.
'; + wireAgentHandlers(); +} + +function renderSynthesizer() { + const el = document.getElementById('rt-synthesizer'); + if (!el) return; + const options = [''] + .concat( + state.agents.map( + (a) => + ``, + ), + ) + .join(''); + el.innerHTML = options; +} + +function historyRow(h) { + const pct = + typeof h.consensusScore === 'number' + ? `${Math.round(h.consensusScore * 100)}%` + : '—'; + const cls = + h.id === state.selectedHistoryId + ? 'rt-history__row rt-history__row--active' + : 'rt-history__row'; + return ` +
+
${escapeHtml(h.topic || '(no topic)')}
+
+ ${escapeHtml(new Date(h.createdAt || 0).toLocaleString())} + ${pct} +
+
`; +} + +async function refreshHistory() { + const el = document.getElementById('rt-history'); + if (!el) return; + try { + const res = await fetch('/api/roundtable/history'); + if (!res.ok) return; + const list = await res.json(); + const rows = Array.isArray(list) ? list.map(historyRow).join('') : ''; + const emptyBlock = ` +
+ +
No past roundtables
+
Configure agents below and press Start
+
`; + el.innerHTML = rows || emptyBlock; + el.querySelectorAll('[data-history-id]').forEach((row) => { + row.addEventListener('click', async () => { + const id = row.getAttribute('data-history-id'); + await loadHistory(id); + }); + }); + } catch { + /* ignore */ + } +} + +async function loadHistory(id) { + try { + const res = await fetch(`/api/roundtable/${encodeURIComponent(id)}`); + if (!res.ok) return; + const record = await res.json(); + const result = record.result || {}; + state.selectedHistoryId = id; + state.events = (result.transcript || []).map((m) => ({ + type: 'turn-complete', + agent: m.agent, + role: m.role, + round: m.round, + content: m.content, + latencyMs: m.latencyMs, + })); + state.synthesis = { + consensusScore: result.consensusScore ?? 0, + summary: result.synthesis || '', + }; + renderTranscript(); + refreshHistory(); + } catch { + /* ignore */ + } +} + +// --------------------------------------------------------------------------- +// Event wiring +// --------------------------------------------------------------------------- + +function wireAgentHandlers() { + document.querySelectorAll('#rt-agents select').forEach((sel) => { + sel.addEventListener('change', () => { + const idx = parseInt(sel.getAttribute('data-idx'), 10); + const field = sel.getAttribute('data-field'); + if (Number.isNaN(idx) || !state.agents[idx]) return; + state.agents[idx][field] = sel.value; + if (field === 'profileName') renderSynthesizer(); + }); + }); + document.querySelectorAll('[data-action="remove-agent"]').forEach((btn) => { + btn.addEventListener('click', () => { + const idx = parseInt(btn.getAttribute('data-idx'), 10); + if (Number.isNaN(idx)) return; + state.agents.splice(idx, 1); + renderAgents(); + renderSynthesizer(); + }); + }); +} + +function wireUi() { + document.getElementById('rt-add-agent')?.addEventListener('click', () => { + const available = state.profiles[0]?.name || ''; + state.agents.push({ + profileName: available, + role: ROLES[state.agents.length % ROLES.length], + }); + renderAgents(); + renderSynthesizer(); + }); + + document.getElementById('rt-topic')?.addEventListener('input', (e) => { + state.topic = e.target.value; + }); + + document.getElementById('rt-rounds')?.addEventListener('input', (e) => { + const n = parseInt(e.target.value, 10); + if (!Number.isNaN(n) && n >= 1 && n <= 10) state.rounds = n; + }); + + document.getElementById('rt-synthesizer')?.addEventListener('change', (e) => { + state.synthesizer = e.target.value; + }); + + document.getElementById('rt-start')?.addEventListener('click', startRun); +} + +// --------------------------------------------------------------------------- +// Orchestration +// --------------------------------------------------------------------------- + +async function startRun() { + if (!state.topic.trim()) { + alert('Enter a topic first'); + return; + } + if (state.agents.length < 2) { + alert('Roundtable needs at least 2 agents'); + return; + } + state.events = []; + state.synthesis = null; + state.runningId = null; + state.selectedHistoryId = null; + renderTranscript(); + + try { + const res = await authFetch('/api/roundtable/run', { + method: 'POST', + body: JSON.stringify({ + topic: state.topic, + agents: state.agents.map((a) => ({ + profileName: a.profileName, + role: a.role, + })), + rounds: state.rounds, + synthesizer: state.synthesizer || undefined, + }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + alert(`Failed to start: ${err.error || res.statusText}`); + return; + } + const body = await res.json(); + state.runningId = body.roundtableId; + } catch (err) { + alert(`Failed to start: ${err.message}`); + } +} + +function onRoundtableEvent(data) { + if (!data) return; + if (state.runningId && data.roundtableId !== state.runningId) return; + const evt = data.event; + if (!evt || !evt.type) return; + + if (evt.type === 'turn-complete') { + state.events.push(evt); + renderTranscript(); + } else if (evt.type === 'synthesis-complete') { + state.synthesis = { + consensusScore: evt.consensusScore ?? 0, + summary: evt.summary || '', + }; + renderTranscript(); + refreshHistory(); + } else if (evt.type === 'persisted') { + refreshHistory(); + } +} + +function onRoundtableError(data) { + if (!data) return; + if (state.runningId && data.roundtableId !== state.runningId) return; + state.events.push({ + type: 'turn-complete', + agent: '(error)', + role: 'neutral', + round: '?', + content: `[error] ${data.error || 'unknown'}`, + latencyMs: 0, + }); + renderTranscript(); +} + +// --------------------------------------------------------------------------- +// Render +// --------------------------------------------------------------------------- + +async function render() { + resetState(); + + listen('roundtable-event', onRoundtableEvent); + listen('roundtable-error', onRoundtableError); + + let profiles = []; + try { + profiles = await api.profiles(); + } catch { + profiles = []; + } + state.profiles = profiles; + + // Seed two default agents so the form is usable immediately. + if (state.agents.length === 0 && profiles.length >= 2) { + state.agents = [ + { profileName: profiles[0].name, role: 'advocate' }, + { profileName: profiles[1].name, role: 'critic' }, + ]; + } else if (state.agents.length === 0 && profiles.length === 1) { + state.agents = [{ profileName: profiles[0].name, role: 'advocate' }]; + } + + setTimeout(() => { + wireUi(); + renderAgents(); + renderSynthesizer(); + renderTranscript(); + refreshHistory(); + }, 0); + + const synthHint = 'Which agent summarises the discussion at the end. Defaults to the first agent.'; + const roundsHint = 'How many turns each agent gets. 2–3 is usually enough.'; + + const sidebarSkeleton = Array.from({ length: 3 }).map(() => ` +
+
+
+
`).join(''); + + return ` +
+

Roundtable

+ MULTI-AGENT DELIBERATION +
+ +
+ +
+
+ +
+ + + + +
+
+
+
+
+
`; +} + +registerView('roundtable', render); diff --git a/packages/dashboard/public/index.html b/packages/dashboard/public/index.html index 95a9eba..317aa7e 100644 --- a/packages/dashboard/public/index.html +++ b/packages/dashboard/public/index.html @@ -8,6 +8,7 @@ +
@@ -38,6 +39,7 @@ +