Merge pull request #243 from multica-ai/feature/multi-agent-backend

feat(daemon): unified agent SDK supporting Claude Code and Codex
This commit is contained in:
LinYushen 2026-03-24 17:55:08 +08:00 committed by GitHub
commit e3ea7bd02c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 3994 additions and 984 deletions

7
.gitignore vendored
View file

@ -9,6 +9,7 @@ out
.turbo
build
bin
dist-electron
*.tsbuildinfo
# env
@ -22,6 +23,9 @@ coverage
server/bin/
server/tmp/
server/migrate
server/daemon
server/multica-cli
server/multica
# Test artifacts
test-results/
@ -30,6 +34,9 @@ apps/web/test-results/
# context (agent workspace)
.context
# local settings
.claude/
# platform specific
*.dmg
*.app

114
CLAUDE.md
View file

@ -1,8 +1,8 @@
# CLAUDE.md
This file gives coding agents high-signal guidance for this repository.
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 1. Project Context
## Project Context
Multica is an AI-native task management platform — like Linear, but with AI agents as first-class citizens.
@ -10,15 +10,15 @@ Multica is an AI-native task management platform — like Linear, but with AI ag
- Supports local (daemon) and cloud agent runtimes
- Built for 2-10 person AI-native teams
## 2. Architecture
## Architecture
**Polyglot monorepo** — Go backend + TypeScript frontend.
- `server/` — Go backend (Chi + sqlc + gorilla/websocket)
- `server/` — Go backend (Chi router, sqlc for DB, gorilla/websocket for real-time)
- `apps/web/` — Next.js 16 frontend (App Router)
- `packages/` — Shared TypeScript packages (ui, types, sdk, utils)
- `packages/` — Shared TypeScript packages (ui, types, sdk, store, hooks, utils)
### 2.1 Web App Structure (`apps/web/`)
### Web App Structure (`apps/web/`)
The frontend uses a **feature-based architecture** with three layers:
@ -42,7 +42,7 @@ apps/web/
**`shared/`** — Code used across multiple features. Currently only `api.ts` (SDK singleton).
### 2.2 State Management
### State Management
- **Zustand** for global client state (`features/auth/store.ts`, `features/workspace/store.ts`).
- **React Context** only for connection lifecycle (`WSProvider` in `features/realtime/`).
@ -55,7 +55,7 @@ apps/web/
- Cross-store reads use `useOtherStore.getState()` inside actions (not hooks).
- Dependency direction: `workspace``auth`, `realtime``auth`, `issues``workspace`. Never reverse.
### 2.3 Import Aliases
### Import Aliases
Use `@/` alias (maps to `apps/web/`):
```typescript
@ -68,7 +68,48 @@ import { StatusIcon } from "@/features/issues/components";
Within a feature, use relative imports. Between features or to shared, use `@/`.
## 3. Core Workflow Commands
### Data Flow
```
Browser → ApiClient (SDK) → REST API (Chi handlers) → sqlc queries → PostgreSQL
Browser ← WSClient (SDK) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskService
```
### Backend Structure (`server/`)
- **Entry points** (`cmd/`): `server` (HTTP API), `daemon` (local agent runtime), `migrate`, `seed`
- **Handlers** (`internal/handler/`): One file per domain (issue, comment, agent, auth, daemon, etc.). Each handler holds `Queries`, `DB`, `Hub`, and `TaskService`.
- **Real-time** (`internal/realtime/`): Hub manages WebSocket clients. Server broadcasts events; inbound WS message routing is still TODO.
- **Auth** (`internal/auth/` + `internal/middleware/`): JWT (HS256). Middleware sets `X-User-ID` and `X-User-Email` headers. Login creates user on-the-fly if not found.
- **Task lifecycle** (`internal/service/task.go`): Orchestrates agent work — enqueue → claim → start → complete/fail. Syncs issue status automatically and broadcasts WS events at each transition.
- **Database**: sqlc generates Go code from SQL in `pkg/db/queries/``pkg/db/generated/`. Migrations in `migrations/`.
- **Routes** (`cmd/server/router.go`): Public routes (auth, health, ws) + protected routes (require JWT) + daemon routes (unauthenticated, separate auth model).
### Frontend Structure (`apps/web/`)
- **App Router layout groups**: `(auth)/` for login, `(dashboard)/` for protected routes
- **Auth context** (`lib/auth-context.tsx`): Global provider for user, workspace, members, agents. Hydrates from localStorage. Provides actor lookup helpers (`getMemberName`, `getAgentName`, `getActorName`).
- **WebSocket context** (`lib/ws-context.tsx`): Wraps `WSClient` from SDK. `useWSEvent()` hook auto-subscribes/unsubscribes.
- **API client** (`lib/api.ts`): Singleton `ApiClient` from `@multica/sdk`, initialized from localStorage.
- **State**: Zustand stores (`@multica/store`) for issues, agents, inbox. WebSocket events keep stores in sync without re-fetching.
### Key Packages
- **`@multica/sdk`**: `ApiClient` (REST) and `WSClient` (WebSocket) classes. All backend communication goes through here.
- **`@multica/types`**: Shared domain types + WebSocket event types (issue:created/updated/deleted, task:*, agent:status, comment:*, inbox:new, daemon:*).
- **`@multica/store`**: Zustand stores — simple arrays with add/update/remove. No persistence; memory only.
- **`@multica/ui`**: shadcn/ui component library with Radix primitives, Tailwind CSS 4, Shiki syntax highlighting for markdown.
- **`@multica/hooks`**: `useRealtime()` (WS → store sync), `useIssues()`, `useAgents()`, `useInbox()` (fetch + cache).
### Multi-tenancy
All queries filter by `workspace_id`. Membership checks gate access. `X-Workspace-ID` header routes requests to the correct workspace.
### Agent Assignees
Assignees are polymorphic — can be a member or an agent. `assignee_type` + `assignee_id` on issues. Agents render with distinct styling (purple background, robot icon).
## Commands
```bash
# One-click setup & run
@ -88,16 +129,34 @@ pnpm test # TS tests (Vitest)
make dev # Run Go server (port 8080)
make daemon # Run local daemon
make test # Go tests
make sqlc # Regenerate sqlc code
make sqlc # Regenerate sqlc code after editing SQL in server/pkg/db/queries/
make migrate-up # Run database migrations
make migrate-down # Rollback migrations
# Run a single Go test
cd server && go test ./internal/handler/ -run TestName
# Run a single TS test
pnpm --filter @multica/web exec vitest run src/path/to/file.test.ts
# Run a single E2E test (requires backend + frontend running)
pnpm exec playwright test e2e/tests/specific-test.spec.ts
# Infrastructure
make db-up # Start shared PostgreSQL
make db-down # Stop shared PostgreSQL
```
## 4. Coding Rules
### Worktree Support
For isolated feature testing with a separate database:
```bash
make worktree-env # Generate .env.worktree with unique DB/ports
make setup-worktree # Setup using .env.worktree
make start-worktree # Start using .env.worktree
```
## Coding Rules
- TypeScript strict mode is enabled; keep types explicit.
- Go code follows standard Go conventions (gofmt, go vet).
@ -108,7 +167,7 @@ make db-down # Stop shared PostgreSQL
- Treat compatibility code as a maintenance cost, not a default safety mechanism. Avoid "just in case" branches that make the codebase harder to reason about.
- Avoid broad refactors unless required by the task.
## 5. UI/UX Rules
## UI/UX Rules
- Prefer `packages/ui` shadcn components over custom implementations.
- **shadcn official components**`packages/ui/src/components/ui/` — keep this directory clean; install missing components via `npx shadcn add`, do not mix in business code.
@ -119,12 +178,12 @@ make db-down # Stop shared PostgreSQL
- Pay close attention to **overflow** (truncate long text, scrollable containers), **alignment**, and **spacing** consistency.
- When unsure about interaction or state design, ask — the user will provide direction.
## 6. Testing Rules
## Testing Rules
- **TypeScript**: Vitest. Mock external/third-party dependencies only.
- **Go**: Standard `go test`. Tests should create their own fixture data in a test database.
## 7. Commit Rules
## Commit Rules
- Use atomic commits grouped by logical intent.
- Conventional format:
@ -135,7 +194,7 @@ make db-down # Stop shared PostgreSQL
- `test(scope): ...`
- `chore(scope): ...`
## 8. Verification Commands
## Minimum Pre-Push Checks
```bash
make check # Runs all checks: typecheck, unit tests, Go tests, E2E
@ -151,7 +210,30 @@ make test # Go tests only
pnpm exec playwright test # E2E only (requires backend + frontend running)
```
## 9. E2E Test Patterns
## AI Agent Verification Loop
After writing or modifying code, always run the full verification pipeline:
```bash
make check
```
This runs all checks in sequence:
1. TypeScript typecheck (`pnpm typecheck`)
2. TypeScript unit tests (`pnpm test`)
3. Go tests (`go test ./...`)
4. E2E tests (auto-starts backend + frontend if needed, runs Playwright)
**Workflow:**
- Write code to satisfy the requirement
- Run `make check`
- If any step fails, read the error output, fix the code, and re-run `make check`
- Repeat until all checks pass
- Only then consider the task complete
**Quick iteration:** If you know only TypeScript or Go is affected, run individual checks first for faster feedback, then finish with a full `make check` before marking work complete.
## E2E Test Patterns
E2E tests should be self-contained. Use the `TestApiClient` fixture for data setup/teardown:

View file

@ -1,4 +1,4 @@
.PHONY: dev daemon build test migrate-up migrate-down sqlc clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down
.PHONY: dev daemon cli build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down
MAIN_ENV_FILE ?= .env
WORKTREE_ENV_FILE ?= .env.worktree
@ -115,11 +115,17 @@ dev:
cd server && go run ./cmd/server
daemon:
cd server && MULTICA_CODEX_WORKDIR="${MULTICA_CODEX_WORKDIR:-$(abspath .)}" go run ./cmd/daemon
cd server && MULTICA_REPOS_ROOT="${MULTICA_REPOS_ROOT:-$(abspath .)}" go run ./cmd/multica daemon
cli:
cd server && go run ./cmd/multica $(ARGS)
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
build:
cd server && go build -o bin/server ./cmd/server
cd server && go build -o bin/daemon ./cmd/daemon
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT)" -o bin/multica-cli ./cmd/multica
test:
$(REQUIRE_ENV)

View file

@ -104,6 +104,7 @@ export default function SettingsPage() {
const [description, setDescription] = useState(
workspace?.description ?? "",
);
const [context, setContext] = useState(workspace?.context ?? "");
const [profileName, setProfileName] = useState(user?.name ?? "");
const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url ?? "");
const [saving, setSaving] = useState(false);
@ -124,6 +125,7 @@ export default function SettingsPage() {
useEffect(() => {
setName(workspace?.name ?? "");
setDescription(workspace?.description ?? "");
setContext(workspace?.context ?? "");
}, [workspace]);
useEffect(() => {
@ -138,7 +140,8 @@ export default function SettingsPage() {
try {
const updated = await api.updateWorkspace(workspace.id, {
name,
description: description || undefined,
description,
context,
});
updateWorkspace(updated);
setSaved(true);
@ -339,6 +342,19 @@ export default function SettingsPage() {
placeholder="What does this workspace focus on?"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">
Context
</Label>
<textarea
value={context}
onChange={(e) => setContext(e.target.value)}
rows={4}
disabled={!canManageWorkspace}
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring resize-none"
placeholder="Background information and context for AI agents working in this workspace"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">
Slug

View file

@ -19,6 +19,7 @@ export const mockWorkspace: Workspace = {
name: "Test Workspace",
slug: "test-ws",
description: "A test workspace",
context: null,
settings: {},
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",

View file

@ -237,14 +237,14 @@ export class ApiClient {
return this.fetch(`/api/workspaces/${id}`);
}
async createWorkspace(data: { name: string; slug: string; description?: string }): Promise<Workspace> {
async createWorkspace(data: { name: string; slug: string; description?: string; context?: string }): Promise<Workspace> {
return this.fetch("/api/workspaces", {
method: "POST",
body: JSON.stringify(data),
});
}
async updateWorkspace(id: string, data: { name?: string; description?: string; settings?: Record<string, unknown> }): Promise<Workspace> {
async updateWorkspace(id: string, data: { name?: string; description?: string; context?: string; settings?: Record<string, unknown> }): Promise<Workspace> {
return this.fetch(`/api/workspaces/${id}`, {
method: "PATCH",
body: JSON.stringify(data),

View file

@ -5,6 +5,7 @@ export interface Workspace {
name: string;
slug: string;
description: string | null;
context: string | null;
settings: Record<string, unknown>;
created_at: string;
updated_at: string;

9
pnpm-lock.yaml generated
View file

@ -137,6 +137,15 @@ importers:
specifier: ^4.1.0
version: 4.1.0(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@8.0.1(@types/node@25.5.0)(jiti@2.6.1))
packages/agent-sdk:
devDependencies:
'@types/node':
specifier: 'catalog:'
version: 25.5.0
typescript:
specifier: 'catalog:'
version: 5.9.3
packages/hooks:
dependencies:
'@multica/sdk':

View file

@ -1,900 +0,0 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
const (
defaultServerURL = "ws://localhost:8080/ws"
defaultDaemonConfigPath = ".multica/daemon.json"
defaultPollInterval = 3 * time.Second
defaultHeartbeatInterval = 15 * time.Second
defaultCodexTimeout = 20 * time.Minute
defaultRuntimeName = "Local Codex"
defaultCodexPath = "codex"
)
type config struct {
ServerBaseURL string
ConfigPath string
WorkspaceID string
WorkspaceFromDisk bool
DaemonID string
DeviceName string
RuntimeName string
CodexPath string
CodexModel string
DefaultWorkdir string
PollInterval time.Duration
HeartbeatInterval time.Duration
CodexTimeout time.Duration
}
type daemon struct {
cfg config
client *daemonClient
logger *log.Logger
}
type daemonClient struct {
baseURL string
client *http.Client
}
type requestError struct {
Method string
Path string
StatusCode int
Body string
}
func (e *requestError) Error() string {
return fmt.Sprintf("%s %s returned %d: %s", e.Method, e.Path, e.StatusCode, e.Body)
}
type daemonRuntime struct {
ID string `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"`
Status string `json:"status"`
}
type daemonPairingSession struct {
Token string `json:"token"`
DaemonID string `json:"daemon_id"`
DeviceName string `json:"device_name"`
RuntimeName string `json:"runtime_name"`
RuntimeType string `json:"runtime_type"`
RuntimeVersion string `json:"runtime_version"`
WorkspaceID *string `json:"workspace_id"`
Status string `json:"status"`
ApprovedAt *string `json:"approved_at"`
ClaimedAt *string `json:"claimed_at"`
ExpiresAt string `json:"expires_at"`
LinkURL *string `json:"link_url"`
}
type daemonPersistedConfig struct {
WorkspaceID string `json:"workspace_id"`
}
type daemonTask struct {
ID string `json:"id"`
AgentID string `json:"agent_id"`
IssueID string `json:"issue_id"`
Context daemonTaskContext `json:"context"`
}
type daemonTaskContext struct {
Issue daemonIssueContext `json:"issue"`
Agent daemonAgentContext `json:"agent"`
Runtime daemonRuntimeContext `json:"runtime"`
}
type daemonIssueContext struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
AcceptanceCriteria []string `json:"acceptance_criteria"`
ContextRefs []string `json:"context_refs"`
Repository *daemonRepoRef `json:"repository"`
}
type daemonAgentContext struct {
ID string `json:"id"`
Name string `json:"name"`
Skills string `json:"skills"`
}
type daemonRuntimeContext struct {
ID string `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"`
DeviceInfo string `json:"device_info"`
}
type daemonRepoRef struct {
URL string `json:"url"`
Branch string `json:"branch"`
Path string `json:"path"`
}
type codexTaskResult struct {
Status string `json:"status"`
Comment string `json:"comment"`
}
func loadConfig() (config, error) {
serverBaseURL, err := normalizeServerBaseURL(envOrDefault("MULTICA_SERVER_URL", defaultServerURL))
if err != nil {
return config{}, err
}
configPath, err := resolveDaemonConfigPath(strings.TrimSpace(os.Getenv("MULTICA_DAEMON_CONFIG")))
if err != nil {
return config{}, err
}
persisted, err := loadPersistedDaemonConfig(configPath)
if err != nil {
return config{}, err
}
workspaceID := strings.TrimSpace(os.Getenv("MULTICA_WORKSPACE_ID"))
workspaceFromDisk := false
if workspaceID == "" {
workspaceID = persisted.WorkspaceID
workspaceFromDisk = workspaceID != ""
}
codexPath := envOrDefault("MULTICA_CODEX_PATH", defaultCodexPath)
if _, err := exec.LookPath(codexPath); err != nil {
return config{}, fmt.Errorf("codex executable not found at %q: %w", codexPath, err)
}
host, err := os.Hostname()
if err != nil || strings.TrimSpace(host) == "" {
host = "local-machine"
}
defaultWorkdir := strings.TrimSpace(os.Getenv("MULTICA_CODEX_WORKDIR"))
if defaultWorkdir == "" {
defaultWorkdir, err = os.Getwd()
if err != nil {
return config{}, fmt.Errorf("resolve working directory: %w", err)
}
}
defaultWorkdir, err = filepath.Abs(defaultWorkdir)
if err != nil {
return config{}, fmt.Errorf("resolve absolute workdir: %w", err)
}
pollInterval, err := durationFromEnv("MULTICA_DAEMON_POLL_INTERVAL", defaultPollInterval)
if err != nil {
return config{}, err
}
heartbeatInterval, err := durationFromEnv("MULTICA_DAEMON_HEARTBEAT_INTERVAL", defaultHeartbeatInterval)
if err != nil {
return config{}, err
}
codexTimeout, err := durationFromEnv("MULTICA_CODEX_TIMEOUT", defaultCodexTimeout)
if err != nil {
return config{}, err
}
return config{
ServerBaseURL: serverBaseURL,
ConfigPath: configPath,
WorkspaceID: workspaceID,
WorkspaceFromDisk: workspaceFromDisk,
DaemonID: envOrDefault("MULTICA_DAEMON_ID", host),
DeviceName: envOrDefault("MULTICA_DAEMON_DEVICE_NAME", host),
RuntimeName: envOrDefault("MULTICA_CODEX_RUNTIME_NAME", defaultRuntimeName),
CodexPath: codexPath,
CodexModel: strings.TrimSpace(os.Getenv("MULTICA_CODEX_MODEL")),
DefaultWorkdir: defaultWorkdir,
PollInterval: pollInterval,
HeartbeatInterval: heartbeatInterval,
CodexTimeout: codexTimeout,
}, nil
}
func newDaemon(cfg config, logger *log.Logger) *daemon {
return &daemon{
cfg: cfg,
client: &daemonClient{baseURL: cfg.ServerBaseURL, client: &http.Client{Timeout: 30 * time.Second}},
logger: logger,
}
}
func (d *daemon) run(ctx context.Context) error {
d.logger.Printf("starting daemon for workspace=%s server=%s runtime=%s workdir=%s",
d.cfg.WorkspaceID, d.cfg.ServerBaseURL, d.cfg.RuntimeName, d.cfg.DefaultWorkdir)
if strings.TrimSpace(d.cfg.WorkspaceID) == "" {
workspaceID, err := d.ensurePaired(ctx)
if err != nil {
return err
}
d.cfg.WorkspaceID = workspaceID
d.logger.Printf("pairing completed for workspace=%s", workspaceID)
}
runtime, err := d.registerRuntimeWithRecovery(ctx)
if err != nil {
return err
}
d.logger.Printf("registered runtime id=%s provider=%s status=%s", runtime.ID, runtime.Provider, runtime.Status)
go d.heartbeatLoop(ctx, runtime.ID)
return d.pollLoop(ctx, runtime.ID)
}
func (d *daemon) registerRuntime(ctx context.Context) (daemonRuntime, error) {
version, err := detectCodexVersion(ctx, d.cfg.CodexPath)
if err != nil {
return daemonRuntime{}, err
}
req := map[string]any{
"workspace_id": d.cfg.WorkspaceID,
"daemon_id": d.cfg.DaemonID,
"device_name": d.cfg.DeviceName,
"runtimes": []map[string]string{
{
"name": d.cfg.RuntimeName,
"type": "codex",
"version": version,
"status": "online",
},
},
}
var resp struct {
Runtimes []daemonRuntime `json:"runtimes"`
}
if err := d.client.postJSON(ctx, "/api/daemon/register", req, &resp); err != nil {
return daemonRuntime{}, fmt.Errorf("register runtime: %w", err)
}
if len(resp.Runtimes) == 0 {
return daemonRuntime{}, fmt.Errorf("register runtime: empty response")
}
return resp.Runtimes[0], nil
}
func (d *daemon) registerRuntimeWithRecovery(ctx context.Context) (daemonRuntime, error) {
runtime, err := d.registerRuntime(ctx)
if err == nil {
return runtime, nil
}
if !d.cfg.WorkspaceFromDisk || !isWorkspaceNotFoundError(err) {
return daemonRuntime{}, err
}
d.logger.Printf(
"persisted workspace=%s is no longer valid; clearing %s and starting a new pairing flow",
d.cfg.WorkspaceID,
d.cfg.ConfigPath,
)
if err := clearPersistedDaemonConfig(d.cfg.ConfigPath); err != nil {
return daemonRuntime{}, err
}
d.cfg.WorkspaceID = ""
d.cfg.WorkspaceFromDisk = false
workspaceID, err := d.ensurePaired(ctx)
if err != nil {
return daemonRuntime{}, fmt.Errorf("repair stale workspace binding: %w", err)
}
d.cfg.WorkspaceID = workspaceID
d.logger.Printf("pairing completed for workspace=%s", workspaceID)
return d.registerRuntime(ctx)
}
func (d *daemon) ensurePaired(ctx context.Context) (string, error) {
version, err := detectCodexVersion(ctx, d.cfg.CodexPath)
if err != nil {
return "", err
}
session, err := d.client.createPairingSession(ctx, map[string]string{
"daemon_id": d.cfg.DaemonID,
"device_name": d.cfg.DeviceName,
"runtime_name": d.cfg.RuntimeName,
"runtime_type": "codex",
"runtime_version": version,
})
if err != nil {
return "", fmt.Errorf("create pairing session: %w", err)
}
if session.LinkURL != nil {
d.logger.Printf("open this link to pair the local Codex runtime: %s", *session.LinkURL)
} else {
d.logger.Printf("pairing session created: %s", session.Token)
}
for {
select {
case <-ctx.Done():
return "", ctx.Err()
default:
}
current, err := d.client.getPairingSession(ctx, session.Token)
if err != nil {
return "", fmt.Errorf("poll pairing session: %w", err)
}
switch current.Status {
case "approved", "claimed":
if current.WorkspaceID == nil || strings.TrimSpace(*current.WorkspaceID) == "" {
return "", fmt.Errorf("pairing session approved without workspace")
}
if err := savePersistedDaemonConfig(d.cfg.ConfigPath, daemonPersistedConfig{
WorkspaceID: strings.TrimSpace(*current.WorkspaceID),
}); err != nil {
return "", err
}
if current.Status != "claimed" {
if _, err := d.client.claimPairingSession(ctx, current.Token); err != nil {
return "", fmt.Errorf("claim pairing session: %w", err)
}
}
return strings.TrimSpace(*current.WorkspaceID), nil
case "expired":
return "", fmt.Errorf("pairing session expired before approval")
}
if err := sleepWithContext(ctx, d.cfg.PollInterval); err != nil {
return "", err
}
}
}
func (d *daemon) heartbeatLoop(ctx context.Context, runtimeID string) {
ticker := time.NewTicker(d.cfg.HeartbeatInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
err := d.client.postJSON(ctx, "/api/daemon/heartbeat", map[string]string{
"runtime_id": runtimeID,
}, nil)
if err != nil {
d.logger.Printf("heartbeat failed: %v", err)
}
}
}
}
func (d *daemon) pollLoop(ctx context.Context, runtimeID string) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
task, err := d.client.claimTask(ctx, runtimeID)
if err != nil {
d.logger.Printf("claim task failed: %v", err)
if err := sleepWithContext(ctx, d.cfg.PollInterval); err != nil {
return err
}
continue
}
if task == nil {
if err := sleepWithContext(ctx, d.cfg.PollInterval); err != nil {
return err
}
continue
}
d.handleTask(ctx, *task)
}
}
func (d *daemon) handleTask(ctx context.Context, task daemonTask) {
d.logger.Printf("picked task=%s issue=%s title=%q", task.ID, task.IssueID, task.Context.Issue.Title)
if err := d.client.startTask(ctx, task.ID); err != nil {
d.logger.Printf("start task %s failed: %v", task.ID, err)
return
}
_ = d.client.reportProgress(ctx, task.ID, "Launching Codex", 1, 2)
result, err := d.runTask(ctx, task)
if err != nil {
d.logger.Printf("task %s failed: %v", task.ID, err)
if failErr := d.client.failTask(ctx, task.ID, err.Error()); failErr != nil {
d.logger.Printf("fail task %s callback failed: %v", task.ID, failErr)
}
return
}
_ = d.client.reportProgress(ctx, task.ID, "Finishing task", 2, 2)
switch result.Status {
case "blocked":
if err := d.client.failTask(ctx, task.ID, result.Comment); err != nil {
d.logger.Printf("report blocked task %s failed: %v", task.ID, err)
}
default:
if err := d.client.completeTask(ctx, task.ID, result.Comment); err != nil {
d.logger.Printf("complete task %s failed: %v", task.ID, err)
}
}
}
func (d *daemon) runTask(ctx context.Context, task daemonTask) (codexTaskResult, error) {
workdir, err := resolveTaskWorkdir(d.cfg.DefaultWorkdir, task.Context.Issue.Repository)
if err != nil {
return codexTaskResult{}, err
}
prompt := buildCodexPrompt(task, workdir)
runCtx, cancel := context.WithTimeout(ctx, d.cfg.CodexTimeout)
defer cancel()
model := d.cfg.CodexModel
if model == "" {
model = "default"
}
startedAt := time.Now()
d.logger.Printf(
"starting codex exec task=%s workdir=%s model=%s timeout=%s",
task.ID,
workdir,
model,
d.cfg.CodexTimeout,
)
result, err := runCodexExec(runCtx, d.cfg, workdir, prompt)
if err != nil {
d.logger.Printf(
"codex exec failed task=%s duration=%s err=%v",
task.ID,
time.Since(startedAt).Round(time.Millisecond),
err,
)
if errors.Is(runCtx.Err(), context.DeadlineExceeded) {
return codexTaskResult{}, fmt.Errorf("Codex timed out after %s", d.cfg.CodexTimeout)
}
return codexTaskResult{}, err
}
d.logger.Printf(
"codex exec finished task=%s duration=%s status=%s",
task.ID,
time.Since(startedAt).Round(time.Millisecond),
result.Status,
)
return result, nil
}
func runCodexExec(ctx context.Context, cfg config, workdir, prompt string) (codexTaskResult, error) {
outputFile, err := os.CreateTemp("", "multica-codex-output-*.json")
if err != nil {
return codexTaskResult{}, fmt.Errorf("create codex output file: %w", err)
}
outputPath := outputFile.Name()
outputFile.Close()
defer os.Remove(outputPath)
schemaFile, err := os.CreateTemp("", "multica-codex-schema-*.json")
if err != nil {
return codexTaskResult{}, fmt.Errorf("create schema file: %w", err)
}
schemaPath := schemaFile.Name()
if _, err := schemaFile.WriteString(codexResultSchema); err != nil {
schemaFile.Close()
return codexTaskResult{}, fmt.Errorf("write schema file: %w", err)
}
schemaFile.Close()
defer os.Remove(schemaPath)
args := []string{
"-a", "never",
"exec",
"--skip-git-repo-check",
"--sandbox", "workspace-write",
"-C", workdir,
"--output-schema", schemaPath,
"-o", outputPath,
prompt,
}
if cfg.CodexModel != "" {
args = append([]string{"-m", cfg.CodexModel}, args...)
}
cmd := exec.CommandContext(ctx, cfg.CodexPath, args...)
var output bytes.Buffer
cmd.Stdout = &output
cmd.Stderr = &output
if err := cmd.Run(); err != nil {
return codexTaskResult{}, fmt.Errorf("codex exec failed: %w\n%s", err, strings.TrimSpace(output.String()))
}
data, err := os.ReadFile(outputPath)
if err != nil {
return codexTaskResult{}, fmt.Errorf("read codex result: %w", err)
}
var result codexTaskResult
if err := json.Unmarshal(data, &result); err != nil {
return codexTaskResult{}, fmt.Errorf("parse codex result: %w", err)
}
if result.Comment == "" {
return codexTaskResult{}, fmt.Errorf("codex returned empty comment")
}
if result.Status == "" {
result.Status = "completed"
}
return result, nil
}
func buildCodexPrompt(task daemonTask, workdir string) string {
var b strings.Builder
b.WriteString("You are running as the local Codex runtime for a Multica agent.\n")
b.WriteString("Complete the assigned issue using the local environment.\n")
b.WriteString("Return a concise Markdown comment suitable for posting back to the issue.\n")
b.WriteString("If you cannot complete the task because context, files, or permissions are missing, return status \"blocked\" and explain the blocker in the comment.\n\n")
fmt.Fprintf(&b, "Working directory: %s\n", workdir)
fmt.Fprintf(&b, "Agent: %s\n", task.Context.Agent.Name)
fmt.Fprintf(&b, "Issue title: %s\n\n", task.Context.Issue.Title)
if task.Context.Issue.Description != "" {
b.WriteString("Issue description:\n")
b.WriteString(task.Context.Issue.Description)
b.WriteString("\n\n")
}
if len(task.Context.Issue.AcceptanceCriteria) > 0 {
b.WriteString("Acceptance criteria:\n")
for _, item := range task.Context.Issue.AcceptanceCriteria {
fmt.Fprintf(&b, "- %s\n", item)
}
b.WriteString("\n")
}
if len(task.Context.Issue.ContextRefs) > 0 {
b.WriteString("Context refs:\n")
for _, item := range task.Context.Issue.ContextRefs {
fmt.Fprintf(&b, "- %s\n", item)
}
b.WriteString("\n")
}
if repo := task.Context.Issue.Repository; repo != nil {
b.WriteString("Repository context:\n")
if repo.URL != "" {
fmt.Fprintf(&b, "- url: %s\n", repo.URL)
}
if repo.Branch != "" {
fmt.Fprintf(&b, "- branch: %s\n", repo.Branch)
}
if repo.Path != "" {
fmt.Fprintf(&b, "- path: %s\n", repo.Path)
}
b.WriteString("\n")
}
if task.Context.Agent.Skills != "" {
b.WriteString("Agent skills/instructions:\n")
b.WriteString(task.Context.Agent.Skills)
b.WriteString("\n\n")
}
b.WriteString("Comment requirements:\n")
b.WriteString("- Lead with the outcome.\n")
b.WriteString("- Mention concrete files or commands if you changed anything.\n")
b.WriteString("- Mention blockers or follow-up actions if relevant.\n")
return b.String()
}
func resolveTaskWorkdir(defaultWorkdir string, repo *daemonRepoRef) (string, error) {
base := defaultWorkdir
if repo == nil || strings.TrimSpace(repo.Path) == "" {
return base, nil
}
path := strings.TrimSpace(repo.Path)
if !filepath.IsAbs(path) {
path = filepath.Join(base, path)
}
path = filepath.Clean(path)
info, err := os.Stat(path)
if err != nil {
return "", fmt.Errorf("repository path not found: %s", path)
}
if !info.IsDir() {
return "", fmt.Errorf("repository path is not a directory: %s", path)
}
return path, nil
}
func detectCodexVersion(ctx context.Context, codexPath string) (string, error) {
cmd := exec.CommandContext(ctx, codexPath, "--version")
data, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("detect codex version: %w", err)
}
return strings.TrimSpace(string(data)), nil
}
func resolveDaemonConfigPath(raw string) (string, error) {
if raw != "" {
return filepath.Abs(raw)
}
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve daemon config path: %w", err)
}
return filepath.Join(home, defaultDaemonConfigPath), nil
}
func loadPersistedDaemonConfig(path string) (daemonPersistedConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return daemonPersistedConfig{}, nil
}
return daemonPersistedConfig{}, fmt.Errorf("read daemon config: %w", err)
}
var cfg daemonPersistedConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return daemonPersistedConfig{}, fmt.Errorf("parse daemon config: %w", err)
}
return cfg, nil
}
func savePersistedDaemonConfig(path string, cfg daemonPersistedConfig) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create daemon config directory: %w", err)
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("encode daemon config: %w", err)
}
if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil {
return fmt.Errorf("write daemon config: %w", err)
}
return nil
}
func clearPersistedDaemonConfig(path string) error {
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("remove daemon config: %w", err)
}
return nil
}
func isWorkspaceNotFoundError(err error) bool {
var reqErr *requestError
if !errors.As(err, &reqErr) {
return false
}
if reqErr.StatusCode != http.StatusNotFound {
return false
}
return strings.Contains(strings.ToLower(reqErr.Body), "workspace not found")
}
func normalizeServerBaseURL(raw string) (string, error) {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return "", fmt.Errorf("invalid MULTICA_SERVER_URL: %w", err)
}
switch u.Scheme {
case "ws":
u.Scheme = "http"
case "wss":
u.Scheme = "https"
case "http", "https":
default:
return "", fmt.Errorf("MULTICA_SERVER_URL must use ws, wss, http, or https")
}
if u.Path == "/ws" {
u.Path = ""
}
u.RawPath = ""
u.RawQuery = ""
u.Fragment = ""
return strings.TrimRight(u.String(), "/"), nil
}
func durationFromEnv(key string, fallback time.Duration) (time.Duration, error) {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback, nil
}
d, err := time.ParseDuration(value)
if err != nil {
return 0, fmt.Errorf("%s: invalid duration %q: %w", key, value, err)
}
return d, nil
}
func envOrDefault(key, fallback string) string {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
return value
}
func sleepWithContext(ctx context.Context, d time.Duration) error {
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func (c *daemonClient) claimTask(ctx context.Context, runtimeID string) (*daemonTask, error) {
var resp struct {
Task *daemonTask `json:"task"`
}
if err := c.postJSON(ctx, fmt.Sprintf("/api/daemon/runtimes/%s/tasks/claim", runtimeID), map[string]any{}, &resp); err != nil {
return nil, err
}
return resp.Task, nil
}
func (c *daemonClient) createPairingSession(ctx context.Context, req map[string]string) (daemonPairingSession, error) {
var resp daemonPairingSession
if err := c.postJSON(ctx, "/api/daemon/pairing-sessions", req, &resp); err != nil {
return daemonPairingSession{}, err
}
return resp, nil
}
func (c *daemonClient) getPairingSession(ctx context.Context, token string) (daemonPairingSession, error) {
var resp daemonPairingSession
if err := c.getJSON(ctx, fmt.Sprintf("/api/daemon/pairing-sessions/%s", url.PathEscape(token)), &resp); err != nil {
return daemonPairingSession{}, err
}
return resp, nil
}
func (c *daemonClient) claimPairingSession(ctx context.Context, token string) (daemonPairingSession, error) {
var resp daemonPairingSession
if err := c.postJSON(ctx, fmt.Sprintf("/api/daemon/pairing-sessions/%s/claim", url.PathEscape(token)), map[string]any{}, &resp); err != nil {
return daemonPairingSession{}, err
}
return resp, nil
}
func (c *daemonClient) startTask(ctx context.Context, taskID string) error {
return c.postJSON(ctx, fmt.Sprintf("/api/daemon/tasks/%s/start", taskID), map[string]any{}, nil)
}
func (c *daemonClient) reportProgress(ctx context.Context, taskID, summary string, step, total int) error {
return c.postJSON(ctx, fmt.Sprintf("/api/daemon/tasks/%s/progress", taskID), map[string]any{
"summary": summary,
"step": step,
"total": total,
}, nil)
}
func (c *daemonClient) completeTask(ctx context.Context, taskID, output string) error {
return c.postJSON(ctx, fmt.Sprintf("/api/daemon/tasks/%s/complete", taskID), map[string]any{
"output": output,
}, nil)
}
func (c *daemonClient) failTask(ctx context.Context, taskID, errMsg string) error {
return c.postJSON(ctx, fmt.Sprintf("/api/daemon/tasks/%s/fail", taskID), map[string]any{
"error": errMsg,
}, nil)
}
func (c *daemonClient) postJSON(ctx context.Context, path string, reqBody any, respBody any) error {
var body io.Reader
if reqBody != nil {
data, err := json.Marshal(reqBody)
if err != nil {
return err
}
body = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, body)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return &requestError{
Method: http.MethodPost,
Path: path,
StatusCode: resp.StatusCode,
Body: strings.TrimSpace(string(data)),
}
}
if respBody == nil {
io.Copy(io.Discard, resp.Body)
return nil
}
return json.NewDecoder(resp.Body).Decode(respBody)
}
func (c *daemonClient) getJSON(ctx context.Context, path string, respBody any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
if err != nil {
return err
}
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return &requestError{
Method: http.MethodGet,
Path: path,
StatusCode: resp.StatusCode,
Body: strings.TrimSpace(string(data)),
}
}
if respBody == nil {
io.Copy(io.Discard, resp.Body)
return nil
}
return json.NewDecoder(resp.Body).Decode(respBody)
}
const codexResultSchema = `{
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["completed", "blocked"]
},
"comment": {
"type": "string"
}
},
"required": ["status", "comment"],
"additionalProperties": false
}`

