mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-09 01:49:49 -10:00
feat(runtime-api): list the agent CLIs already installed on this computer
GET /v1/agents/external reports Claude, Codex, Gemini CLI, OpenCode, Kimi
Code, Mistral Vibe, Qoder, CodeBuddy and Grok. Each binary is resolved on
PATH and in the usual per-user bin directories, then probed concurrently
with `--version` and, where the CLI speaks Agent Client Protocol itself,
one `initialize` over stdio. The response carries the version, whether ACP
is native or needs a separate adapter, the agent's own auth methods, and
its website. `runnable` stays false: this route reports, it does not start
an agent.
The probes are child processes that never receive Codewhale or provider
credentials: CODEWHALE_*, *_API_KEY, *_AUTH_TOKEN, *_ACCESS_KEY and
*_SECRET_KEY are removed from the child environment. Nothing is stored.
Timeouts are 8 s for the handshake and 5 s for --version, so a missing or
hanging CLI cannot stall the route.
Validation:
- Farm build on Boat (farm-722523): `cargo build --bin codewhale`, exit 0,
with the exact working-tree copies of the two source files.
- Live route against that binary on the same VM:
- Real CLIs on the image: claude 2.1.319 (adapter), codex 0.167.0
(adapter), opencode 1.19.5 (native, agent auth), kimi 2.5.2 (native,
terminal auth), mistral-vibe 3.1.0 (native, agent auth); gemini,
qoder, codebuddy and grok reported not_installed.
- Fixture binaries for all nine: each reported the expected
adapter/native kind, version and both auth method types.
- Environment: 32 fixture invocations, zero leaked names (no
CODEWHALE_HOME, provider *_API_KEY or CODEWHALE_SECRET_SENTINEL).
- Nothing written under CODEWHALE_HOME.
- `node web/scripts/check-facts.mjs` exit 0 after regenerating the derived
docs from a clean HEAD export.
- Not run here: clippy in this session (it passed on these exact file
contents earlier today); hosted CI.
This commit is contained in:
1 parent
0d1ffc1cb2
commit
c4a7d694a0
6 files changed
+383
-2
No files matched your search
@@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- **Runtime API: find the agent CLIs already installed on this computer.**
|
||||
`GET /v1/agents/external` reports Claude, Codex, Gemini CLI, OpenCode, Kimi
|
||||
Code, Mistral Vibe, Qoder, CodeBuddy and Grok, with the version each one
|
||||
reports and whether it answers Agent Client Protocol itself. The probe runs
|
||||
`--version` and one `initialize` handshake, gets no Codewhale or provider
|
||||
credentials, and stores nothing. Read-only: it does not start an agent yet.
|
||||
- **Runtime API: check a provider key before storing it.**
|
||||
`POST /v1/providers/{id}/key/check` asks the provider's own `/models`
|
||||
endpoint once whether it accepts a key, and never stores it. A key the
|
||||
|
||||
@@ -116,6 +116,7 @@ mod avatars;
|
||||
mod computer_display;
|
||||
mod context;
|
||||
mod diagnostics;
|
||||
mod external_agents;
|
||||
mod git;
|
||||
mod jobs;
|
||||
pub mod listener;
|
||||
@@ -3408,6 +3409,10 @@ pub fn build_router(state: RuntimeApiState) -> Router {
|
||||
secrets::PROVIDER_KEY_BODY_LIMIT_BYTES,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/v1/agents/external",
|
||||
get(external_agents::list_external_agents),
|
||||
)
|
||||
.route("/v1/providers", get(list_providers))
|
||||
.route("/v1/providers/{id}/models", get(list_provider_models))
|
||||
.route(
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::Json;
|
||||
use serde_json::{Value, json};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::Command;
|
||||
use tokio::task::JoinSet;
|
||||
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(8);
|
||||
const VERSION_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const MAX_PROBE_LINE_BYTES: usize = 256 * 1024;
|
||||
const MAX_TEXT_CHARS: usize = 80;
|
||||
const MAX_AUTH_METHODS: usize = 8;
|
||||
|
||||
struct Agent {
|
||||
id: &'static str,
|
||||
name: &'static str,
|
||||
binary: &'static str,
|
||||
acp_args: Option<&'static [&'static str]>,
|
||||
website: &'static str,
|
||||
}
|
||||
|
||||
const AGENTS: &[Agent] = &[
|
||||
Agent {
|
||||
id: "claude",
|
||||
name: "Claude Agent",
|
||||
binary: "claude",
|
||||
acp_args: None,
|
||||
website: "https://github.com/agentclientprotocol/claude-agent-acp",
|
||||
},
|
||||
Agent {
|
||||
id: "codex",
|
||||
name: "Codex",
|
||||
binary: "codex",
|
||||
acp_args: None,
|
||||
website: "https://github.com/agentclientprotocol/codex-acp",
|
||||
},
|
||||
Agent {
|
||||
id: "gemini",
|
||||
name: "Gemini CLI",
|
||||
binary: "gemini",
|
||||
acp_args: Some(&["--acp"]),
|
||||
website: "https://geminicli.com",
|
||||
},
|
||||
Agent {
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
binary: "opencode",
|
||||
acp_args: Some(&["acp"]),
|
||||
website: "https://opencode.ai",
|
||||
},
|
||||
Agent {
|
||||
id: "kimi",
|
||||
name: "Kimi Code",
|
||||
binary: "kimi",
|
||||
acp_args: Some(&["acp"]),
|
||||
website: "https://moonshotai.github.io/kimi-code/",
|
||||
},
|
||||
Agent {
|
||||
id: "mistral-vibe",
|
||||
name: "Mistral Vibe",
|
||||
binary: "vibe-acp",
|
||||
acp_args: Some(&[]),
|
||||
website: "https://mistral.ai/products/vibe",
|
||||
},
|
||||
Agent {
|
||||
id: "qoder",
|
||||
name: "Qoder",
|
||||
binary: "qodercli",
|
||||
acp_args: Some(&["--acp"]),
|
||||
website: "https://qoder.com",
|
||||
},
|
||||
Agent {
|
||||
id: "codebuddy",
|
||||
name: "CodeBuddy",
|
||||
binary: "codebuddy",
|
||||
acp_args: Some(&["--acp"]),
|
||||
website: "https://www.codebuddy.cn/cli/",
|
||||
},
|
||||
Agent {
|
||||
id: "grok",
|
||||
name: "Grok",
|
||||
binary: "grok",
|
||||
acp_args: Some(&["agent", "stdio"]),
|
||||
website: "https://x.ai/cli",
|
||||
},
|
||||
];
|
||||
|
||||
const USER_BIN_DIRS: &[&str] = &[
|
||||
".local/bin",
|
||||
".npm-global/bin",
|
||||
".opencode/bin",
|
||||
".kimi-code/bin",
|
||||
".bun/bin",
|
||||
".cargo/bin",
|
||||
];
|
||||
const SYSTEM_BIN_DIRS: &[&str] = &["/opt/homebrew/bin", "/usr/local/bin"];
|
||||
|
||||
fn resolve(binary: &str) -> Option<PathBuf> {
|
||||
let mut dirs: Vec<PathBuf> = std::env::var_os("PATH")
|
||||
.map(|path| std::env::split_paths(&path).collect())
|
||||
.unwrap_or_default();
|
||||
if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
|
||||
dirs.extend(USER_BIN_DIRS.iter().map(|dir| home.join(dir)));
|
||||
}
|
||||
dirs.extend(SYSTEM_BIN_DIRS.iter().map(PathBuf::from));
|
||||
dirs.into_iter()
|
||||
.map(|dir| dir.join(binary))
|
||||
.find(|candidate| is_executable(candidate))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn is_executable(path: &Path) -> bool {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::metadata(path)
|
||||
.is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn is_executable(path: &Path) -> bool {
|
||||
path.is_file()
|
||||
}
|
||||
|
||||
fn foreign_command(path: &Path, args: &[&str]) -> Command {
|
||||
let mut command = Command::new(path);
|
||||
command
|
||||
.args(args)
|
||||
.current_dir(std::env::temp_dir())
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.kill_on_drop(true);
|
||||
for (name, _) in std::env::vars_os() {
|
||||
let Some(name) = name.to_str() else { continue };
|
||||
if name.starts_with("CODEWHALE_")
|
||||
|| name.ends_with("_API_KEY")
|
||||
|| name.ends_with("_AUTH_TOKEN")
|
||||
|| name.ends_with("_ACCESS_KEY")
|
||||
|| name.ends_with("_SECRET_KEY")
|
||||
{
|
||||
command.env_remove(name);
|
||||
}
|
||||
}
|
||||
command
|
||||
}
|
||||
|
||||
fn clean(text: &str) -> String {
|
||||
text.chars()
|
||||
.filter(|ch| !ch.is_control())
|
||||
.take(MAX_TEXT_CHARS)
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn version_token(text: &str) -> Option<String> {
|
||||
text.split(|ch: char| ch.is_whitespace() || ch == '(' || ch == ')' || ch == ',')
|
||||
.map(|token| token.trim_start_matches('v'))
|
||||
.find(|token| {
|
||||
token.chars().next().is_some_and(|ch| ch.is_ascii_digit()) && token.contains('.')
|
||||
})
|
||||
.map(clean)
|
||||
}
|
||||
|
||||
async fn cli_version(path: &Path) -> Option<String> {
|
||||
let mut command = foreign_command(path, &["--version"]);
|
||||
command.stdin(Stdio::null());
|
||||
let output = tokio::time::timeout(VERSION_TIMEOUT, command.output())
|
||||
.await
|
||||
.ok()?
|
||||
.ok()?;
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
version_token(text.lines().next().unwrap_or_default())
|
||||
}
|
||||
|
||||
async fn acp_initialize(path: &Path, args: &[&str]) -> Option<Value> {
|
||||
let mut child = foreign_command(path, args).spawn().ok()?;
|
||||
let mut stdin = child.stdin.take()?;
|
||||
let stdout = child.stdout.take()?;
|
||||
let request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": 1,
|
||||
"clientCapabilities": {
|
||||
"fs": { "readTextFile": false, "writeTextFile": false },
|
||||
"terminal": false
|
||||
},
|
||||
"clientInfo": {
|
||||
"name": "codewhale",
|
||||
"title": "Codewhale",
|
||||
"version": env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut line = request.to_string();
|
||||
line.push('\n');
|
||||
stdin.write_all(line.as_bytes()).await.ok()?;
|
||||
stdin.flush().await.ok()?;
|
||||
let mut lines = BufReader::new(stdout).lines();
|
||||
let reply = tokio::time::timeout(PROBE_TIMEOUT, async {
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
if line.len() > MAX_PROBE_LINE_BYTES {
|
||||
return None;
|
||||
}
|
||||
let Ok(message) = serde_json::from_str::<Value>(&line) else {
|
||||
continue;
|
||||
};
|
||||
if message.get("id") == Some(&json!(1)) {
|
||||
return message.get("result").cloned();
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
drop(stdin);
|
||||
let _ = child.start_kill();
|
||||
let _ = child.wait().await;
|
||||
reply
|
||||
}
|
||||
|
||||
fn auth_methods(result: &Value) -> Vec<Value> {
|
||||
result
|
||||
.get("authMethods")
|
||||
.and_then(Value::as_array)
|
||||
.map(|methods| {
|
||||
methods
|
||||
.iter()
|
||||
.take(MAX_AUTH_METHODS)
|
||||
.filter_map(|method| {
|
||||
let id = clean(method.get("id")?.as_str()?);
|
||||
let name = method
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.map_or_else(|| id.clone(), clean);
|
||||
let kind = method
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.map_or("agent", clean_kind);
|
||||
Some(json!({ "id": id, "name": name, "type": kind }))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn clean_kind(kind: &str) -> &'static str {
|
||||
if kind == "terminal" {
|
||||
"terminal"
|
||||
} else {
|
||||
"agent"
|
||||
}
|
||||
}
|
||||
|
||||
async fn describe(agent: &'static Agent) -> Value {
|
||||
let Some(path) = resolve(agent.binary) else {
|
||||
return json!({
|
||||
"id": agent.id,
|
||||
"name": agent.name,
|
||||
"state": "not_installed",
|
||||
"version": null,
|
||||
"acp": null,
|
||||
"auth_methods": [],
|
||||
"website": agent.website,
|
||||
"runnable": false,
|
||||
});
|
||||
};
|
||||
let (version, initialized) = tokio::join!(cli_version(&path), async {
|
||||
match agent.acp_args {
|
||||
Some(args) => acp_initialize(&path, args).await,
|
||||
None => None,
|
||||
}
|
||||
});
|
||||
let acp = match (&initialized, agent.acp_args) {
|
||||
(Some(_), _) => "native",
|
||||
(None, Some(_)) => "unreachable",
|
||||
(None, None) => "adapter",
|
||||
};
|
||||
let reported = initialized
|
||||
.as_ref()
|
||||
.and_then(|result| result.pointer("/agentInfo/version"))
|
||||
.and_then(Value::as_str)
|
||||
.map(clean);
|
||||
json!({
|
||||
"id": agent.id,
|
||||
"name": agent.name,
|
||||
"state": "installed",
|
||||
"version": reported.or(version),
|
||||
"acp": acp,
|
||||
"auth_methods": initialized.as_ref().map(auth_methods).unwrap_or_default(),
|
||||
"website": agent.website,
|
||||
"runnable": false,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn list_external_agents() -> Json<Value> {
|
||||
let mut tasks = JoinSet::new();
|
||||
for agent in AGENTS {
|
||||
tasks.spawn(describe(agent));
|
||||
}
|
||||
let mut found = Vec::with_capacity(AGENTS.len());
|
||||
while let Some(result) = tasks.join_next().await {
|
||||
if let Ok(entry) = result {
|
||||
found.push(entry);
|
||||
}
|
||||
}
|
||||
found.sort_by_key(|entry| {
|
||||
AGENTS
|
||||
.iter()
|
||||
.position(|agent| entry.get("id").and_then(Value::as_str) == Some(agent.id))
|
||||
.unwrap_or(usize::MAX)
|
||||
});
|
||||
Json(json!({ "agents": found }))
|
||||
}
|
||||
@@ -3040,6 +3040,33 @@ other failure to ask (a network error, a timeout, another status) answers
|
||||
The route applies `PUT`'s credential-writability rules and key-shape limits and
|
||||
refuses OpenAI Codex. An Engine without this route answers `404`.
|
||||
|
||||
### `GET /v1/agents/external` — agent CLIs installed on this computer
|
||||
|
||||
```json
|
||||
// response
|
||||
{ "agents": [
|
||||
{ "id": "opencode", "name": "OpenCode", "state": "installed",
|
||||
"version": "1.19.5", "acp": "native",
|
||||
"auth_methods": [{ "id": "login", "name": "Log in", "type": "agent" }],
|
||||
"website": "https://opencode.ai", "runnable": false },
|
||||
{ "id": "qoder", "name": "Qoder", "state": "not_installed", "version": null,
|
||||
"acp": null, "auth_methods": [], "website": "https://qoder.com", "runnable": false }
|
||||
] }
|
||||
```
|
||||
|
||||
Reports which external agent CLIs this computer already has, without starting a
|
||||
session. It covers Claude, Codex, Gemini CLI, OpenCode, Kimi Code, Mistral Vibe,
|
||||
Qoder, CodeBuddy and Grok. `state` is `installed` or `not_installed`; a binary is
|
||||
looked up on `PATH` and in the usual per-user bin directories. For an installed
|
||||
agent the route runs `--version` and, where the CLI speaks Agent Client Protocol
|
||||
itself, one `initialize` request on stdin, concurrently. `acp` is `native` when
|
||||
that handshake answered, `adapter` for CLIs that need a separate adapter
|
||||
process, and `unreachable` when the handshake did not answer. `auth_methods`
|
||||
comes from the handshake; `type: "terminal"` means the agent can only be signed
|
||||
in by running it in a terminal. The probes time out after 8 s (5 s for
|
||||
`--version`), receive no Codewhale or provider credentials, and leave nothing
|
||||
stored. `runnable` is `false`: this route reports, it does not start an agent.
|
||||
|
||||
### `DELETE /v1/providers/{id}/key` — clear a Codewhale-owned credential
|
||||
|
||||
```json
|
||||
|
||||
@@ -2333,6 +2333,30 @@ Runtime 的提供商或模型默认值。
|
||||
可视为“无法验证”,仍可 `PUT` 该 key。该路由遵循与 `PUT` 相同的凭据可写规则和 key 形状限制,并拒绝
|
||||
OpenAI Codex。没有此路由的 Engine 返回 `404`。
|
||||
|
||||
### `GET /v1/agents/external` —— 本机已安装的外部 agent CLI
|
||||
|
||||
```json
|
||||
// 响应
|
||||
{ "agents": [
|
||||
{ "id": "opencode", "name": "OpenCode", "state": "installed",
|
||||
"version": "1.19.5", "acp": "native",
|
||||
"auth_methods": [{ "id": "login", "name": "Log in", "type": "agent" }],
|
||||
"website": "https://opencode.ai", "runnable": false },
|
||||
{ "id": "qoder", "name": "Qoder", "state": "not_installed", "version": null,
|
||||
"acp": null, "auth_methods": [], "website": "https://qoder.com", "runnable": false }
|
||||
] }
|
||||
```
|
||||
|
||||
报告本机已有哪些外部 agent CLI,不启动任何会话。覆盖 Claude、Codex、Gemini
|
||||
CLI、OpenCode、Kimi Code、Mistral Vibe、Qoder、CodeBuddy 和 Grok。`state` 为
|
||||
`installed` 或 `not_installed`;程序会在 `PATH` 以及常见的用户 bin 目录中查找。
|
||||
对已安装的 agent,该路由会同时执行 `--version`,并在该 CLI 本身支持 Agent
|
||||
Client Protocol 时通过 stdin 发送一次 `initialize`。`acp` 为 `native` 表示握手
|
||||
成功,`adapter` 表示需要单独的适配器进程,`unreachable` 表示握手没有回应。
|
||||
`auth_methods` 来自握手结果;`type: "terminal"` 表示只能在该 agent 自己的终端里
|
||||
登录。探测超时为 8 秒(`--version` 为 5 秒),不会收到任何 Codewhale 或服务商
|
||||
凭据,也不会保存任何内容。`runnable` 为 `false`:该路由只做报告,不启动 agent。
|
||||
|
||||
### `DELETE /v1/providers/{id}/key` —— 清除 Codewhale 拥有的凭据
|
||||
|
||||
```json
|
||||
|
||||
@@ -293,8 +293,8 @@
|
||||
"sources": [
|
||||
{
|
||||
"path": "docs/RUNTIME_API.md",
|
||||
"sha256": "0973aca9e8293123218532d9fbf6068a7efa103560b9581b6eedf32f4da85974",
|
||||
"content": "# Codewhale Engine Runtime API & Integration Contract\n\n> 阅读简体中文版:[zh_hans/RUNTIME_API.md](zh_hans/RUNTIME_API.md)。\n\n`codewhale app-server --http` exposes the [Codewhale Engine](ARCHITECTURE.md) through\nits canonical local Runtime API and control plane.\nLocal SDKs, mobile/remote-control clients, and editor integrations talk to it\ninstead of screen-scraping terminal output. It serves the full HTTP/SSE runtime\nAPI (`/v1/*`), a JSON-RPC control transport over stdio, and the phone-friendly\nmobile page. `codewhale doctor --json` provides machine-readable health, and\n`codewhale serve --acp` speaks the Agent Client Protocol over stdio for editors\nsuch as Zed.\n\n`codewhale serve --http` / `serve --mobile` remain as **compatibility aliases**\nfor `codewhale app-server --http` / `--mobile`; both launch the identical\nserver. Desktop, web, SDK and custom integrations should target\n`app-server --http`. `--stdio` and `--socket` expose compatibility controls.\nBare `app-server` selects initialized JSONL control over stdio. Local stdio,\nsocket and ACP owners do not bind a hidden TCP listener. Explicit `--http`\nretains HTTP/SSE and compatibility routes on the same Runtime owner.\n\n`codewhale exec` is the separate one-shot headless worker path (stream-json,\nfleet worker subprocess, CI primitive). It is not part of this API, but it\nshares the same runtime, provider/model resolution, permission profiles, and\nevent vocabulary.\n\nThis document is the stable integration contract for native workbench\napplications (and other local supervisors) that embed the Codewhale Engine.\n\n## Architecture\n\n```\nlocal supervisor / SDK / automation harness\n │\n ├─ codewhale app-server --http → HTTP/SSE runtime API (/v1/*) [canonical]\n ├─ codewhale app-server --mobile → runtime API + mobile control page\n ├─ codewhale app-server --stdio → JSON-RPC control transport over stdio\n ├─ codewhale app-server --socket → same JSON-RPC over a unix domain socket (desktop daemon)\n ├─ codewhale doctor --json → machine-readable health & capability\n ├─ codewhale serve --acp → ACP stdio agent for editors such as Zed\n ├─ codewhale serve --mcp → MCP stdio server\n ├─ codewhale serve --http/--mobile → legacy aliases for `app-server --http/--mobile`\n └─ codewhale exec [args] → one-shot headless worker (stream-json)\n```\n\nThe engine runs as a local-only process. All APIs bind to `localhost` by\ndefault. No hosted relay, no provider-token custody, no secret leakage.\n\nFor the read-only record of what a thread or turn did, see\n[`docs/RECEIPTS.md`](RECEIPTS.md): `codewhale receipts` on the CLI and the\n`/receipt` routes under **Threads** below.\n\n## Runtime API entrypoints\n\n| Entry | Transport | Use |\n|---|---|---|\n| `codewhale web [--port 7878]` | HTTP/SSE on `127.0.0.1:7878` + embedded client | First-class loopback-only browser client; opens the default browser |\n| `codewhale app-server --http` | HTTP/SSE on `127.0.0.1:7878` | Full `/v1/*` runtime API (canonical) |\n| `codewhale app-server --mobile` | HTTP/SSE on loopback + `/mobile` | Runtime API + local mobile control page |\n| `codewhale app-server --stdio` | Initialized JSONL over stdio | Compatibility controls and explicit tool-free turns through the held manager; no hidden TCP listener or full Codex interaction channel. |\n| `codewhale app-server --socket [--socket-path P]` | RPC text messages over WebSocket on a `0600` Unix domain socket (macOS/Linux) or an owner-only named pipe (Windows) | Desktop daemon: multi-client, kernel peer identity checked, `daemon/attach` claim handshake. Windows native-app qualification remains open. |\n| `codewhale app-server` | Initialized JSONL over stdio | Same selection as `--stdio`; HTTP requires explicit `--http`. |\n| `codewhale serve --http` / `--mobile` | same server as `app-server --http`/`--mobile` | Compatibility aliases |\n\n`app-server --http` and `--mobile` launch the same mature runtime API server\nhistorically reached through `serve --http` — no routes or behavior changed, so\nevery endpoint documented below is identical across both entrypoints. The\nruntime API token is read from `--auth-token`, then `CODEWHALE_RUNTIME_TOKEN`,\nthen `DEEPSEEK_RUNTIME_TOKEN`; use `--insecure-no-auth` only with a loopback\nbind. The `serve` compatibility aliases keep their `--insecure` flag.\nBare `app-server` does not open an HTTP port. Selected attached HTTP listeners\nretain their explicit bearer and non-loopback security checks.\n\nInitialized stdio/private-socket controls support `thread/loaded/list` with\n`{cursor?: string | null, limit?: number | null}`. The result is\n`{data: string[], nextCursor: string | null}` from the held manager's loaded\nengine snapshot, including idle and archived engines still in memory. It dLine truncated
|
||||
"sha256": "65db9be1e8b8c252679b54d264b7551f0591e6f8e184739524efb665ed20b35b",
|
||||
"content": "# Codewhale Engine Runtime API & Integration Contract\n\n> 阅读简体中文版:[zh_hans/RUNTIME_API.md](zh_hans/RUNTIME_API.md)。\n\n`codewhale app-server --http` exposes the [Codewhale Engine](ARCHITECTURE.md) through\nits canonical local Runtime API and control plane.\nLocal SDKs, mobile/remote-control clients, and editor integrations talk to it\ninstead of screen-scraping terminal output. It serves the full HTTP/SSE runtime\nAPI (`/v1/*`), a JSON-RPC control transport over stdio, and the phone-friendly\nmobile page. `codewhale doctor --json` provides machine-readable health, and\n`codewhale serve --acp` speaks the Agent Client Protocol over stdio for editors\nsuch as Zed.\n\n`codewhale serve --http` / `serve --mobile` remain as **compatibility aliases**\nfor `codewhale app-server --http` / `--mobile`; both launch the identical\nserver. Desktop, web, SDK and custom integrations should target\n`app-server --http`. `--stdio` and `--socket` expose compatibility controls.\nBare `app-server` selects initialized JSONL control over stdio. Local stdio,\nsocket and ACP owners do not bind a hidden TCP listener. Explicit `--http`\nretains HTTP/SSE and compatibility routes on the same Runtime owner.\n\n`codewhale exec` is the separate one-shot headless worker path (stream-json,\nfleet worker subprocess, CI primitive). It is not part of this API, but it\nshares the same runtime, provider/model resolution, permission profiles, and\nevent vocabulary.\n\nThis document is the stable integration contract for native workbench\napplications (and other local supervisors) that embed the Codewhale Engine.\n\n## Architecture\n\n```\nlocal supervisor / SDK / automation harness\n │\n ├─ codewhale app-server --http → HTTP/SSE runtime API (/v1/*) [canonical]\n ├─ codewhale app-server --mobile → runtime API + mobile control page\n ├─ codewhale app-server --stdio → JSON-RPC control transport over stdio\n ├─ codewhale app-server --socket → same JSON-RPC over a unix domain socket (desktop daemon)\n ├─ codewhale doctor --json → machine-readable health & capability\n ├─ codewhale serve --acp → ACP stdio agent for editors such as Zed\n ├─ codewhale serve --mcp → MCP stdio server\n ├─ codewhale serve --http/--mobile → legacy aliases for `app-server --http/--mobile`\n └─ codewhale exec [args] → one-shot headless worker (stream-json)\n```\n\nThe engine runs as a local-only process. All APIs bind to `localhost` by\ndefault. No hosted relay, no provider-token custody, no secret leakage.\n\nFor the read-only record of what a thread or turn did, see\n[`docs/RECEIPTS.md`](RECEIPTS.md): `codewhale receipts` on the CLI and the\n`/receipt` routes under **Threads** below.\n\n## Runtime API entrypoints\n\n| Entry | Transport | Use |\n|---|---|---|\n| `codewhale web [--port 7878]` | HTTP/SSE on `127.0.0.1:7878` + embedded client | First-class loopback-only browser client; opens the default browser |\n| `codewhale app-server --http` | HTTP/SSE on `127.0.0.1:7878` | Full `/v1/*` runtime API (canonical) |\n| `codewhale app-server --mobile` | HTTP/SSE on loopback + `/mobile` | Runtime API + local mobile control page |\n| `codewhale app-server --stdio` | Initialized JSONL over stdio | Compatibility controls and explicit tool-free turns through the held manager; no hidden TCP listener or full Codex interaction channel. |\n| `codewhale app-server --socket [--socket-path P]` | RPC text messages over WebSocket on a `0600` Unix domain socket (macOS/Linux) or an owner-only named pipe (Windows) | Desktop daemon: multi-client, kernel peer identity checked, `daemon/attach` claim handshake. Windows native-app qualification remains open. |\n| `codewhale app-server` | Initialized JSONL over stdio | Same selection as `--stdio`; HTTP requires explicit `--http`. |\n| `codewhale serve --http` / `--mobile` | same server as `app-server --http`/`--mobile` | Compatibility aliases |\n\n`app-server --http` and `--mobile` launch the same mature runtime API server\nhistorically reached through `serve --http` — no routes or behavior changed, so\nevery endpoint documented below is identical across both entrypoints. The\nruntime API token is read from `--auth-token`, then `CODEWHALE_RUNTIME_TOKEN`,\nthen `DEEPSEEK_RUNTIME_TOKEN`; use `--insecure-no-auth` only with a loopback\nbind. The `serve` compatibility aliases keep their `--insecure` flag.\nBare `app-server` does not open an HTTP port. Selected attached HTTP listeners\nretain their explicit bearer and non-loopback security checks.\n\nInitialized stdio/private-socket controls support `thread/loaded/list` with\n`{cursor?: string | null, limit?: number | null}`. The result is\n`{data: string[], nextCursor: string | null}` from the held manager's loaded\nengine snapshot, including idle and archived engines still in memory. It dLine truncated
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user