Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,41 @@ graph TB

**AI Provider** — The AI backend that powers Alice. Claude (via Agent SDK, supports OAuth login or API key) or Vercel AI SDK (Anthropic, OpenAI, Google). Switchable at runtime — no restart needed.

## Two kinds of chat

OpenAlice ships two paths for chatting with Alice. They have very different performance characteristics, and picking the right one matters more than it looks.

### Workspace chat (recommended when available)

A chat-type **workspace** is a directory + git repo + a persistent terminal session running the native CLI of your chosen agent (`claude`, `codex`, or `shell`). The CLI process handles all model interaction, prompt caching, and rendering — OpenAlice's job is to plumb its MCP server into the workspace and surface the terminal in the UI.

- **Native prompt cache.** Claude Code, Codex, and the other agent CLIs implement vendor-specific cache control we can't replicate. On a long conversation this is often a 10× cost reduction.
- **Native frontend.** TUI rendering, syntax highlighting, diff display — the CLI vendor has already tuned these for their model.
- **Full tool surface.** The CLI sees the workspace's local files plus OpenAlice's MCP tools. No "greatest-common-denominator" trimming.
- **Stable.** No `ChatHook` protocol layer between you and the model.

The only requirement: the CLI binary has to be installed on the host running OpenAlice.

### Traditional chat

The original `/chat` page. OpenAlice's `ChatHook` calls the agent SDK (Vercel AI SDK or Anthropic Agent SDK) directly, normalizes events through its own layer, and renders them.

- **No shell required.** Works in any environment OpenAlice runs in.
- **Reachable by connectors.** Telegram, MCP Ask, and webhook-pushed messages all land here because those surfaces have no terminal to host a CLI in.