View file

@ -1,27 +0,0 @@
package main
import (
"context"
"errors"
"log"
"os"
"os/signal"
"syscall"
)
func main() {
cfg, err := loadConfig()
if err != nil {
log.Fatal(err)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
logger := log.New(os.Stdout, "multica-daemon: ", log.LstdFlags)
d := newDaemon(cfg, logger)
if err := d.run(ctx); err != nil && !errors.Is(err, context.Canceled) {
logger.Fatal(err)
}
}

View file

@ -0,0 +1,205 @@
package main
import (
"context"
"fmt"
"net/url"
"os"
"time"
"github.com/spf13/cobra"
"github.com/multica-ai/multica/server/internal/cli"
)
var agentCmd = &cobra.Command{
Use: "agent",
Short: "Manage agents",
}
var agentListCmd = &cobra.Command{
Use: "list",
Short: "List agents in the workspace",
RunE: runAgentList,
}
var agentGetCmd = &cobra.Command{
Use: "get <id>",
Short: "Get agent details",
Args: cobra.ExactArgs(1),
RunE: runAgentGet,
}
var agentDeleteCmd = &cobra.Command{
Use: "delete <id>",
Short: "Delete an agent",
Args: cobra.ExactArgs(1),
RunE: runAgentDelete,
}
var agentStopCmd = &cobra.Command{
Use: "stop <id>",
Short: "Stop an agent (set status to offline)",
Args: cobra.ExactArgs(1),
RunE: runAgentStop,
}
func init() {
agentCmd.AddCommand(agentListCmd)
agentCmd.AddCommand(agentGetCmd)
agentCmd.AddCommand(agentDeleteCmd)
agentCmd.AddCommand(agentStopCmd)
agentListCmd.Flags().String("output", "table", "Output format: table or json")
agentGetCmd.Flags().String("output", "json", "Output format: table or json")
}
func newAPIClient(cmd *cobra.Command) (*cli.APIClient, error) {
serverURL := resolveServerURL(cmd)
workspaceID := resolveWorkspaceID(cmd)
if serverURL == "" {
return nil, fmt.Errorf("server URL not set: use --server-url flag, MULTICA_SERVER_URL env, or 'multica-cli config set server_url <url>'")
}
return cli.NewAPIClient(serverURL, workspaceID), nil
}
func resolveServerURL(cmd *cobra.Command) string {
val := cli.FlagOrEnv(cmd, "server-url", "MULTICA_SERVER_URL", "")
if val != "" {
return val
}
cfg, err := cli.LoadCLIConfig()
if err != nil {
return "http://localhost:8080"
}
if cfg.ServerURL != "" {
return cfg.ServerURL
}
return "http://localhost:8080"
}
func resolveWorkspaceID(cmd *cobra.Command) string {
val := cli.FlagOrEnv(cmd, "workspace-id", "MULTICA_WORKSPACE_ID", "")
if val != "" {
return val
}
cfg, _ := cli.LoadCLIConfig()
if cfg.WorkspaceID != "" {
return cfg.WorkspaceID
}
// Fallback: try daemon.json for workspace_id
return cli.LoadWorkspaceIDFromDaemonConfig()
}
func runAgentList(cmd *cobra.Command, _ []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var agents []map[string]any
path := "/api/agents"
if client.WorkspaceID != "" {
path += "?" + url.Values{"workspace_id": {client.WorkspaceID}}.Encode()
}
if err := client.GetJSON(ctx, path, &agents); err != nil {
return fmt.Errorf("list agents: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, agents)
}
headers := []string{"ID", "NAME", "STATUS", "RUNTIME"}
rows := make([][]string, 0, len(agents))
for _, a := range agents {
rows = append(rows, []string{
strVal(a, "id"),
strVal(a, "name"),
strVal(a, "status"),
strVal(a, "runtime_mode"),
})
}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
func runAgentGet(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var agent map[string]any
if err := client.GetJSON(ctx, "/api/agents/"+args[0], &agent); err != nil {
return fmt.Errorf("get agent: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "table" {
headers := []string{"ID", "NAME", "STATUS", "RUNTIME", "DESCRIPTION"}
rows := [][]string{{
strVal(agent, "id"),
strVal(agent, "name"),
strVal(agent, "status"),
strVal(agent, "runtime_mode"),
strVal(agent, "description"),
}}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}
return cli.PrintJSON(os.Stdout, agent)
}
func runAgentDelete(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := client.DeleteJSON(ctx, "/api/agents/"+args[0]); err != nil {
return fmt.Errorf("delete agent: %w", err)
}
fmt.Fprintf(os.Stderr, "Agent %s deleted.\n", args[0])
return nil
}
func runAgentStop(cmd *cobra.Command, args []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
body := map[string]any{"status": "offline"}
if err := client.PutJSON(ctx, "/api/agents/"+args[0], body, nil); err != nil {
return fmt.Errorf("stop agent: %w", err)
}
fmt.Fprintf(os.Stderr, "Agent %s stopped.\n", args[0])
return nil
}
func strVal(m map[string]any, key string) string {
v, ok := m[key]
if !ok || v == nil {
return ""
}
return fmt.Sprintf("%v", v)
}

View file

@ -0,0 +1,79 @@
package main
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/multica-ai/multica/server/internal/cli"
)
var configCmd = &cobra.Command{
Use: "config",
Short: "Manage CLI configuration",
}
var configShowCmd = &cobra.Command{
Use: "show",
Short: "Show current CLI configuration",
RunE: runConfigShow,
}
var configSetCmd = &cobra.Command{
Use: "set <key> <value>",
Short: "Set a CLI configuration value",
Long: "Supported keys: server_url, workspace_id",
Args: cobra.ExactArgs(2),
RunE: runConfigSet,
}
func init() {
configCmd.AddCommand(configShowCmd)
configCmd.AddCommand(configSetCmd)
}
func runConfigShow(_ *cobra.Command, _ []string) error {
cfg, err := cli.LoadCLIConfig()
if err != nil {
return err
}
path, _ := cli.CLIConfigPath()
fmt.Fprintf(os.Stdout, "Config file: %s\n", path)
fmt.Fprintf(os.Stdout, "server_url: %s\n", valueOrDefault(cfg.ServerURL, "(not set)"))
fmt.Fprintf(os.Stdout, "workspace_id: %s\n", valueOrDefault(cfg.WorkspaceID, "(not set)"))
return nil
}
func runConfigSet(_ *cobra.Command, args []string) error {
key, value := args[0], args[1]
cfg, err := cli.LoadCLIConfig()
if err != nil {
return err
}
switch key {
case "server_url":
cfg.ServerURL = value
case "workspace_id":
cfg.WorkspaceID = value
default:
return fmt.Errorf("unknown config key %q (supported: server_url, workspace_id)", key)
}
if err := cli.SaveCLIConfig(cfg); err != nil {
return err
}
fmt.Fprintf(os.Stderr, "Set %s = %s\n", key, value)
return nil
}
func valueOrDefault(v, fallback string) string {
if v == "" {
return fallback
}
return v
}

View file

@ -0,0 +1,77 @@
package main
import (
"context"
"errors"
"log"
"os"
"os/signal"
"syscall"
"github.com/spf13/cobra"
"github.com/multica-ai/multica/server/internal/cli"
"github.com/multica-ai/multica/server/internal/daemon"
)
var daemonCmd = &cobra.Command{
Use: "daemon",
Short: "Run the local agent runtime daemon",
Long: "Start the daemon process that polls for tasks and executes them using local agent CLIs (Claude, Codex).",
RunE: runDaemon,
}
func init() {
f := daemonCmd.Flags()
f.String("repos-root", "", "Base directory for task repositories (env: MULTICA_REPOS_ROOT)")
f.String("config-path", "", "Path to daemon config file (env: MULTICA_DAEMON_CONFIG)")
f.String("daemon-id", "", "Unique daemon identifier (env: MULTICA_DAEMON_ID)")
f.String("device-name", "", "Human-readable device name (env: MULTICA_DAEMON_DEVICE_NAME)")
f.String("runtime-name", "", "Runtime display name (env: MULTICA_AGENT_RUNTIME_NAME)")
f.Duration("poll-interval", 0, "Task poll interval (env: MULTICA_DAEMON_POLL_INTERVAL)")
f.Duration("heartbeat-interval", 0, "Heartbeat interval (env: MULTICA_DAEMON_HEARTBEAT_INTERVAL)")
f.Duration("agent-timeout", 0, "Per-task timeout (env: MULTICA_AGENT_TIMEOUT)")
}
func runDaemon(cmd *cobra.Command, _ []string) error {
overrides := daemon.Overrides{
ServerURL: cli.FlagOrEnv(cmd, "server-url", "MULTICA_SERVER_URL", ""),
WorkspaceID: cli.FlagOrEnv(cmd, "workspace-id", "MULTICA_WORKSPACE_ID", ""),
ReposRoot: flagString(cmd, "repos-root"),
ConfigPath: flagString(cmd, "config-path"),
DaemonID: flagString(cmd, "daemon-id"),
DeviceName: flagString(cmd, "device-name"),
RuntimeName: flagString(cmd, "runtime-name"),
}
if d, _ := cmd.Flags().GetDuration("poll-interval"); d > 0 {
overrides.PollInterval = d
}
if d, _ := cmd.Flags().GetDuration("heartbeat-interval"); d > 0 {
overrides.HeartbeatInterval = d
}
if d, _ := cmd.Flags().GetDuration("agent-timeout"); d > 0 {
overrides.AgentTimeout = d
}
cfg, err := daemon.LoadConfig(overrides)
if err != nil {
return err
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
logger := log.New(os.Stdout, "multica-daemon: ", log.LstdFlags)
d := daemon.New(cfg, logger)
if err := d.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
return err
}
return nil
}
func flagString(cmd *cobra.Command, name string) string {
val, _ := cmd.Flags().GetString(name)
return val
}

View file

@ -0,0 +1,63 @@
package main
import (
"context"
"fmt"
"os"
"time"
"github.com/spf13/cobra"
"github.com/multica-ai/multica/server/internal/cli"
)
var runtimeCmd = &cobra.Command{
Use: "runtime",
Short: "Manage agent runtimes",
}
var runtimeListCmd = &cobra.Command{
Use: "list",
Short: "List agent runtimes",
RunE: runRuntimeList,
}
func init() {
runtimeCmd.AddCommand(runtimeListCmd)
runtimeListCmd.Flags().String("output", "table", "Output format: table or json")
}
func runRuntimeList(cmd *cobra.Command, _ []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var runtimes []map[string]any
if err := client.GetJSON(ctx, "/api/runtimes", &runtimes); err != nil {
return fmt.Errorf("list runtimes: %w", err)
}
output, _ := cmd.Flags().GetString("output")
if output == "json" {
return cli.PrintJSON(os.Stdout, runtimes)
}
headers := []string{"ID", "NAME", "PROVIDER", "STATUS", "DEVICE"}
rows := make([][]string, 0, len(runtimes))
for _, r := range runtimes {
rows = append(rows, []string{
strVal(r, "id"),
strVal(r, "name"),
strVal(r, "provider"),
strVal(r, "status"),
strVal(r, "device_info"),
})
}
cli.PrintTable(os.Stdout, headers, rows)
return nil
}

View file

@ -0,0 +1,36 @@
package main
import (
"context"
"fmt"
"os"
"time"
"github.com/spf13/cobra"
)
var statusCmd = &cobra.Command{
Use: "status",
Short: "Check server health",
RunE: runStatus,
}
func runStatus(cmd *cobra.Command, _ []string) error {
client, err := newAPIClient(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
body, err := client.HealthCheck(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "Server unreachable: %v\n", err)
return err
}
fmt.Fprintf(os.Stdout, "Server: %s\n", client.BaseURL)
fmt.Fprintf(os.Stdout, "Status: %s\n", body)
return nil
}

View file

@ -0,0 +1,15 @@
package main
import (
"fmt"
"github.com/spf13/cobra"
)
var versionCmd = &cobra.Command{
Use: "version",
Short: "Print version information",
Run: func(_ *cobra.Command, _ []string) {
fmt.Printf("multica-cli %s (commit: %s)\n", version, commit)
},
}

View file

@ -0,0 +1,38 @@
package main
import (
"os"
"github.com/spf13/cobra"
)
var (
version = "dev"
commit = "unknown"
)
var rootCmd = &cobra.Command{
Use: "multica-cli",
Short: "Multica CLI — local agent runtime and management tool",
Long: "multica-cli manages local agent runtimes and provides control commands for the Multica platform.",
SilenceUsage: true,
SilenceErrors: true,
}
func init() {
rootCmd.PersistentFlags().String("server-url", "", "Multica server URL (env: MULTICA_SERVER_URL)")
rootCmd.PersistentFlags().String("workspace-id", "", "Workspace ID (env: MULTICA_WORKSPACE_ID)")
rootCmd.AddCommand(daemonCmd)
rootCmd.AddCommand(agentCmd)
rootCmd.AddCommand(runtimeCmd)
rootCmd.AddCommand(configCmd)
rootCmd.AddCommand(statusCmd)
rootCmd.AddCommand(versionCmd)
}
func main() {
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}

View file

@ -5,16 +5,18 @@ go 1.26.1
require (
github.com/go-chi/chi/v5 v5.2.5
github.com/go-chi/cors v1.2.2
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.8.0
github.com/spf13/cobra v1.10.2
)
require (
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.8.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/crypto v0.49.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.35.0 // indirect
)

View file

@ -1,4 +1,7 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
@ -7,6 +10,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63Y
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@ -15,15 +20,24 @@ github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -0,0 +1,129 @@
package cli
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// APIClient is a REST client for the Multica server API.
// Used by ctrl subcommands (agent, runtime, status, etc.).
type APIClient struct {
BaseURL string
WorkspaceID string
HTTPClient *http.Client
}
// NewAPIClient creates a new API client for ctrl commands.
func NewAPIClient(baseURL, workspaceID string) *APIClient {
return &APIClient{
BaseURL: strings.TrimRight(baseURL, "/"),
WorkspaceID: workspaceID,
HTTPClient: &http.Client{Timeout: 15 * time.Second},
}
}
// GetJSON performs a GET request and decodes the JSON response.
func (c *APIClient) GetJSON(ctx context.Context, path string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil)
if err != nil {
return err
}
if c.WorkspaceID != "" {
req.Header.Set("X-Workspace-ID", c.WorkspaceID)
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("GET %s returned %d: %s", path, resp.StatusCode, strings.TrimSpace(string(data)))
}
if out == nil {
return nil
}
return json.NewDecoder(resp.Body).Decode(out)
}
// DeleteJSON performs a DELETE request.
func (c *APIClient) DeleteJSON(ctx context.Context, path string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.BaseURL+path, nil)
if err != nil {
return err
}
if c.WorkspaceID != "" {
req.Header.Set("X-Workspace-ID", c.WorkspaceID)
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("DELETE %s returned %d: %s", path, resp.StatusCode, strings.TrimSpace(string(data)))
}
return nil
}
// PutJSON performs a PUT request with a JSON body.
func (c *APIClient) PutJSON(ctx context.Context, path string, body any, out any) error {
data, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPut, c.BaseURL+path, bytes.NewReader(data))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if c.WorkspaceID != "" {
req.Header.Set("X-Workspace-ID", c.WorkspaceID)
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
respData, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("PUT %s returned %d: %s", path, resp.StatusCode, strings.TrimSpace(string(respData)))
}
if out == nil {
return nil
}
return json.NewDecoder(resp.Body).Decode(out)
}
// HealthCheck hits the /health endpoint and returns the response body.
func (c *APIClient) HealthCheck(ctx context.Context) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/health", nil)
if err != nil {
return "", err
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if resp.StatusCode >= 400 {
return "", fmt.Errorf("health check returned %d: %s", resp.StatusCode, strings.TrimSpace(string(data)))
}
return strings.TrimSpace(string(data)), nil
}

View file

@ -0,0 +1,85 @@
package cli
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
)
const defaultCLIConfigPath = ".multica/config.json"
// CLIConfig holds persistent CLI settings.
type CLIConfig struct {
ServerURL string `json:"server_url,omitempty"`
WorkspaceID string `json:"workspace_id,omitempty"`
}
// CLIConfigPath returns the default path for the CLI config file.
func CLIConfigPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve CLI config path: %w", err)
}
return filepath.Join(home, defaultCLIConfigPath), nil
}
// LoadCLIConfig reads the CLI config from disk.
func LoadCLIConfig() (CLIConfig, error) {
path, err := CLIConfigPath()
if err != nil {
return CLIConfig{}, err
}
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return CLIConfig{}, nil
}
return CLIConfig{}, fmt.Errorf("read CLI config: %w", err)
}
var cfg CLIConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return CLIConfig{}, fmt.Errorf("parse CLI config: %w", err)
}
return cfg, nil
}
// LoadWorkspaceIDFromDaemonConfig reads workspace_id from ~/.multica/daemon.json
// as a fallback when it's not set in the CLI config.
func LoadWorkspaceIDFromDaemonConfig() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
data, err := os.ReadFile(filepath.Join(home, ".multica/daemon.json"))
if err != nil {
return ""
}
var cfg struct {
WorkspaceID string `json:"workspace_id"`
}
if json.Unmarshal(data, &cfg) != nil {
return ""
}
return cfg.WorkspaceID
}
// SaveCLIConfig writes the CLI config to disk.
func SaveCLIConfig(cfg CLIConfig) error {
path, err := CLIConfigPath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create CLI config directory: %w", err)
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("encode CLI config: %w", err)
}
if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil {
return fmt.Errorf("write CLI config: %w", err)
}
return nil
}

View file

@ -0,0 +1,21 @@
package cli
import (
"os"
"strings"
"github.com/spf13/cobra"
)
// FlagOrEnv returns the flag value if set, otherwise the environment variable value,
// otherwise the fallback.
func FlagOrEnv(cmd *cobra.Command, flagName, envKey, fallback string) string {
if cmd.Flags().Changed(flagName) {
val, _ := cmd.Flags().GetString(flagName)
return val
}
if v := strings.TrimSpace(os.Getenv(envKey)); v != "" {
return v
}
return fallback
}

View file

@ -0,0 +1,26 @@
package cli
import (
"encoding/json"
"fmt"
"io"
"strings"
"text/tabwriter"
)
// PrintTable writes a simple table with headers and rows to w.
func PrintTable(w io.Writer, headers []string, rows [][]string) {
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, strings.Join(headers, "\t"))
for _, row := range rows {
fmt.Fprintln(tw, strings.Join(row, "\t"))
}
tw.Flush()
}
// PrintJSON writes v as indented JSON to w.
func PrintJSON(w io.Writer, v any) error {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(v)
}

View file

@ -0,0 +1,182 @@
package daemon
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// requestError is returned by postJSON/getJSON when the server responds with an error status.
type requestError struct {
Method string
Path string
StatusCode int
Body string
}
func (e *requestError) Error() string {
return fmt.Sprintf("%s %s returned %d: %s", e.Method, e.Path, e.StatusCode, e.Body)
}
// isWorkspaceNotFoundError returns true if the error is a 404 with "workspace not found" body.
func isWorkspaceNotFoundError(err error) bool {
var reqErr *requestError
if !errors.As(err, &reqErr) {
return false
}
if reqErr.StatusCode != http.StatusNotFound {
return false
}
return strings.Contains(strings.ToLower(reqErr.Body), "workspace not found")
}
// Client handles HTTP communication with the Multica server daemon API.
type Client struct {
baseURL string
client *http.Client
}
// NewClient creates a new daemon API client.
func NewClient(baseURL string) *Client {
return &Client{
baseURL: baseURL,
client: &http.Client{Timeout: 30 * time.Second},
}
}
func (c *Client) ClaimTask(ctx context.Context, runtimeID string) (*Task, error) {
var resp struct {
Task *Task `json:"task"`
}
if err := c.postJSON(ctx, fmt.Sprintf("/api/daemon/runtimes/%s/tasks/claim", runtimeID), map[string]any{}, &resp); err != nil {
return nil, err
}
return resp.Task, nil
}
func (c *Client) CreatePairingSession(ctx context.Context, req map[string]string) (PairingSession, error) {
var resp PairingSession
if err := c.postJSON(ctx, "/api/daemon/pairing-sessions", req, &resp); err != nil {
return PairingSession{}, err
}
return resp, nil
}
func (c *Client) GetPairingSession(ctx context.Context, token string) (PairingSession, error) {
var resp PairingSession
if err := c.getJSON(ctx, fmt.Sprintf("/api/daemon/pairing-sessions/%s", url.PathEscape(token)), &resp); err != nil {
return PairingSession{}, err
}
return resp, nil
}
func (c *Client) ClaimPairingSession(ctx context.Context, token string) (PairingSession, error) {
var resp PairingSession
if err := c.postJSON(ctx, fmt.Sprintf("/api/daemon/pairing-sessions/%s/claim", url.PathEscape(token)), map[string]any{}, &resp); err != nil {
return PairingSession{}, err
}
return resp, nil
}
func (c *Client) StartTask(ctx context.Context, taskID string) error {
return c.postJSON(ctx, fmt.Sprintf("/api/daemon/tasks/%s/start", taskID), map[string]any{}, nil)
}
func (c *Client) ReportProgress(ctx context.Context, taskID, summary string, step, total int) error {
return c.postJSON(ctx, fmt.Sprintf("/api/daemon/tasks/%s/progress", taskID), map[string]any{
"summary": summary,
"step": step,
"total": total,
}, nil)
}
func (c *Client) CompleteTask(ctx context.Context, taskID, output string) error {
return c.postJSON(ctx, fmt.Sprintf("/api/daemon/tasks/%s/complete", taskID), map[string]any{
"output": output,
}, nil)
}
func (c *Client) FailTask(ctx context.Context, taskID, errMsg string) error {
return c.postJSON(ctx, fmt.Sprintf("/api/daemon/tasks/%s/fail", taskID), map[string]any{
"error": errMsg,
}, nil)
}
func (c *Client) SendHeartbeat(ctx context.Context, runtimeID string) error {
return c.postJSON(ctx, "/api/daemon/heartbeat", map[string]string{
"runtime_id": runtimeID,
}, nil)
}
func (c *Client) Register(ctx context.Context, req map[string]any) ([]Runtime, error) {
var resp struct {
Runtimes []Runtime `json:"runtimes"`
}
if err := c.postJSON(ctx, "/api/daemon/register", req, &resp); err != nil {
return nil, err
}
return resp.Runtimes, nil
}
func (c *Client) postJSON(ctx context.Context, path string, reqBody any, respBody any) error {
var body io.Reader
if reqBody != nil {
data, err := json.Marshal(reqBody)
if err != nil {
return err
}
body = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, body)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return &requestError{Method: http.MethodPost, Path: path, StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(data))}
}
if respBody == nil {
io.Copy(io.Discard, resp.Body)
return nil
}
return json.NewDecoder(resp.Body).Decode(respBody)
}
func (c *Client) getJSON(ctx context.Context, path string, respBody any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
if err != nil {
return err
}
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return &requestError{Method: http.MethodGet, Path: path, StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(data))}
}
if respBody == nil {
io.Copy(io.Discard, resp.Body)
return nil
}
return json.NewDecoder(resp.Body).Decode(respBody)
}

View file

@ -0,0 +1,254 @@
package daemon
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
const (
DefaultServerURL = "ws://localhost:8080/ws"
DefaultDaemonConfigPath = ".multica/daemon.json"
DefaultPollInterval = 3 * time.Second
DefaultHeartbeatInterval = 15 * time.Second
DefaultAgentTimeout = 20 * time.Minute
DefaultRuntimeName = "Local Agent"
)
// Config holds all daemon configuration.
type Config struct {
ServerBaseURL string
ConfigPath string
WorkspaceID string
DaemonID string
DeviceName string
RuntimeName string
Agents map[string]AgentEntry // "claude" -> entry, "codex" -> entry
ReposRoot string // parent directory containing all repos
PollInterval time.Duration
HeartbeatInterval time.Duration
AgentTimeout time.Duration
}
// Overrides allows CLI flags to override environment variables and defaults.
// Zero values are ignored and the env/default value is used instead.
type Overrides struct {
ServerURL string
WorkspaceID string
ReposRoot string
ConfigPath string
PollInterval time.Duration
HeartbeatInterval time.Duration
AgentTimeout time.Duration
DaemonID string
DeviceName string
RuntimeName string
}
// LoadConfig builds the daemon configuration from environment variables,
// persisted config, and optional CLI flag overrides.
func LoadConfig(overrides Overrides) (Config, error) {
// Server URL: override > env > default
rawServerURL := envOrDefault("MULTICA_SERVER_URL", DefaultServerURL)
if overrides.ServerURL != "" {
rawServerURL = overrides.ServerURL
}
serverBaseURL, err := NormalizeServerBaseURL(rawServerURL)
if err != nil {
return Config{}, err
}
// Config path
rawConfigPath := strings.TrimSpace(os.Getenv("MULTICA_DAEMON_CONFIG"))
if overrides.ConfigPath != "" {
rawConfigPath = overrides.ConfigPath
}
configPath, err := resolveDaemonConfigPath(rawConfigPath)
if err != nil {
return Config{}, err
}
// Load persisted config
persisted, err := LoadPersistedConfig(configPath)
if err != nil {
return Config{}, err
}
// Workspace ID: override > env > persisted
workspaceID := strings.TrimSpace(os.Getenv("MULTICA_WORKSPACE_ID"))
if workspaceID == "" {
workspaceID = persisted.WorkspaceID
}
if overrides.WorkspaceID != "" {
workspaceID = overrides.WorkspaceID
}
// Probe available agent CLIs
agents := map[string]AgentEntry{}
claudePath := envOrDefault("MULTICA_CLAUDE_PATH", "claude")
if _, err := exec.LookPath(claudePath); err == nil {
agents["claude"] = AgentEntry{
Path: claudePath,
Model: strings.TrimSpace(os.Getenv("MULTICA_CLAUDE_MODEL")),
}
}
codexPath := envOrDefault("MULTICA_CODEX_PATH", "codex")
if _, err := exec.LookPath(codexPath); err == nil {
agents["codex"] = AgentEntry{
Path: codexPath,
Model: strings.TrimSpace(os.Getenv("MULTICA_CODEX_MODEL")),
}
}
if len(agents) == 0 {
return Config{}, fmt.Errorf("no agent CLI found: install claude or codex and ensure it is on PATH")
}
// Host info
host, err := os.Hostname()
if err != nil || strings.TrimSpace(host) == "" {
host = "local-machine"
}
// Repos root: override > env > cwd
reposRoot := strings.TrimSpace(os.Getenv("MULTICA_REPOS_ROOT"))
if overrides.ReposRoot != "" {
reposRoot = overrides.ReposRoot
}
if reposRoot == "" {
reposRoot, err = os.Getwd()
if err != nil {
return Config{}, fmt.Errorf("resolve working directory: %w", err)
}
}
reposRoot, err = filepath.Abs(reposRoot)
if err != nil {
return Config{}, fmt.Errorf("resolve absolute repos root: %w", err)
}
// Durations: override > env > default
pollInterval, err := durationFromEnv("MULTICA_DAEMON_POLL_INTERVAL", DefaultPollInterval)
if err != nil {
return Config{}, err
}
if overrides.PollInterval > 0 {
pollInterval = overrides.PollInterval
}
heartbeatInterval, err := durationFromEnv("MULTICA_DAEMON_HEARTBEAT_INTERVAL", DefaultHeartbeatInterval)
if err != nil {
return Config{}, err
}
if overrides.HeartbeatInterval > 0 {
heartbeatInterval = overrides.HeartbeatInterval
}
agentTimeout, err := durationFromEnv("MULTICA_AGENT_TIMEOUT", DefaultAgentTimeout)
if err != nil {
return Config{}, err
}
if overrides.AgentTimeout > 0 {
agentTimeout = overrides.AgentTimeout
}
// String overrides
daemonID := envOrDefault("MULTICA_DAEMON_ID", host)
if overrides.DaemonID != "" {
daemonID = overrides.DaemonID
}
deviceName := envOrDefault("MULTICA_DAEMON_DEVICE_NAME", host)
if overrides.DeviceName != "" {
deviceName = overrides.DeviceName
}
runtimeName := envOrDefault("MULTICA_AGENT_RUNTIME_NAME", DefaultRuntimeName)
if overrides.RuntimeName != "" {
runtimeName = overrides.RuntimeName
}
return Config{
ServerBaseURL: serverBaseURL,
ConfigPath: configPath,
WorkspaceID: workspaceID,
DaemonID: daemonID,
DeviceName: deviceName,
RuntimeName: runtimeName,
Agents: agents,
ReposRoot: reposRoot,
PollInterval: pollInterval,
HeartbeatInterval: heartbeatInterval,
AgentTimeout: agentTimeout,
}, nil
}
// NormalizeServerBaseURL converts a WebSocket or HTTP URL to a base HTTP URL.
func NormalizeServerBaseURL(raw string) (string, error) {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return "", fmt.Errorf("invalid MULTICA_SERVER_URL: %w", err)
}
switch u.Scheme {
case "ws":
u.Scheme = "http"
case "wss":
u.Scheme = "https"
case "http", "https":
default:
return "", fmt.Errorf("MULTICA_SERVER_URL must use ws, wss, http, or https")
}
if u.Path == "/ws" {
u.Path = ""
}
u.RawPath = ""
u.RawQuery = ""
u.Fragment = ""
return strings.TrimRight(u.String(), "/"), nil
}
func resolveDaemonConfigPath(raw string) (string, error) {
if raw != "" {
return filepath.Abs(raw)
}
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve daemon config path: %w", err)
}
return filepath.Join(home, DefaultDaemonConfigPath), nil
}
// LoadPersistedConfig reads the daemon config from disk.
func LoadPersistedConfig(path string) (PersistedConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return PersistedConfig{}, nil
}
return PersistedConfig{}, fmt.Errorf("read daemon config: %w", err)
}
var cfg PersistedConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return PersistedConfig{}, fmt.Errorf("parse daemon config: %w", err)
}
return cfg, nil
}
// SavePersistedConfig writes the daemon config to disk.
func SavePersistedConfig(path string, cfg PersistedConfig) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create daemon config directory: %w", err)
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("encode daemon config: %w", err)
}
if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil {
return fmt.Errorf("write daemon config: %w", err)
}
return nil
}

View file

@ -0,0 +1,328 @@
package daemon
import (
"context"
"fmt"
"log"
"strings"
"time"
"github.com/multica-ai/multica/server/pkg/agent"
)
// Daemon is the local agent runtime that polls for and executes tasks.
type Daemon struct {
cfg Config
client *Client
logger *log.Logger
}
// New creates a new Daemon instance.
func New(cfg Config, logger *log.Logger) *Daemon {
return &Daemon{
cfg: cfg,
client: NewClient(cfg.ServerBaseURL),
logger: logger,
}
}
// Run starts the daemon: pairs if needed, registers runtimes, then polls for tasks.
func (d *Daemon) Run(ctx context.Context) error {
agentNames := make([]string, 0, len(d.cfg.Agents))
for name := range d.cfg.Agents {
agentNames = append(agentNames, name)
}
d.logger.Printf("starting daemon agents=%v workspace=%s server=%s repos_root=%s",
agentNames, d.cfg.WorkspaceID, d.cfg.ServerBaseURL, d.cfg.ReposRoot)
if strings.TrimSpace(d.cfg.WorkspaceID) == "" {
workspaceID, err := d.ensurePaired(ctx)
if err != nil {
return err
}
d.cfg.WorkspaceID = workspaceID
d.logger.Printf("pairing completed for workspace=%s", workspaceID)
}
runtimes, err := d.registerRuntimes(ctx)
if err != nil {
return err
}
runtimeIDs := make([]string, 0, len(runtimes))
for _, rt := range runtimes {
d.logger.Printf("registered runtime id=%s provider=%s status=%s", rt.ID, rt.Provider, rt.Status)
runtimeIDs = append(runtimeIDs, rt.ID)
}
go d.heartbeatLoop(ctx, runtimeIDs)
return d.pollLoop(ctx, runtimeIDs)
}
func (d *Daemon) registerRuntimes(ctx context.Context) ([]Runtime, error) {
var runtimes []map[string]string
for name, entry := range d.cfg.Agents {
version, err := agent.DetectVersion(ctx, entry.Path)
if err != nil {
d.logger.Printf("skip registering %s: %v", name, err)
continue
}
runtimes = append(runtimes, map[string]string{
"name": fmt.Sprintf("Local %s", strings.ToUpper(name[:1])+name[1:]),
"type": name,
"version": version,
"status": "online",
})
}
if len(runtimes) == 0 {
return nil, fmt.Errorf("no agent runtimes could be registered")
}
req := map[string]any{
"workspace_id": d.cfg.WorkspaceID,
"daemon_id": d.cfg.DaemonID,
"device_name": d.cfg.DeviceName,
"runtimes": runtimes,
}
rts, err := d.client.Register(ctx, req)
if err != nil {
return nil, fmt.Errorf("register runtimes: %w", err)
}
if len(rts) == 0 {
return nil, fmt.Errorf("register runtimes: empty response")
}
return rts, nil
}
func (d *Daemon) ensurePaired(ctx context.Context) (string, error) {
// Use a deterministic agent for the pairing session metadata (prefer codex for backward compat).
var firstName string
var firstEntry AgentEntry
for _, preferred := range []string{"codex", "claude"} {
if entry, ok := d.cfg.Agents[preferred]; ok {
firstName = preferred
firstEntry = entry
break
}
}
version, err := agent.DetectVersion(ctx, firstEntry.Path)
if err != nil {
return "", err
}
session, err := d.client.CreatePairingSession(ctx, map[string]string{
"daemon_id": d.cfg.DaemonID,
"device_name": d.cfg.DeviceName,
"runtime_name": d.cfg.RuntimeName,
"runtime_type": firstName,
"runtime_version": version,
})
if err != nil {
return "", fmt.Errorf("create pairing session: %w", err)
}
if session.LinkURL != nil {
d.logger.Printf("open this link to pair the daemon: %s", *session.LinkURL)
} else {
d.logger.Printf("pairing session created: %s", session.Token)
}
for {
select {
case <-ctx.Done():
return "", ctx.Err()
default:
}
current, err := d.client.GetPairingSession(ctx, session.Token)
if err != nil {
return "", fmt.Errorf("poll pairing session: %w", err)
}
switch current.Status {
case "approved", "claimed":
if current.WorkspaceID == nil || strings.TrimSpace(*current.WorkspaceID) == "" {
return "", fmt.Errorf("pairing session approved without workspace")
}
if err := SavePersistedConfig(d.cfg.ConfigPath, PersistedConfig{
WorkspaceID: strings.TrimSpace(*current.WorkspaceID),
}); err != nil {
return "", err
}
if current.Status != "claimed" {
if _, err := d.client.ClaimPairingSession(ctx, current.Token); err != nil {
return "", fmt.Errorf("claim pairing session: %w", err)
}
}
return strings.TrimSpace(*current.WorkspaceID), nil
case "expired":
return "", fmt.Errorf("pairing session expired before approval")
}
if err := sleepWithContext(ctx, d.cfg.PollInterval); err != nil {
return "", err
}
}
}
func (d *Daemon) heartbeatLoop(ctx context.Context, runtimeIDs []string) {
ticker := time.NewTicker(d.cfg.HeartbeatInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
for _, rid := range runtimeIDs {
if err := d.client.SendHeartbeat(ctx, rid); err != nil {
d.logger.Printf("heartbeat failed for runtime %s: %v", rid, err)
}
}
}
}
}
func (d *Daemon) pollLoop(ctx context.Context, runtimeIDs []string) error {
pollOffset := 0
pollCount := 0
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
claimed := false
n := len(runtimeIDs)
for i := 0; i < n; i++ {
rid := runtimeIDs[(pollOffset+i)%n]
task, err := d.client.ClaimTask(ctx, rid)
if err != nil {
d.logger.Printf("claim task failed for runtime %s: %v", rid, err)
continue
}
if task != nil {
d.logger.Printf("poll: got task=%s issue=%s title=%q", task.ID, task.IssueID, task.Context.Issue.Title)
d.handleTask(ctx, *task)
claimed = true
pollOffset = (pollOffset + i + 1) % n
break
}
}
if !claimed {
pollCount++
if pollCount%20 == 1 {
d.logger.Printf("poll: no tasks (runtimes=%v, cycle=%d)", runtimeIDs, pollCount)
}
pollOffset = (pollOffset + 1) % n
if err := sleepWithContext(ctx, d.cfg.PollInterval); err != nil {
return err
}
} else {
pollCount = 0
}
}
}
func (d *Daemon) handleTask(ctx context.Context, task Task) {
provider := task.Context.Runtime.Provider
d.logger.Printf("picked task=%s issue=%s provider=%s title=%q", task.ID, task.IssueID, provider, task.Context.Issue.Title)
if err := d.client.StartTask(ctx, task.ID); err != nil {
d.logger.Printf("start task %s failed: %v", task.ID, err)
return
}
_ = d.client.ReportProgress(ctx, task.ID, fmt.Sprintf("Launching %s", provider), 1, 2)
result, err := d.runTask(ctx, task)
if err != nil {
d.logger.Printf("task %s failed: %v", task.ID, err)
if failErr := d.client.FailTask(ctx, task.ID, err.Error()); failErr != nil {
d.logger.Printf("fail task %s callback failed: %v", task.ID, failErr)
}
return
}
_ = d.client.ReportProgress(ctx, task.ID, "Finishing task", 2, 2)
switch result.Status {
case "blocked":
if err := d.client.FailTask(ctx, task.ID, result.Comment); err != nil {
d.logger.Printf("report blocked task %s failed: %v", task.ID, err)
}
default:
d.logger.Printf("task %s completed status=%s", task.ID, result.Status)
if err := d.client.CompleteTask(ctx, task.ID, result.Comment); err != nil {
d.logger.Printf("complete task %s failed: %v", task.ID, err)
}
}
}
func (d *Daemon) runTask(ctx context.Context, task Task) (TaskResult, error) {
provider := task.Context.Runtime.Provider
entry, ok := d.cfg.Agents[provider]
if !ok {
return TaskResult{}, fmt.Errorf("no agent configured for provider %q", provider)
}
workdir, err := ResolveTaskWorkdir(d.cfg.ReposRoot, task.Context.Issue.Repository)
if err != nil {
return TaskResult{}, err
}
prompt := BuildPrompt(task, workdir)
backend, err := agent.New(provider, agent.Config{
ExecutablePath: entry.Path,
Logger: d.logger,
})
if err != nil {
return TaskResult{}, fmt.Errorf("create agent backend: %w", err)
}
d.logger.Printf(
"starting %s task=%s workdir=%s model=%s timeout=%s",
provider, task.ID, workdir, entry.Model, d.cfg.AgentTimeout,
)
session, err := backend.Execute(ctx, prompt, agent.ExecOptions{
Cwd: workdir,
Model: entry.Model,
Timeout: d.cfg.AgentTimeout,
})
if err != nil {
return TaskResult{}, err
}
// Drain message channel (log tool uses, ignore text since Result has output)
go func() {
for msg := range session.Messages {
switch msg.Type {
case agent.MessageToolUse:
d.logger.Printf("[%s] tool-use: %s (call=%s)", provider, msg.Tool, msg.CallID)
case agent.MessageError:
d.logger.Printf("[%s] error: %s", provider, msg.Content)
}
}
}()
result := <-session.Result
switch result.Status {
case "completed":
if result.Output == "" {
return TaskResult{}, fmt.Errorf("%s returned empty output", provider)
}
return TaskResult{Status: "completed", Comment: result.Output}, nil
case "timeout":
return TaskResult{}, fmt.Errorf("%s timed out after %s", provider, d.cfg.AgentTimeout)
default:
errMsg := result.Error
if errMsg == "" {
errMsg = fmt.Sprintf("%s execution %s", provider, result.Status)
}
return TaskResult{Status: "blocked", Comment: errMsg}, nil
}
}

