Add ACP-backed role sessions

This commit is contained in:
zenord
2026-08-16 10:08:03 +08:00
parent d59c4a654f
commit a80fe68d57
34 changed files with 1683 additions and 887 deletions
+23
View File
@@ -0,0 +1,23 @@
import type { AcpBackendConfig } from "../config.js";
import type { AcpBackendSpec } from "./types.js";
export class AcpBackendRegistry {
private readonly backends = new Map<string, AcpBackendSpec>();
constructor(configs: AcpBackendConfig[]) {
for (const config of configs) this.register({ ...config });
}
register(backend: AcpBackendSpec): void {
if (this.backends.has(backend.id)) throw new Error(`Duplicate ACP backend: ${backend.id}`);
this.backends.set(backend.id, backend);
}
get(id: string): AcpBackendSpec {
const backend = this.backends.get(id);
if (!backend) throw new Error(`Unknown ACP backend: ${id}`);
return backend;
}
list(): string[] { return [...this.backends.keys()].sort(); }
}
+8
View File
@@ -0,0 +1,8 @@
import type { AcpBackendConfig } from "../../config.js";
import type { AcpBackendSpec } from "../types.js";
export function kimiBackend(config: AcpBackendConfig): AcpBackendSpec {
if (config.id !== "kimi") throw new Error(`Expected kimi backend, got '${config.id}'`);
if (!config.args.includes("acp")) throw new Error("Kimi ACP backend args must include 'acp'");
return { ...config };
}
+125
View File
@@ -0,0 +1,125 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { Readable, Writable } from "node:stream";
import * as acp from "@agentclientprotocol/sdk";
import type { AgentCapabilities, InitializeResponse, RequestPermissionRequest, RequestPermissionResponse, SessionNotification } from "@agentclientprotocol/sdk";
import type { RolePolicy } from "../config.js";
export interface AcpClientOptions {
initializeTimeoutMs: number;
policy: RolePolicy;
}
export class AcpClient {
private readonly connection: acp.ClientConnection;
private capabilities: AgentCapabilities = {};
private activeSessionId?: string;
private collecting = false;
private chunks: string[] = [];
constructor(private readonly child: ChildProcessWithoutNullStreams, private readonly options: AcpClientOptions) {
const app = acp.client({ name: "gori-agent" })
.onRequest(acp.methods.client.session.requestPermission, ({ params }) => decidePermission(params, options.policy))
.onNotification(acp.methods.client.session.update, ({ params }) => this.handleUpdate(params));
const stream = acp.ndJsonStream(
Writable.toWeb(child.stdin) as WritableStream<Uint8Array>,
Readable.toWeb(child.stdout) as ReadableStream<Uint8Array>
);
this.connection = app.connect(stream);
}
async initialize(): Promise<InitializeResponse> {
const response = await withTimeout(this.connection.agent.request(acp.methods.agent.initialize, {
protocolVersion: acp.PROTOCOL_VERSION,
clientCapabilities: {},
clientInfo: { name: "gori-agent", version: "0.1.0" }
}), this.options.initializeTimeoutMs, "ACP initialize timed out");
if (response.protocolVersion !== acp.PROTOCOL_VERSION) throw new Error(`Unsupported ACP protocol version ${response.protocolVersion}`);
this.capabilities = response.agentCapabilities || {};
return response;
}
async newSession(cwd: string): Promise<string> {
const response = await this.connection.agent.request(acp.methods.agent.session.new, { cwd, mcpServers: [] });
this.activeSessionId = response.sessionId;
return response.sessionId;
}
async resumeSession(sessionId: string, cwd: string): Promise<void> {
this.collecting = false;
this.chunks = [];
if (this.capabilities.sessionCapabilities?.resume) {
await this.connection.agent.request(acp.methods.agent.session.resume, { sessionId, cwd, mcpServers: [] });
} else if (this.capabilities.loadSession) {
await this.connection.agent.request(acp.methods.agent.session.load, { sessionId, cwd, mcpServers: [] });
} else {
throw new Error("ACP backend cannot resume or load sessions");
}
this.activeSessionId = sessionId;
this.chunks = [];
}
async prompt(text: string, cancellationSignal?: AbortSignal): Promise<string> {
if (!this.activeSessionId) throw new Error("ACP session is not active");
this.chunks = [];
this.collecting = true;
try {
await this.connection.agent.request(acp.methods.agent.session.prompt, {
sessionId: this.activeSessionId,
prompt: [{ type: "text", text }]
}, cancellationSignal ? { cancellationSignal } : undefined);
return this.chunks.join("").trim();
} finally {
this.collecting = false;
}
}
async cancel(): Promise<void> {
if (this.activeSessionId) await this.connection.agent.notify(acp.methods.agent.session.cancel, { sessionId: this.activeSessionId });
}
async closeSession(): Promise<void> {
if (this.activeSessionId && this.capabilities.sessionCapabilities?.close) {
await this.connection.agent.request(acp.methods.agent.session.close, { sessionId: this.activeSessionId }).catch(() => undefined);
}
}
close(error?: unknown): void { this.connection.close(error); }
private handleUpdate(notification: SessionNotification): void {
if (!this.collecting || notification.sessionId !== this.activeSessionId) return;
const update = notification.update;
if (update.sessionUpdate === "agent_message_chunk" && update.content.type === "text") this.chunks.push(update.content.text);
}
}
export function decidePermission(request: RequestPermissionRequest, policy: RolePolicy): RequestPermissionResponse {
if (policy.permissionMode === "deny") return reject(request);
const allowOption = request.options.find((option) => option.kind === "allow_once") || request.options.find((option) => option.kind === "allow_always");
if (!allowOption) return reject(request);
if (policy.permissionMode === "auto") return { outcome: { outcome: "selected", optionId: allowOption.optionId } };
const name = String(request.toolCall.name || request.toolCall.kind || "").toLowerCase();
const title = String(request.toolCall.title || "").toLowerCase();
const allowedTool = policy.allowedTools.some((tool) => name === tool.toLowerCase() || title.startsWith(tool.toLowerCase()));
if (!allowedTool) return reject(request);
if (name === "bash" || name === "terminal" || title.startsWith("bash") || title.startsWith("terminal")) {
if (request.toolCall.rawInput === undefined || policy.allowedCommandPatterns.length === 0) return reject(request);
const input = typeof request.toolCall.rawInput === "string" ? request.toolCall.rawInput : JSON.stringify(request.toolCall.rawInput);
if (!policy.allowedCommandPatterns.some((pattern) => new RegExp(pattern).test(input))) return reject(request);
}
return { outcome: { outcome: "selected", optionId: allowOption.optionId } };
}
function reject(request: RequestPermissionRequest): RequestPermissionResponse {
const option = request.options.find((item) => item.kind === "reject_once") || request.options.find((item) => item.kind === "reject_always");
return option ? { outcome: { outcome: "selected", optionId: option.optionId } } : { outcome: { outcome: "cancelled" } };
}
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
let timer: NodeJS.Timeout | undefined;
try {
return await Promise.race([promise, new Promise<T>((_resolve, reject) => { timer = setTimeout(() => reject(new Error(message)), timeoutMs); })]);
} finally {
if (timer) clearTimeout(timer);
}
}
+34
View File
@@ -0,0 +1,34 @@
import { spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
export type BackendStatus = "ready" | "not-found" | "needs-adapter";
export interface DiscoveredBackend { id: string; command: string; args: string[]; status: BackendStatus; version?: string; reason?: string }
export async function discoverBackends(): Promise<DiscoveredBackend[]> {
const kimi = findExecutable("kimi") || (fs.existsSync("/home/ubuntu/.kimi-code/bin/kimi") ? "/home/ubuntu/.kimi-code/bin/kimi" : undefined);
return [
kimi ? { id: "kimi", command: kimi, args: ["acp"], status: "ready", version: await version(kimi) } : { id: "kimi", command: "kimi", args: ["acp"], status: "not-found", reason: "Kimi executable not found" },
{ id: "codex", command: "codex-acp", args: [], status: findExecutable("codex-acp") ? "ready" : "needs-adapter", reason: "Requires codex-acp adapter" },
{ id: "pi", command: "pi-acp", args: [], status: findExecutable("pi-acp") ? "ready" : "needs-adapter", reason: "Requires a Pi ACP adapter" }
];
}
function findExecutable(command: string): string | undefined {
for (const entry of (process.env.PATH || "").split(path.delimiter)) {
const candidate = path.join(entry, command);
try { fs.accessSync(candidate, fs.constants.X_OK); return candidate; } catch { /* continue */ }
}
return undefined;
}
function version(command: string): Promise<string | undefined> {
return new Promise((resolve) => {
const child = spawn(command, ["--version"], { stdio: ["ignore", "pipe", "pipe"] });
let output = "";
const timer = setTimeout(() => { child.kill(); resolve(undefined); }, 2_000);
child.stdout.on("data", (chunk) => { output += chunk; });
child.on("close", () => { clearTimeout(timer); resolve(output.trim().split(/\r?\n/)[0] || undefined); });
child.on("error", () => { clearTimeout(timer); resolve(undefined); });
});
}
+10
View File
@@ -0,0 +1,10 @@
import { spawn } from "node:child_process";
import type { AcpBackendConfig } from "../config.js";
import type { InitializeResponse } from "@agentclientprotocol/sdk";
import { AcpClient } from "./client.js";
export async function probeAcpBackend(backend: AcpBackendConfig, timeoutMs = 10_000): Promise<InitializeResponse> {
const child = spawn(backend.command, backend.args, { stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, ...backend.env } });
const client = new AcpClient(child, { initializeTimeoutMs: timeoutMs, policy: { permissionMode: "deny", allowedTools: [], allowedCommandPatterns: [] } });
try { return await client.initialize(); } finally { client.close(); child.kill("SIGTERM"); }
}
+150
View File
@@ -0,0 +1,150 @@
import type { AcpConfig } from "../config.js";
import { chatKeyFor, type DurableSessionStore, type SessionBinding } from "../core/durable-session-store.js";
import type { RoleRegistry } from "../roles/role-registry.js";
import type { AcpBackendRegistry } from "./backend-registry.js";
import type { ConversationRequest, ConversationResponse, ConversationRuntime, RuntimeStats } from "./types.js";
import { AcpWorker } from "./worker.js";
export class AcpSessionManager implements ConversationRuntime {
private readonly workers = new Map<string, AcpWorker>();
private readonly inFlight = new Map<string, AcpWorker>();
private readonly sweeper: NodeJS.Timeout;
private crashes = 0;
private shuttingDown = false;
constructor(
private readonly config: AcpConfig,
private readonly backends: AcpBackendRegistry,
private readonly roles: RoleRegistry,
private readonly store: DurableSessionStore
) {
this.sweeper = setInterval(() => void this.sweep(), config.sweepIntervalMs);
this.sweeper.unref();
}
async prompt(request: ConversationRequest): Promise<ConversationResponse> {
if (this.shuttingDown) throw new Error("ACP runtime is shutting down");
const chatKey = chatKeyFor(request.platform, request.chatId);
const role = this.roles.get(this.store.getSelectedRole(chatKey, this.roles.defaultId()));
let binding = this.store.getBinding(chatKey, role.id);
if (binding && (binding.roleFingerprint !== role.fingerprint || binding.backendId !== role.backend || binding.workspace !== role.workspace)) {
await this.dropBinding(binding);
binding = undefined;
}
const worker = await this.acquireWorker(role.id, binding);
this.inFlight.set(chatKey, worker);
try {
if (!binding) {
const now = Date.now();
await worker.prompt(role.bootstrap);
binding = {
chatKey, roleId: role.id, backendId: role.backend, nativeSessionId: worker.nativeSessionId!,
workspace: role.workspace, roleFingerprint: role.fingerprint, createdAt: now, updatedAt: now
};
await this.store.setBinding(binding);
}
const text = await worker.prompt(request.text);
await this.store.touchBinding(chatKey, role.id);
return { text: text || "(ACP agent returned no text)", roleId: role.id, backendId: role.backend };
} catch (error) {
if (worker.nativeSessionId) this.workers.delete(workerKey(worker.backend.id, worker.nativeSessionId));
await worker.terminate();
throw error;
} finally {
if (this.inFlight.get(chatKey) === worker) this.inFlight.delete(chatKey);
}
}
async cancel(platform: string, chatId: string): Promise<boolean> {
const worker = this.inFlight.get(chatKeyFor(platform, chatId));
return worker ? worker.cancel() : false;
}
async reset(platform: string, chatId: string): Promise<void> {
const chatKey = chatKeyFor(platform, chatId);
await this.cancel(platform, chatId);
const roleId = this.store.getSelectedRole(chatKey, this.roles.defaultId());
const binding = await this.store.deleteBinding(chatKey, roleId);
if (binding) await this.stopWorker(binding.backendId, binding.nativeSessionId);
}
async selectRole(platform: string, chatId: string, roleId: string): Promise<void> {
this.roles.get(roleId);
await this.store.setSelectedRole(chatKeyFor(platform, chatId), roleId);
}
selectedRole(platform: string, chatId: string): string {
return this.store.getSelectedRole(chatKeyFor(platform, chatId), this.roles.defaultId());
}
status(platform: string, chatId: string): Record<string, string | number | boolean> {
const chatKey = chatKeyFor(platform, chatId);
const roleId = this.store.getSelectedRole(chatKey, this.roles.defaultId());
const binding = this.store.getBinding(chatKey, roleId);
return { role: roleId, backend: this.roles.get(roleId).backend, persisted: Boolean(binding), running: this.inFlight.has(chatKey) };
}
stats(): RuntimeStats {
return { activeWorkers: this.workers.size, inFlight: this.inFlight.size, crashes: this.crashes, persistedBindings: this.store.stats().bindings };
}
async shutdown(): Promise<void> {
if (this.shuttingDown) return;
this.shuttingDown = true;
clearInterval(this.sweeper);
await Promise.all([...this.inFlight.values()].map((worker) => worker.cancel().catch(() => false)));
await Promise.all([...this.workers.values()].map((worker) => worker.terminate()));
this.workers.clear();
this.inFlight.clear();
}
private async acquireWorker(roleId: string, binding?: SessionBinding): Promise<AcpWorker> {
const role = this.roles.get(roleId);
if (binding) {
const existing = this.workers.get(workerKey(binding.backendId, binding.nativeSessionId));
if (existing) return existing;
}
await this.ensureCapacity();
const worker = new AcpWorker(this.backends.get(role.backend), role, this.config, (crashed, error) => {
this.crashes++;
if (crashed.nativeSessionId) this.workers.delete(workerKey(crashed.backend.id, crashed.nativeSessionId));
console.error(`ACP worker crash: ${error.message}`);
});
const nativeSessionId = await worker.start(binding?.nativeSessionId);
this.workers.set(workerKey(role.backend, nativeSessionId), worker);
return worker;
}
private async ensureCapacity(): Promise<void> {
if (this.workers.size < this.config.maxProcesses) return;
const candidate = [...this.workers.entries()].filter(([, worker]) => !worker.inFlight).sort((a, b) => a[1].lastUsedAt - b[1].lastUsedAt)[0];
if (!candidate) throw new Error(`ACP worker limit reached (${this.config.maxProcesses})`);
this.workers.delete(candidate[0]);
await candidate[1].terminate();
}
private async sweep(): Promise<void> {
const cutoff = Date.now() - this.config.idleTimeoutMs;
const expired = [...this.workers.entries()].filter(([, worker]) => !worker.inFlight && worker.lastUsedAt < cutoff);
for (const [key, worker] of expired) {
this.workers.delete(key);
await worker.terminate();
}
}
private async dropBinding(binding: SessionBinding): Promise<void> {
await this.store.deleteBinding(binding.chatKey, binding.roleId);
await this.stopWorker(binding.backendId, binding.nativeSessionId);
}
private async stopWorker(backendId: string, nativeSessionId: string): Promise<void> {
const key = workerKey(backendId, nativeSessionId);
const worker = this.workers.get(key);
if (!worker) return;
this.workers.delete(key);
await worker.terminate();
}
}
function workerKey(backendId: string, nativeSessionId: string): string { return `${backendId}:${nativeSessionId}`; }
+44
View File
@@ -0,0 +1,44 @@
import type { RolePolicy } from "../config.js";
export interface AcpBackendSpec {
id: string;
command: string;
args: string[];
env: Record<string, string>;
}
export interface ConversationRequest {
platform: string;
chatId: string;
userId: string;
text: string;
messageId?: string;
}
export interface ConversationResponse {
text: string;
roleId: string;
backendId: string;
}
export interface RuntimeStats {
activeWorkers: number;
inFlight: number;
crashes: number;
persistedBindings: number;
}
export interface ConversationRuntime {
prompt(request: ConversationRequest): Promise<ConversationResponse>;
cancel(platform: string, chatId: string): Promise<boolean>;
reset(platform: string, chatId: string): Promise<void>;
selectRole(platform: string, chatId: string, roleId: string): Promise<void>;
selectedRole(platform: string, chatId: string): string;
status(platform: string, chatId: string): Record<string, string | number | boolean>;
stats(): RuntimeStats;
shutdown(): Promise<void>;
}
export interface PermissionContext {
policy: RolePolicy;
}
+128
View File
@@ -0,0 +1,128 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import type { AcpConfig } from "../config.js";
import type { ResolvedRole } from "../roles/role-registry.js";
import { AcpClient } from "./client.js";
import type { AcpBackendSpec } from "./types.js";
export class AcpWorker {
private child?: ChildProcessWithoutNullStreams;
private client?: AcpClient;
private abort?: AbortController;
private exited = false;
private stopping = false;
private stderrBytes = 0;
nativeSessionId?: string;
lastUsedAt = Date.now();
inFlight = false;
constructor(
readonly backend: AcpBackendSpec,
readonly role: ResolvedRole,
private readonly config: AcpConfig,
private readonly onCrash: (worker: AcpWorker, error: Error) => void
) {}
async start(nativeSessionId?: string): Promise<string> {
this.child = spawn(this.backend.command, this.backend.args, {
cwd: this.role.workspace,
env: { ...process.env, ...this.backend.env },
shell: false,
stdio: ["pipe", "pipe", "pipe"]
});
this.child.stderr.on("data", (chunk: Buffer) => this.logStderr(chunk));
this.child.once("error", (error) => this.crashed(error));
this.child.once("close", (code, signal) => {
this.exited = true;
if (!this.stopping) this.crashed(new Error(`ACP worker exited (code=${code ?? "null"}, signal=${signal ?? "null"})`));
});
this.client = new AcpClient(this.child, { initializeTimeoutMs: this.config.initializeTimeoutMs, policy: this.role.policy });
try {
await this.client.initialize();
if (nativeSessionId) {
await this.client.resumeSession(nativeSessionId, this.role.workspace);
this.nativeSessionId = nativeSessionId;
} else {
this.nativeSessionId = await this.client.newSession(this.role.workspace);
}
return this.nativeSessionId;
} catch (error) {
await this.terminate();
throw error;
}
}
async prompt(text: string): Promise<string> {
if (!this.client || !this.nativeSessionId || this.exited) throw new Error("ACP worker is not available");
if (this.inFlight) throw new Error("ACP worker already has an in-flight turn");
this.inFlight = true;
this.lastUsedAt = Date.now();
this.abort = new AbortController();
let timeout: NodeJS.Timeout | undefined;
const timeoutPromise = new Promise<never>((_resolve, reject) => {
timeout = setTimeout(() => {
void this.client?.cancel();
this.abort?.abort();
reject(new Error(`ACP prompt timed out after ${this.config.promptTimeoutMs}ms`));
setTimeout(() => { if (!this.exited) void this.terminate(); }, this.config.cancelGraceMs).unref();
}, this.config.promptTimeoutMs);
});
try {
return await Promise.race([this.client.prompt(text, this.abort.signal), timeoutPromise]);
} finally {
if (timeout) clearTimeout(timeout);
this.inFlight = false;
this.abort = undefined;
this.lastUsedAt = Date.now();
}
}
async cancel(): Promise<boolean> {
if (!this.inFlight || !this.client) return false;
await this.client.cancel();
this.abort?.abort();
setTimeout(() => { if (this.inFlight) void this.terminate(); }, this.config.cancelGraceMs).unref();
return true;
}
async terminate(): Promise<void> {
if (this.stopping) return;
this.stopping = true;
if (this.client && !this.exited) {
await Promise.race([
this.client.closeSession().catch(() => undefined),
new Promise<void>((resolve) => setTimeout(resolve, this.config.cancelGraceMs))
]);
}
this.client?.close();
if (this.child && !this.exited) {
this.child.kill("SIGTERM");
await waitForExit(this.child, this.config.cancelGraceMs);
if (!this.exited) {
this.child.kill("SIGKILL");
await waitForExit(this.child, this.config.cancelGraceMs);
}
}
}
private logStderr(chunk: Buffer): void {
const remaining = Math.max(0, 16_384 - this.stderrBytes);
if (!remaining) return;
const text = chunk.subarray(0, remaining).toString("utf8").trimEnd();
this.stderrBytes += Buffer.byteLength(text);
if (text) console.error(`[acp:${this.backend.id}] ${text}`);
}
private crashed(error: Error): void {
if (this.stopping) return;
this.stopping = true;
this.onCrash(this, error);
}
}
function waitForExit(child: ChildProcessWithoutNullStreams, timeoutMs: number): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve();
return new Promise((resolve) => {
const timer = setTimeout(resolve, timeoutMs);
child.once("close", () => { clearTimeout(timer); resolve(); });
});
}
+46 -106
View File
@@ -1,145 +1,85 @@
#!/usr/bin/env node
import process from "node:process";
import { discoverAgents } from "./agents/discovery.js";
import { discoverBackends } from "./acp/discovery.js";
import { loadConfigFile } from "./cli/config-file.js";
import { runDoctor } from "./cli/doctor.js";
import { localBaseUrl } from "./cli/net.js";
import { printFeishu } from "./cli/print.js";
import { runSetup } from "./cli/setup.js";
import { localBaseUrl } from "./cli/net.js";
import { defaultStateFile } from "./config.js";
import { startServer } from "./server.js";
interface ParsedArgs {
command?: string;
rest: string[];
configPath?: string;
json: boolean;
help: boolean;
}
interface ParsedArgs { command?: string; rest: string[]; configPath?: string; json: boolean; help: boolean }
async function main(argv: string[]): Promise<number> {
const parsed = parseArgs(argv);
if (parsed.help || !parsed.command) {
printHelp();
return 0;
}
if (parsed.command === "setup") {
await runSetup(parsed.configPath);
return 0;
}
if (parsed.command === "discover-agents") {
const agents = await discoverAgents();
if (parsed.json) {
console.log(JSON.stringify(agents, null, 2));
} else {
for (const agent of agents) {
const version = agent.version ? ` (${agent.version})` : "";
const reason = agent.reason ? ` - ${agent.reason}` : "";
console.log(`${agent.name}\t${agent.status}\t${agent.command} ${agent.args.join(" ")}${version}${reason}`.trim());
}
if (parsed.help || !parsed.command) { printHelp(); return 0; }
if (parsed.command === "setup") { await runSetup(parsed.configPath); return 0; }
if (parsed.command === "discover-backends" || parsed.command === "discover-agents") {
const backends = await discoverBackends();
if (parsed.json) console.log(JSON.stringify(backends, null, 2));
else for (const backend of backends) {
const version = backend.version ? ` (${backend.version})` : "";
const reason = backend.reason ? ` - ${backend.reason}` : "";
console.log(`${backend.id}\t${backend.status}\t${backend.command} ${backend.args.join(" ")}${version}${reason}`.trim());
}
return 0;
}
if (parsed.command === "start") {
const loaded = loadConfigFile(parsed.configPath);
startServer(loaded.config);
return await new Promise(() => undefined);
const running = await startServer(loaded.config);
return await new Promise<number>((resolve) => {
let stopping = false;
const stop = (signal: string): void => {
if (stopping) return;
stopping = true;
console.log(`Received ${signal}; shutting down...`);
void running.shutdown().then(() => resolve(0), (error) => { console.error(error); resolve(1); });
};
process.once("SIGINT", () => stop("SIGINT"));
process.once("SIGTERM", () => stop("SIGTERM"));
});
}
if (parsed.command === "status") {
await printStatus(parsed.configPath);
return 0;
}
if (parsed.command === "doctor") {
const loaded = loadConfigFile(parsed.configPath);
return runDoctor(loaded.config, loaded.path);
}
if (parsed.command === "status") { await printStatus(parsed.configPath); return 0; }
if (parsed.command === "doctor") { const loaded = loadConfigFile(parsed.configPath); return runDoctor(loaded.config, loaded.path); }
if (parsed.command === "print") {
const topic = parsed.rest[0];
const loaded = loadConfigFile(parsed.configPath);
if (topic === "feishu") {
await printFeishu(loaded.config);
return 0;
}
console.error(`Unknown print topic: ${topic || "(missing)"}`);
console.error("Available: feishu");
return 1;
if (parsed.rest[0] === "feishu") { await printFeishu(loaded.config); return 0; }
console.error(`Unknown print topic: ${parsed.rest[0] || "(missing)"}`); return 1;
}
console.error(`Unknown command: ${parsed.command}`);
printHelp();
return 1;
console.error(`Unknown command: ${parsed.command}`); printHelp(); return 1;
}
function parseArgs(argv: string[]): ParsedArgs {
const rest: string[] = [];
let command: string | undefined;
let configPath: string | undefined;
let json = false;
let help = false;
const rest: string[] = []; let command: string | undefined; let configPath: string | undefined; let json = false; let help = false;
for (let index = 0; index < argv.length; index++) {
const arg = argv[index];
if (arg === "--help" || arg === "-h") {
help = true;
} else if (arg === "--json") {
json = true;
} else if (arg === "--config") {
configPath = argv[++index];
if (!configPath) throw new Error("--config requires a path");
} else if (!command) {
command = arg;
} else {
rest.push(arg);
}
if (arg === "--help" || arg === "-h") help = true;
else if (arg === "--json") json = true;
else if (arg === "--config") { configPath = argv[++index]; if (!configPath) throw new Error("--config requires a path"); }
else if (!command) command = arg; else rest.push(arg);
}
return { command, rest, configPath, json, help };
}
async function printStatus(configPath?: string): Promise<void> {
const loaded = loadConfigFile(configPath);
const baseUrl = localBaseUrl(loaded.config);
const loaded = loadConfigFile(configPath); const baseUrl = localBaseUrl(loaded.config);
console.log(`Config: ${loaded.path}${loaded.exists ? "" : " (seeded from config.example.json)"}`);
console.log(`Server: ${loaded.config.server.host}:${loaded.config.server.port}`);
console.log(`Default agent: ${loaded.config.defaultAgent}`);
console.log(`Agents: ${loaded.config.agents.map((agent) => agent.name).join(", ")}`);
console.log(`Enabled platforms: ${Object.entries(loaded.config.platforms).filter(([, value]) => value.enabled).map(([name]) => name).join(", ") || "none"}`);
console.log(`Default role: ${loaded.config.defaultRole}`);
console.log(`Roles: ${loaded.config.roles.map(({ id }) => id).join(", ")}`);
console.log(`Backends: ${loaded.config.backends.map(({ id }) => id).join(", ")}`);
console.log(`State file: ${defaultStateFile(loaded.config)}`);
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 1_000);
const response = await fetch(`${baseUrl}/health`, { signal: controller.signal });
clearTimeout(timer);
console.log(`Health probe: HTTP ${response.status}`);
} catch {
console.log("Health probe: not reachable on local URL");
}
const response = await fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(1_000) });
console.log(`Health probe: HTTP ${response.status} ${await response.text()}`);
} catch { console.log("Health probe: not reachable on local URL"); }
}
function printHelp(): void {
console.log(`gori-agent - local CLI-agent gateway setup and operations
Usage:
gori-agent setup [--config path]
gori-agent discover-agents [--json]
gori-agent start [--config path]
gori-agent status [--config path]
gori-agent doctor [--config path]
gori-agent print feishu [--config path]
gori-agent --help
No-link fallback:
npm run gori-agent -- setup`);
console.log(`gori-agent - multi-IM ACP agent gateway\n\nUsage:\n gori-agent setup [--config path]\n gori-agent discover-backends [--json]\n gori-agent start [--config path]\n gori-agent status [--config path]\n gori-agent doctor [--config path]\n gori-agent print feishu [--config path]\n\nDeprecated alias: discover-agents`);
}
main(process.argv.slice(2)).then((code) => {
if (Number.isInteger(code)) process.exitCode = code;
}).catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
main(process.argv.slice(2)).then((code) => { if (Number.isInteger(code)) process.exitCode = code; }).catch((error) => {
console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1;
});
+42 -41
View File
@@ -1,63 +1,64 @@
import fs from "node:fs";
import type { AppConfig } from "../config.js";
import { discoverAgents } from "../agents/discovery.js";
import path from "node:path";
import { probeAcpBackend } from "../acp/probe.js";
import { defaultStateFile, type AppConfig } from "../config.js";
import { RoleRegistry } from "../roles/role-registry.js";
import { printUrlHints } from "./net.js";
export async function runDoctor(config: AppConfig, configPath: string): Promise<number> {
let problems = 0;
console.log(`Config: ${configPath}`);
console.log(`Server: ${config.server.host}:${config.server.port}`);
if (!config.agents.some((agent) => agent.name === config.defaultAgent)) {
console.log(`ERROR: defaultAgent '${config.defaultAgent}' is not in agents[].`);
problems++;
} else {
console.log(`Default agent: ${config.defaultAgent}`);
try {
new RoleRegistry(config);
console.log(`Default role: ${config.defaultRole}`);
} catch (error) {
problems += reportError(error instanceof Error ? error.message : String(error));
}
for (const agent of config.agents) {
if (agent.cwd && !fs.existsSync(agent.cwd)) {
console.log(`WARN: agent '${agent.name}' cwd does not exist: ${agent.cwd}`);
for (const role of config.roles) {
if (!fs.existsSync(role.workspace)) problems += reportError(`role '${role.id}' workspace does not exist: ${role.workspace}`);
if (role.policy.permissionMode === "auto") console.log(`WARN: role '${role.id}' auto-approves every permission request.`);
if (role.policy.permissionMode === "allowlist" && role.policy.allowedTools.includes("bash") && role.policy.allowedCommandPatterns.length === 0) {
console.log(`WARN: role '${role.id}' allows bash by name but has no command patterns; bash requests will be denied.`);
}
}
for (const backend of config.backends) {
try {
const initialized = await probeAcpBackend(backend, config.acp.initializeTimeoutMs);
const capabilities = initialized.agentCapabilities;
console.log(`ACP '${backend.id}': ready (${initialized.agentInfo?.name || "unknown"} ${initialized.agentInfo?.version || ""})`);
console.log(` load=${Boolean(capabilities?.loadSession)} resume=${Boolean(capabilities?.sessionCapabilities?.resume)} list=${Boolean(capabilities?.sessionCapabilities?.list)} close=${Boolean(capabilities?.sessionCapabilities?.close)}`);
if (!capabilities?.loadSession && !capabilities?.sessionCapabilities?.resume) problems += reportError(`backend '${backend.id}' cannot restore sessions`);
console.log(" model config: not advertised by initialize; using the Kimi default model");
} catch (error) {
problems += reportError(`ACP '${backend.id}' initialize failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
const stateFile = defaultStateFile(config);
try {
const stateDirectory = path.dirname(stateFile);
fs.mkdirSync(stateDirectory, { recursive: true });
fs.accessSync(stateDirectory, fs.constants.R_OK | fs.constants.W_OK);
console.log(`State file: ${stateFile}`);
} catch (error) {
problems += reportError(`state directory is unavailable: ${error instanceof Error ? error.message : String(error)}`);
}
const enabledPlatforms = Object.entries(config.platforms).filter(([, value]) => value.enabled).map(([name]) => name);
console.log(`Enabled platforms: ${enabledPlatforms.join(", ") || "none"}`);
if (enabledPlatforms.length === 0) {
console.log("WARN: no IM platform is enabled.");
}
if (config.platforms.feishu.enabled) {
if (!config.platforms.feishu.appId) problems += error("Feishu appId is empty.");
if (!config.platforms.feishu.appSecret || config.platforms.feishu.appSecret === "replace-me") warn("Feishu appSecret is missing or placeholder.");
if (!config.platforms.feishu.verificationToken || config.platforms.feishu.verificationToken === "replace-me") warn("Feishu verificationToken is missing or placeholder.");
}
if (config.platforms.qq.enabled) {
console.log(`QQ connection mode: ${config.platforms.qq.connectionMode}`);
if (!config.platforms.qq.appId) problems += error("QQ appId is empty.");
if (!config.platforms.qq.clientSecret || config.platforms.qq.clientSecret === "replace-me") warn("QQ clientSecret is missing or placeholder.");
if (config.platforms.qq.connectionMode === "webhook" && config.platforms.qq.verifySignature) {
const callbackSecret = config.platforms.qq.botSecret || config.platforms.qq.clientSecret;
if (!callbackSecret || callbackSecret === "replace-me") problems += error("QQ botSecret or clientSecret is required when verifySignature is true.");
if (!config.platforms.qq.appId) problems += reportError("QQ appId is empty.");
if (!config.platforms.qq.clientSecret || config.platforms.qq.clientSecret === "replace-me") console.log("WARN: QQ clientSecret is missing or placeholder.");
if (config.platforms.qq.connectionMode === "webhook" && config.platforms.qq.verifySignature && !(config.platforms.qq.botSecret || config.platforms.qq.clientSecret)) {
problems += reportError("QQ botSecret or clientSecret is required for webhook signature verification.");
}
}
const discovered = await discoverAgents();
console.log("Discovered local agents:");
for (const agent of discovered) {
const version = agent.version ? ` (${agent.version})` : "";
const reason = agent.reason ? ` - ${agent.reason}` : "";
console.log(`- ${agent.name}: ${agent.status}${version}${reason}`);
}
await printUrlHints(config);
return problems > 0 ? 1 : 0;
}
function error(message: string): 1 {
console.log(`ERROR: ${message}`);
return 1;
}
function warn(message: string): void {
console.log(`WARN: ${message}`);
}
function reportError(message: string): 1 { console.log(`ERROR: ${message}`); return 1; }
+51 -282
View File
@@ -1,297 +1,66 @@
import path from "node:path";
import type { AppConfig, CliAgentConfig } from "../config.js";
import type { DiscoveredAgent } from "../agents/discovery.js";
import { discoverAgents, echoAgent } from "../agents/discovery.js";
import { listConfiguredKimiModels } from "../agents/kimi-models.js";
import { createPromptSession, type Choice } from "./prompt.js";
import { discoverBackends } from "../acp/discovery.js";
import type { AppConfig } from "../config.js";
import { createPromptSession } from "./prompt.js";
import { loadConfigFile, projectRoot, writeConfigFile } from "./config-file.js";
import { configureFeishu } from "./setup-feishu.js";
import { configureGenericWebhook } from "./setup-webhook.js";
import { configureWeixin } from "./setup-weixin.js";
import { printUrlHints } from "./net.js";
type PlatformName = keyof AppConfig["platforms"];
const PLATFORM_CHOICES: Choice<PlatformName>[] = [
{ label: "Feishu/Lark", value: "feishu", hint: "full inbound/outbound adapter" },
{ label: "WeChat external webhook", value: "weixin", hint: "personal WeChat bridge scaffold" },
{ label: "WeCom scaffold", value: "wecom", hint: "inbound is not implemented in v1" },
{ label: "QQ Bot webhook", value: "qq", hint: "official QQ Bot HTTP callback" },
{ label: "Generic webhook", value: "webhook", hint: "signed JSON webhook" }
];
const GORI_SKILL = "/home/ubuntu/gori-space/gori-deploy/.kimi-code/skills/gori-update/SKILL.md";
export async function runSetup(configPath?: string): Promise<void> {
const loaded = loadConfigFile(configPath);
const prompt = createPromptSession();
try {
console.log("gori-agent setup");
console.log(`Config target: ${loaded.path}${loaded.exists ? "" : " (will create from config.example.json)"}`);
await printUrlHints(loaded.config);
console.log("gori-agent ACP setup");
console.log(`Config target: ${loaded.path}`);
const discovered = await discoverBackends();
for (const backend of discovered) console.log(`- ${backend.id}: ${backend.status}${backend.version ? ` (${backend.version})` : ""}${backend.reason ? ` - ${backend.reason}` : ""}`);
const kimi = discovered.find((backend) => backend.id === "kimi" && backend.status === "ready");
if (!kimi) throw new Error("Kimi ACP backend is not available");
const discovered = await discoverAgents(projectRoot());
console.log("\nDetected local agents:");
for (const agent of discovered) {
const version = agent.version ? ` (${agent.version})` : "";
const reason = agent.reason ? ` - ${agent.reason}` : "";
console.log(`- ${agent.label} [${agent.name}]: ${agent.status}${version}${reason}`);
const existingRole = loaded.config.roles.find((role) => role.id === loaded.config.defaultRole);
const workspace = path.resolve(await prompt.ask("Assistant workspace", existingRole?.workspace || projectRoot()));
const includeOps = await prompt.askBoolean("Include ops role with the existing gori-update skill", loaded.config.roles.some((role) => role.id === "ops"));
const roles: AppConfig["roles"] = [{
id: "assistant", backend: "kimi", workspace, persona: existingRole?.persona || "", skills: [],
policy: { permissionMode: "deny", allowedTools: [], allowedCommandPatterns: [] }
}];
const skills: AppConfig["skills"] = [];
if (includeOps) {
skills.push({ id: "gori-update", file: GORI_SKILL, maxBytes: 256_000 });
roles.push(opsRole());
}
const selectedAgent = await prompt.choose(
"\nChoose an agent",
discovered.map((agent) => ({
label: `${agent.label} (${agent.name})`,
value: agent,
hint: agent.status === "ready" ? agent.command : agent.reason
})),
Math.max(0, discovered.findIndex((agent) => agent.name === loaded.config.defaultAgent))
);
const agentConfig = await configureAgent(prompt, selectedAgent, loaded.config);
const selectedPlatform = await prompt.choose("\nChoose an IM platform", PLATFORM_CHOICES, 0);
const keepOtherPlatforms = await prompt.askBoolean("Preserve existing enabled settings for other platforms", false);
const nextConfig = await buildNextConfig(prompt, loaded.config, agentConfig, selectedPlatform, keepOtherPlatforms);
console.log("\nPlanned config summary:");
console.log(`- defaultAgent: ${nextConfig.defaultAgent}`);
console.log(`- agents: ${nextConfig.agents.map((agent) => agent.name).join(", ")}`);
console.log(`- enabled platforms: ${Object.entries(nextConfig.platforms).filter(([, value]) => value.enabled).map(([name]) => name).join(", ") || "none"}`);
console.log("- secrets are not printed");
if (await prompt.askBoolean(`Write config to ${loaded.path}`, false)) {
writeConfigFile(loaded.path, nextConfig);
console.log(`Wrote ${loaded.path}`);
} else {
console.log("No changes written.");
}
} finally {
prompt.close();
}
}
async function configureAgent(prompt: ReturnType<typeof createPromptSession>, discovered: DiscoveredAgent, config: AppConfig): Promise<CliAgentConfig> {
const existing = config.agents.find((agent) => agent.name === discovered.name);
const defaultCwd = existing?.cwd || discovered.cwd || projectRoot();
if (discovered.status === "needs-config") {
console.log(`\n${discovered.label} needs manual command details before it is enabled.`);
const command = await prompt.ask("Command", existing?.command || discovered.command);
const argsText = await prompt.ask("Args, separated by spaces", (existing?.args || discovered.args).join(" "));
const inputMode = await prompt.choose("Input mode", [
{ label: "Append prompt as final argv", value: "arg" as const },
{ label: "Send prompt to stdin", value: "stdin" as const }
], (existing?.inputMode || discovered.inputMode) === "stdin" ? 1 : 0);
const extraPermissionArgs = await prompt.ask("Extra auto/yolo permission args, if this agent needs them", "");
const cwd = await prompt.ask("Working directory", defaultCwd);
return agentFromParts(discovered.name, command, [...splitArgs(argsText), ...splitArgs(extraPermissionArgs)], inputMode, cwd);
}
const cwd = await prompt.ask("Working directory", defaultCwd);
const command = existing?.command || discovered.command;
const baseArgs = existing?.args || discovered.args;
const modelArgs = discovered.name === "kimi" ? await configureKimiArgs(prompt, baseArgs, command) : baseArgs;
const args = await configurePermissionArgs(prompt, discovered, modelArgs);
return agentFromParts(discovered.name, command, args, existing?.inputMode || discovered.inputMode, cwd);
}
async function configurePermissionArgs(prompt: ReturnType<typeof createPromptSession>, discovered: DiscoveredAgent, args: string[]): Promise<string[]> {
if (!discovered.permissionModes || discovered.permissionModes.length === 0) return args;
const selected = await prompt.choose(
`Permission mode for ${discovered.label}`,
discovered.permissionModes.map((mode) => ({
label: mode.label,
value: mode,
hint: mode.warning
})),
0
);
if (selected.warning) console.log(`Note: ${selected.warning}`);
return mergePermissionArgs(args, selected.args);
}
function mergePermissionArgs(args: string[], permissionArgs: string[]): string[] {
const merged = [...args];
for (const arg of permissionArgs) {
if (!merged.includes(arg)) merged.push(arg);
}
return merged;
}
async function configureKimiArgs(prompt: ReturnType<typeof createPromptSession>, existingArgs: string[], kimiCommand: string): Promise<string[]> {
console.log("\nKimi Code supports temporary model selection with --model / -m.");
console.log("Setup reads your local Kimi provider/model list via `kimi provider list --json`.");
console.log("Leave it on default to use default_model from your Kimi Code config.");
const configuredModels = await listConfiguredKimiModels(kimiCommand);
const modelChoices: Choice<string>[] = [
{ label: "Use Kimi default model", value: "", hint: "respect default_model in Kimi Code config" },
...configuredModels.map((model) => ({
label: model.id,
value: model.id,
hint: model.source === "configured" ? "from local Kimi provider config" : "built-in fallback"
})),
{ label: "Custom model alias", value: "__custom__", hint: "type any provider/model alias" }
];
const existingModel = findKimiModel(existingArgs);
const defaultIndex = existingModel
? modelChoices.findIndex((choice) => choice.value === existingModel)
: 0;
const modelChoice = await prompt.choose("Choose Kimi Code model", modelChoices, defaultIndex >= 0 ? defaultIndex : modelChoices.length - 1);
const model = modelChoice === "__custom__"
? await prompt.ask("Custom Kimi model alias", existingModel || configuredModels[0]?.id || "kimi-for-coding")
: modelChoice;
return withKimiModel(existingArgs, model);
}
function findKimiModel(args: string[]): string | undefined {
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if ((arg === "-m" || arg === "--model") && args[index + 1]) return args[index + 1];
if (arg.startsWith("--model=")) return arg.slice("--model=".length);
}
return undefined;
}
function withKimiModel(args: string[], model: string): string[] {
const cleaned: string[] = [];
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "-m" || arg === "--model") {
index += 1;
continue;
}
if (arg.startsWith("--model=")) continue;
cleaned.push(arg);
}
if (!model) return ensureKimiPromptArg(cleaned);
return ["-m", model, ...ensureKimiPromptArg(cleaned)];
}
function ensureKimiPromptArg(args: string[]): string[] {
return args.includes("-p") || args.includes("--prompt") ? args : [...args, "-p"];
}
async function buildNextConfig(
prompt: ReturnType<typeof createPromptSession>,
existing: AppConfig,
selectedAgent: CliAgentConfig,
selectedPlatform: PlatformName,
keepOtherPlatforms: boolean
): Promise<AppConfig> {
const platforms: AppConfig["platforms"] = keepOtherPlatforms
? structuredClone(existing.platforms)
: disableAllPlatforms(existing.platforms);
if (selectedPlatform === "feishu") {
platforms.feishu = await configureFeishu(prompt, existing.platforms.feishu);
} else if (selectedPlatform === "webhook") {
platforms.webhook = await configureGenericWebhook(prompt, existing.platforms.webhook);
} else if (selectedPlatform === "weixin") {
platforms.weixin = await configureWeixin(prompt, existing.platforms.weixin);
} else if (selectedPlatform === "wecom") {
console.log("\nWarning: WeCom inbound webhook is a scaffold and returns 501 in v1.");
platforms.wecom = {
enabled: true,
corpId: await prompt.ask("WeCom corpId", existing.platforms.wecom.corpId),
agentId: await prompt.ask("WeCom agentId", existing.platforms.wecom.agentId),
secret: await prompt.ask(existing.platforms.wecom.secret ? "WeCom secret (leave blank to keep existing)" : "WeCom secret") || existing.platforms.wecom.secret
const next: AppConfig = {
...loaded.config,
configVersion: 2,
backends: [{ id: "kimi", command: kimi.command, args: ["acp"], env: {} }],
skills,
defaultRole: "assistant",
roles,
platforms: structuredClone(loaded.config.platforms)
};
} else if (selectedPlatform === "qq") {
const connectionMode = await prompt.choose("QQ connection mode", [
{ label: "WebSocket gateway", value: "websocket" as const, hint: "recommended; no public callback URL needed" },
{ label: "HTTP callback webhook", value: "webhook" as const, hint: "requires public HTTPS callback URL" }
], existing.platforms.qq.connectionMode === "webhook" ? 1 : 0);
console.log("\nMigration summary:");
console.log(`- default role: ${next.defaultRole}`);
console.log(`- roles: ${next.roles.map(({ id }) => id).join(", ")}`);
console.log(`- backend: ${kimi.command} acp`);
console.log("- all existing platform settings and credentials are preserved");
if (await prompt.askBoolean(`Write config to ${loaded.path}`, false)) {
writeConfigFile(loaded.path, next);
console.log(`Wrote ${loaded.path}`);
} else console.log("No changes written.");
} finally { prompt.close(); }
}
if (connectionMode === "websocket") {
console.log("\nQQ WebSocket gateway will actively connect to QQ; no public domain is needed.");
const intentsText = await prompt.ask("QQ gateway intents", String(existing.platforms.qq.intents));
platforms.qq = {
enabled: true,
connectionMode,
appId: await prompt.ask("QQ appId", existing.platforms.qq.appId),
clientSecret: await prompt.ask(existing.platforms.qq.clientSecret ? "QQ clientSecret (leave blank to keep existing)" : "QQ clientSecret") || existing.platforms.qq.clientSecret,
botSecret: existing.platforms.qq.botSecret,
verifySignature: existing.platforms.qq.verifySignature,
botNames: splitCommaList(await prompt.ask("QQ bot names, comma separated", existing.platforms.qq.botNames.join(", "))),
intents: parsePositiveInt(intentsText, existing.platforms.qq.intents),
shard: existing.platforms.qq.shard
};
} else {
console.log("\nQQ Bot HTTP callback endpoint: /webhook/qq");
platforms.qq = {
enabled: true,
connectionMode,
appId: await prompt.ask("QQ appId", existing.platforms.qq.appId),
clientSecret: await prompt.ask(existing.platforms.qq.clientSecret ? "QQ clientSecret (leave blank to keep existing)" : "QQ clientSecret") || existing.platforms.qq.clientSecret,
botSecret: await prompt.ask(existing.platforms.qq.botSecret ? "QQ botSecret for callback signing (leave blank to keep existing)" : "QQ botSecret for callback signing, blank to reuse clientSecret", "") || existing.platforms.qq.botSecret,
verifySignature: await prompt.askBoolean("Verify QQ callback signatures", existing.platforms.qq.verifySignature),
botNames: splitCommaList(await prompt.ask("QQ bot names, comma separated", existing.platforms.qq.botNames.join(", "))),
intents: existing.platforms.qq.intents,
shard: existing.platforms.qq.shard
};
function opsRole(): AppConfig["roles"][number] {
return {
id: "ops", backend: "kimi", workspace: "/home/ubuntu/gori-space",
persona: "你是 Gori 团队运维角色。严格遵循 gori-update skill;有风险或需要外部确认时停止并报告。",
skills: ["gori-update"],
policy: {
permissionMode: "allowlist",
allowedTools: ["read", "grep", "glob", "bash"],
allowedCommandPatterns: [
"^(?:.*\\\"command\\\":\\\")?(?:git (?:status|log|diff|pull --ff-only)|bash gori-deploy/(?:build\\.sh|dist/deploy-[a-z-]+\\.sh)|docker (?:ps|logs))"
]
}
}
const echo = echoConfig();
const agents = selectedAgent.name === "echo" ? [echo] : [selectedAgent, echo];
return {
server: existing.server,
policy: existing.policy,
defaultAgent: selectedAgent.name,
agents,
platforms
};
}
function disableAllPlatforms(platforms: AppConfig["platforms"]): AppConfig["platforms"] {
return {
feishu: { ...platforms.feishu, enabled: false },
wecom: { ...platforms.wecom, enabled: false },
qq: { ...platforms.qq, enabled: false },
webhook: { ...platforms.webhook, enabled: false },
weixin: { ...platforms.weixin, enabled: false }
};
}
function echoConfig(): CliAgentConfig {
const echo = echoAgent(projectRoot());
return {
name: echo.name,
command: echo.command,
args: echo.args,
inputMode: echo.inputMode,
cwd: echo.cwd,
timeoutMs: 30_000,
outputMaxBytes: 64_000
};
}
function agentFromParts(name: string, command: string, args: string[], inputMode: "stdin" | "arg", cwd: string): CliAgentConfig {
return {
name,
command,
args,
inputMode,
cwd: path.resolve(cwd),
timeoutMs: 120_000,
outputMaxBytes: 64_000
};
}
function splitArgs(value: string): string[] {
return value.split(" ").map((part) => part.trim()).filter(Boolean);
}
function parsePositiveInt(value: string, fallback: number): number {
const parsed = Number.parseInt(value, 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
function splitCommaList(value: string): string[] {
return value.split(",").map((part) => part.trim()).filter(Boolean);
}
+132 -55
View File
@@ -3,102 +3,179 @@ import path from "node:path";
import process from "node:process";
import { z } from "zod";
const cliAgentSchema = z.object({
name: z.string().min(1),
command: z.string().min(1),
args: z.array(z.string()).default([]),
inputMode: z.enum(["stdin", "arg"]).default("stdin"),
timeoutMs: z.number().int().positive().default(120_000),
outputMaxBytes: z.number().int().positive().default(64_000),
cwd: z.string().optional()
});
const policySchema = z.object({
allowedUsers: z.array(z.string()).default([]),
allowedChats: z.array(z.string()).default([]),
requireMentionInGroup: z.boolean().default(false)
});
const rolePolicySchema = z.object({
permissionMode: z.enum(["deny", "allowlist", "auto"]).default("deny"),
allowedTools: z.array(z.string()).default([]),
allowedCommandPatterns: z.array(z.string()).default([])
});
const backendSchema = z.object({
id: z.string().min(1),
command: z.string().min(1),
args: z.array(z.string()).default([]),
env: z.record(z.string()).default({})
});
const skillSchema = z.object({
id: z.string().min(1),
file: z.string().min(1),
maxBytes: z.number().int().positive().default(256_000)
});
const roleSchema = z.object({
id: z.string().min(1),
backend: z.string().min(1),
workspace: z.string().min(1),
persona: z.string().default(""),
skills: z.array(z.string()).default([]),
policy: rolePolicySchema.default({})
});
const acpSchema = z.object({
stateFile: z.string().default(""),
initializeTimeoutMs: z.number().int().positive().default(10_000),
promptTimeoutMs: z.number().int().positive().default(600_000),
cancelGraceMs: z.number().int().positive().default(5_000),
idleTimeoutMs: z.number().int().positive().default(1_800_000),
sweepIntervalMs: z.number().int().positive().default(60_000),
maxProcesses: z.number().int().positive().default(8)
});
const feishuSchema = z.object({
enabled: z.boolean().default(false),
appId: z.string().default(""),
appSecret: z.string().default(""),
verificationToken: z.string().default(""),
botNames: z.array(z.string()).default([])
enabled: z.boolean().default(false), appId: z.string().default(""), appSecret: z.string().default(""),
verificationToken: z.string().default(""), botNames: z.array(z.string()).default([])
});
const wecomSchema = z.object({
enabled: z.boolean().default(false),
corpId: z.string().default(""),
agentId: z.string().default(""),
secret: z.string().default("")
enabled: z.boolean().default(false), corpId: z.string().default(""), agentId: z.string().default(""), secret: z.string().default("")
});
const qqSchema = z.object({
enabled: z.boolean().default(false),
connectionMode: z.enum(["websocket", "webhook"]).default("websocket"),
appId: z.string().default(""),
clientSecret: z.string().default(""),
botSecret: z.string().default(""),
verifySignature: z.boolean().default(true),
botNames: z.array(z.string()).default([]),
enabled: z.boolean().default(false), connectionMode: z.enum(["websocket", "webhook"]).default("websocket"),
appId: z.string().default(""), clientSecret: z.string().default(""), botSecret: z.string().default(""),
verifySignature: z.boolean().default(true), botNames: z.array(z.string()).default([]),
intents: z.number().int().positive().default(1 << 25),
shard: z.tuple([z.number().int().nonnegative(), z.number().int().positive()]).default([0, 1])
});
const webhookSchema = z.object({
enabled: z.boolean().default(true),
secret: z.string().default("")
});
const webhookSchema = z.object({ enabled: z.boolean().default(true), secret: z.string().default("") });
const weixinSchema = z.object({
enabled: z.boolean().default(false),
mode: z.enum(["external-webhook", "not-implemented"]).default("external-webhook"),
secret: z.string().default("")
enabled: z.boolean().default(false), mode: z.enum(["external-webhook", "not-implemented"]).default("external-webhook"), secret: z.string().default("")
});
export const configSchema = z.object({
configVersion: z.literal(2),
server: z.object({
host: z.string().default("0.0.0.0"),
port: z.number().int().positive().max(65_535).default(3000),
publicBaseUrl: z.string().default("")
host: z.string().default("0.0.0.0"), port: z.number().int().positive().max(65_535).default(3000), publicBaseUrl: z.string().default("")
}).default({}),
policy: policySchema.default({}),
defaultAgent: z.string().min(1).default("echo"),
agents: z.array(cliAgentSchema).min(1),
acp: acpSchema.default({}),
backends: z.array(backendSchema).min(1),
skills: z.array(skillSchema).default([]),
defaultRole: z.string().min(1),
roles: z.array(roleSchema).min(1),
platforms: z.object({
feishu: feishuSchema.default({}),
wecom: wecomSchema.default({}),
qq: qqSchema.default({}),
webhook: webhookSchema.default({}),
weixin: weixinSchema.default({})
feishu: feishuSchema.default({}), wecom: wecomSchema.default({}), qq: qqSchema.default({}),
webhook: webhookSchema.default({}), weixin: weixinSchema.default({})
}).default({})
});
export type AppConfig = z.infer<typeof configSchema>;
export type CliAgentConfig = z.infer<typeof cliAgentSchema>;
export type GatewayPolicy = z.infer<typeof policySchema>;
export type AcpConfig = AppConfig["acp"];
export type AcpBackendConfig = AppConfig["backends"][number];
export type RoleConfig = AppConfig["roles"][number];
export type RolePolicy = RoleConfig["policy"];
export type SkillConfig = AppConfig["skills"][number];
// Kept only so legacy CliAgent source remains type-checkable; it is not used by the runtime.
export interface CliAgentConfig {
name: string;
command: string;
args: string[];
inputMode: "stdin" | "arg";
timeoutMs: number;
outputMaxBytes: number;
cwd?: string;
}
interface V1AgentConfig {
name?: string;
command?: string;
args?: string[];
cwd?: string;
}
export function resolveConfigPath(configPath = process.env.GORI_GATEWAY_CONFIG): string {
return path.resolve(configPath || "config.example.json");
}
export function parseConfig(rawConfig: unknown): AppConfig {
const config = configSchema.parse(rawConfig);
const candidate = isRecord(rawConfig) && rawConfig.configVersion === 2 ? rawConfig : migrateV1Config(rawConfig);
const config = configSchema.parse(candidate);
validateUnique(config.backends.map((item) => item.id), "backend");
validateUnique(config.roles.map((item) => item.id), "role");
validateUnique(config.skills.map((item) => item.id), "skill");
if (!config.agents.some((agent) => agent.name === config.defaultAgent)) {
throw new Error(`defaultAgent '${config.defaultAgent}' is not present in agents`);
const backendIds = new Set(config.backends.map((item) => item.id));
const skillIds = new Set(config.skills.map((item) => item.id));
if (!config.roles.some((role) => role.id === config.defaultRole)) throw new Error(`defaultRole '${config.defaultRole}' is not present in roles`);
for (const role of config.roles) {
if (!path.isAbsolute(role.workspace)) throw new Error(`role '${role.id}' workspace must be absolute`);
if (!backendIds.has(role.backend)) throw new Error(`role '${role.id}' references unknown backend '${role.backend}'`);
for (const skill of role.skills) if (!skillIds.has(skill)) throw new Error(`role '${role.id}' references unknown skill '${skill}'`);
for (const pattern of role.policy.allowedCommandPatterns) {
try { new RegExp(pattern); } catch { throw new Error(`role '${role.id}' has invalid command pattern '${pattern}'`); }
}
}
return config;
}
export function migrateV1Config(rawConfig: unknown): unknown {
if (!isRecord(rawConfig)) throw new Error("configuration must be an object");
const agents = Array.isArray(rawConfig.agents) ? rawConfig.agents.filter(isRecord) as V1AgentConfig[] : [];
const defaultAgent = typeof rawConfig.defaultAgent === "string" ? rawConfig.defaultAgent : "";
const selected = agents.find((agent) => agent.name === defaultAgent);
if (!selected || selected.name !== "kimi" || !selected.command || path.basename(selected.command) !== "kimi") {
throw new Error("v1 migration only supports a Kimi default agent; configure an ACP backend explicitly for other agents");
}
return {
configVersion: 2,
server: rawConfig.server,
policy: rawConfig.policy,
acp: {},
backends: [{ id: "kimi", command: selected.command, args: ["acp"] }],
skills: [],
defaultRole: "assistant",
roles: [{
id: "assistant", backend: "kimi", workspace: path.resolve(selected.cwd || process.cwd()), persona: "", skills: [],
policy: { permissionMode: "deny", allowedTools: [], allowedCommandPatterns: [] }
}],
platforms: rawConfig.platforms
};
}
export function defaultStateFile(config: AppConfig): string {
if (config.acp.stateFile) return path.resolve(config.acp.stateFile);
const home = process.env.GORI_AGENT_HOME || process.cwd();
return path.join(path.resolve(home), "state", "acp-sessions.json");
}
export function loadConfig(configPath = process.env.GORI_GATEWAY_CONFIG): AppConfig {
return loadConfigFromPath(resolveConfigPath(configPath));
}
export function loadConfigFromPath(configPath: string): AppConfig {
const raw = fs.readFileSync(configPath, "utf8");
const parsed = JSON.parse(raw) as unknown;
return parseConfig(parsed);
return parseConfig(JSON.parse(fs.readFileSync(configPath, "utf8")) as unknown);
}
function validateUnique(ids: string[], label: string): void {
if (new Set(ids).size !== ids.length) throw new Error(`${label} IDs must be unique`);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+19 -50
View File
@@ -1,57 +1,26 @@
import { AgentRegistry } from "../agents/agent-registry.js";
import { SessionStore } from "./session-store.js";
import type { IncomingMessage } from "./types.js";
export type CommandKind = "help" | "roles" | "role" | "status" | "cancel" | "new";
export interface CommandResult {
handled: boolean;
text?: string;
export interface ParsedCommand {
kind: CommandKind;
argument?: string;
deprecatedAlias?: boolean;
}
export class CommandRouter {
constructor(
private readonly agents: AgentRegistry,
private readonly sessions: SessionStore
) {}
route(message: IncomingMessage, sessionId: string): CommandResult {
const text = message.text.trim();
if (!text.startsWith("/")) return { handled: false };
const [command, ...args] = text.split(/\s+/);
switch (command) {
case "/help":
return {
handled: true,
text: [
"Commands:",
"/help - show this help",
"/status - show gateway/session status",
"/agents - list available agents",
"/agent <name> - select an agent for this chat",
"/new - reset this chat session"
].join("\n")
};
case "/status": {
const session = this.sessions.getSession(sessionId);
return {
handled: true,
text: `OK\nplatform=${message.platform}\nchat=${message.chatId}\nagent=${session.selectedAgent || this.agents.defaultName()}`
};
}
case "/agents":
return { handled: true, text: `Available agents: ${this.agents.list().join(", ")}` };
case "/agent": {
const agentName = args[0];
if (!agentName) return { handled: true, text: "Usage: /agent <name>" };
if (!this.agents.has(agentName)) return { handled: true, text: `Unknown agent: ${agentName}` };
this.sessions.setSelectedAgent(sessionId, agentName);
return { handled: true, text: `Selected agent: ${agentName}` };
}
case "/new":
this.sessions.reset(sessionId);
return { handled: true, text: "Started a new session for this chat." };
default:
return { handled: false };
parse(text: string): ParsedCommand | undefined {
const trimmed = text.trim();
if (!trimmed.startsWith("/")) return undefined;
const [command, ...args] = trimmed.split(/\s+/);
switch (command.toLowerCase()) {
case "/help": return { kind: "help" };
case "/roles": return { kind: "roles" };
case "/role": return { kind: "role", argument: args[0] };
case "/status": return { kind: "status" };
case "/cancel": return { kind: "cancel" };
case "/new": return { kind: "new" };
case "/agents": return { kind: "roles", deprecatedAlias: true };
case "/agent": return { kind: "role", argument: args[0], deprecatedAlias: true };
default: return undefined;
}
}
}
+150
View File
@@ -0,0 +1,150 @@
import fs from "node:fs";
import path from "node:path";
export interface ChatState {
selectedRole: string;
createdAt: number;
updatedAt: number;
}
export interface SessionBinding {
chatKey: string;
roleId: string;
backendId: string;
nativeSessionId: string;
workspace: string;
roleFingerprint: string;
createdAt: number;
updatedAt: number;
}
interface StoreData {
version: 1;
chats: Record<string, ChatState>;
bindings: Record<string, SessionBinding>;
}
const EMPTY: StoreData = { version: 1, chats: {}, bindings: {} };
export class DurableSessionStore {
private data: StoreData = structuredClone(EMPTY);
private queue: Promise<void> = Promise.resolve();
private lockFd?: fs.promises.FileHandle;
private closed = false;
constructor(readonly file: string) {}
async open(): Promise<void> {
await fs.promises.mkdir(path.dirname(this.file), { recursive: true });
const lockFile = `${this.file}.lock`;
try {
this.lockFd = await acquireLock(lockFile);
await this.lockFd.writeFile(`${process.pid}\n`);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`Session state is locked by another gori-agent instance: ${lockFile}`);
throw error;
}
try {
const raw = await fs.promises.readFile(this.file, "utf8");
const parsed = JSON.parse(raw) as StoreData;
if (parsed.version !== 1 || !parsed.chats || !parsed.bindings) throw new Error("unsupported or malformed state data");
this.data = parsed;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
await this.releaseLock();
throw new Error(`Cannot read session state '${this.file}'; original file was preserved: ${error instanceof Error ? error.message : String(error)}`);
}
}
}
getSelectedRole(chatKey: string, defaultRole: string): string {
return this.data.chats[chatKey]?.selectedRole || defaultRole;
}
async setSelectedRole(chatKey: string, roleId: string): Promise<void> {
const now = Date.now();
const current = this.data.chats[chatKey];
this.data.chats[chatKey] = { selectedRole: roleId, createdAt: current?.createdAt || now, updatedAt: now };
await this.persist();
}
getBinding(chatKey: string, roleId: string): SessionBinding | undefined {
const value = this.data.bindings[bindingKey(chatKey, roleId)];
return value ? structuredClone(value) : undefined;
}
async setBinding(binding: SessionBinding): Promise<void> {
this.data.bindings[bindingKey(binding.chatKey, binding.roleId)] = structuredClone(binding);
await this.persist();
}
async touchBinding(chatKey: string, roleId: string): Promise<void> {
const binding = this.data.bindings[bindingKey(chatKey, roleId)];
if (!binding) return;
binding.updatedAt = Date.now();
await this.persist();
}
async deleteBinding(chatKey: string, roleId: string): Promise<SessionBinding | undefined> {
const key = bindingKey(chatKey, roleId);
const existing = this.data.bindings[key];
delete this.data.bindings[key];
if (existing) await this.persist();
return existing;
}
stats(): { chats: number; bindings: number } {
return { chats: Object.keys(this.data.chats).length, bindings: Object.keys(this.data.bindings).length };
}
async flush(): Promise<void> { await this.queue; }
async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
await this.flush();
await this.releaseLock();
}
private persist(): Promise<void> {
if (this.closed) return Promise.reject(new Error("Session store is closed"));
const snapshot = JSON.stringify(this.data, null, 2) + "\n";
this.queue = this.queue.then(() => atomicWrite(this.file, snapshot));
return this.queue;
}
private async releaseLock(): Promise<void> {
if (!this.lockFd) return;
await this.lockFd.close();
this.lockFd = undefined;
await fs.promises.unlink(`${this.file}.lock`).catch((error: NodeJS.ErrnoException) => {
if (error.code !== "ENOENT") throw error;
});
}
}
export function chatKeyFor(platform: string, chatId: string): string { return `${platform}:${chatId}`; }
export function bindingKey(chatKey: string, roleId: string): string { return `${chatKey}\u0000${roleId}`; }
async function acquireLock(lockFile: string): Promise<fs.promises.FileHandle> {
return fs.promises.open(lockFile, "wx", 0o600);
}
async function atomicWrite(file: string, content: string): Promise<void> {
const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
const handle = await fs.promises.open(temp, "wx", 0o600);
try {
await handle.writeFile(content, "utf8");
await handle.sync();
} finally {
await handle.close();
}
try {
await fs.promises.rename(temp, file);
const dir = await fs.promises.open(path.dirname(file), "r");
try { await dir.sync(); } finally { await dir.close(); }
} catch (error) {
await fs.promises.unlink(temp).catch(() => undefined);
throw error;
}
}
+62 -75
View File
@@ -1,112 +1,99 @@
import type { ConversationRuntime } from "../acp/types.js";
import type { GatewayPolicy } from "../config.js";
import { AgentRegistry } from "../agents/agent-registry.js";
import type { RoleRegistry } from "../roles/role-registry.js";
import type { PlatformAdapter } from "./adapter.js";
import { CommandRouter } from "./command-router.js";
import { SessionStore, sessionIdFor } from "./session-store.js";
import { CommandRouter, type ParsedCommand } from "./command-router.js";
import { chatKeyFor } from "./durable-session-store.js";
import type { IncomingMessage } from "./types.js";
export interface GatewayResult {
ok: boolean;
reply?: string;
ignored?: boolean;
error?: string;
}
export interface GatewayResult { ok: boolean; reply?: string; ignored?: boolean; error?: string }
export class Gateway {
private readonly locks = new Map<string, Promise<void>>();
readonly commandRouter: CommandRouter;
readonly commandRouter = new CommandRouter();
constructor(
private readonly policy: GatewayPolicy,
private readonly agents: AgentRegistry,
private readonly sessions: SessionStore
) {
this.commandRouter = new CommandRouter(agents, sessions);
}
constructor(private readonly policy: GatewayPolicy, private readonly runtime: ConversationRuntime, private readonly roles: RoleRegistry) {}
async receive(message: IncomingMessage, adapter: PlatformAdapter, options: { synchronous?: boolean } = {}): Promise<GatewayResult> {
const policyError = this.checkPolicy(message);
if (policyError) return { ok: true, ignored: true, error: policyError };
if (policyError) {
console.log(`Message ignored by policy: ${policyError} (${message.platform} ${message.chatId} ${message.userId})`);
return { ok: true, ignored: true, error: policyError };
}
const sessionId = sessionIdFor(message.platform, message.chatId);
return this.withChatLock(sessionId, async () => {
const command = this.commandRouter.route(message, sessionId);
if (command.handled) {
const reply = command.text || "";
await adapter.sendMessage({
target: { platform: message.platform, chatId: message.chatId, userId: message.userId, raw: message.raw },
text: reply,
replyTo: message.messageId
});
return { ok: true, reply };
}
const session = this.sessions.getSession(sessionId);
const agent = this.agents.get(session.selectedAgent);
const command = this.commandRouter.parse(message.text);
if (command?.kind === "cancel") return this.reply(message, adapter, await this.cancelText(message), options);
if (command?.kind === "new") await this.runtime.cancel(message.platform, message.chatId);
const chatKey = chatKeyFor(message.platform, message.chatId);
return this.withChatLock(chatKey, async () => {
try {
const response = await agent.run({
input: message.text,
sessionId,
platform: message.platform,
chatId: message.chatId,
userId: message.userId,
messageId: message.messageId
});
await adapter.sendMessage({
target: { platform: message.platform, chatId: message.chatId, userId: message.userId, raw: message.raw },
text: response.text,
replyTo: message.messageId
});
return { ok: true, reply: response.text };
const reply = command ? await this.executeCommand(command, message) : (await this.runtime.prompt({
platform: message.platform, chatId: message.chatId, userId: message.userId, text: message.text, messageId: message.messageId
})).text;
return this.reply(message, adapter, reply, options);
} catch (error) {
const errorText = error instanceof Error ? error.message : String(error);
const reply = `Agent error: ${errorText}`;
if (!options.synchronous) {
await adapter.sendMessage({
target: { platform: message.platform, chatId: message.chatId, userId: message.userId, raw: message.raw },
text: reply,
replyTo: message.messageId
});
}
if (!options.synchronous) await this.send(message, adapter, reply);
return { ok: false, error: errorText, reply };
}
});
}
stats(): { sessions: number; lockedChats: number } {
return { ...this.sessions.stats(), lockedChats: this.locks.size };
stats(): ReturnType<ConversationRuntime["stats"]> & { lockedChats: number } { return { ...this.runtime.stats(), lockedChats: this.locks.size }; }
private async executeCommand(command: ParsedCommand, message: IncomingMessage): Promise<string> {
const prefix = command.deprecatedAlias ? "Deprecated alias; use /role or /roles.\n" : "";
switch (command.kind) {
case "help": return ["Commands:", "/roles", "/role <id>", "/status", "/cancel", "/new", "/help"].join("\n");
case "roles": return `${prefix}Available roles: ${this.roles.list().join(", ")}`;
case "role":
if (!command.argument) return `${prefix}Usage: /role <id>`;
if (!this.roles.has(command.argument)) return `${prefix}Unknown role: ${command.argument}`;
await this.runtime.cancel(message.platform, message.chatId);
await this.runtime.selectRole(message.platform, message.chatId, command.argument);
return `${prefix}Selected role: ${command.argument}`;
case "status": {
const status = this.runtime.status(message.platform, message.chatId);
return `OK\n${Object.entries(status).map(([key, value]) => `${key}=${value}`).join("\n")}`;
}
case "new":
await this.runtime.reset(message.platform, message.chatId);
return "Started a new native ACP session for this chat and role.";
case "cancel": return this.cancelText(message);
}
}
private async cancelText(message: IncomingMessage): Promise<string> {
return await this.runtime.cancel(message.platform, message.chatId) ? "Cancellation requested." : "No active turn to cancel.";
}
private async reply(message: IncomingMessage, adapter: PlatformAdapter, reply: string, options: { synchronous?: boolean }): Promise<GatewayResult> {
if (!options.synchronous) await this.send(message, adapter, reply);
return { ok: true, reply };
}
private send(message: IncomingMessage, adapter: PlatformAdapter, text: string): Promise<void> {
return adapter.sendMessage({ target: { platform: message.platform, chatId: message.chatId, userId: message.userId, raw: message.raw }, text, replyTo: message.messageId });
}
private checkPolicy(message: IncomingMessage): string | undefined {
if (this.policy.allowedUsers.length > 0 && !this.policy.allowedUsers.includes(message.userId)) {
return `User not allowed: ${message.userId}`;
}
if (this.policy.allowedChats.length > 0 && !this.policy.allowedChats.includes(message.chatId)) {
return `Chat not allowed: ${message.chatId}`;
}
if (this.policy.requireMentionInGroup && message.isGroup && !message.mentionsBot) {
return "Mention required in group chat";
}
if (this.policy.allowedUsers.length > 0 && !this.policy.allowedUsers.includes(message.userId)) return `User not allowed: ${message.userId}`;
if (this.policy.allowedChats.length > 0 && !this.policy.allowedChats.includes(message.chatId)) return `Chat not allowed: ${message.chatId}`;
if (this.policy.requireMentionInGroup && message.isGroup && !message.mentionsBot) return "Mention required in group chat";
return undefined;
}
private async withChatLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
const previous = this.locks.get(key) || Promise.resolve();
let release!: () => void;
const current = new Promise<void>((resolve) => {
release = resolve;
});
const current = new Promise<void>((resolve) => { release = resolve; });
const queued = previous.then(() => current);
this.locks.set(key, queued);
await previous;
try {
return await fn();
} finally {
try { return await fn(); } finally {
release();
if (this.locks.get(key) === queued) {
this.locks.delete(key);
}
if (this.locks.get(key) === queued) this.locks.delete(key);
}
}
}
+14 -3
View File
@@ -44,7 +44,11 @@ export class QqAdapter implements PlatformAdapter {
handleDispatch(payload: QqWebhookPayload): boolean {
const message = this.normalizeMessage(payload);
if (!message) return false;
if (!message) {
console.log(`QQ recv ${payload.t} ignored (empty text or missing ids)`);
return false;
}
console.log(`QQ recv ${payload.t} chat=${message.chatId} user=${message.userId} msgId=${message.messageId || ""} text=${JSON.stringify(message.text)}`);
void this.gateway.receive(message, this).catch((error) => {
console.error("QQ gateway error", error);
});
@@ -54,11 +58,13 @@ export class QqAdapter implements PlatformAdapter {
async sendMessage(message: OutgoingMessage): Promise<void> {
const token = await this.getAccessToken();
const raw = (message.target.raw || {}) as Record<string, unknown>;
const groupOpenId = typeof raw.group_openid === "string" ? raw.group_openid : undefined;
const userOpenId = typeof raw.user_openid === "string" ? raw.user_openid : undefined;
const author = (raw.author || {}) as Record<string, unknown>;
const groupOpenId = stringField(raw.group_openid) || stringField(raw.group_id);
const userOpenId = stringField(raw.user_openid) || stringField(author.user_openid);
const isGroup = Boolean(groupOpenId) || message.target.chatId.startsWith("group:");
const targetId = groupOpenId || userOpenId || message.target.chatId.replace(/^group:/, "").replace(/^user:/, "");
const path = isGroup ? `/v2/groups/${encodeURIComponent(targetId)}/messages` : `/v2/users/${encodeURIComponent(targetId)}/messages`;
console.log(`QQ send -> ${path} msgId=${message.replyTo || ""} length=${message.text.length}`);
const response = await fetch(`https://api.sgroup.qq.com${path}`, {
method: "POST",
@@ -141,10 +147,15 @@ function chatIdFor(data: QqWebhookEventData, isGroup: boolean): string | undefin
if (data.channel_id) return `channel:${data.channel_id}`;
if (data.guild_id) return `guild:${data.guild_id}`;
if (!isGroup && data.user_openid) return `user:${data.user_openid}`;
if (!isGroup && data.author?.user_openid) return `user:${data.author.user_openid}`;
if (!isGroup && data.author?.id) return `user:${data.author.id}`;
return undefined;
}
function stringField(value: unknown): string | undefined {
return typeof value === "string" && value ? value : undefined;
}
function stripConfiguredBotNames(text: string, botNames: string[]): string {
let cleaned = text;
for (const name of botNames) {
+1 -1
View File
@@ -131,7 +131,7 @@ export class QqGatewayClient {
shard: this.config.shard,
properties: {
$os: process.platform,
$browser: "gori-agent-gateway",
$browser: "gori-agent",
$device: os.hostname()
}
}
+2
View File
@@ -50,6 +50,8 @@ export interface QqWebhookEventData {
author?: {
id?: string;
user_openid?: string;
member_openid?: string;
union_openid?: string;
username?: string;
};
}
+51
View File
@@ -0,0 +1,51 @@
import crypto from "node:crypto";
import type { AppConfig, RoleConfig } from "../config.js";
import { SkillLoader, type LoadedSkill } from "./skill-loader.js";
export interface ResolvedRole extends RoleConfig {
loadedSkills: LoadedSkill[];
fingerprint: string;
bootstrap: string;
}
export class RoleRegistry {
private readonly roles = new Map<string, ResolvedRole>();
constructor(private readonly config: AppConfig) {
const loader = new SkillLoader(config.skills);
for (const role of config.roles) {
const loadedSkills = role.skills.map((id) => loader.load(id));
const fingerprint = crypto.createHash("sha256").update(JSON.stringify({
id: role.id,
backend: role.backend,
workspace: role.workspace,
persona: role.persona,
policy: role.policy,
skills: loadedSkills.map(({ id, file, hash }) => ({ id, file, hash }))
})).digest("hex");
this.roles.set(role.id, { ...role, loadedSkills, fingerprint, bootstrap: buildBootstrap(role, loadedSkills) });
}
}
get(id?: string): ResolvedRole {
const roleId = id || this.config.defaultRole;
const role = this.roles.get(roleId);
if (!role) throw new Error(`Unknown role: ${roleId}`);
return role;
}
has(id: string): boolean { return this.roles.has(id); }
list(): string[] { return [...this.roles.keys()].sort(); }
defaultId(): string { return this.config.defaultRole; }
}
function buildBootstrap(role: RoleConfig, skills: LoadedSkill[]): string {
return [
"Initialize this ACP session with the following role. Treat these instructions as persistent context. Reply only with READY.",
`Role: ${role.id}`,
`Workspace: ${role.workspace}`,
role.persona ? `Persona:\n${role.persona}` : "Persona: general coding assistant",
`Permission policy enforced by the ACP client: ${JSON.stringify(role.policy)}`,
...skills.map((skill) => `Skill ${skill.id} (${skill.file}):\n${skill.content}`)
].join("\n\n");
}
+24
View File
@@ -0,0 +1,24 @@
import crypto from "node:crypto";
import fs from "node:fs";
import type { SkillConfig } from "../config.js";
export interface LoadedSkill {
id: string;
file: string;
content: string;
hash: string;
}
export class SkillLoader {
constructor(private readonly skills: SkillConfig[]) {}
load(id: string): LoadedSkill {
const config = this.skills.find((skill) => skill.id === id);
if (!config) throw new Error(`Unknown skill: ${id}`);
const stat = fs.statSync(config.file);
if (!stat.isFile()) throw new Error(`Skill is not a file: ${config.file}`);
if (stat.size > config.maxBytes) throw new Error(`Skill '${id}' exceeds ${config.maxBytes} bytes`);
const content = fs.readFileSync(config.file, "utf8");
return { id, file: config.file, content, hash: crypto.createHash("sha256").update(content).digest("hex") };
}
}
+53 -61
View File
@@ -1,73 +1,59 @@
import express from "express";
import type { Server } from "node:http";
import { fileURLToPath } from "node:url";
import { loadConfig, type AppConfig } from "./config.js";
import { AgentRegistry } from "./agents/agent-registry.js";
import { CliAgent } from "./agents/cli-agent.js";
import { AcpBackendRegistry } from "./acp/backend-registry.js";
import { AcpSessionManager } from "./acp/session-manager.js";
import { defaultStateFile, loadConfig, type AppConfig } from "./config.js";
import type { PlatformAdapter } from "./core/adapter.js";
import { DurableSessionStore } from "./core/durable-session-store.js";
import { Gateway } from "./core/gateway.js";
import { PlatformRegistry } from "./core/platform-registry.js";
import { SessionStore } from "./core/session-store.js";
import type { PlatformAdapter } from "./core/adapter.js";
import { FeishuAdapter } from "./platforms/feishu/adapter.js";
import { WeComAdapter } from "./platforms/wecom/adapter.js";
import { QqAdapter } from "./platforms/qq/adapter.js";
import { QqGatewayClient } from "./platforms/qq/gateway-client.js";
import { WeComAdapter } from "./platforms/wecom/adapter.js";
import { GenericWebhookAdapter } from "./platforms/webhook/adapter.js";
import { WeixinAdapter } from "./platforms/weixin/adapter.js";
import { RoleRegistry } from "./roles/role-registry.js";
export interface GatewayRuntime {
app: express.Express;
gateway: Gateway;
sessionManager: AcpSessionManager;
store: DurableSessionStore;
qqGatewayClient?: QqGatewayClient;
shutdown(): Promise<void>;
}
export function createGatewayRuntime(config: AppConfig): GatewayRuntime {
const sessions = new SessionStore();
const agents = new AgentRegistry(config.defaultAgent);
for (const agentConfig of config.agents) {
agents.register(new CliAgent(agentConfig));
}
const gateway = new Gateway(config.policy, agents, sessions);
export async function createGatewayRuntime(config: AppConfig): Promise<GatewayRuntime> {
const store = new DurableSessionStore(defaultStateFile(config));
await store.open();
const roles = new RoleRegistry(config);
const sessionManager = new AcpSessionManager(config.acp, new AcpBackendRegistry(config.backends), roles, store);
const gateway = new Gateway(config.policy, sessionManager, roles);
const platforms = new PlatformRegistry();
const qqAdapter = new QqAdapter(config.platforms.qq, gateway);
const adapters: PlatformAdapter[] = [
new FeishuAdapter(config.platforms.feishu, gateway),
new WeComAdapter(config.platforms.wecom),
qqAdapter,
new GenericWebhookAdapter(config.platforms.webhook, gateway),
new WeixinAdapter(config.platforms.weixin, gateway)
new FeishuAdapter(config.platforms.feishu, gateway), new WeComAdapter(config.platforms.wecom), qqAdapter,
new GenericWebhookAdapter(config.platforms.webhook, gateway), new WeixinAdapter(config.platforms.weixin, gateway)
];
for (const adapter of adapters) platforms.register(adapter);
const app = express();
app.use(express.json({
limit: "1mb",
verify: (req, _res, buf) => {
(req as express.Request & { rawBody?: Buffer }).rawBody = Buffer.from(buf);
}
}));
app.get("/health", (_req, res) => {
res.json({ ok: true, gateway: gateway.stats() });
});
app.get("/platforms", (_req, res) => {
res.json({ ok: true, platforms: platforms.list(), agents: agents.list() });
});
app.use(express.json({ limit: "1mb", verify: (req, _res, buf) => {
(req as express.Request & { rawBody?: Buffer }).rawBody = Buffer.from(buf);
} }));
app.get("/health", (_req, res) => res.json({ ok: true, acp: gateway.stats() }));
app.get("/platforms", (_req, res) => res.json({ ok: true, platforms: platforms.list(), roles: roles.list(), backends: config.backends.map(({ id }) => id) }));
function mountWebhook(routeName: string, adapterName = routeName): void {
app.post(`/webhook/${routeName}`, async (req, res) => {
try {
const response = await platforms.get(adapterName).handleWebhook({
req,
body: req.body,
headers: req.headers,
query: req.query,
req, body: req.body, headers: req.headers, query: req.query,
rawBody: (req as express.Request & { rawBody?: Buffer }).rawBody
});
if (response.headers) {
for (const [key, value] of Object.entries(response.headers)) res.setHeader(key, value);
}
if (response.headers) for (const [key, value] of Object.entries(response.headers)) res.setHeader(key, value);
res.status(response.status || 200).json(response.body ?? { ok: true });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
@@ -76,36 +62,42 @@ export function createGatewayRuntime(config: AppConfig): GatewayRuntime {
}
});
}
for (const platformName of ["feishu", "wecom", "qq", "weixin"]) {
mountWebhook(platformName);
}
for (const name of ["feishu", "wecom", "qq", "weixin"]) mountWebhook(name);
mountWebhook("generic", "webhook");
const qqGatewayClient = config.platforms.qq.enabled && config.platforms.qq.connectionMode === "websocket"
? new QqGatewayClient(config.platforms.qq, qqAdapter)
: undefined;
return { app, qqGatewayClient };
? new QqGatewayClient(config.platforms.qq, qqAdapter) : undefined;
let closing: Promise<void> | undefined;
const runtime: GatewayRuntime = {
app, gateway, sessionManager, store, qqGatewayClient,
shutdown: () => closing ||= (async () => {
qqGatewayClient?.stop();
await sessionManager.shutdown();
await store.close();
})()
};
return runtime;
}
export function createApp(config: AppConfig): express.Express {
return createGatewayRuntime(config).app;
}
export async function createApp(config: AppConfig): Promise<express.Express> { return (await createGatewayRuntime(config)).app; }
export function startServer(config: AppConfig): Server {
const runtime = createGatewayRuntime(config);
export interface RunningServer { server: Server; runtime: GatewayRuntime; shutdown(): Promise<void> }
export async function startServer(config: AppConfig): Promise<RunningServer> {
const runtime = await createGatewayRuntime(config);
const server = runtime.app.listen(config.server.port, config.server.host, () => {
console.log(`gori-agent-gateway listening on ${config.server.host}:${config.server.port}`);
if (runtime.qqGatewayClient) {
console.log("QQ websocket gateway enabled; connecting to QQ...");
runtime.qqGatewayClient.start();
}
console.log(`gori-agent listening on ${config.server.host}:${config.server.port}`);
if (runtime.qqGatewayClient) { console.log("QQ websocket gateway enabled; connecting to QQ..."); runtime.qqGatewayClient.start(); }
});
server.on("close", () => runtime.qqGatewayClient?.stop());
return server;
let closing: Promise<void> | undefined;
return { server, runtime, shutdown: () => closing ||= (async () => {
runtime.qqGatewayClient?.stop();
const runtimeShutdown = runtime.shutdown();
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
await runtimeShutdown;
})() };
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
startServer(loadConfig());
void startServer(loadConfig()).catch((error) => { console.error(error); process.exitCode = 1; });
}