The cost: token usage is high (cache control is OpenAlice's responsibility and still incomplete), capability is constrained (each new CLI feature has to be re-implemented at the `ChatHook` layer), and `ChatHook` itself is still maturing.

### Which one should I use?

| Scenario | Use |
| --- | --- |
| Interactive UI chats with Alice on this machine | **Workspace chat** |
| Connector-pushed messages (Telegram bot, webhook callbacks) | Traditional chat |
| Long sessions where token cost matters | **Workspace chat** |
| Environment with no shell / no CLI installed | Traditional chat |

Today the connectors are wired to traditional chat. If shell-bridged connectors arrive later, they can opt into workspace chat too — the two paths are designed to coexist, not replace each other.

## Quick Start

Prerequisites: Node.js 22+, pnpm 10+, [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated.
Expand Down
33 changes: 33 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,39 @@ the item when done — git log is the history.
third parties (Together, Groq, vLLM, LM Studio, Ollama) a
proper light chat path. Same structural shape as the Anthropic
one. ~3-4h.
- [ ] **Profile + AI Provider model needs structural rethink.**
Surfaced 2026-05-13 during workspaces' per-workspace codex
override testing (commit `6b52853`). `ai-provider-manager.json`
profiles (Kimi/MiniMax/DeepSeek/Claude Pro) are **claude-shaped**
— each profile's `baseUrl` is the vendor's Anthropic-compat
endpoint (e.g. `https://api.moonshot.ai/anthropic`) and `model`
is the Anthropic-side model name. Applying the same profile to
codex via `WorkspaceAIConfigModal`'s shared Apply-from-profile
quick-pick silently produces invalid configs: codex speaks
OpenAI Responses shape, POSTs against an Anthropic endpoint →
`POST /anthropic/responses` → 404.

The shape mismatch isn't a quick-fix item — disabling
Apply-in-codex-tab or adding "no `/anthropic` in baseUrl"
sanity checks just covers one foot-gun each. "AI Provider"
today conflates four concepts that have started to diverge:
Anthropic-API endpoints, OpenAI-API endpoints, **vendor
identity** (Moonshot/DeepSeek/etc., one credential, multiple
endpoint shapes), and the `(baseUrl, model)` triple stored
per-profile. Each consumer (chat path / workspace claude /
workspace codex / future Anthropic-native / future
OpenAI-native) needs a different slice. Bolting more
`(baseUrl, apiKey, model)` triples onto the profile struct
keeps stacking the conflation.

Adjacent work that should design together (don't ship in
isolation): the **Native Anthropic / Native OpenAI provider**
TODOs above — their concrete client-side input shapes
clarify what profile dimensions actually need to exist. The
per-workspace override modal (just shipped) hits the same
foot-gun and would benefit from the redesigned profile model
directly. Land all three in one focused pass.

- [ ] Unified config hot-reload. Right now every consumer of a config
section has to solve "did the user edit this?" on its own —
Telegram/MCP-Ask via `reconnectConnectors`, opentypebb via lazy
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "open-alice",
"version": "0.10.0-beta.4",
"version": "0.10.0-beta.5",
"description": "File-based trading agent engine",
"type": "module",
"scripts": {
Expand Down
62 changes: 37 additions & 25 deletions src/webui/routes/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@

import { Hono } from 'hono';
import { randomUUID } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { resolve as resolvePath } from 'node:path';
import { readFile, rm } from 'node:fs/promises';
import { join, resolve as resolvePath } from 'node:path';

import { listDir, PathTraversal, readWorkspaceFile, writeWorkspaceFile } from '../../workspaces/file-service.js';
import { gitLog, gitStatus } from '../../workspaces/git-service.js';
Expand All @@ -29,6 +29,8 @@ export function createWorkspaceRoutes(svc: WorkspaceService): Hono {
templates: svc.templates.list().map((t) => ({
name: t.name,
...(t.description !== undefined ? { description: t.description } : {}),
...(t.displayName !== undefined ? { displayName: t.displayName } : {}),
...(t.groupOrder !== undefined ? { groupOrder: t.groupOrder } : {}),
defaultAgents: t.defaultAgents,
})),
});
Expand Down Expand Up @@ -546,8 +548,11 @@ async function readClaudeConfig(workspaceDir: string): Promise<ClaudeConfigShape
async function writeClaudeConfig(workspaceDir: string, cfg: AgentConfigInput): Promise<void> {
const hasAny = cfg.baseUrl || cfg.apiKey || cfg.model;
if (!hasAny) {
// Reset: empty settings.local.json so claude falls back to global.
await writeWorkspaceFile(workspaceDir, CLAUDE_SETTINGS_PATH, '{}\n');
// Reset: delete the settings file so claude falls back to its global
// OAuth / settings. We don't leave an empty `{}` behind — workspace
// files exist only when there's an actual override.
const filePath = join(workspaceDir, CLAUDE_SETTINGS_PATH);
await rm(filePath, { force: true });
return;
}
const out: Record<string, unknown> = {};
Expand Down Expand Up @@ -598,37 +603,44 @@ async function readCodexConfig(workspaceDir: string): Promise<CodexConfigShape |
}

async function writeCodexConfig(workspaceDir: string, cfg: AgentConfigInput): Promise<void> {
// Always preserve the MCP block (constant content, OpenAlice infrastructure).
// Provider block + top-level `model` / `model_provider` only when configured.
const mcpBlock =
'[mcp_servers.openalice]\n' +
'url = "${OPENALICE_MCP_URL:-http://127.0.0.1:3001/mcp}"\n';

const hasProvider = !!(cfg.baseUrl || cfg.model);
let toml = mcpBlock;
if (hasProvider) {

if (!hasProvider) {
// Reset: tear down the workspace's entire `.codex/` directory. The
// adapter's `composeEnv` won't set `CODEX_HOME` when the directory is
// absent, so codex falls back to the user's global `~/.codex/`. We
// don't leave empty stubs behind — workspace files exist only when
// there's an actual override. Note: `CODEX_HOME` is exclusive (not a
// merge layer), so a half-empty `.codex/` would *shadow* the user's
// global login and break auth. Full teardown is the only safe reset.
const codexDir = join(workspaceDir, '.codex');
await rm(codexDir, { recursive: true, force: true });
return;
}

// Provider override. config.toml carries only model / model_provider /
// [model_providers.*] — the OpenAlice MCP server entry is wired per-spawn
// via the codex adapter's `-c mcp_servers.openalice.url=...` flag, so we
// don't repeat it here.
let toml = '';
if (cfg.model) toml += `model = ${tomlString(cfg.model)}\n`;
if (cfg.baseUrl) toml += `model_provider = "${CODEX_PROVIDER_NAME}"\n`;
if (cfg.baseUrl) {
toml += '\n';
if (cfg.model) toml += `model = ${tomlString(cfg.model)}\n`;
if (cfg.baseUrl) toml += `model_provider = "${CODEX_PROVIDER_NAME}"\n`;
if (cfg.baseUrl) {
toml += '\n';
toml += `[model_providers.${CODEX_PROVIDER_NAME}]\n`;
toml += `name = "OpenAlice workspace provider"\n`;
toml += `base_url = ${tomlString(cfg.baseUrl)}\n`;
toml += `env_key = "${CODEX_KEY_ENV_NAME}"\n`;
toml += `wire_api = "${cfg.wireApi ?? 'chat'}"\n`;
}
toml += `[model_providers.${CODEX_PROVIDER_NAME}]\n`;
toml += `name = "OpenAlice workspace provider"\n`;
toml += `base_url = ${tomlString(cfg.baseUrl)}\n`;
toml += `env_key = "${CODEX_KEY_ENV_NAME}"\n`;
toml += `wire_api = "${cfg.wireApi ?? 'chat'}"\n`;
}
await writeWorkspaceFile(workspaceDir, CODEX_CONFIG_PATH, toml);

// env.json: holds the per-workspace API key codex picks up via env_key.
// Adapter's composeEnv reads this and exports at spawn. Empty / missing
// file = no workspace key (codex falls back to ~/.codex/auth.json OAuth).
// Adapter's composeEnv reads this and exports at spawn.
if (cfg.apiKey) {
const envObj: Record<string, string> = { [CODEX_KEY_ENV_NAME]: cfg.apiKey };
await writeWorkspaceFile(workspaceDir, CODEX_ENV_PATH, JSON.stringify(envObj, null, 2) + '\n');
} else {
// Reset key: write empty object so the adapter sees nothing to inject.
await writeWorkspaceFile(workspaceDir, CODEX_ENV_PATH, '{}\n');
}
}
Expand Down
75 changes: 43 additions & 32 deletions src/workspaces/adapters/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,28 @@ import type { BootstrapContext, CliAdapter, SpawnContext } from '../cli-adapter.
* pre-writes that entry so the launcher's spawn doesn't stall on the
* prompt.
*
* AI provider config: each workspace owns its own `.codex/` (we set
* `CODEX_HOME=<cwd>/.codex` via `composeEnv` below). The workspace's
* `config.toml` carries the OpenAlice MCP server entry + any
* UI-configured `[model_providers.*]` blocks; `auth.json` starts as a
* symlink to `~/.codex/auth.json` (graceful fallback to global login)
* and gets replaced by the UI with a real file when the user picks a
* workspace-specific provider. The MCP-flag translation that the
* launcher originally did via `mcpJsonToCodexFlags` is gone — codex
* reads MCP entries directly from the workspace's `config.toml` now.
* AI provider model — two modes, mutually exclusive:
*
* 1. **Default (no override).** Workspace has no `.codex/` directory.
* Adapter doesn't set `CODEX_HOME`. Codex reads the user's global
* `~/.codex/auth.json` + `~/.codex/config.toml` — exactly what a
* vanilla `codex` invocation in any project does. The OpenAlice MCP
* server is wired via the per-invocation `-c mcp_servers.openalice.url=...`
* flag in `composeCommand` below, so MCP is visible without
* polluting the user's global config.
*
* 2. **Override (user-configured via OpenAlice UI).** Workspace has its
* own `.codex/{config.toml, env.json[, auth.json]}`. Adapter sets
* `CODEX_HOME=<cwd>/.codex`. Codex reads workspace files only,
* isolated from global state.
*
* No symlinks, no global-fallback inheritance. The `-c` flag is OpenAlice's
* "local MCP registration" — analogous to claude's `.mcp.json` cwd
* discovery, but driven via codex's CLI override flag since codex has no
* cwd-MCP convention of its own.
*/
const OPENALICE_MCP_URL_DEFAULT = 'http://127.0.0.1:3001/mcp';

export const codexAdapter: CliAdapter = {
id: 'codex',
displayName: 'Codex',
Expand All @@ -45,41 +57,40 @@ export const codexAdapter: CliAdapter = {
transcriptDiscovery: 'none',
},

/**
* Always prepends `-c mcp_servers.openalice.url="..."` so OpenAlice MCP
* is visible per-spawn without writing to `~/.codex/config.toml`. The
* flag overrides any same-key entry in the read config.toml (verified
* empirically), and adds a new key when none exists — safe in both
* default and override modes.
*/
composeCommand(_base: readonly string[], ctx: SpawnContext): readonly string[] {
const head = ['codex'];
const mcpUrl = process.env['OPENALICE_MCP_URL'] ?? OPENALICE_MCP_URL_DEFAULT;
const head = ['codex', '-c', `mcp_servers.openalice.url="${mcpUrl}"`];
if (ctx.resume === undefined) return head;
if (ctx.resume === 'last') return [...head, 'resume', '--last'];
return [...head, 'resume', ctx.resume.sessionId];
},

/**
* Point codex at the workspace's own `.codex/` and inject any
* UI-configured env vars (api keys named by `[model_providers.X].env_key`
* in the workspace's `config.toml`).
* Set `CODEX_HOME` only when workspace has its own `.codex/` directory
* (override mode). Otherwise codex falls back to its own `~/.codex/`,
* which is its normal behavior in any uninvolved project. The "reset
* to default" UI action deletes the entire `.codex/` directory so the
* adapter naturally falls back here.
*
* Defensive: only set `CODEX_HOME` when `<cwd>/.codex/auth.json` exists.
* Codex *crashes* ("Codex requires a login") if `CODEX_HOME` points at a
* dir without `auth.json`. Workspaces created by the current bootstrap
* always have it (real file or symlink to `~/.codex/auth.json`); legacy
* workspaces from before this change don't — those silently fall back
* to the global `~/.codex/` and lose workspace-MCP wiring until
* recreated. That's the migration story.
*
* `.codex/env.json` is the workspace's per-CLI env contribution. Codex
* has no notion of "literal api key in config" — its `env_key` field
* indirects through an env var. The OpenAlice UI writes the user's
* chosen key into `env.json` (e.g. `{"OPENALICE_WORKSPACE_KEY":"sk-..."}`)
* and the adapter exports those at spawn so codex's `env_key` lookup
* resolves. This is the one place we DO bridge a file → env (because
* codex requires env), but the source of truth is still the workspace
* file, not OpenAlice's internal state.
* `.codex/env.json` is OpenAlice's per-workspace key bridge. Codex's
* `[model_providers.X].env_key` field indirects through an env var; the
* UI writes the chosen key into `env.json` and the adapter exports it
* at spawn so codex's `env_key` lookup resolves. This is the only place
* we bridge file → env, and the source of truth is still the workspace
* file (not OpenAlice's internal state).
*/
composeEnv(ctx: SpawnContext): Record<string, string> {
const result: Record<string, string> = {};
const workspaceCodex = join(ctx.cwd, '.codex');
if (existsSync(join(workspaceCodex, 'auth.json'))) {
result['CODEX_HOME'] = workspaceCodex;
}
if (!existsSync(workspaceCodex)) return result;
result['CODEX_HOME'] = workspaceCodex;
const envFile = join(workspaceCodex, 'env.json');
if (existsSync(envFile)) {
try {
Expand Down
10 changes: 9 additions & 1 deletion src/workspaces/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* Lifecycle: `createWorkspaceService()` at plugin start; `dispose()` at stop.
*/

import { existsSync } from 'node:fs';
import { join } from 'node:path';

import { claudeAdapter } from './adapters/claude.js';
Expand Down Expand Up @@ -172,7 +173,14 @@ export async function createWorkspaceService(): Promise<WorkspaceService> {
startedAt: liveEntry?.startedAt ?? null,
};
});
return { ...w, sessions };
// Workspace AI provider override signals — read by the Overview
// dashboard for the "⚙ Workspace override" footer per card. Cheap
// (single statSync each) so it's safe on the regular list poll.
const agentOverride = {
claude: existsSync(join(w.dir, '.claude', 'settings.local.json')),
codex: existsSync(join(w.dir, '.codex')),
};
return { ...w, sessions, agentOverride };
};

const dispose = async (reason: string): Promise<void> => {
Expand Down
25 changes: 25 additions & 0 deletions src/workspaces/template-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ import type { Logger } from './logger.js';
export interface TemplateMeta {
readonly name: string;
readonly description?: string;
/**
* Human-readable name surfaced in the UI (dashboard section headers,
* etc.). Sourced from `template.json`'s `displayName` key. Falls back
* to a humanized form of `name` on the client when missing.
*/
readonly displayName?: string;
/**
* Sort key for grouping workspaces by template type in the dashboard.
* Lower = earlier. Sourced from `template.json`'s `groupOrder` key.
* Templates without a declared `groupOrder` sort after declared ones,
* by name. New template types just need to add this field to their
* `template.json` — no frontend code change required.
*/
readonly groupOrder?: number;
/** Absolute path to the template's `bootstrap.sh`. */
readonly bootstrapScript: string;
/** Absolute path to the template's `files/` directory (may not exist). */
Expand Down Expand Up @@ -55,6 +69,8 @@ export class TemplateRegistry {
const meta: TemplateMeta = {
name,
...(tplMeta.description !== undefined ? { description: tplMeta.description } : {}),
...(tplMeta.displayName !== undefined ? { displayName: tplMeta.displayName } : {}),
...(tplMeta.groupOrder !== undefined ? { groupOrder: tplMeta.groupOrder } : {}),
bootstrapScript,
filesDir,
defaultAgents: tplMeta.defaultAgents,
Expand Down Expand Up @@ -96,6 +112,8 @@ export class TemplateRegistry {

interface ParsedTemplateMeta {
readonly description?: string;
readonly displayName?: string;
readonly groupOrder?: number;
readonly defaultAgents: readonly string[];
}

Expand All @@ -112,11 +130,18 @@ async function readTemplateMeta(path: string): Promise<ParsedTemplateMeta> {
if (typeof parsed !== 'object' || parsed === null) return fallback;
const obj = parsed as Record<string, unknown>;
const description = typeof obj['description'] === 'string' ? obj['description'] : undefined;
const displayName = typeof obj['displayName'] === 'string' ? obj['displayName'] : undefined;
const groupOrder =
typeof obj['groupOrder'] === 'number' && Number.isFinite(obj['groupOrder'])
? obj['groupOrder']
: undefined;
const defaultAgents = Array.isArray(obj['defaultAgents'])
? obj['defaultAgents'].filter((a): a is string => typeof a === 'string')
: null;
return {
...(description !== undefined ? { description } : {}),
...(displayName !== undefined ? { displayName } : {}),
...(groupOrder !== undefined ? { groupOrder } : {}),
defaultAgents: defaultAgents && defaultAgents.length > 0 ? defaultAgents : ['claude'],
};
} catch {
Expand Down
Loading
Loading