View file

@ -1,4 +1,4 @@
package main
package daemon
import (
"net/http"
@ -11,9 +11,9 @@ import (
func TestNormalizeServerBaseURL(t *testing.T) {
t.Parallel()
got, err := normalizeServerBaseURL("ws://localhost:8080/ws")
got, err := NormalizeServerBaseURL("ws://localhost:8080/ws")
if err != nil {
t.Fatalf("normalizeServerBaseURL returned error: %v", err)
t.Fatalf("NormalizeServerBaseURL returned error: %v", err)
}
if got != "http://localhost:8080" {
t.Fatalf("expected http://localhost:8080, got %s", got)
@ -29,27 +29,27 @@ func TestResolveTaskWorkdirUsesRepoPathWhenPresent(t *testing.T) {
t.Fatalf("mkdir repo: %v", err)
}
got, err := resolveTaskWorkdir(root, &daemonRepoRef{Path: "repo"})
got, err := ResolveTaskWorkdir(root, &RepoRef{Path: "repo"})
if err != nil {
t.Fatalf("resolveTaskWorkdir returned error: %v", err)
t.Fatalf("ResolveTaskWorkdir returned error: %v", err)
}
if got != repoPath {
t.Fatalf("expected %s, got %s", repoPath, got)
}
}
func TestBuildCodexPromptIncludesIssueAndSkills(t *testing.T) {
func TestBuildPromptIncludesIssueAndSkills(t *testing.T) {
t.Parallel()
prompt := buildCodexPrompt(daemonTask{
Context: daemonTaskContext{
Issue: daemonIssueContext{
prompt := BuildPrompt(Task{
Context: TaskContext{
Issue: IssueContext{
Title: "Fix failing test",
Description: "Investigate and fix the test failure.",
AcceptanceCriteria: []string{"tests pass"},
ContextRefs: []string{"log snippet"},
},
Agent: daemonAgentContext{
Agent: AgentContext{
Name: "Local Codex",
Skills: "Be concise.",
},

View file

@ -0,0 +1,41 @@
package daemon
import (
"context"
"fmt"
"os"
"strings"
"time"
)
func envOrDefault(key, fallback string) string {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
return value
}
func durationFromEnv(key string, fallback time.Duration) (time.Duration, error) {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback, nil
}
d, err := time.ParseDuration(value)
if err != nil {
return 0, fmt.Errorf("%s: invalid duration %q: %w", key, value, err)
}
return d, nil
}
func sleepWithContext(ctx context.Context, d time.Duration) error {
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}

View file

@ -0,0 +1,99 @@
package daemon
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// BuildPrompt constructs the task prompt for an agent CLI.
func BuildPrompt(task Task, workdir string) string {
var b strings.Builder
b.WriteString("You are running as a local coding agent for a Multica workspace.\n")
b.WriteString("Complete the assigned issue using the local environment.\n")
b.WriteString("Return a concise Markdown comment suitable for posting back to the issue.\n")
b.WriteString("If you cannot complete the task because context, files, or permissions are missing, return status \"blocked\" and explain the blocker in the comment.\n\n")
fmt.Fprintf(&b, "Working directory: %s\n", workdir)
fmt.Fprintf(&b, "Agent: %s\n", task.Context.Agent.Name)
fmt.Fprintf(&b, "Issue title: %s\n\n", task.Context.Issue.Title)
if task.Context.Issue.Description != "" {
b.WriteString("Issue description:\n")
b.WriteString(task.Context.Issue.Description)
b.WriteString("\n\n")
}
if len(task.Context.Issue.AcceptanceCriteria) > 0 {
b.WriteString("Acceptance criteria:\n")
for _, item := range task.Context.Issue.AcceptanceCriteria {
fmt.Fprintf(&b, "- %s\n", item)
}
b.WriteString("\n")
}
if len(task.Context.Issue.ContextRefs) > 0 {
b.WriteString("Context refs:\n")
for _, item := range task.Context.Issue.ContextRefs {
fmt.Fprintf(&b, "- %s\n", item)
}
b.WriteString("\n")
}
if repo := task.Context.Issue.Repository; repo != nil {
b.WriteString("Repository context:\n")
if repo.URL != "" {
fmt.Fprintf(&b, "- url: %s\n", repo.URL)
}
if repo.Branch != "" {
fmt.Fprintf(&b, "- branch: %s\n", repo.Branch)
}
if repo.Path != "" {
fmt.Fprintf(&b, "- path: %s\n", repo.Path)
}
b.WriteString("\n")
}
if task.Context.WorkspaceContext != "" {
b.WriteString("Workspace context:\n")
b.WriteString(task.Context.WorkspaceContext)
b.WriteString("\n\n")
}
if task.Context.Agent.Skills != "" {
b.WriteString("Agent skills/instructions:\n")
b.WriteString(task.Context.Agent.Skills)
b.WriteString("\n\n")
}
b.WriteString("Comment requirements:\n")
b.WriteString("- Lead with the outcome.\n")
b.WriteString("- Mention concrete files or commands if you changed anything.\n")
b.WriteString("- Mention blockers or follow-up actions if relevant.\n")
return b.String()
}
// ResolveTaskWorkdir determines the working directory for a task.
func ResolveTaskWorkdir(reposRoot string, repo *RepoRef) (string, error) {
base := reposRoot
if repo == nil || strings.TrimSpace(repo.Path) == "" {
return base, nil
}
path := strings.TrimSpace(repo.Path)
if !filepath.IsAbs(path) {
path = filepath.Join(base, path)
}
path = filepath.Clean(path)
info, err := os.Stat(path)
if err != nil {
return "", fmt.Errorf("repository path not found: %s", path)
}
if !info.IsDir() {
return "", fmt.Errorf("repository path is not a directory: %s", path)
}
return path, nil
}

View file

@ -0,0 +1,90 @@
package daemon
// AgentEntry describes a single available agent CLI.
type AgentEntry struct {
Path string // path to CLI binary
Model string // model override (optional)
}
// Runtime represents a registered daemon runtime.
type Runtime struct {
ID string `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"`
Status string `json:"status"`
}
// PairingSession represents a daemon pairing session.
type PairingSession struct {
Token string `json:"token"`
DaemonID string `json:"daemon_id"`
DeviceName string `json:"device_name"`
RuntimeName string `json:"runtime_name"`
RuntimeType string `json:"runtime_type"`
RuntimeVersion string `json:"runtime_version"`
WorkspaceID *string `json:"workspace_id"`
Status string `json:"status"`
ApprovedAt *string `json:"approved_at"`
ClaimedAt *string `json:"claimed_at"`
ExpiresAt string `json:"expires_at"`
LinkURL *string `json:"link_url"`
}
// PersistedConfig is the JSON structure saved to ~/.multica/daemon.json.
type PersistedConfig struct {
WorkspaceID string `json:"workspace_id"`
}
// Task represents a claimed task from the server.
type Task struct {
ID string `json:"id"`
AgentID string `json:"agent_id"`
IssueID string `json:"issue_id"`
Context TaskContext `json:"context"`
}
// TaskContext contains the snapshot context for a task.
type TaskContext struct {
Issue IssueContext `json:"issue"`
Agent AgentContext `json:"agent"`
Runtime RuntimeContext `json:"runtime"`
WorkspaceContext string `json:"workspace_context,omitempty"`
}
// IssueContext holds issue details for task execution.
type IssueContext struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
AcceptanceCriteria []string `json:"acceptance_criteria"`
ContextRefs []string `json:"context_refs"`
Repository *RepoRef `json:"repository"`
}
// AgentContext holds agent details for task execution.
type AgentContext struct {
ID string `json:"id"`
Name string `json:"name"`
Skills string `json:"skills"`
}
// RuntimeContext holds runtime details for task execution.
type RuntimeContext struct {
ID string `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"`
DeviceInfo string `json:"device_info"`
}
// RepoRef points to a repository for an issue.
type RepoRef struct {
URL string `json:"url"`
Branch string `json:"branch"`
Path string `json:"path"`
}
// TaskResult is the outcome of executing a task.
type TaskResult struct {
Status string `json:"status"`
Comment string `json:"comment"`
}

View file

@ -1,7 +1,10 @@
package handler
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"github.com/go-chi/chi/v5"
@ -240,9 +243,54 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
agent, _ = h.Queries.GetAgent(r.Context(), agent.ID)
}
// Best-effort: create an initialization issue assigned to the new agent.
h.createAgentInitIssue(r.Context(), agent, parseUUID(ownerID))
writeJSON(w, http.StatusCreated, agentToResponse(agent))
}
// createAgentInitIssue creates an initialization issue assigned to a newly created agent.
// It incorporates workspace context so the agent can set up its environment.
// Failures are silently ignored — the agent creation itself has already succeeded.
func (h *Handler) createAgentInitIssue(ctx context.Context, agent db.Agent, creatorID pgtype.UUID) {
ws, err := h.Queries.GetWorkspace(ctx, agent.WorkspaceID)
if err != nil {
return
}
var desc string
if ws.Context.Valid && ws.Context.String != "" {
desc = fmt.Sprintf("Initialize the development environment for agent **%s**.\n\n## Workspace Context\n\n%s\n\n## Instructions\n\n- Set up the local development environment based on the workspace context above\n- Clone and configure any referenced repositories\n- Verify access to the codebase and tools\n- Report back on what was set up and any issues encountered", agent.Name, ws.Context.String)
} else {
desc = fmt.Sprintf("Initialize the development environment for agent **%s**.\n\n## Instructions\n\n- Explore the local working directory and understand the project structure\n- Verify access to the codebase and tools\n- Report back on what was found and any issues encountered", agent.Name)
}
issue, err := h.Queries.CreateIssue(ctx, db.CreateIssueParams{
WorkspaceID: agent.WorkspaceID,
Title: "Initialize environment for " + agent.Name,
Description: strToText(desc),
Status: "todo",
Priority: "medium",
AssigneeType: pgtype.Text{String: "agent", Valid: true},
AssigneeID: agent.ID,
CreatorType: "member",
CreatorID: creatorID,
AcceptanceCriteria: []byte("[]"),
ContextRefs: []byte("[]"),
Position: 0,
})
if err != nil {
return
}
h.broadcast("issue:created", map[string]any{"issue": issueToResponse(issue)})
// Enqueue the task directly — we know the agent is assigned and status is "todo".
if _, err := h.TaskService.EnqueueTaskForIssue(ctx, issue); err != nil {
log.Printf("createAgentInitIssue: enqueue task failed for issue %s: %v", issue.Title, err)
}
}
type UpdateAgentRequest struct {
Name *string `json:"name"`
Description *string `json:"description"`

View file

@ -11,13 +11,14 @@ import (
)
type WorkspaceResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Description *string `json:"description"`
Settings any `json:"settings"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Context *string `json:"context"`
Settings any `json:"settings"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func workspaceToResponse(w db.Workspace) WorkspaceResponse {
@ -33,6 +34,7 @@ func workspaceToResponse(w db.Workspace) WorkspaceResponse {
Name: w.Name,
Slug: w.Slug,
Description: textToPtr(w.Description),
Context: textToPtr(w.Context),
Settings: settings,
CreatedAt: timestampToString(w.CreatedAt),
UpdatedAt: timestampToString(w.UpdatedAt),
@ -95,6 +97,7 @@ type CreateWorkspaceRequest struct {
Name string `json:"name"`
Slug string `json:"slug"`
Description *string `json:"description"`
Context *string `json:"context"`
}
func (h *Handler) CreateWorkspace(w http.ResponseWriter, r *http.Request) {
@ -128,6 +131,7 @@ func (h *Handler) CreateWorkspace(w http.ResponseWriter, r *http.Request) {
Name: req.Name,
Slug: req.Slug,
Description: ptrToText(req.Description),
Context: ptrToText(req.Context),
})
if err != nil {
if isUniqueViolation(err) {
@ -159,6 +163,7 @@ func (h *Handler) CreateWorkspace(w http.ResponseWriter, r *http.Request) {
type UpdateWorkspaceRequest struct {
Name *string `json:"name"`
Description *string `json:"description"`
Context *string `json:"context"`
Settings any `json:"settings"`
}
@ -188,6 +193,9 @@ func (h *Handler) UpdateWorkspace(w http.ResponseWriter, r *http.Request) {
if req.Description != nil {
params.Description = pgtype.Text{String: *req.Description, Valid: true}
}
if req.Context != nil {
params.Context = pgtype.Text{String: *req.Context, Valid: true}
}
if req.Settings != nil {
s, _ := json.Marshal(req.Settings)
params.Settings = s

View file

@ -42,7 +42,13 @@ func (s *TaskService) EnqueueTaskForIssue(ctx context.Context, issue db.Issue) (
return db.AgentTaskQueue{}, fmt.Errorf("load runtime: %w", err)
}
snapshot := buildContextSnapshot(issue, agent, runtime)
// Include workspace context in the snapshot when available.
var workspaceContext string
if ws, err := s.Queries.GetWorkspace(ctx, issue.WorkspaceID); err == nil && ws.Context.Valid {
workspaceContext = ws.Context.String
}
snapshot := buildContextSnapshot(issue, agent, runtime, workspaceContext)
contextJSON, _ := json.Marshal(snapshot)
task, err := s.Queries.CreateAgentTaskWithContext(ctx, db.CreateAgentTaskWithContextParams{
@ -169,6 +175,7 @@ func (s *TaskService) CompleteTask(ctx context.Context, taskID pgtype.UUID, resu
s.createAgentComment(ctx, task.IssueID, task.AgentID, payload.Output, "comment")
}
}
if issueErr == nil {
s.createInboxForIssueCreator(ctx, issue, "review_requested", "attention", "Review requested: "+issue.Title, "")
}
@ -250,7 +257,7 @@ func (s *TaskService) updateAgentStatus(ctx context.Context, agentID pgtype.UUID
s.broadcast(protocol.EventAgentStatus, map[string]any{"agent": agentToMap(agent)})
}
func buildContextSnapshot(issue db.Issue, agent db.Agent, runtime db.AgentRuntime) map[string]any {
func buildContextSnapshot(issue db.Issue, agent db.Agent, runtime db.AgentRuntime, workspaceContext string) map[string]any {
var ac []string
if issue.AcceptanceCriteria != nil {
json.Unmarshal(issue.AcceptanceCriteria, &ac)
@ -274,7 +281,7 @@ func buildContextSnapshot(issue db.Issue, agent db.Agent, runtime db.AgentRuntim
json.Unmarshal(runtime.Metadata, &metadata)
}
return map[string]any{
m := map[string]any{
"issue": map[string]any{
"id": util.UUIDToString(issue.ID),
"title": issue.Title,
@ -298,6 +305,10 @@ func buildContextSnapshot(issue db.Issue, agent db.Agent, runtime db.AgentRuntim
"metadata": metadata,
},
}
if workspaceContext != "" {
m["workspace_context"] = workspaceContext
}
return m
}
func priorityToInt(p string) int32 {

View file

@ -0,0 +1 @@
ALTER TABLE workspace DROP COLUMN IF EXISTS context;

View file

@ -0,0 +1 @@
ALTER TABLE workspace ADD COLUMN context TEXT;

99
server/pkg/agent/agent.go Normal file
View file

@ -0,0 +1,99 @@
// Package agent provides a unified interface for executing prompts via
// coding agents (Claude Code, Codex). It mirrors the happy-cli AgentBackend
// pattern, translated to idiomatic Go.
package agent
import (
"context"
"fmt"
"log"
"time"
)
// Backend is the unified interface for executing prompts via coding agents.
type Backend interface {
// Execute runs a prompt and returns a Session for streaming results.
// The caller should read from Session.Messages (optional) and wait on
// Session.Result for the final outcome.
Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error)
}
// ExecOptions configures a single execution.
type ExecOptions struct {
Cwd string
Model string
SystemPrompt string
MaxTurns int
Timeout time.Duration
}
// Session represents a running agent execution.
type Session struct {
// Messages streams events as the agent works. The channel is closed
// when the agent finishes (before Result is sent).
Messages <-chan Message
// Result receives exactly one value — the final outcome — then closes.
Result <-chan Result
}
// MessageType identifies the kind of Message.
type MessageType string
const (
MessageText MessageType = "text"
MessageToolUse MessageType = "tool-use"
MessageToolResult MessageType = "tool-result"
MessageStatus MessageType = "status"
MessageError MessageType = "error"
MessageLog MessageType = "log"
)
// Message is a unified event emitted by an agent during execution.
type Message struct {
Type MessageType
Content string // text content (Text, Error, Log)
Tool string // tool name (ToolUse, ToolResult)
CallID string // tool call ID (ToolUse, ToolResult)
Input map[string]any // tool input (ToolUse)
Output string // tool output (ToolResult)
Status string // agent status string (Status)
Level string // log level (Log)
}
// Result is the final outcome after an agent session completes.
type Result struct {
Status string // "completed", "failed", "aborted", "timeout"
Output string // accumulated text output
Error string // error message if failed
DurationMs int64
SessionID string
}
// Config configures a Backend instance.
type Config struct {
ExecutablePath string // path to CLI binary (claude or codex)
Env map[string]string // extra environment variables
Logger *log.Logger
}
// New creates a Backend for the given agent type.
// Supported types: "claude", "codex".
func New(agentType string, cfg Config) (Backend, error) {
if cfg.Logger == nil {
cfg.Logger = log.Default()
}
switch agentType {
case "claude":
return &claudeBackend{cfg: cfg}, nil
case "codex":
return &codexBackend{cfg: cfg}, nil
default:
return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codex)", agentType)
}
}
// DetectVersion runs the agent CLI with --version and returns the output.
func DetectVersion(ctx context.Context, executablePath string) (string, error) {
return detectCLIVersion(ctx, executablePath)
}

View file

@ -0,0 +1,53 @@
package agent
import (
"context"
"testing"
)
func TestNewReturnsClaudeBackend(t *testing.T) {
t.Parallel()
b, err := New("claude", Config{ExecutablePath: "/nonexistent/claude"})
if err != nil {
t.Fatalf("New(claude) error: %v", err)
}
if _, ok := b.(*claudeBackend); !ok {
t.Fatalf("expected *claudeBackend, got %T", b)
}
}
func TestNewReturnsCodexBackend(t *testing.T) {
t.Parallel()
b, err := New("codex", Config{ExecutablePath: "/nonexistent/codex"})
if err != nil {
t.Fatalf("New(codex) error: %v", err)
}
if _, ok := b.(*codexBackend); !ok {
t.Fatalf("expected *codexBackend, got %T", b)
}
}
func TestNewRejectsUnknownType(t *testing.T) {
t.Parallel()
_, err := New("gpt", Config{})
if err == nil {
t.Fatal("expected error for unknown agent type")
}
}
func TestNewDefaultsLogger(t *testing.T) {
t.Parallel()
b, _ := New("claude", Config{})
cb := b.(*claudeBackend)
if cb.cfg.Logger == nil {
t.Fatal("expected non-nil logger")
}
}
func TestDetectVersionFailsForMissingBinary(t *testing.T) {
t.Parallel()
_, err := DetectVersion(context.Background(), "/nonexistent/binary")
if err == nil {
t.Fatal("expected error for missing binary")
}
}

348
server/pkg/agent/claude.go Normal file
View file

@ -0,0 +1,348 @@
package agent
import (
"bufio"
"context"
"encoding/json"
"fmt"
"log"
"os"
"os/exec"
"strings"
"time"
)
// claudeBackend implements Backend by spawning the Claude Code CLI
// with --output-format stream-json.
type claudeBackend struct {
cfg Config
}
func (b *claudeBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) {
execPath := b.cfg.ExecutablePath
if execPath == "" {
execPath = "claude"
}
if _, err := exec.LookPath(execPath); err != nil {
return nil, fmt.Errorf("claude executable not found at %q: %w", execPath, err)
}
timeout := opts.Timeout
if timeout == 0 {
timeout = 20 * time.Minute
}
runCtx, cancel := context.WithTimeout(ctx, timeout)
args := []string{
"--output-format", "stream-json",
"--verbose",
"--permission-mode", "bypassPermissions",
}
if opts.Model != "" {
args = append(args, "--model", opts.Model)
}
if opts.MaxTurns > 0 {
args = append(args, "--max-turns", fmt.Sprintf("%d", opts.MaxTurns))
}
if opts.SystemPrompt != "" {
args = append(args, "--append-system-prompt", opts.SystemPrompt)
}
args = append(args, "-p", prompt)
cmd := exec.CommandContext(runCtx, execPath, args...)
if opts.Cwd != "" {
cmd.Dir = opts.Cwd
}
cmd.Env = buildEnv(b.cfg.Env)
stdout, err := cmd.StdoutPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("claude stdout pipe: %w", err)
}
stdin, err := cmd.StdinPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("claude stdin pipe: %w", err)
}
cmd.Stderr = newLogWriter(b.cfg.Logger, "[claude:stderr] ")
if err := cmd.Start(); err != nil {
cancel()
return nil, fmt.Errorf("start claude: %w", err)
}
b.cfg.Logger.Printf("[claude] started pid=%d cwd=%s model=%s", cmd.Process.Pid, opts.Cwd, opts.Model)
msgCh := make(chan Message, 256)
resCh := make(chan Result, 1)
go func() {
defer cancel()
defer close(msgCh)
defer close(resCh)
defer stdin.Close()
startTime := time.Now()
var output strings.Builder
var sessionID string
finalStatus := "completed"
var finalError string
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var msg claudeSDKMessage
if err := json.Unmarshal([]byte(line), &msg); err != nil {
continue
}
switch msg.Type {
case "assistant":
b.handleAssistant(msg, msgCh, &output)
case "user":
b.handleUser(msg, msgCh)
case "system":
if msg.SessionID != "" {
sessionID = msg.SessionID
}
trySend(msgCh, Message{Type: MessageStatus, Status: "running"})
case "result":
sessionID = msg.SessionID
if msg.ResultText != "" {
output.Reset()
output.WriteString(msg.ResultText)
}
if msg.IsError {
finalStatus = "failed"
finalError = msg.ResultText
}
case "log":
if msg.Log != nil {
trySend(msgCh, Message{
Type: MessageLog,
Level: msg.Log.Level,
Content: msg.Log.Message,
})
}
case "control_request":
b.handleControlRequest(msg, stdin)
}
}
// Wait for process exit
exitErr := cmd.Wait()
duration := time.Since(startTime)
if runCtx.Err() == context.DeadlineExceeded {
finalStatus = "timeout"
finalError = fmt.Sprintf("claude timed out after %s", timeout)
} else if runCtx.Err() == context.Canceled {
finalStatus = "aborted"
finalError = "execution cancelled"
} else if exitErr != nil && finalStatus == "completed" {
finalStatus = "failed"
finalError = fmt.Sprintf("claude exited with error: %v", exitErr)
}
b.cfg.Logger.Printf("[claude] finished pid=%d status=%s duration=%s",
cmd.Process.Pid, finalStatus, duration.Round(time.Millisecond))
resCh <- Result{
Status: finalStatus,
Output: output.String(),
Error: finalError,
DurationMs: duration.Milliseconds(),
SessionID: sessionID,
}
}()
return &Session{Messages: msgCh, Result: resCh}, nil
}
func (b *claudeBackend) handleAssistant(msg claudeSDKMessage, ch chan<- Message, output *strings.Builder) {
var content claudeMessageContent
if err := json.Unmarshal(msg.Message, &content); err != nil {
return
}
for _, block := range content.Content {
switch block.Type {
case "text":
if block.Text != "" {
output.WriteString(block.Text)
trySend(ch, Message{Type: MessageText, Content: block.Text})
}
case "tool_use":
var input map[string]any
if block.Input != nil {
_ = json.Unmarshal(block.Input, &input)
}
trySend(ch, Message{
Type: MessageToolUse,
Tool: block.Name,
CallID: block.ID,
Input: input,
})
}
}
}
func (b *claudeBackend) handleUser(msg claudeSDKMessage, ch chan<- Message) {
var content claudeMessageContent
if err := json.Unmarshal(msg.Message, &content); err != nil {
return
}
for _, block := range content.Content {
if block.Type == "tool_result" {
resultStr := ""
if block.Content != nil {
resultStr = string(block.Content)
}
trySend(ch, Message{
Type: MessageToolResult,
CallID: block.ToolUseID,
Output: resultStr,
})
}
}
}
func (b *claudeBackend) handleControlRequest(msg claudeSDKMessage, stdin interface{ Write([]byte) (int, error) }) {
// Auto-approve all tool uses in autonomous/daemon mode.
var req claudeControlRequestPayload
if err := json.Unmarshal(msg.Request, &req); err != nil {
return
}
var inputMap map[string]any
if req.Input != nil {
_ = json.Unmarshal(req.Input, &inputMap)
}
if inputMap == nil {
inputMap = map[string]any{}
}
response := map[string]any{
"type": "control_response",
"response": map[string]any{
"subtype": "success",
"request_id": msg.RequestID,
"response": map[string]any{
"behavior": "allow",
"updatedInput": inputMap,
},
},
}
data, err := json.Marshal(response)
if err != nil {
b.cfg.Logger.Printf("[claude] failed to marshal control response: %v", err)
return
}
data = append(data, '\n')
if _, err := stdin.Write(data); err != nil {
b.cfg.Logger.Printf("[claude] failed to write control response: %v", err)
}
}
// ── Claude SDK JSON types ──
type claudeSDKMessage struct {
Type string `json:"type"`
Message json.RawMessage `json:"message,omitempty"`
Subtype string `json:"subtype,omitempty"`
SessionID string `json:"session_id,omitempty"`
// result fields
ResultText string `json:"result,omitempty"`
IsError bool `json:"is_error,omitempty"`
DurationMs float64 `json:"duration_ms,omitempty"`
NumTurns int `json:"num_turns,omitempty"`
// log fields
Log *claudeLogEntry `json:"log,omitempty"`
// control request fields
RequestID string `json:"request_id,omitempty"`
Request json.RawMessage `json:"request,omitempty"`
}
type claudeLogEntry struct {
Level string `json:"level"`
Message string `json:"message"`
}
type claudeMessageContent struct {
Role string `json:"role"`
Content []claudeContentBlock `json:"content"`
}
type claudeContentBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
ToolUseID string `json:"tool_use_id,omitempty"`
Content json.RawMessage `json:"content,omitempty"`
}
type claudeControlRequestPayload struct {
Subtype string `json:"subtype"`
ToolName string `json:"tool_name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
}
// ── Shared helpers ──
func trySend(ch chan<- Message, msg Message) {
select {
case ch <- msg:
default:
// Channel full — drop message. Final output is accumulated separately
// in Result.Output, so only streaming consumers are affected.
}
}
func buildEnv(extra map[string]string) []string {
env := os.Environ()
for k, v := range extra {
env = append(env, k+"="+v)
}
return env
}
func detectCLIVersion(ctx context.Context, execPath string) (string, error) {
cmd := exec.CommandContext(ctx, execPath, "--version")
data, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("detect version for %s: %w", execPath, err)
}
return strings.TrimSpace(string(data)), nil
}
// logWriter adapts a *log.Logger to an io.Writer for capturing stderr.
type logWriter struct {
logger *log.Logger
prefix string
}
func newLogWriter(logger *log.Logger, prefix string) *logWriter {
return &logWriter{logger: logger, prefix: prefix}
}
func (w *logWriter) Write(p []byte) (int, error) {
text := strings.TrimSpace(string(p))
if text != "" {
w.logger.Printf("%s%s", w.prefix, text)
}
return len(p), nil
}

View file

@ -0,0 +1,229 @@
package agent
import (
"bytes"
"encoding/json"
"log"
"strings"
"testing"
)
func TestClaudeHandleAssistantText(t *testing.T) {
t.Parallel()
b := &claudeBackend{cfg: Config{Logger: log.Default()}}
ch := make(chan Message, 10)
var output strings.Builder
msg := claudeSDKMessage{
Type: "assistant",
Message: mustMarshal(t, claudeMessageContent{
Role: "assistant",
Content: []claudeContentBlock{
{Type: "text", Text: "Hello world"},
},
}),
}
b.handleAssistant(msg, ch, &output)
if output.String() != "Hello world" {
t.Fatalf("expected output 'Hello world', got %q", output.String())
}
select {
case m := <-ch:
if m.Type != MessageText || m.Content != "Hello world" {
t.Fatalf("unexpected message: %+v", m)
}
default:
t.Fatal("expected message on channel")
}
}
func TestClaudeHandleAssistantToolUse(t *testing.T) {
t.Parallel()
b := &claudeBackend{cfg: Config{Logger: log.Default()}}
ch := make(chan Message, 10)
var output strings.Builder
msg := claudeSDKMessage{
Type: "assistant",
Message: mustMarshal(t, claudeMessageContent{
Role: "assistant",
Content: []claudeContentBlock{
{
Type: "tool_use",
ID: "call-1",
Name: "Read",
Input: mustMarshal(t, map[string]any{"path": "/tmp/foo"}),
},
},
}),
}
b.handleAssistant(msg, ch, &output)
if output.String() != "" {
t.Fatalf("tool_use should not add to output, got %q", output.String())
}
select {
case m := <-ch:
if m.Type != MessageToolUse || m.Tool != "Read" || m.CallID != "call-1" {
t.Fatalf("unexpected message: %+v", m)
}
if m.Input["path"] != "/tmp/foo" {
t.Fatalf("expected input path /tmp/foo, got %v", m.Input["path"])
}
default:
t.Fatal("expected message on channel")
}
}
func TestClaudeHandleUserToolResult(t *testing.T) {
t.Parallel()
b := &claudeBackend{cfg: Config{Logger: log.Default()}}
ch := make(chan Message, 10)
msg := claudeSDKMessage{
Type: "user",
Message: mustMarshal(t, claudeMessageContent{
Role: "user",
Content: []claudeContentBlock{
{
Type: "tool_result",
ToolUseID: "call-1",
Content: mustMarshal(t, "file contents here"),
},
},
}),
}
b.handleUser(msg, ch)
select {
case m := <-ch:
if m.Type != MessageToolResult || m.CallID != "call-1" {
t.Fatalf("unexpected message: %+v", m)
}
default:
t.Fatal("expected message on channel")
}
}
func TestClaudeHandleControlRequestAutoApproves(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
b := &claudeBackend{cfg: Config{Logger: log.New(&buf, "", 0)}}
var written bytes.Buffer
msg := claudeSDKMessage{
Type: "control_request",
RequestID: "req-42",
Request: mustMarshal(t, claudeControlRequestPayload{
Subtype: "tool_use",
ToolName: "Bash",
Input: mustMarshal(t, map[string]any{"command": "ls"}),
}),
}
b.handleControlRequest(msg, &written)
var resp map[string]any
if err := json.Unmarshal(bytes.TrimSpace(written.Bytes()), &resp); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if resp["type"] != "control_response" {
t.Fatalf("expected type control_response, got %v", resp["type"])
}
respInner := resp["response"].(map[string]any)
if respInner["request_id"] != "req-42" {
t.Fatalf("expected request_id req-42, got %v", respInner["request_id"])
}
innerResp := respInner["response"].(map[string]any)
if innerResp["behavior"] != "allow" {
t.Fatalf("expected behavior allow, got %v", innerResp["behavior"])
}
}
func TestClaudeHandleAssistantInvalidJSON(t *testing.T) {
t.Parallel()
b := &claudeBackend{cfg: Config{Logger: log.Default()}}
ch := make(chan Message, 10)
var output strings.Builder
msg := claudeSDKMessage{
Type: "assistant",
Message: json.RawMessage(`invalid json`),
}
// Should not panic
b.handleAssistant(msg, ch, &output)
if output.String() != "" {
t.Fatalf("expected empty output for invalid JSON, got %q", output.String())
}
select {
case m := <-ch:
t.Fatalf("expected no message, got %+v", m)
default:
}
}
func TestTrySendDropsWhenFull(t *testing.T) {
t.Parallel()
ch := make(chan Message, 1)
// Fill the channel
trySend(ch, Message{Type: MessageText, Content: "first"})
// This should not block
trySend(ch, Message{Type: MessageText, Content: "second"})
m := <-ch
if m.Content != "first" {
t.Fatalf("expected 'first', got %q", m.Content)
}
select {
case m := <-ch:
t.Fatalf("expected empty channel, got %+v", m)
default:
}
}
func TestBuildEnvAppendsExtras(t *testing.T) {
t.Parallel()
env := buildEnv(map[string]string{"FOO": "bar", "BAZ": "qux"})
found := 0
for _, e := range env {
if e == "FOO=bar" || e == "BAZ=qux" {
found++
}
}
if found != 2 {
t.Fatalf("expected 2 extra env vars, found %d", found)
}
}
func TestBuildEnvNilExtras(t *testing.T) {
t.Parallel()
env := buildEnv(nil)
if len(env) == 0 {
t.Fatal("expected at least system env vars")
}
}
func mustMarshal(t *testing.T, v any) json.RawMessage {
t.Helper()
data, err := json.Marshal(v)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
return data
}

654
server/pkg/agent/codex.go Normal file
View file

@ -0,0 +1,654 @@
package agent
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os/exec"
"strings"
"sync"
"time"
)
// codexBackend implements Backend by spawning `codex app-server --listen stdio://`
// and communicating via JSON-RPC 2.0 over stdin/stdout.
type codexBackend struct {
cfg Config
}
func (b *codexBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) {
execPath := b.cfg.ExecutablePath
if execPath == "" {
execPath = "codex"
}
if _, err := exec.LookPath(execPath); err != nil {
return nil, fmt.Errorf("codex executable not found at %q: %w", execPath, err)
}
timeout := opts.Timeout
if timeout == 0 {
timeout = 20 * time.Minute
}
runCtx, cancel := context.WithTimeout(ctx, timeout)
cmd := exec.CommandContext(runCtx, execPath, "app-server", "--listen", "stdio://")
if opts.Cwd != "" {
cmd.Dir = opts.Cwd
}
cmd.Env = buildEnv(b.cfg.Env)
stdout, err := cmd.StdoutPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("codex stdout pipe: %w", err)
}
stdin, err := cmd.StdinPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("codex stdin pipe: %w", err)
}
cmd.Stderr = newLogWriter(b.cfg.Logger, "[codex:stderr] ")
if err := cmd.Start(); err != nil {
cancel()
return nil, fmt.Errorf("start codex: %w", err)
}
b.cfg.Logger.Printf("[codex] started app-server pid=%d cwd=%s", cmd.Process.Pid, opts.Cwd)
msgCh := make(chan Message, 256)
resCh := make(chan Result, 1)
var outputMu sync.Mutex
var output strings.Builder
// turnDone is set before starting the reader goroutine so there is no
// race between the lifecycle goroutine writing and the reader reading.
turnDone := make(chan bool, 1) // true = aborted
c := &codexClient{
cfg: b.cfg,
stdin: stdin,
pending: make(map[int]*pendingRPC),
notificationProtocol: "unknown",
onMessage: func(msg Message) {
if msg.Type == MessageText {
outputMu.Lock()
output.WriteString(msg.Content)
outputMu.Unlock()
}
trySend(msgCh, msg)
},
onTurnDone: func(aborted bool) {
select {
case turnDone <- aborted:
default:
}
},
}
// Start reading stdout in background
readerDone := make(chan struct{})
go func() {
defer close(readerDone)
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
c.handleLine(line)
}
c.closeAllPending(fmt.Errorf("codex process exited"))
}()
// Drive the session lifecycle in a goroutine.
// Shutdown sequence: lifecycle goroutine closes stdin + cancels context →
// codex process exits → reader goroutine's scanner.Scan() returns false →
// readerDone closes → lifecycle goroutine collects final output and sends Result.
go func() {
defer cancel()
defer close(msgCh)
defer close(resCh)
defer func() {
stdin.Close()
_ = cmd.Wait()
}()
startTime := time.Now()
finalStatus := "completed"
var finalError string
// 1. Initialize handshake
_, err := c.request(runCtx, "initialize", map[string]any{
"clientInfo": map[string]any{
"name": "multica-agent-sdk",
"title": "Multica Agent SDK",
"version": "0.2.0",
},
"capabilities": map[string]any{
"experimentalApi": true,
},
})
if err != nil {
finalStatus = "failed"
finalError = fmt.Sprintf("codex initialize failed: %v", err)
resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()}
return
}
c.notify("initialized")
// 2. Start thread
threadResult, err := c.request(runCtx, "thread/start", map[string]any{
"model": nilIfEmpty(opts.Model),
"modelProvider": nil,
"profile": nil,
"cwd": opts.Cwd,
"approvalPolicy": nil,
"sandbox": "workspace-write",
"config": nil,
"baseInstructions": nil,
"developerInstructions": nilIfEmpty(opts.SystemPrompt),
"compactPrompt": nil,
"includeApplyPatchTool": nil,
"experimentalRawEvents": false,
"persistExtendedHistory": true,
})
if err != nil {
finalStatus = "failed"
finalError = fmt.Sprintf("codex thread/start failed: %v", err)
resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()}
return
}
threadID := extractThreadID(threadResult)
if threadID == "" {
finalStatus = "failed"
finalError = "codex thread/start returned no thread ID"
resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()}
return
}
c.threadID = threadID
b.cfg.Logger.Printf("[codex] thread started: %s", threadID)
// 3. Send turn and wait for completion
_, err = c.request(runCtx, "turn/start", map[string]any{
"threadId": threadID,
"input": []map[string]any{
{"type": "text", "text": prompt},
},
})
if err != nil {
finalStatus = "failed"
finalError = fmt.Sprintf("codex turn/start failed: %v", err)
resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()}
return
}
// Wait for turn completion or context cancellation
select {
case aborted := <-turnDone:
if aborted {
finalStatus = "aborted"
finalError = "turn was aborted"
}
case <-runCtx.Done():
if runCtx.Err() == context.DeadlineExceeded {
finalStatus = "timeout"
finalError = fmt.Sprintf("codex timed out after %s", timeout)
} else {
finalStatus = "aborted"
finalError = "execution cancelled"
}
}
duration := time.Since(startTime)
b.cfg.Logger.Printf("[codex] finished pid=%d status=%s duration=%s",
cmd.Process.Pid, finalStatus, duration.Round(time.Millisecond))
// Close stdin and cancel context to signal the app-server to exit.
// Without this, the long-running codex process keeps stdout open and
// the reader goroutine blocks forever on scanner.Scan().
stdin.Close()
cancel()
// Wait for the reader goroutine to finish so all output is accumulated.
<-readerDone
outputMu.Lock()
finalOutput := output.String()
outputMu.Unlock()
resCh <- Result{
Status: finalStatus,
Output: finalOutput,
Error: finalError,
DurationMs: duration.Milliseconds(),
}
}()
return &Session{Messages: msgCh, Result: resCh}, nil
}
// ── codexClient: JSON-RPC 2.0 transport ──
type codexClient struct {
cfg Config
stdin interface{ Write([]byte) (int, error) }
mu sync.Mutex
nextID int
pending map[int]*pendingRPC
threadID string
turnID string
onMessage func(Message)
onTurnDone func(aborted bool)
notificationProtocol string // "unknown", "legacy", "raw"
turnStarted bool
completedTurnIDs map[string]bool
}
type pendingRPC struct {
ch chan rpcResult
method string
}
type rpcResult struct {
result json.RawMessage
err error
}
func (c *codexClient) request(ctx context.Context, method string, params any) (json.RawMessage, error) {
c.mu.Lock()
c.nextID++
id := c.nextID
pr := &pendingRPC{ch: make(chan rpcResult, 1), method: method}
c.pending[id] = pr
c.mu.Unlock()
msg := map[string]any{
"jsonrpc": "2.0",
"id": id,
"method": method,
"params": params,
}
data, err := json.Marshal(msg)
if err != nil {
c.mu.Lock()
delete(c.pending, id)
c.mu.Unlock()
return nil, err
}
data = append(data, '\n')
if _, err := c.stdin.Write(data); err != nil {
c.mu.Lock()
delete(c.pending, id)
c.mu.Unlock()
return nil, fmt.Errorf("write %s: %w", method, err)
}
select {
case res := <-pr.ch:
return res.result, res.err
case <-ctx.Done():
c.mu.Lock()
delete(c.pending, id)
c.mu.Unlock()
return nil, ctx.Err()
}
}
func (c *codexClient) notify(method string) {
msg := map[string]any{
"jsonrpc": "2.0",
"method": method,
}
data, _ := json.Marshal(msg)
data = append(data, '\n')
_, _ = c.stdin.Write(data)
}
func (c *codexClient) respond(id int, result any) {
msg := map[string]any{
"jsonrpc": "2.0",
"id": id,
"result": result,
}
data, _ := json.Marshal(msg)
data = append(data, '\n')
_, _ = c.stdin.Write(data)
}
func (c *codexClient) closeAllPending(err error) {
c.mu.Lock()
defer c.mu.Unlock()
for id, pr := range c.pending {
pr.ch <- rpcResult{err: err}
delete(c.pending, id)
}
}
func (c *codexClient) handleLine(line string) {
var raw map[string]json.RawMessage
if err := json.Unmarshal([]byte(line), &raw); err != nil {
return
}
// Check if it's a response to our request
if _, hasID := raw["id"]; hasID {
if _, hasResult := raw["result"]; hasResult {
c.handleResponse(raw)
return
}
if _, hasError := raw["error"]; hasError {
c.handleResponse(raw)
return
}
// Server request (has id + method)
if _, hasMethod := raw["method"]; hasMethod {
c.handleServerRequest(raw)
return
}
}
// Notification (no id, has method)
if _, hasMethod := raw["method"]; hasMethod {
c.handleNotification(raw)
}
}
func (c *codexClient) handleResponse(raw map[string]json.RawMessage) {
var id int
if err := json.Unmarshal(raw["id"], &id); err != nil {
return
}
c.mu.Lock()
pr, ok := c.pending[id]
if ok {
delete(c.pending, id)
}
c.mu.Unlock()
if !ok {
return
}
if errData, hasErr := raw["error"]; hasErr {
var rpcErr struct {
Code int `json:"code"`
Message string `json:"message"`
}
_ = json.Unmarshal(errData, &rpcErr)
pr.ch <- rpcResult{err: fmt.Errorf("%s: %s (code=%d)", pr.method, rpcErr.Message, rpcErr.Code)}
} else {
pr.ch <- rpcResult{result: raw["result"]}
}
}
func (c *codexClient) handleServerRequest(raw map[string]json.RawMessage) {
var id int
_ = json.Unmarshal(raw["id"], &id)
var method string
_ = json.Unmarshal(raw["method"], &method)
// Auto-approve all exec/patch requests in daemon mode
switch method {
case "item/commandExecution/requestApproval", "execCommandApproval":
c.respond(id, map[string]any{"decision": "accept"})
case "item/fileChange/requestApproval", "applyPatchApproval":
c.respond(id, map[string]any{"decision": "accept"})
default:
c.respond(id, map[string]any{})
}
}
func (c *codexClient) handleNotification(raw map[string]json.RawMessage) {
var method string
_ = json.Unmarshal(raw["method"], &method)
var params map[string]any
if p, ok := raw["params"]; ok {
_ = json.Unmarshal(p, &params)
}
// Legacy codex/event notifications
if method == "codex/event" || strings.HasPrefix(method, "codex/event/") {
c.notificationProtocol = "legacy"
msgData, ok := params["msg"]
if !ok {
return
}
msgMap, ok := msgData.(map[string]any)
if !ok {
return
}
c.handleEvent(msgMap)
return
}
// Raw v2 notifications
if c.notificationProtocol != "legacy" {
if c.notificationProtocol == "unknown" &&
(method == "turn/started" || method == "turn/completed" ||
method == "thread/started" || strings.HasPrefix(method, "item/")) {
c.notificationProtocol = "raw"
}
if c.notificationProtocol == "raw" {
c.handleRawNotification(method, params)
}
}
}
func (c *codexClient) handleEvent(msg map[string]any) {
msgType, _ := msg["type"].(string)
switch msgType {
case "task_started":
c.turnStarted = true
if c.onMessage != nil {
c.onMessage(Message{Type: MessageStatus, Status: "running"})
}
case "agent_message":
text, _ := msg["message"].(string)
if text != "" && c.onMessage != nil {
c.onMessage(Message{Type: MessageText, Content: text})
}
case "exec_command_begin":
callID, _ := msg["call_id"].(string)
command, _ := msg["command"].(string)
if c.onMessage != nil {
c.onMessage(Message{
Type: MessageToolUse,
Tool: "exec_command",
CallID: callID,
Input: map[string]any{"command": command},
})
}
case "exec_command_end":
callID, _ := msg["call_id"].(string)
output, _ := msg["output"].(string)
if c.onMessage != nil {
c.onMessage(Message{
Type: MessageToolResult,
Tool: "exec_command",
CallID: callID,
Output: output,
})
}
case "patch_apply_begin":
callID, _ := msg["call_id"].(string)
if c.onMessage != nil {
c.onMessage(Message{
Type: MessageToolUse,
Tool: "patch_apply",
CallID: callID,
})
}
case "patch_apply_end":
callID, _ := msg["call_id"].(string)
if c.onMessage != nil {
c.onMessage(Message{
Type: MessageToolResult,
Tool: "patch_apply",
CallID: callID,
})
}
case "task_complete":
if c.onTurnDone != nil {
c.onTurnDone(false)
}
case "turn_aborted":
if c.onTurnDone != nil {
c.onTurnDone(true)
}
}
}
func (c *codexClient) handleRawNotification(method string, params map[string]any) {
switch method {
case "turn/started":
c.turnStarted = true
if turnID := extractNestedString(params, "turn", "id"); turnID != "" {
c.turnID = turnID
}
if c.onMessage != nil {
c.onMessage(Message{Type: MessageStatus, Status: "running"})
}
case "turn/completed":
turnID := extractNestedString(params, "turn", "id")
status := extractNestedString(params, "turn", "status")
aborted := status == "cancelled" || status == "canceled" ||
status == "aborted" || status == "interrupted"
if c.completedTurnIDs == nil {
c.completedTurnIDs = map[string]bool{}
}
if turnID != "" {
if c.completedTurnIDs[turnID] {
return
}
c.completedTurnIDs[turnID] = true
}
if c.onTurnDone != nil {
c.onTurnDone(aborted)
}
case "thread/status/changed":
statusType := extractNestedString(params, "status", "type")
if statusType == "idle" && c.turnStarted {
if c.onTurnDone != nil {
c.onTurnDone(false)
}
}
default:
if strings.HasPrefix(method, "item/") {
c.handleItemNotification(method, params)
}
}
}
func (c *codexClient) handleItemNotification(method string, params map[string]any) {
item, ok := params["item"].(map[string]any)
if !ok {
return
}
itemType, _ := item["type"].(string)
itemID, _ := item["id"].(string)
switch {
case method == "item/started" && itemType == "commandExecution":
command, _ := item["command"].(string)
if c.onMessage != nil {
c.onMessage(Message{
Type: MessageToolUse,
Tool: "exec_command",
CallID: itemID,
Input: map[string]any{"command": command},
})
}
case method == "item/completed" && itemType == "commandExecution":
output, _ := item["aggregatedOutput"].(string)
if c.onMessage != nil {
c.onMessage(Message{
Type: MessageToolResult,
Tool: "exec_command",
CallID: itemID,
Output: output,
})
}
case method == "item/started" && itemType == "fileChange":
if c.onMessage != nil {
c.onMessage(Message{
Type: MessageToolUse,
Tool: "patch_apply",
CallID: itemID,
})
}
case method == "item/completed" && itemType == "fileChange":
if c.onMessage != nil {
c.onMessage(Message{
Type: MessageToolResult,
Tool: "patch_apply",
CallID: itemID,
})
}
case method == "item/completed" && itemType == "agentMessage":
text, _ := item["text"].(string)
if text != "" && c.onMessage != nil {
c.onMessage(Message{Type: MessageText, Content: text})
}
phase, _ := item["phase"].(string)
if phase == "final_answer" && c.turnStarted {
if c.onTurnDone != nil {
c.onTurnDone(false)
}
}
}
}
// ── Helpers ──
func extractThreadID(result json.RawMessage) string {
var r struct {
Thread struct {
ID string `json:"id"`
} `json:"thread"`
}
if err := json.Unmarshal(result, &r); err != nil {
return ""
}
return r.Thread.ID
}
func extractNestedString(m map[string]any, keys ...string) string {
current := any(m)
for _, key := range keys {
obj, ok := current.(map[string]any)
if !ok {
return ""
}
current = obj[key]
}
s, _ := current.(string)
return s
}
func nilIfEmpty(s string) any {
if s == "" {
return nil
}
return s
}

View file

@ -0,0 +1,545 @@
package agent
import (
"encoding/json"
"fmt"
"log"
"sync"
"testing"
)
func newTestCodexClient(t *testing.T) (*codexClient, *fakeStdin, []Message) {
t.Helper()
fs := &fakeStdin{}
var mu sync.Mutex
var messages []Message
c := &codexClient{
cfg: Config{Logger: log.Default()},
stdin: fs,
pending: make(map[int]*pendingRPC),
onMessage: func(msg Message) {
mu.Lock()
messages = append(messages, msg)
mu.Unlock()
},
onTurnDone: func(aborted bool) {},
}
return c, fs, messages
}
type fakeStdin struct {
mu sync.Mutex
data []byte
}
func (f *fakeStdin) Write(p []byte) (int, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.data = append(f.data, p...)
return len(p), nil
}
func (f *fakeStdin) Lines() []string {
f.mu.Lock()
defer f.mu.Unlock()
var lines []string
for _, line := range splitLines(string(f.data)) {
if line != "" {
lines = append(lines, line)
}
}
return lines
}
func splitLines(s string) []string {
var lines []string
start := 0
for i, c := range s {
if c == '\n' {
lines = append(lines, s[start:i])
start = i + 1
}
}
if start < len(s) {
lines = append(lines, s[start:])
}
return lines
}
func TestCodexHandleResponseSuccess(t *testing.T) {
t.Parallel()
c, _, _ := newTestCodexClient(t)
// Register a pending request
pr := &pendingRPC{ch: make(chan rpcResult, 1), method: "test"}
c.mu.Lock()
c.pending[1] = pr
c.mu.Unlock()
c.handleLine(`{"jsonrpc":"2.0","id":1,"result":{"ok":true}}`)
res := <-pr.ch
if res.err != nil {
t.Fatalf("expected no error, got %v", res.err)
}
var parsed map[string]any
if err := json.Unmarshal(res.result, &parsed); err != nil {
t.Fatalf("unmarshal result: %v", err)
}
if parsed["ok"] != true {
t.Fatalf("expected ok=true, got %v", parsed["ok"])
}
}
func TestCodexHandleResponseError(t *testing.T) {
t.Parallel()
c, _, _ := newTestCodexClient(t)
pr := &pendingRPC{ch: make(chan rpcResult, 1), method: "test"}
c.mu.Lock()
c.pending[1] = pr
c.mu.Unlock()
c.handleLine(`{"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":"bad request"}}`)
res := <-pr.ch
if res.err == nil {
t.Fatal("expected error")
}
if res.result != nil {
t.Fatalf("expected nil result, got %v", res.result)
}
}
func TestCodexHandleServerRequestAutoApproves(t *testing.T) {
t.Parallel()
c, fs, _ := newTestCodexClient(t)
// Command execution approval
c.handleLine(`{"jsonrpc":"2.0","id":10,"method":"item/commandExecution/requestApproval","params":{}}`)
lines := fs.Lines()
if len(lines) != 1 {
t.Fatalf("expected 1 response, got %d", len(lines))
}
var resp map[string]any
if err := json.Unmarshal([]byte(lines[0]), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp["id"] != float64(10) {
t.Fatalf("expected id=10, got %v", resp["id"])
}
result := resp["result"].(map[string]any)
if result["decision"] != "accept" {
t.Fatalf("expected decision=accept, got %v", result["decision"])
}
}
func TestCodexHandleServerRequestFileChangeApproval(t *testing.T) {
t.Parallel()
c, fs, _ := newTestCodexClient(t)
c.handleLine(`{"jsonrpc":"2.0","id":11,"method":"applyPatchApproval","params":{}}`)
lines := fs.Lines()
if len(lines) != 1 {
t.Fatalf("expected 1 response, got %d", len(lines))
}
var resp map[string]any
if err := json.Unmarshal([]byte(lines[0]), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
result := resp["result"].(map[string]any)
if result["decision"] != "accept" {
t.Fatalf("expected decision=accept, got %v", result["decision"])
}
}
func TestCodexLegacyEventTaskStarted(t *testing.T) {
t.Parallel()
c, _, _ := newTestCodexClient(t)
var gotStatus bool
c.onMessage = func(msg Message) {
if msg.Type == MessageStatus && msg.Status == "running" {
gotStatus = true
}
}
c.handleLine(`{"jsonrpc":"2.0","method":"codex/event","params":{"msg":{"type":"task_started"}}}`)
if !gotStatus {
t.Fatal("expected status=running message")
}
if !c.turnStarted {
t.Fatal("expected turnStarted=true")
}
if c.notificationProtocol != "legacy" {
t.Fatalf("expected protocol=legacy, got %q", c.notificationProtocol)
}
}
func TestCodexLegacyEventAgentMessage(t *testing.T) {
t.Parallel()
c, _, _ := newTestCodexClient(t)
var gotText string
c.onMessage = func(msg Message) {
if msg.Type == MessageText {
gotText = msg.Content
}
}
c.handleLine(`{"jsonrpc":"2.0","method":"codex/event","params":{"msg":{"type":"agent_message","message":"I found the bug"}}}`)
if gotText != "I found the bug" {
t.Fatalf("expected text 'I found the bug', got %q", gotText)
}
}
func TestCodexLegacyEventExecCommand(t *testing.T) {
t.Parallel()
c, _, _ := newTestCodexClient(t)
var messages []Message
c.onMessage = func(msg Message) {
messages = append(messages, msg)
}
c.handleLine(`{"jsonrpc":"2.0","method":"codex/event","params":{"msg":{"type":"exec_command_begin","call_id":"c1","command":"ls -la"}}}`)
c.handleLine(`{"jsonrpc":"2.0","method":"codex/event","params":{"msg":{"type":"exec_command_end","call_id":"c1","output":"total 42"}}}`)
if len(messages) != 2 {
t.Fatalf("expected 2 messages, got %d", len(messages))
}
if messages[0].Type != MessageToolUse || messages[0].Tool != "exec_command" || messages[0].CallID != "c1" {
t.Fatalf("unexpected begin message: %+v", messages[0])
}
if messages[1].Type != MessageToolResult || messages[1].CallID != "c1" || messages[1].Output != "total 42" {
t.Fatalf("unexpected end message: %+v", messages[1])
}
}
func TestCodexLegacyEventTaskComplete(t *testing.T) {
t.Parallel()
c, _, _ := newTestCodexClient(t)
var done bool
c.onTurnDone = func(aborted bool) {
done = true
if aborted {
t.Fatal("expected aborted=false")
}
}
c.handleLine(`{"jsonrpc":"2.0","method":"codex/event","params":{"msg":{"type":"task_complete"}}}`)
if !done {
t.Fatal("expected onTurnDone to be called")
}
}
func TestCodexLegacyEventTurnAborted(t *testing.T) {
t.Parallel()
c, _, _ := newTestCodexClient(t)
var abortedResult bool
c.onTurnDone = func(aborted bool) {
abortedResult = aborted
}
c.handleLine(`{"jsonrpc":"2.0","method":"codex/event","params":{"msg":{"type":"turn_aborted"}}}`)
if !abortedResult {
t.Fatal("expected aborted=true")
}
}
func TestCodexRawTurnStarted(t *testing.T) {
t.Parallel()
c, _, _ := newTestCodexClient(t)
// The zero value "" doesn't match "unknown", so protocol auto-detection
// won't trigger. Set it explicitly as production code would.
c.notificationProtocol = "unknown"
var gotStatus bool
c.onMessage = func(msg Message) {
if msg.Type == MessageStatus && msg.Status == "running" {
gotStatus = true
}
}
c.handleLine(`{"jsonrpc":"2.0","method":"turn/started","params":{"turn":{"id":"turn-1"}}}`)
if !gotStatus {
t.Fatal("expected status=running message")
}
if c.notificationProtocol != "raw" {
t.Fatalf("expected protocol=raw, got %q", c.notificationProtocol)
}
if c.turnID != "turn-1" {
t.Fatalf("expected turnID=turn-1, got %q", c.turnID)
}
}
func TestCodexRawTurnCompleted(t *testing.T) {
t.Parallel()
c, _, _ := newTestCodexClient(t)
c.notificationProtocol = "raw"
var doneCount int
c.onTurnDone = func(aborted bool) {
doneCount++
if aborted {
t.Fatal("expected aborted=false")
}
}
c.handleLine(`{"jsonrpc":"2.0","method":"turn/completed","params":{"turn":{"id":"turn-1","status":"completed"}}}`)
if doneCount != 1 {
t.Fatalf("expected onTurnDone called once, got %d", doneCount)
}
}
func TestCodexRawTurnCompletedDeduplication(t *testing.T) {
t.Parallel()
c, _, _ := newTestCodexClient(t)
c.notificationProtocol = "raw"
var doneCount int
c.onTurnDone = func(aborted bool) {
doneCount++
}
c.handleLine(`{"jsonrpc":"2.0","method":"turn/completed","params":{"turn":{"id":"turn-1","status":"completed"}}}`)
c.handleLine(`{"jsonrpc":"2.0","method":"turn/completed","params":{"turn":{"id":"turn-1","status":"completed"}}}`)
if doneCount != 1 {
t.Fatalf("expected deduplication, but onTurnDone called %d times", doneCount)
}
}
func TestCodexRawTurnCompletedAborted(t *testing.T) {
t.Parallel()
c, _, _ := newTestCodexClient(t)
c.notificationProtocol = "raw"
var wasAborted bool
c.onTurnDone = func(aborted bool) {
wasAborted = aborted
}
c.handleLine(`{"jsonrpc":"2.0","method":"turn/completed","params":{"turn":{"id":"turn-2","status":"cancelled"}}}`)
if !wasAborted {
t.Fatal("expected aborted=true for cancelled status")
}
}
func TestCodexRawItemCommandExecution(t *testing.T) {
t.Parallel()
c, _, _ := newTestCodexClient(t)
c.notificationProtocol = "raw"
var messages []Message
c.onMessage = func(msg Message) {
messages = append(messages, msg)
}
c.handleLine(`{"jsonrpc":"2.0","method":"item/started","params":{"item":{"type":"commandExecution","id":"item-1","command":"git status"}}}`)
c.handleLine(`{"jsonrpc":"2.0","method":"item/completed","params":{"item":{"type":"commandExecution","id":"item-1","aggregatedOutput":"on branch main"}}}`)
if len(messages) != 2 {
t.Fatalf("expected 2 messages, got %d", len(messages))
}
if messages[0].Type != MessageToolUse || messages[0].Tool != "exec_command" || messages[0].Input["command"] != "git status" {
t.Fatalf("unexpected start message: %+v", messages[0])
}
if messages[1].Type != MessageToolResult || messages[1].Output != "on branch main" {
t.Fatalf("unexpected complete message: %+v", messages[1])
}
}
func TestCodexRawItemAgentMessageFinalAnswer(t *testing.T) {
t.Parallel()
c, _, _ := newTestCodexClient(t)
c.notificationProtocol = "raw"
c.turnStarted = true
var gotText string
var turnDone bool
c.onMessage = func(msg Message) {
if msg.Type == MessageText {
gotText = msg.Content
}
}
c.onTurnDone = func(aborted bool) {
turnDone = true
}
c.handleLine(`{"jsonrpc":"2.0","method":"item/completed","params":{"item":{"type":"agentMessage","id":"msg-1","text":"Done!","phase":"final_answer"}}}`)
if gotText != "Done!" {
t.Fatalf("expected text 'Done!', got %q", gotText)
}
if !turnDone {
t.Fatal("expected onTurnDone for final_answer")
}
}
func TestCodexRawThreadStatusIdle(t *testing.T) {
t.Parallel()
c, _, _ := newTestCodexClient(t)
c.notificationProtocol = "raw"
c.turnStarted = true
var turnDone bool
c.onTurnDone = func(aborted bool) {
turnDone = true
if aborted {
t.Fatal("expected aborted=false for idle")
}
}
c.handleLine(`{"jsonrpc":"2.0","method":"thread/status/changed","params":{"status":{"type":"idle"}}}`)
if !turnDone {
t.Fatal("expected onTurnDone for idle status")
}
}
func TestCodexCloseAllPending(t *testing.T) {
t.Parallel()
c, _, _ := newTestCodexClient(t)
pr1 := &pendingRPC{ch: make(chan rpcResult, 1), method: "m1"}
pr2 := &pendingRPC{ch: make(chan rpcResult, 1), method: "m2"}
c.mu.Lock()
c.pending[1] = pr1
c.pending[2] = pr2
c.mu.Unlock()
c.closeAllPending(fmt.Errorf("test error"))
r1 := <-pr1.ch
if r1.err == nil {
t.Fatal("expected error for pending 1")
}
r2 := <-pr2.ch
if r2.err == nil {
t.Fatal("expected error for pending 2")
}
c.mu.Lock()
defer c.mu.Unlock()
if len(c.pending) != 0 {
t.Fatalf("expected empty pending map, got %d", len(c.pending))
}
}
func TestCodexHandleInvalidJSON(t *testing.T) {
t.Parallel()
c, _, _ := newTestCodexClient(t)
// Should not panic
c.handleLine("not json at all")
c.handleLine("")
c.handleLine("{}")
}
func TestExtractThreadID(t *testing.T) {
t.Parallel()
data := json.RawMessage(`{"thread":{"id":"t-123"}}`)
got := extractThreadID(data)
if got != "t-123" {
t.Fatalf("expected t-123, got %q", got)
}
}
func TestExtractThreadIDMissing(t *testing.T) {
t.Parallel()
got := extractThreadID(json.RawMessage(`{}`))
if got != "" {
t.Fatalf("expected empty, got %q", got)
}
}
func TestExtractNestedString(t *testing.T) {
t.Parallel()
m := map[string]any{
"a": map[string]any{
"b": "value",
},
}
got := extractNestedString(m, "a", "b")
if got != "value" {
t.Fatalf("expected 'value', got %q", got)
}
}
func TestExtractNestedStringMissingKey(t *testing.T) {
t.Parallel()
m := map[string]any{"a": "flat"}
got := extractNestedString(m, "a", "b")
if got != "" {
t.Fatalf("expected empty, got %q", got)
}
}
func TestNilIfEmpty(t *testing.T) {
t.Parallel()
if nilIfEmpty("") != nil {
t.Fatal("expected nil for empty string")
}
if nilIfEmpty("hello") != "hello" {
t.Fatal("expected 'hello'")
}
}
func TestCodexProtocolDetectionLegacyBlocksRaw(t *testing.T) {
t.Parallel()
c, _, _ := newTestCodexClient(t)
var messages []Message
c.onMessage = func(msg Message) {
messages = append(messages, msg)
}
// First: receive a legacy event -> locks to "legacy"
c.handleLine(`{"jsonrpc":"2.0","method":"codex/event","params":{"msg":{"type":"task_started"}}}`)
if c.notificationProtocol != "legacy" {
t.Fatalf("expected legacy, got %q", c.notificationProtocol)
}
// Now send a raw notification -> should be ignored
messagesBefore := len(messages)
c.handleLine(`{"jsonrpc":"2.0","method":"turn/started","params":{"turn":{"id":"turn-1"}}}`)
if len(messages) != messagesBefore {
t.Fatal("raw notification should be ignored in legacy mode")
}
}

View file

@ -92,6 +92,24 @@ type DaemonConnection struct {
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type DaemonPairingSession struct {
ID pgtype.UUID `json:"id"`
Token string `json:"token"`
DaemonID string `json:"daemon_id"`
DeviceName string `json:"device_name"`
RuntimeName string `json:"runtime_name"`
RuntimeType string `json:"runtime_type"`
RuntimeVersion string `json:"runtime_version"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
ApprovedBy pgtype.UUID `json:"approved_by"`
Status string `json:"status"`
ApprovedAt pgtype.Timestamptz `json:"approved_at"`
ClaimedAt pgtype.Timestamptz `json:"claimed_at"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type InboxItem struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
@ -172,4 +190,5 @@ type Workspace struct {
Settings []byte `json:"settings"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Context pgtype.Text `json:"context"`
}

View file

@ -12,19 +12,25 @@ import (
)
const createWorkspace = `-- name: CreateWorkspace :one
INSERT INTO workspace (name, slug, description)
VALUES ($1, $2, $3)
RETURNING id, name, slug, description, settings, created_at, updated_at
INSERT INTO workspace (name, slug, description, context)
VALUES ($1, $2, $3, $4)
RETURNING id, name, slug, description, settings, created_at, updated_at, context
`
type CreateWorkspaceParams struct {
Name string `json:"name"`
Slug string `json:"slug"`
Description pgtype.Text `json:"description"`
Context pgtype.Text `json:"context"`
}
func (q *Queries) CreateWorkspace(ctx context.Context, arg CreateWorkspaceParams) (Workspace, error) {
row := q.db.QueryRow(ctx, createWorkspace, arg.Name, arg.Slug, arg.Description)
row := q.db.QueryRow(ctx, createWorkspace,
arg.Name,
arg.Slug,
arg.Description,
arg.Context,
)
var i Workspace
err := row.Scan(
&i.ID,
@ -34,6 +40,7 @@ func (q *Queries) CreateWorkspace(ctx context.Context, arg CreateWorkspaceParams
&i.Settings,
&i.CreatedAt,
&i.UpdatedAt,
&i.Context,
)
return i, err
}
@ -48,7 +55,7 @@ func (q *Queries) DeleteWorkspace(ctx context.Context, id pgtype.UUID) error {
}
const getWorkspace = `-- name: GetWorkspace :one
SELECT id, name, slug, description, settings, created_at, updated_at FROM workspace
SELECT id, name, slug, description, settings, created_at, updated_at, context FROM workspace
WHERE id = $1
`
@ -63,12 +70,13 @@ func (q *Queries) GetWorkspace(ctx context.Context, id pgtype.UUID) (Workspace,
&i.Settings,
&i.CreatedAt,
&i.UpdatedAt,
&i.Context,
)
return i, err
}
const getWorkspaceBySlug = `-- name: GetWorkspaceBySlug :one
SELECT id, name, slug, description, settings, created_at, updated_at FROM workspace
SELECT id, name, slug, description, settings, created_at, updated_at, context FROM workspace
WHERE slug = $1
`
@ -83,12 +91,13 @@ func (q *Queries) GetWorkspaceBySlug(ctx context.Context, slug string) (Workspac
&i.Settings,
&i.CreatedAt,
&i.UpdatedAt,
&i.Context,
)
return i, err
}
const listWorkspaces = `-- name: ListWorkspaces :many
SELECT w.id, w.name, w.slug, w.description, w.settings, w.created_at, w.updated_at FROM workspace w
SELECT w.id, w.name, w.slug, w.description, w.settings, w.created_at, w.updated_at, w.context FROM workspace w
JOIN member m ON m.workspace_id = w.id
WHERE m.user_id = $1
ORDER BY w.created_at ASC
@ -111,6 +120,7 @@ func (q *Queries) ListWorkspaces(ctx context.Context, userID pgtype.UUID) ([]Wor
&i.Settings,
&i.CreatedAt,
&i.UpdatedAt,
&i.Context,
); err != nil {
return nil, err
}
@ -126,16 +136,18 @@ const updateWorkspace = `-- name: UpdateWorkspace :one
UPDATE workspace SET
name = COALESCE($2, name),
description = COALESCE($3, description),
settings = COALESCE($4, settings),
context = COALESCE($4, context),
settings = COALESCE($5, settings),
updated_at = now()
WHERE id = $1
RETURNING id, name, slug, description, settings, created_at, updated_at
RETURNING id, name, slug, description, settings, created_at, updated_at, context
`
type UpdateWorkspaceParams struct {
ID pgtype.UUID `json:"id"`
Name pgtype.Text `json:"name"`
Description pgtype.Text `json:"description"`
Context pgtype.Text `json:"context"`
Settings []byte `json:"settings"`
}
@ -144,6 +156,7 @@ func (q *Queries) UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams
arg.ID,
arg.Name,
arg.Description,
arg.Context,
arg.Settings,
)
var i Workspace
@ -155,6 +168,7 @@ func (q *Queries) UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams
&i.Settings,
&i.CreatedAt,
&i.UpdatedAt,
&i.Context,
)
return i, err
}

View file

@ -13,14 +13,15 @@ SELECT * FROM workspace
WHERE slug = $1;
-- name: CreateWorkspace :one
INSERT INTO workspace (name, slug, description)
VALUES ($1, $2, $3)
INSERT INTO workspace (name, slug, description, context)
VALUES ($1, $2, $3, $4)
RETURNING *;
-- name: UpdateWorkspace :one
UPDATE workspace SET
name = COALESCE(sqlc.narg('name'), name),
description = COALESCE(sqlc.narg('description'), description),
context = COALESCE(sqlc.narg('context'), context),
settings = COALESCE(sqlc.narg('settings'), settings),
updated_at = now()
WHERE id = $1