mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 11:02:17 +00:00
new gateway
This commit is contained in:
parent
01958298e0
commit
9a0b848789
34 changed files with 1632 additions and 290 deletions
|
|
@ -187,7 +187,8 @@ ${chalk.bold("Commands:")}
|
|||
${APP_NAME} remove <source> [-l] Remove extension source from settings
|
||||
${APP_NAME} update [source] Update installed extensions (skips pinned sources)
|
||||
${APP_NAME} list List installed extensions from settings
|
||||
${APP_NAME} daemon Run in long-lived daemon mode (extensions stay active)
|
||||
${APP_NAME} gateway Run the always-on gateway process
|
||||
${APP_NAME} daemon Alias for gateway
|
||||
${APP_NAME} config Open TUI to enable/disable package resources
|
||||
${APP_NAME} <command> --help Show help for install/remove/update/list
|
||||
|
||||
|
|
|
|||
652
packages/coding-agent/src/core/gateway-runtime.ts
Normal file
652
packages/coding-agent/src/core/gateway-runtime.ts
Normal file
|
|
@ -0,0 +1,652 @@
|
|||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||
import { join } from "node:path";
|
||||
import { URL } from "node:url";
|
||||
import type { ImageContent } from "@mariozechner/pi-ai";
|
||||
import type { AgentSession, AgentSessionEvent } from "./agent-session.js";
|
||||
import { SessionManager } from "./session-manager.js";
|
||||
|
||||
export interface GatewayConfig {
|
||||
bind: string;
|
||||
port: number;
|
||||
bearerToken?: string;
|
||||
session: {
|
||||
idleMinutes: number;
|
||||
maxQueuePerSession: number;
|
||||
};
|
||||
webhook: {
|
||||
enabled: boolean;
|
||||
basePath: string;
|
||||
secret?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type GatewaySessionFactory = (sessionKey: string) => Promise<AgentSession>;
|
||||
|
||||
export interface GatewayMessageRequest {
|
||||
sessionKey: string;
|
||||
text: string;
|
||||
source?: "interactive" | "rpc" | "extension";
|
||||
images?: ImageContent[];
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GatewayMessageResult {
|
||||
ok: boolean;
|
||||
response: string;
|
||||
error?: string;
|
||||
sessionKey: string;
|
||||
}
|
||||
|
||||
export interface GatewaySessionSnapshot {
|
||||
sessionKey: string;
|
||||
sessionId: string;
|
||||
messageCount: number;
|
||||
queueDepth: number;
|
||||
processing: boolean;
|
||||
lastActiveAt: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface GatewayRuntimeOptions {
|
||||
config: GatewayConfig;
|
||||
primarySessionKey: string;
|
||||
primarySession: AgentSession;
|
||||
createSession: GatewaySessionFactory;
|
||||
log?: (message: string) => void;
|
||||
}
|
||||
|
||||
interface GatewayQueuedMessage {
|
||||
request: GatewayMessageRequest;
|
||||
resolve: (result: GatewayMessageResult) => void;
|
||||
}
|
||||
|
||||
type GatewayEvent =
|
||||
| { type: "hello"; sessionKey: string; snapshot: GatewaySessionSnapshot }
|
||||
| { type: "session_state"; sessionKey: string; snapshot: GatewaySessionSnapshot }
|
||||
| { type: "turn_start"; sessionKey: string }
|
||||
| { type: "turn_end"; sessionKey: string }
|
||||
| { type: "message_start"; sessionKey: string; role?: string }
|
||||
| { type: "token"; sessionKey: string; delta: string; contentIndex: number }
|
||||
| { type: "thinking"; sessionKey: string; delta: string; contentIndex: number }
|
||||
| { type: "tool_start"; sessionKey: string; toolCallId: string; toolName: string; args: unknown }
|
||||
| { type: "tool_update"; sessionKey: string; toolCallId: string; toolName: string; partialResult: unknown }
|
||||
| {
|
||||
type: "tool_complete";
|
||||
sessionKey: string;
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
result: unknown;
|
||||
isError: boolean;
|
||||
}
|
||||
| { type: "message_complete"; sessionKey: string; text: string }
|
||||
| { type: "error"; sessionKey: string; error: string }
|
||||
| { type: "aborted"; sessionKey: string };
|
||||
|
||||
interface ManagedGatewaySession {
|
||||
sessionKey: string;
|
||||
session: AgentSession;
|
||||
queue: GatewayQueuedMessage[];
|
||||
processing: boolean;
|
||||
createdAt: number;
|
||||
lastActiveAt: number;
|
||||
listeners: Set<(event: GatewayEvent) => void>;
|
||||
unsubscribe: () => void;
|
||||
}
|
||||
|
||||
let activeGatewayRuntime: GatewayRuntime | null = null;
|
||||
|
||||
export function setActiveGatewayRuntime(runtime: GatewayRuntime | null): void {
|
||||
activeGatewayRuntime = runtime;
|
||||
}
|
||||
|
||||
export function getActiveGatewayRuntime(): GatewayRuntime | null {
|
||||
return activeGatewayRuntime;
|
||||
}
|
||||
|
||||
export class GatewayRuntime {
|
||||
private readonly config: GatewayConfig;
|
||||
private readonly primarySessionKey: string;
|
||||
private readonly primarySession: AgentSession;
|
||||
private readonly createSession: GatewaySessionFactory;
|
||||
private readonly log: (message: string) => void;
|
||||
private readonly sessions = new Map<string, ManagedGatewaySession>();
|
||||
private readonly sessionDirRoot: string;
|
||||
private server: Server | null = null;
|
||||
private idleSweepTimer: NodeJS.Timeout | null = null;
|
||||
private ready = false;
|
||||
|
||||
constructor(options: GatewayRuntimeOptions) {
|
||||
this.config = options.config;
|
||||
this.primarySessionKey = options.primarySessionKey;
|
||||
this.primarySession = options.primarySession;
|
||||
this.createSession = options.createSession;
|
||||
this.log = options.log ?? (() => {});
|
||||
this.sessionDirRoot = join(options.primarySession.sessionManager.getSessionDir(), "..", "gateway-sessions");
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.server) return;
|
||||
|
||||
await this.ensureSession(this.primarySessionKey, this.primarySession);
|
||||
this.server = createServer((request, response) => {
|
||||
void this.handleHttpRequest(request, response).catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.writeJson(response, 500, { error: message });
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.server?.once("error", reject);
|
||||
this.server?.listen(this.config.port, this.config.bind, () => {
|
||||
this.server?.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
this.idleSweepTimer = setInterval(() => {
|
||||
void this.evictIdleSessions();
|
||||
}, 60_000);
|
||||
this.ready = true;
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.ready = false;
|
||||
if (this.idleSweepTimer) {
|
||||
clearInterval(this.idleSweepTimer);
|
||||
this.idleSweepTimer = null;
|
||||
}
|
||||
if (this.server) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.server?.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
this.server = null;
|
||||
}
|
||||
for (const [sessionKey, managedSession] of this.sessions) {
|
||||
managedSession.unsubscribe();
|
||||
if (sessionKey !== this.primarySessionKey) {
|
||||
managedSession.session.dispose();
|
||||
}
|
||||
}
|
||||
this.sessions.clear();
|
||||
}
|
||||
|
||||
isReady(): boolean {
|
||||
return this.ready;
|
||||
}
|
||||
|
||||
getAddress(): { bind: string; port: number } {
|
||||
return { bind: this.config.bind, port: this.config.port };
|
||||
}
|
||||
|
||||
async enqueueMessage(request: GatewayMessageRequest): Promise<GatewayMessageResult> {
|
||||
const managedSession = await this.ensureSession(request.sessionKey);
|
||||
if (managedSession.queue.length >= this.config.session.maxQueuePerSession) {
|
||||
return {
|
||||
ok: false,
|
||||
response: "",
|
||||
error: `Queue full (${this.config.session.maxQueuePerSession} pending).`,
|
||||
sessionKey: request.sessionKey,
|
||||
};
|
||||
}
|
||||
|
||||
return new Promise<GatewayMessageResult>((resolve) => {
|
||||
managedSession.queue.push({ request, resolve });
|
||||
this.emitState(managedSession);
|
||||
void this.processNext(managedSession);
|
||||
});
|
||||
}
|
||||
|
||||
async addSubscriber(sessionKey: string, listener: (event: GatewayEvent) => void): Promise<() => void> {
|
||||
const managedSession = await this.ensureSession(sessionKey);
|
||||
managedSession.listeners.add(listener);
|
||||
listener({ type: "hello", sessionKey, snapshot: this.createSnapshot(managedSession) });
|
||||
return () => {
|
||||
managedSession.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
abortSession(sessionKey: string): boolean {
|
||||
const managedSession = this.sessions.get(sessionKey);
|
||||
if (!managedSession?.processing) {
|
||||
return false;
|
||||
}
|
||||
void managedSession.session.abort().catch((error) => {
|
||||
this.emit(managedSession, {
|
||||
type: "error",
|
||||
sessionKey,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
clearQueue(sessionKey: string): void {
|
||||
const managedSession = this.sessions.get(sessionKey);
|
||||
if (!managedSession) return;
|
||||
managedSession.queue.length = 0;
|
||||
this.emitState(managedSession);
|
||||
}
|
||||
|
||||
async resetSession(sessionKey: string): Promise<void> {
|
||||
const managedSession = this.sessions.get(sessionKey);
|
||||
if (!managedSession) return;
|
||||
|
||||
if (sessionKey === this.primarySessionKey) {
|
||||
await managedSession.session.newSession();
|
||||
managedSession.queue.length = 0;
|
||||
managedSession.processing = false;
|
||||
managedSession.lastActiveAt = Date.now();
|
||||
this.emitState(managedSession);
|
||||
return;
|
||||
}
|
||||
|
||||
if (managedSession.processing) {
|
||||
await managedSession.session.abort();
|
||||
}
|
||||
managedSession.unsubscribe();
|
||||
managedSession.session.dispose();
|
||||
this.sessions.delete(sessionKey);
|
||||
}
|
||||
|
||||
listSessions(): GatewaySessionSnapshot[] {
|
||||
return Array.from(this.sessions.values()).map((session) => this.createSnapshot(session));
|
||||
}
|
||||
|
||||
getSession(sessionKey: string): GatewaySessionSnapshot | undefined {
|
||||
const session = this.sessions.get(sessionKey);
|
||||
return session ? this.createSnapshot(session) : undefined;
|
||||
}
|
||||
|
||||
private async ensureSession(sessionKey: string, existingSession?: AgentSession): Promise<ManagedGatewaySession> {
|
||||
const found = this.sessions.get(sessionKey);
|
||||
if (found) {
|
||||
found.lastActiveAt = Date.now();
|
||||
return found;
|
||||
}
|
||||
|
||||
const session = existingSession ?? (await this.createSession(sessionKey));
|
||||
const managedSession: ManagedGatewaySession = {
|
||||
sessionKey,
|
||||
session,
|
||||
queue: [],
|
||||
processing: false,
|
||||
createdAt: Date.now(),
|
||||
lastActiveAt: Date.now(),
|
||||
listeners: new Set(),
|
||||
unsubscribe: () => {},
|
||||
};
|
||||
managedSession.unsubscribe = session.subscribe((event) => {
|
||||
this.handleSessionEvent(managedSession, event);
|
||||
});
|
||||
this.sessions.set(sessionKey, managedSession);
|
||||
this.emitState(managedSession);
|
||||
return managedSession;
|
||||
}
|
||||
|
||||
private async processNext(managedSession: ManagedGatewaySession): Promise<void> {
|
||||
if (managedSession.processing || managedSession.queue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const queued = managedSession.queue.shift();
|
||||
if (!queued) return;
|
||||
|
||||
managedSession.processing = true;
|
||||
managedSession.lastActiveAt = Date.now();
|
||||
this.emitState(managedSession);
|
||||
|
||||
try {
|
||||
await managedSession.session.prompt(queued.request.text, {
|
||||
images: queued.request.images,
|
||||
source: queued.request.source ?? "extension",
|
||||
});
|
||||
const response = getLastAssistantText(managedSession.session);
|
||||
queued.resolve({
|
||||
ok: true,
|
||||
response,
|
||||
sessionKey: managedSession.sessionKey,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes("aborted")) {
|
||||
this.emit(managedSession, { type: "aborted", sessionKey: managedSession.sessionKey });
|
||||
} else {
|
||||
this.emit(managedSession, { type: "error", sessionKey: managedSession.sessionKey, error: message });
|
||||
}
|
||||
queued.resolve({
|
||||
ok: false,
|
||||
response: "",
|
||||
error: message,
|
||||
sessionKey: managedSession.sessionKey,
|
||||
});
|
||||
} finally {
|
||||
managedSession.processing = false;
|
||||
managedSession.lastActiveAt = Date.now();
|
||||
this.emitState(managedSession);
|
||||
if (managedSession.queue.length > 0) {
|
||||
void this.processNext(managedSession);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleSessionEvent(managedSession: ManagedGatewaySession, event: AgentSessionEvent): void {
|
||||
switch (event.type) {
|
||||
case "turn_start":
|
||||
this.emit(managedSession, { type: "turn_start", sessionKey: managedSession.sessionKey });
|
||||
return;
|
||||
case "turn_end":
|
||||
this.emit(managedSession, { type: "turn_end", sessionKey: managedSession.sessionKey });
|
||||
return;
|
||||
case "message_start":
|
||||
this.emit(managedSession, {
|
||||
type: "message_start",
|
||||
sessionKey: managedSession.sessionKey,
|
||||
role: event.message.role,
|
||||
});
|
||||
return;
|
||||
case "message_update":
|
||||
switch (event.assistantMessageEvent.type) {
|
||||
case "text_delta":
|
||||
this.emit(managedSession, {
|
||||
type: "token",
|
||||
sessionKey: managedSession.sessionKey,
|
||||
delta: event.assistantMessageEvent.delta,
|
||||
contentIndex: event.assistantMessageEvent.contentIndex,
|
||||
});
|
||||
return;
|
||||
case "thinking_delta":
|
||||
this.emit(managedSession, {
|
||||
type: "thinking",
|
||||
sessionKey: managedSession.sessionKey,
|
||||
delta: event.assistantMessageEvent.delta,
|
||||
contentIndex: event.assistantMessageEvent.contentIndex,
|
||||
});
|
||||
return;
|
||||
}
|
||||
return;
|
||||
case "message_end":
|
||||
if (event.message.role === "assistant") {
|
||||
this.emit(managedSession, {
|
||||
type: "message_complete",
|
||||
sessionKey: managedSession.sessionKey,
|
||||
text: extractMessageText(event.message),
|
||||
});
|
||||
}
|
||||
return;
|
||||
case "tool_execution_start":
|
||||
this.emit(managedSession, {
|
||||
type: "tool_start",
|
||||
sessionKey: managedSession.sessionKey,
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
args: event.args,
|
||||
});
|
||||
return;
|
||||
case "tool_execution_update":
|
||||
this.emit(managedSession, {
|
||||
type: "tool_update",
|
||||
sessionKey: managedSession.sessionKey,
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
partialResult: event.partialResult,
|
||||
});
|
||||
return;
|
||||
case "tool_execution_end":
|
||||
this.emit(managedSession, {
|
||||
type: "tool_complete",
|
||||
sessionKey: managedSession.sessionKey,
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
result: event.result,
|
||||
isError: event.isError,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private emit(managedSession: ManagedGatewaySession, event: GatewayEvent): void {
|
||||
for (const listener of managedSession.listeners) {
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
private emitState(managedSession: ManagedGatewaySession): void {
|
||||
this.emit(managedSession, {
|
||||
type: "session_state",
|
||||
sessionKey: managedSession.sessionKey,
|
||||
snapshot: this.createSnapshot(managedSession),
|
||||
});
|
||||
}
|
||||
|
||||
private createSnapshot(managedSession: ManagedGatewaySession): GatewaySessionSnapshot {
|
||||
return {
|
||||
sessionKey: managedSession.sessionKey,
|
||||
sessionId: managedSession.session.sessionId,
|
||||
messageCount: managedSession.session.messages.length,
|
||||
queueDepth: managedSession.queue.length,
|
||||
processing: managedSession.processing,
|
||||
lastActiveAt: managedSession.lastActiveAt,
|
||||
createdAt: managedSession.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
private async evictIdleSessions(): Promise<void> {
|
||||
const cutoff = Date.now() - this.config.session.idleMinutes * 60_000;
|
||||
for (const [sessionKey, managedSession] of this.sessions) {
|
||||
if (sessionKey === this.primarySessionKey) {
|
||||
continue;
|
||||
}
|
||||
if (managedSession.processing || managedSession.queue.length > 0) {
|
||||
continue;
|
||||
}
|
||||
if (managedSession.lastActiveAt > cutoff) {
|
||||
continue;
|
||||
}
|
||||
if (managedSession.listeners.size > 0) {
|
||||
continue;
|
||||
}
|
||||
managedSession.unsubscribe();
|
||||
managedSession.session.dispose();
|
||||
this.sessions.delete(sessionKey);
|
||||
this.log(`evicted idle session ${sessionKey}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleHttpRequest(request: IncomingMessage, response: ServerResponse): Promise<void> {
|
||||
const method = request.method ?? "GET";
|
||||
const url = new URL(
|
||||
request.url ?? "/",
|
||||
`http://${request.headers.host ?? `${this.config.bind}:${this.config.port}`}`,
|
||||
);
|
||||
const path = url.pathname;
|
||||
|
||||
if (method === "GET" && path === "/health") {
|
||||
this.writeJson(response, 200, { ok: true, ready: this.ready });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && path === "/ready") {
|
||||
this.requireAuth(request, response);
|
||||
if (response.writableEnded) return;
|
||||
this.writeJson(response, 200, { ok: true, ready: this.ready, sessions: this.sessions.size });
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.config.webhook.enabled && method === "POST" && path.startsWith(this.config.webhook.basePath)) {
|
||||
await this.handleWebhookRequest(path, request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
this.requireAuth(request, response);
|
||||
if (response.writableEnded) return;
|
||||
|
||||
if (method === "GET" && path === "/sessions") {
|
||||
this.writeJson(response, 200, { sessions: this.listSessions() });
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionMatch = path.match(/^\/sessions\/([^/]+)(?:\/(events|messages|abort|reset))?$/);
|
||||
if (!sessionMatch) {
|
||||
this.writeJson(response, 404, { error: "Not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionKey = decodeURIComponent(sessionMatch[1]);
|
||||
const action = sessionMatch[2];
|
||||
|
||||
if (!action && method === "GET") {
|
||||
const session = await this.ensureSession(sessionKey);
|
||||
this.writeJson(response, 200, { session: this.createSnapshot(session) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "events" && method === "GET") {
|
||||
await this.handleSse(sessionKey, request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "messages" && method === "POST") {
|
||||
const body = await this.readJsonBody(request);
|
||||
const text = typeof body.text === "string" ? body.text : "";
|
||||
if (!text.trim()) {
|
||||
this.writeJson(response, 400, { error: "Missing text" });
|
||||
return;
|
||||
}
|
||||
const result = await this.enqueueMessage({
|
||||
sessionKey,
|
||||
text,
|
||||
source: "extension",
|
||||
});
|
||||
this.writeJson(response, result.ok ? 200 : 500, result);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "abort" && method === "POST") {
|
||||
this.writeJson(response, 200, { ok: this.abortSession(sessionKey) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "reset" && method === "POST") {
|
||||
await this.resetSession(sessionKey);
|
||||
this.writeJson(response, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
this.writeJson(response, 405, { error: "Method not allowed" });
|
||||
}
|
||||
|
||||
private async handleWebhookRequest(path: string, request: IncomingMessage, response: ServerResponse): Promise<void> {
|
||||
const route = path.slice(this.config.webhook.basePath.length).replace(/^\/+/, "") || "default";
|
||||
if (this.config.webhook.secret) {
|
||||
const presentedSecret = request.headers["x-pi-webhook-secret"];
|
||||
if (presentedSecret !== this.config.webhook.secret) {
|
||||
this.writeJson(response, 401, { error: "Invalid webhook secret" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const body = await this.readJsonBody(request);
|
||||
const text = typeof body.text === "string" ? body.text : "";
|
||||
if (!text.trim()) {
|
||||
this.writeJson(response, 400, { error: "Missing text" });
|
||||
return;
|
||||
}
|
||||
|
||||
const conversationId =
|
||||
typeof body.sessionKey === "string"
|
||||
? body.sessionKey
|
||||
: `webhook:${route}:${typeof body.sender === "string" ? body.sender : "default"}`;
|
||||
const result = await this.enqueueMessage({
|
||||
sessionKey: conversationId,
|
||||
text,
|
||||
source: "extension",
|
||||
metadata: typeof body.metadata === "object" && body.metadata ? (body.metadata as Record<string, unknown>) : {},
|
||||
});
|
||||
this.writeJson(response, result.ok ? 200 : 500, result);
|
||||
}
|
||||
|
||||
private async handleSse(sessionKey: string, request: IncomingMessage, response: ServerResponse): Promise<void> {
|
||||
response.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
});
|
||||
response.write("\n");
|
||||
|
||||
const unsubscribe = await this.addSubscriber(sessionKey, (event) => {
|
||||
response.write(`data: ${JSON.stringify(event)}\n\n`);
|
||||
});
|
||||
request.on("close", () => {
|
||||
unsubscribe();
|
||||
});
|
||||
}
|
||||
|
||||
private requireAuth(request: IncomingMessage, response: ServerResponse): void {
|
||||
if (!this.config.bearerToken) {
|
||||
return;
|
||||
}
|
||||
const header = request.headers.authorization;
|
||||
if (header === `Bearer ${this.config.bearerToken}`) {
|
||||
return;
|
||||
}
|
||||
this.writeJson(response, 401, { error: "Unauthorized" });
|
||||
}
|
||||
|
||||
private async readJsonBody(request: IncomingMessage): Promise<Record<string, unknown>> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of request) {
|
||||
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
||||
}
|
||||
if (chunks.length === 0) {
|
||||
return {};
|
||||
}
|
||||
const body = Buffer.concat(chunks).toString("utf8");
|
||||
return JSON.parse(body) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
private writeJson(response: ServerResponse, statusCode: number, payload: unknown): void {
|
||||
response.statusCode = statusCode;
|
||||
response.setHeader("content-type", "application/json; charset=utf-8");
|
||||
response.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
getGatewaySessionDir(sessionKey: string): string {
|
||||
return join(this.sessionDirRoot, sanitizeSessionKey(sessionKey));
|
||||
}
|
||||
}
|
||||
|
||||
function extractMessageText(message: { content: unknown }): string {
|
||||
if (!Array.isArray(message.content)) {
|
||||
return "";
|
||||
}
|
||||
return message.content
|
||||
.filter((part): part is { type: "text"; text: string } => {
|
||||
return typeof part === "object" && part !== null && "type" in part && "text" in part && part.type === "text";
|
||||
})
|
||||
.map((part) => part.text)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function getLastAssistantText(session: AgentSession): string {
|
||||
for (let index = session.messages.length - 1; index >= 0; index--) {
|
||||
const message = session.messages[index];
|
||||
if (message.role === "assistant") {
|
||||
return extractMessageText(message);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function sanitizeSessionKey(sessionKey: string): string {
|
||||
return sessionKey.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||
}
|
||||
|
||||
export function createGatewaySessionManager(cwd: string, sessionKey: string, sessionDirRoot: string): SessionManager {
|
||||
return SessionManager.create(cwd, join(sessionDirRoot, sanitizeSessionKey(sessionKey)));
|
||||
}
|
||||
|
|
@ -43,6 +43,26 @@ export interface MarkdownSettings {
|
|||
codeBlockIndent?: string; // default: " "
|
||||
}
|
||||
|
||||
export interface GatewaySessionSettings {
|
||||
idleMinutes?: number;
|
||||
maxQueuePerSession?: number;
|
||||
}
|
||||
|
||||
export interface GatewayWebhookSettings {
|
||||
enabled?: boolean;
|
||||
basePath?: string;
|
||||
secret?: string;
|
||||
}
|
||||
|
||||
export interface GatewaySettings {
|
||||
enabled?: boolean;
|
||||
bind?: string;
|
||||
port?: number;
|
||||
bearerToken?: string;
|
||||
session?: GatewaySessionSettings;
|
||||
webhook?: GatewayWebhookSettings;
|
||||
}
|
||||
|
||||
export type TransportSetting = Transport;
|
||||
|
||||
/**
|
||||
|
|
@ -93,6 +113,7 @@ export interface Settings {
|
|||
autocompleteMaxVisible?: number; // Max visible items in autocomplete dropdown (default: 5)
|
||||
showHardwareCursor?: boolean; // Show terminal cursor while still positioning it for IME
|
||||
markdown?: MarkdownSettings;
|
||||
gateway?: GatewaySettings;
|
||||
}
|
||||
|
||||
/** Deep merge settings: project/overrides take precedence, nested objects merge recursively */
|
||||
|
|
@ -912,4 +933,8 @@ export class SettingsManager {
|
|||
getCodeBlockIndent(): string {
|
||||
return this.settings.markdown?.codeBlockIndent ?? " ";
|
||||
}
|
||||
|
||||
getGatewaySettings(): GatewaySettings {
|
||||
return structuredClone(this.settings.gateway ?? {});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -140,6 +140,19 @@ export {
|
|||
} from "./core/extensions/index.js";
|
||||
// Footer data provider (git branch + extension statuses - data not otherwise available to extensions)
|
||||
export type { ReadonlyFooterDataProvider } from "./core/footer-data-provider.js";
|
||||
export {
|
||||
createGatewaySessionManager,
|
||||
type GatewayConfig,
|
||||
type GatewayMessageRequest,
|
||||
type GatewayMessageResult,
|
||||
GatewayRuntime,
|
||||
type GatewayRuntimeOptions,
|
||||
type GatewaySessionFactory,
|
||||
type GatewaySessionSnapshot,
|
||||
getActiveGatewayRuntime,
|
||||
sanitizeSessionKey,
|
||||
setActiveGatewayRuntime,
|
||||
} from "./core/gateway-runtime.js";
|
||||
export { convertToLlm } from "./core/messages.js";
|
||||
export { ModelRegistry } from "./core/model-registry.js";
|
||||
export type {
|
||||
|
|
@ -198,6 +211,7 @@ export {
|
|||
} from "./core/session-manager.js";
|
||||
export {
|
||||
type CompactionSettings,
|
||||
type GatewaySettings,
|
||||
type ImageSettings,
|
||||
type PackageSource,
|
||||
type RetrySettings,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
* createAgentSession() options. The SDK does the heavy lifting.
|
||||
*/
|
||||
|
||||
import { join } from "node:path";
|
||||
import { type ImageContent, modelsAreEqual, supportsXhigh } from "@mariozechner/pi-ai";
|
||||
import chalk from "chalk";
|
||||
import { createInterface } from "readline";
|
||||
|
|
@ -17,6 +18,7 @@ import { APP_NAME, getAgentDir, getModelsPath, VERSION } from "./config.js";
|
|||
import { AuthStorage } from "./core/auth-storage.js";
|
||||
import { exportFromFile } from "./core/export-html/index.js";
|
||||
import type { LoadExtensionsResult } from "./core/extensions/index.js";
|
||||
import { createGatewaySessionManager } from "./core/gateway-runtime.js";
|
||||
import { KeybindingsManager } from "./core/keybindings.js";
|
||||
import { ModelRegistry } from "./core/model-registry.js";
|
||||
import { resolveCliModel, resolveModelScope, type ScopedModel } from "./core/model-resolver.js";
|
||||
|
|
@ -81,9 +83,10 @@ interface PackageCommandOptions {
|
|||
|
||||
function printDaemonHelp(): void {
|
||||
console.log(`${chalk.bold("Usage:")}
|
||||
${APP_NAME} gateway [options] [messages...]
|
||||
${APP_NAME} daemon [options] [messages...]
|
||||
|
||||
Run pi as a long-lived daemon (non-interactive) with extensions enabled.
|
||||
Run pi as a long-lived gateway (non-interactive) with extensions enabled.
|
||||
Messages passed as positional args are sent once at startup.
|
||||
|
||||
Options:
|
||||
|
|
@ -553,9 +556,9 @@ async function handleConfigCommand(args: string[]): Promise<boolean> {
|
|||
}
|
||||
|
||||
export async function main(args: string[]) {
|
||||
const isDaemonCommand = args[0] === "daemon";
|
||||
const parsedArgs = isDaemonCommand ? args.slice(1) : args;
|
||||
const offlineMode = args.includes("--offline") || isTruthyEnvFlag(process.env.PI_OFFLINE);
|
||||
const isGatewayCommand = args[0] === "daemon" || args[0] === "gateway";
|
||||
const parsedArgs = isGatewayCommand ? args.slice(1) : args;
|
||||
const offlineMode = parsedArgs.includes("--offline") || isTruthyEnvFlag(process.env.PI_OFFLINE);
|
||||
if (offlineMode) {
|
||||
process.env.PI_OFFLINE = "1";
|
||||
process.env.PI_SKIP_VERSION_CHECK = "1";
|
||||
|
|
@ -634,7 +637,7 @@ export async function main(args: string[]) {
|
|||
}
|
||||
|
||||
if (parsed.help) {
|
||||
if (isDaemonCommand) {
|
||||
if (isGatewayCommand) {
|
||||
printDaemonHelp();
|
||||
} else {
|
||||
printHelp();
|
||||
|
|
@ -648,13 +651,13 @@ export async function main(args: string[]) {
|
|||
process.exit(0);
|
||||
}
|
||||
|
||||
if (isDaemonCommand && parsed.mode === "rpc") {
|
||||
console.error(chalk.red("Cannot use --mode rpc with the daemon command."));
|
||||
if (isGatewayCommand && parsed.mode === "rpc") {
|
||||
console.error(chalk.red("Cannot use --mode rpc with the gateway command."));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Read piped stdin content (if any) - skip for daemon and RPC modes
|
||||
if (!isDaemonCommand && parsed.mode !== "rpc") {
|
||||
if (!isGatewayCommand && parsed.mode !== "rpc") {
|
||||
const stdinContent = await readPipedStdin();
|
||||
if (stdinContent !== undefined) {
|
||||
// Force print mode since interactive mode requires a TTY for keyboard input
|
||||
|
|
@ -684,7 +687,7 @@ export async function main(args: string[]) {
|
|||
}
|
||||
|
||||
const { initialMessage, initialImages } = await prepareInitialMessage(parsed, settingsManager.getImageAutoResize());
|
||||
const isInteractive = !isDaemonCommand && !parsed.print && parsed.mode === undefined;
|
||||
const isInteractive = !isGatewayCommand && !parsed.print && parsed.mode === undefined;
|
||||
const mode = parsed.mode || "text";
|
||||
initTheme(settingsManager.getTheme(), isInteractive);
|
||||
|
||||
|
|
@ -789,11 +792,44 @@ export async function main(args: string[]) {
|
|||
verbose: parsed.verbose,
|
||||
});
|
||||
await mode.run();
|
||||
} else if (isDaemonCommand) {
|
||||
} else if (isGatewayCommand) {
|
||||
const gatewayLoaderOptions = {
|
||||
additionalExtensionPaths: firstPass.extensions,
|
||||
additionalSkillPaths: firstPass.skills,
|
||||
additionalPromptTemplatePaths: firstPass.promptTemplates,
|
||||
additionalThemePaths: firstPass.themes,
|
||||
noExtensions: firstPass.noExtensions,
|
||||
noSkills: firstPass.noSkills,
|
||||
noPromptTemplates: firstPass.noPromptTemplates,
|
||||
noThemes: firstPass.noThemes,
|
||||
systemPrompt: firstPass.systemPrompt,
|
||||
appendSystemPrompt: firstPass.appendSystemPrompt,
|
||||
};
|
||||
const gatewaySessionRoot = join(agentDir, "gateway-sessions");
|
||||
const daemonOptions: DaemonModeOptions = {
|
||||
initialMessage,
|
||||
initialImages,
|
||||
messages: parsed.messages,
|
||||
gateway: settingsManager.getGatewaySettings(),
|
||||
createSession: async (sessionKey) => {
|
||||
const gatewayResourceLoader = new DefaultResourceLoader({
|
||||
cwd,
|
||||
agentDir,
|
||||
settingsManager,
|
||||
...gatewayLoaderOptions,
|
||||
});
|
||||
await gatewayResourceLoader.reload();
|
||||
const gatewaySessionOptions: CreateAgentSessionOptions = {
|
||||
...sessionOptions,
|
||||
authStorage,
|
||||
modelRegistry,
|
||||
settingsManager,
|
||||
resourceLoader: gatewayResourceLoader,
|
||||
sessionManager: createGatewaySessionManager(cwd, sessionKey, gatewaySessionRoot),
|
||||
};
|
||||
const { session: gatewaySession } = await createAgentSession(gatewaySessionOptions);
|
||||
return gatewaySession;
|
||||
},
|
||||
};
|
||||
await runDaemonMode(session, daemonOptions);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@
|
|||
|
||||
import type { ImageContent } from "@mariozechner/pi-ai";
|
||||
import type { AgentSession } from "../core/agent-session.js";
|
||||
import { GatewayRuntime, type GatewaySessionFactory, setActiveGatewayRuntime } from "../core/gateway-runtime.js";
|
||||
import type { GatewaySettings } from "../core/settings-manager.js";
|
||||
|
||||
/**
|
||||
* Options for daemon mode.
|
||||
|
|
@ -19,6 +21,10 @@ export interface DaemonModeOptions {
|
|||
initialImages?: ImageContent[];
|
||||
/** Additional startup messages (sent after initialMessage, one by one). */
|
||||
messages?: string[];
|
||||
/** Factory for creating additional gateway-owned sessions. */
|
||||
createSession: GatewaySessionFactory;
|
||||
/** Gateway config from settings/env. */
|
||||
gateway: GatewaySettings;
|
||||
}
|
||||
|
||||
function createCommandContextActions(session: AgentSession) {
|
||||
|
|
@ -71,12 +77,39 @@ export async function runDaemonMode(session: AgentSession, options: DaemonModeOp
|
|||
const ready = new Promise<void>((resolve) => {
|
||||
resolveReady = resolve;
|
||||
});
|
||||
const gatewayBind = process.env.PI_GATEWAY_BIND ?? options.gateway.bind ?? "127.0.0.1";
|
||||
const gatewayPort = Number.parseInt(process.env.PI_GATEWAY_PORT ?? "", 10) || options.gateway.port || 8787;
|
||||
const gatewayToken = process.env.PI_GATEWAY_TOKEN ?? options.gateway.bearerToken;
|
||||
const gateway = new GatewayRuntime({
|
||||
config: {
|
||||
bind: gatewayBind,
|
||||
port: gatewayPort,
|
||||
bearerToken: gatewayToken,
|
||||
session: {
|
||||
idleMinutes: options.gateway.session?.idleMinutes ?? 60,
|
||||
maxQueuePerSession: options.gateway.session?.maxQueuePerSession ?? 8,
|
||||
},
|
||||
webhook: {
|
||||
enabled: options.gateway.webhook?.enabled ?? true,
|
||||
basePath: options.gateway.webhook?.basePath ?? "/webhooks",
|
||||
secret: process.env.PI_GATEWAY_WEBHOOK_SECRET ?? options.gateway.webhook?.secret,
|
||||
},
|
||||
},
|
||||
primarySessionKey: "web:main",
|
||||
primarySession: session,
|
||||
createSession: options.createSession,
|
||||
log: (message) => {
|
||||
console.error(`[pi-gateway] ${message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const shutdown = async (reason: "signal" | "extension"): Promise<void> => {
|
||||
if (isShuttingDown) return;
|
||||
isShuttingDown = true;
|
||||
|
||||
console.error(`[co-mono-daemon] shutdown requested: ${reason}`);
|
||||
console.error(`[pi-gateway] shutdown requested: ${reason}`);
|
||||
setActiveGatewayRuntime(null);
|
||||
await gateway.stop();
|
||||
|
||||
const runner = session.extensionRunner;
|
||||
if (runner?.hasHandlers("session_shutdown")) {
|
||||
|
|
@ -90,7 +123,7 @@ export async function runDaemonMode(session: AgentSession, options: DaemonModeOp
|
|||
const handleShutdownSignal = (signal: NodeJS.Signals) => {
|
||||
void shutdown("signal").catch((error) => {
|
||||
console.error(
|
||||
`[co-mono-daemon] shutdown failed for ${signal}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
`[pi-gateway] shutdown failed for ${signal}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -102,7 +135,7 @@ export async function runDaemonMode(session: AgentSession, options: DaemonModeOp
|
|||
process.once("SIGHUP", () => handleShutdownSignal("SIGHUP"));
|
||||
|
||||
process.on("unhandledRejection", (error) => {
|
||||
console.error(`[co-mono-daemon] unhandled rejection: ${error instanceof Error ? error.message : String(error)}`);
|
||||
console.error(`[pi-gateway] unhandled rejection: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
|
||||
await session.bindExtensions({
|
||||
|
|
@ -110,7 +143,7 @@ export async function runDaemonMode(session: AgentSession, options: DaemonModeOp
|
|||
shutdownHandler: () => {
|
||||
void shutdown("extension").catch((error) => {
|
||||
console.error(
|
||||
`[co-mono-daemon] extension shutdown failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
`[pi-gateway] extension shutdown failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -135,7 +168,11 @@ export async function runDaemonMode(session: AgentSession, options: DaemonModeOp
|
|||
await session.prompt(message);
|
||||
}
|
||||
|
||||
console.error(`[co-mono-daemon] startup complete (session=${session.sessionId ?? "unknown"})`);
|
||||
await gateway.start();
|
||||
setActiveGatewayRuntime(gateway);
|
||||
console.error(
|
||||
`[pi-gateway] startup complete (session=${session.sessionId ?? "unknown"}, bind=${gatewayBind}, port=${gatewayPort})`,
|
||||
);
|
||||
|
||||
// Keep process alive forever.
|
||||
const keepAlive = setInterval(() => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue