agent-Specialization/cli/legacy-ink-src/api.ts
JOJO d97c1423b1 feat(cli): rewrite CLI on opentui with Gateway host Bearer channel
- replace Ink implementation with opentui + React 19 (old version kept at cli/legacy-ink-src/ for field reference)
- all traffic goes through Gateway host Bearer token; workspace bound via X-Astrion-Workspace-Id header
- auto-spawn server only when no instance is alive (never kill/restart running ones); stale server without token is detected
- astrion launcher (bin/astrion, symlink-aware, --version/--help fast-exit)
- slash menu panels (session/model/mode/permission/path/context/approvals...) with shared selector component
- backend: host workspace list/create dual-channel endpoints, host token generated at startup
- i18n reads OS locale at startup (zh/en)

Co-authored-by: Astrion powered by Kimi-K3 <astrion-agent@users.noreply.github.com>
2026-09-11 17:27:53 +08:00

316 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { spawn, type ChildProcess } from 'node:child_process';
import { setTimeout as sleep } from 'node:timers/promises';
import { basename } from 'node:path';
import type { ExecutionMode, ModelDefinition, PermissionMode, RunMode, Workspace } from './types.js';
export type ApiConfig = {
baseUrl: string;
repoRoot: string;
cwd: string;
port: number;
};
export type TaskPollResult = {
task_id: string;
status: string;
error?: string;
conversation_id?: string;
events: Array<{ idx: number; type: string; data: any; ts?: number }>;
next_offset: number;
// 事件窗口当前最小 idx协议 §5.2offset 落后于此值说明中间事件已被裁剪
window_start?: number;
};
export class ApiClient {
private cookie = '';
private csrfToken = '';
private serverProcess: ChildProcess | null = null;
constructor(private readonly config: ApiConfig) {}
get baseUrl(): string {
return this.config.baseUrl;
}
async ensureConnected(): Promise<void> {
const ok = await this.tryFetchCsrf();
if (!ok) {
this.startServer();
await this.waitForServer();
await this.fetchCsrf();
}
await this.hostLogin();
await this.fetchCsrf();
}
private startServer(): void {
if (this.serverProcess) return;
const env = {
...process.env,
TERMINAL_SANDBOX_MODE: 'host',
HOST_PROJECT_PATH: this.config.cwd,
WEB_SERVER_PORT: String(this.config.port),
AGENTS_CLI_STARTED_SERVER: '1',
};
this.serverProcess = spawn(
process.execPath.includes('node') ? 'python3' : 'python3',
['-m', 'server.app', '--path', this.config.cwd, '--port', String(this.config.port), '--thinking-mode'],
{
cwd: this.config.repoRoot,
env,
detached: true,
stdio: 'ignore',
},
);
this.serverProcess.unref();
}
private async waitForServer(): Promise<void> {
const deadline = Date.now() + 20_000;
let lastError: unknown;
while (Date.now() < deadline) {
const ok = await this.tryFetchCsrf().catch((err) => {
lastError = err;
return false;
});
if (ok) return;
await sleep(500);
}
throw new Error(`无法连接本地 Web 服务:${String(lastError || 'timeout')}`);
}
private async tryFetchCsrf(): Promise<boolean> {
try {
await this.fetchCsrf();
return true;
} catch {
return false;
}
}
async fetchCsrf(): Promise<string> {
const data = await this.request<{ token: string }>('/api/csrf-token', { method: 'GET', skipCsrf: true, allowUnauthorized: true });
this.csrfToken = data.token;
return this.csrfToken;
}
async hostLogin(): Promise<void> {
await this.request('/host-login', { method: 'POST' });
}
async getStatus(): Promise<any> {
return this.request('/api/status', { method: 'GET' });
}
async listModels(): Promise<ModelDefinition[]> {
const res = await this.request<{ items?: ModelDefinition[]; data?: any }>('/api/v1/models', { method: 'GET' });
return res.items || res.data?.items || [];
}
async getPersonalization(): Promise<any> {
return this.request('/api/personalization', { method: 'GET' });
}
async listWorkspaces(): Promise<{ current_workspace_id: string; default_workspace_id: string; workspaces: Workspace[] }> {
const res = await this.request<{ data: any }>('/api/host/workspaces', { method: 'GET' });
return res.data;
}
async createWorkspace(path: string, label = basename(path)): Promise<{ workspace: Workspace; created: boolean }> {
const res = await this.request<{ data: any }>('/api/host/workspaces/create', {
method: 'POST',
body: { path, label },
});
return res.data;
}
async selectWorkspace(workspaceId: string): Promise<void> {
await this.request('/api/host/workspaces/select', {
method: 'POST',
body: { workspace_id: workspaceId },
});
}
async createConversation(): Promise<any> {
return this.request('/api/conversations', { method: 'POST', body: {} });
}
async listConversations(limit = 20): Promise<any[]> {
const res = await this.request<{ data: any }>(`/api/conversations?limit=${limit}`, { method: 'GET' });
return res.data?.conversations || res.data || [];
}
async loadConversation(conversationId: string): Promise<any> {
return this.request(`/api/conversations/${encodeURIComponent(conversationId)}/load`, { method: 'PUT', body: {} });
}
async getCurrentConversation(): Promise<any> {
const res = await this.request<{ data: any }>('/api/conversations/current', { method: 'GET' });
return res.data;
}
async compressConversation(conversationId: string): Promise<any> {
return this.request(`/api/conversations/${encodeURIComponent(conversationId)}/compress`, { method: 'POST', body: {} });
}
async setModel(modelKey: string): Promise<any> {
return this.request('/api/model', { method: 'POST', body: { model_key: modelKey } });
}
async setRunMode(mode: RunMode): Promise<any> {
return this.request('/api/thinking-mode', { method: 'POST', body: { mode } });
}
async getPermissionMode(): Promise<any> {
return this.request('/api/permission-mode', { method: 'GET' });
}
async setPermissionMode(mode: PermissionMode): Promise<any> {
return this.request('/api/permission-mode', { method: 'POST', body: { mode } });
}
async getExecutionMode(): Promise<any> {
return this.request('/api/execution-mode', { method: 'GET' });
}
async getConversationVersioning(conversationId: string): Promise<any> {
const res = await this.request<{ data: any }>(`/api/conversations/${encodeURIComponent(conversationId)}/versioning`, { method: 'GET' });
return res.data;
}
async setConversationVersioning(conversationId: string, enabled: boolean, trackingMode?: string): Promise<any> {
const res = await this.request<{ data: any }>(`/api/conversations/${encodeURIComponent(conversationId)}/versioning`, {
method: 'POST',
body: { enabled, ...(trackingMode ? { tracking_mode: trackingMode } : {}) },
});
return res.data;
}
async getConversationTokens(conversationId: string): Promise<any> {
const res = await this.request<{ data: any }>(`/api/conversations/${encodeURIComponent(conversationId)}/tokens`, { method: 'GET' });
return res.data;
}
async listSubAgents(): Promise<any[]> {
const res = await this.request<{ data: any[] }>('/api/sub_agents', { method: 'GET' });
return Array.isArray(res.data) ? res.data : [];
}
async listBackgroundCommands(): Promise<any[]> {
const res = await this.request<{ data: any[] }>('/api/background_commands', { method: 'GET' });
return Array.isArray(res.data) ? res.data : [];
}
async setExecutionMode(mode: ExecutionMode): Promise<any> {
return this.request('/api/execution-mode', { method: 'POST', body: { mode } });
}
async getToolSettings(): Promise<any> {
return this.request('/api/tool-settings', { method: 'GET' });
}
async setToolCategory(category: string, enabled: boolean): Promise<any> {
return this.request('/api/tool-settings', { method: 'POST', body: { category, enabled } });
}
async getPathAuthorization(): Promise<any> {
return this.request('/api/path-authorization', { method: 'GET' });
}
async setPathAuthorization(writablePaths: string[], readableExtraPaths: string[]): Promise<any> {
return this.request('/api/path-authorization', {
method: 'POST',
body: { writable_paths: writablePaths, readable_extra_paths: readableExtraPaths },
});
}
async createTask(payload: { message: string; conversation_id?: string; model_key?: string; run_mode?: RunMode }): Promise<any> {
const res = await this.request<{ data: any }>('/api/tasks', {
method: 'POST',
body: payload,
});
return res.data;
}
async pollTask(taskId: string, offset: number): Promise<TaskPollResult> {
const res = await this.request<{ data: TaskPollResult }>(`/api/tasks/${encodeURIComponent(taskId)}?from=${offset}`, { method: 'GET' });
return res.data;
}
async cancelTask(taskId: string): Promise<void> {
await this.request(`/api/tasks/${encodeURIComponent(taskId)}/cancel`, { method: 'POST', body: {} });
}
async decideApproval(approvalId: string, decision: 'approved' | 'rejected'): Promise<any> {
return this.request(`/api/tool-approvals/${encodeURIComponent(approvalId)}/decision`, {
method: 'POST',
body: { decision },
});
}
private async request<T = any>(path: string, options: { method: string; body?: any; skipCsrf?: boolean; allowUnauthorized?: boolean }): Promise<T> {
const headers: Record<string, string> = { Accept: 'application/json' };
if (this.cookie) headers.Cookie = this.cookie;
if (options.body !== undefined) headers['Content-Type'] = 'application/json';
if (!options.skipCsrf && options.method !== 'GET' && this.csrfToken) headers['X-CSRF-Token'] = this.csrfToken;
const response = await fetch(`${this.config.baseUrl}${path}`, {
method: options.method,
headers,
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
redirect: 'manual',
});
this.captureCookies(response);
if (response.status === 401 && !options.allowUnauthorized) {
await this.fetchCsrf();
await this.hostLogin();
return this.request(path, { ...options, allowUnauthorized: true });
}
const text = await response.text();
let data: any = {};
if (text) {
try {
data = JSON.parse(text);
} catch {
data = { success: false, error: text };
}
}
if (!response.ok || data?.success === false) {
throw new Error(data?.error || data?.message || `HTTP ${response.status}`);
}
return data as T;
}
private captureCookies(response: Response): void {
const anyHeaders = response.headers as any;
const rawCookies: string[] = typeof anyHeaders.getSetCookie === 'function'
? anyHeaders.getSetCookie()
: [];
const fallback = response.headers.get('set-cookie');
if (fallback) rawCookies.push(fallback);
if (!rawCookies.length) return;
const current = new Map<string, string>();
for (const part of this.cookie.split(';')) {
const trimmed = part.trim();
if (!trimmed) continue;
const eq = trimmed.indexOf('=');
if (eq > 0) current.set(trimmed.slice(0, eq), trimmed.slice(eq + 1));
}
for (const raw of rawCookies) {
const cookiePair = raw.split(';', 1)[0];
const eq = cookiePair.indexOf('=');
if (eq > 0) current.set(cookiePair.slice(0, eq), cookiePair.slice(eq + 1));
}
this.cookie = Array.from(current.entries()).map(([key, value]) => `${key}=${value}`).join('; ');
}
}
export function createDefaultApiClient(cwd: string, repoRoot: string): ApiClient {
const port = Number(process.env.AGENTS_API_PORT || process.env.WEB_SERVER_PORT || 8091);
const baseUrl = process.env.AGENTS_API_BASE || `http://127.0.0.1:${port}`;
return new ApiClient({ baseUrl, repoRoot, cwd, port });
}