server for rowboatx

This commit is contained in:
Ramnique Singh 2025-12-02 13:24:58 +05:30
parent ae877e70ae
commit 9ad6331fbc
38 changed files with 2223 additions and 1088 deletions

3
apps/cli/.gitignore vendored
View file

@ -1,2 +1,3 @@
node_modules/
dist/
dist/
.vercel

View file

@ -116,20 +116,4 @@ yargs(hideBin(process.argv))
modelConfig();
}
)
.command(
"update-state <agent> <run_id>",
"Update state for a run",
(y) => y
.positional("agent", {
type: "string",
description: "The agent to run",
})
.positional("run_id", {
type: "string",
description: "The run id to update",
}),
(argv) => {
updateState(argv.agent, argv.run_id);
}
)
.parse();

File diff suppressed because it is too large Load diff

View file

@ -6,7 +6,7 @@
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "rm -rf dist && tsc",
"copilot": "npm run build && node dist/x.js"
"server": "node dist/server.js"
},
"files": [
"dist",
@ -21,7 +21,6 @@
"description": "",
"devDependencies": {
"@types/node": "^24.9.1",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
},
"dependencies": {
@ -30,9 +29,14 @@
"@ai-sdk/openai": "^2.0.53",
"@ai-sdk/openai-compatible": "^1.0.27",
"@ai-sdk/provider": "^2.0.0",
"@hono/node-server": "^1.19.6",
"@hono/standard-validator": "^0.1.5",
"@modelcontextprotocol/sdk": "^1.20.2",
"@openrouter/ai-sdk-provider": "^1.2.6",
"ai": "^5.0.102",
"awilix": "^12.0.5",
"hono": "^4.10.7",
"hono-openapi": "^1.1.1",
"json-schema-to-zod": "^2.6.1",
"nanoid": "^5.1.6",
"ollama-ai-provider-v2": "^1.5.4",

View file

@ -0,0 +1,45 @@
import { WorkDir } from "../config/config.js";
import fs from "fs/promises";
import path from "path";
import z from "zod";
import { Agent } from "./agents.js";
export interface IAgentsRepo {
list(): Promise<z.infer<typeof Agent>[]>;
fetch(id: string): Promise<z.infer<typeof Agent>>;
create(agent: z.infer<typeof Agent>): Promise<void>;
update(id: string, agent: z.infer<typeof Agent>): Promise<void>;
delete(id: string): Promise<void>;
}
export class FSAgentsRepo implements IAgentsRepo {
async list(): Promise<z.infer<typeof Agent>[]> {
const result: z.infer<typeof Agent>[] = [];
// list all json files in workdir/agents/
const agentsDir = path.join(WorkDir, "agents");
const files = await fs.readdir(agentsDir);
for (const file of files) {
const contents = await fs.readFile(path.join(agentsDir, file), "utf8");
result.push(Agent.parse(JSON.parse(contents)));
}
return result;
}
async fetch(id: string): Promise<z.infer<typeof Agent>> {
const contents = await fs.readFile(path.join(WorkDir, "agents", `${id}.json`), "utf8");
return Agent.parse(JSON.parse(contents));
}
async create(agent: z.infer<typeof Agent>): Promise<void> {
await fs.writeFile(path.join(WorkDir, "agents", `${agent.name}.json`), JSON.stringify(agent, null, 2));
}
async update(id: string, agent: z.infer<typeof Agent>): Promise<void> {
await fs.writeFile(path.join(WorkDir, "agents", `${id}.json`), JSON.stringify(agent, null, 2));
}
async delete(id: string): Promise<void> {
await fs.unlink(path.join(WorkDir, "agents", `${id}.json`));
}
}

View file

@ -1,19 +1,102 @@
import { jsonSchema, ModelMessage, modelMessageSchema } from "ai";
import fs from "fs";
import path from "path";
import { getModelConfig, WorkDir } from "../config/config.js";
import { Agent, ToolAttachment } from "../entities/agent.js";
import { WorkDir } from "../config/config.js";
import { Agent, ToolAttachment } from "./agents.js";
import { AssistantContentPart, AssistantMessage, Message, MessageList, ProviderOptions, ToolCallPart, ToolMessage, UserMessage } from "../entities/message.js";
import { runIdGenerator } from "./run-id-gen.js";
import { LanguageModel, stepCountIs, streamText, tool, Tool, ToolSet } from "ai";
import { z } from "zod";
import { getProvider } from "./models.js";
import { LlmStepStreamEvent } from "../entities/llm-step-events.js";
import { execTool } from "./exec-tool.js";
import { AskHumanRequestEvent, RunEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent } from "../entities/run-events.js";
import { BuiltinTools } from "./builtin-tools.js";
import { CopilotAgent } from "../assistant/agent.js";
import { isBlocked } from "./command-executor.js";
import { execTool } from "../application/lib/exec-tool.js";
import { MessageEvent, AskHumanRequestEvent, RunEvent, ToolInvocationEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent } from "../entities/run-events.js";
import { BuiltinTools } from "../application/lib/builtin-tools.js";
import { CopilotAgent } from "../application/assistant/agent.js";
import { isBlocked } from "../application/lib/command-executor.js";
import container from "../di/container.js";
import { IModelConfigRepo } from "../models/repo.js";
import { getProvider } from "../models/models.js";
import { IAgentsRepo } from "./repo.js";
import { IdGen, IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js";
import { IBus } from "../application/lib/bus.js";
import { IMessageQueue } from "../application/lib/message-queue.js";
import { IRunsRepo } from "../runs/repo.js";
import { IRunsLock } from "../runs/lock.js";
export interface IAgentRuntime {
trigger(runId: string): Promise<void>;
}
export class AgentRuntime implements IAgentRuntime {
private runsRepo: IRunsRepo;
private idGenerator: IMonotonicallyIncreasingIdGenerator;
private bus: IBus;
private messageQueue: IMessageQueue;
private modelConfigRepo: IModelConfigRepo;
private runsLock: IRunsLock;
constructor({
runsRepo,
idGenerator,
bus,
messageQueue,
modelConfigRepo,
runsLock,
}: {
runsRepo: IRunsRepo;
idGenerator: IMonotonicallyIncreasingIdGenerator;
bus: IBus;
messageQueue: IMessageQueue;
modelConfigRepo: IModelConfigRepo;
runsLock: IRunsLock;
}) {
this.runsRepo = runsRepo;
this.idGenerator = idGenerator;
this.bus = bus;
this.messageQueue = messageQueue;
this.modelConfigRepo = modelConfigRepo;
this.runsLock = runsLock;
}
async trigger(runId: string): Promise<void> {
if (!await this.runsLock.lock(runId)) {
console.log(`unable to acquire lock on run ${runId}`);
return;
}
try {
while (true) {
let eventCount = 0;
const run = await this.runsRepo.fetch(runId);
if (!run) {
throw new Error(`Run ${runId} not found`);
}
const state = new AgentState();
for (const event of run.log) {
state.ingest(event);
}
for await (const event of streamAgent({
state,
idGenerator: this.idGenerator,
runId,
messageQueue: this.messageQueue,
modelConfigRepo: this.modelConfigRepo,
})) {
eventCount++;
if (event.type !== "llm-stream-event") {
await this.runsRepo.appendEvents(runId, [event]);
}
await this.bus.publish(event);
}
// if no events, break
if (!eventCount) {
break;
}
}
} finally {
await this.runsLock.release(runId);
}
}
}
export async function mapAgentTool(t: z.infer<typeof ToolAttachment>): Promise<Tool> {
switch (t.type) {
@ -128,8 +211,8 @@ export class StreamStepMessageBuilder {
});
break;
case "finish-step":
this.providerOptions = event.providerOptions;
break;
this.providerOptions = event.providerOptions;
break;
}
}
@ -171,9 +254,8 @@ export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
if (id === "copilot") {
return CopilotAgent;
}
const agentPath = path.join(WorkDir, "agents", `${id}.json`);
const agent = fs.readFileSync(agentPath, "utf8");
return Agent.parse(JSON.parse(agent));
const repo = container.resolve<IAgentsRepo>('agentsRepo');
return await repo.fetch(id);
}
export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelMessage[] {
@ -262,10 +344,9 @@ async function buildTools(agent: z.infer<typeof Agent>): Promise<ToolSet> {
}
export class AgentState {
logger: RunLogger | null = null;
runId: string | null = null;
agent: z.infer<typeof Agent> | null = null;
agentName: string;
agentName: string | null = null;
messages: z.infer<typeof MessageList> = [];
lastAssistantMsg: z.infer<typeof AssistantMessage> | null = null;
subflowStates: Record<string, AgentState> = {};
@ -276,20 +357,6 @@ export class AgentState {
allowedToolCallIds: Record<string, true> = {};
deniedToolCallIds: Record<string, true> = {};
constructor(agentName: string, runId?: string) {
this.agentName = agentName;
this.runId = runId || runIdGenerator.next();
this.logger = new RunLogger(this.runId);
if (!runId) {
this.logger.log({
type: "start",
runId: this.runId,
agentName: this.agentName,
subflow: [],
});
}
}
getPendingPermissions(): z.infer<typeof ToolPermissionRequestEvent>[] {
const response: z.infer<typeof ToolPermissionRequestEvent>[] = [];
for (const [id, subflowState] of Object.entries(this.subflowStates)) {
@ -346,6 +413,9 @@ export class AgentState {
ingest(event: z.infer<typeof RunEvent>) {
if (event.subflow.length > 0) {
const { subflow, ...rest } = event;
if (!this.subflowStates[subflow[0]]) {
this.subflowStates[subflow[0]] = new AgentState();
}
this.subflowStates[subflow[0]].ingest({
...rest,
subflow: subflow.slice(1),
@ -353,6 +423,10 @@ export class AgentState {
return;
}
switch (event.type) {
case "start":
this.runId = event.runId;
this.agentName = event.agentName;
break;
case "message":
this.messages.push(event.message);
if (event.message.content instanceof Array) {
@ -371,9 +445,6 @@ export class AgentState {
this.lastAssistantMsg = event.message;
}
break;
case "spawn-subflow":
this.subflowStates[event.toolCallId] = new AgentState(event.agentName);
break;
case "tool-permission-request":
this.pendingToolPermissionRequests[event.toolCall.toolCallId] = event;
break;
@ -406,27 +477,33 @@ export class AgentState {
break;
}
}
ingestAndLog(event: z.infer<typeof RunEvent>) {
this.ingest(event);
this.logger!.log(event);
}
*ingestAndLogAndYield(event: z.infer<typeof RunEvent>): Generator<z.infer<typeof RunEvent>, void, unknown> {
this.ingestAndLog(event);
yield event;
}
}
export async function* streamAgent(state: AgentState): AsyncGenerator<z.infer<typeof RunEvent>, void, unknown> {
// get model config
const modelConfig = await getModelConfig();
export async function* streamAgent({
state,
idGenerator,
runId,
messageQueue,
modelConfigRepo,
}: {
state: AgentState,
idGenerator: IMonotonicallyIncreasingIdGenerator;
runId: string;
messageQueue: IMessageQueue;
modelConfigRepo: IModelConfigRepo;
}): AsyncGenerator<z.infer<typeof RunEvent>, void, unknown> {
async function* processEvent(event: z.infer<typeof RunEvent>): AsyncGenerator<z.infer<typeof RunEvent>, void, unknown> {
state.ingest(event);
yield event;
}
const modelConfig = await modelConfigRepo.getConfig();
if (!modelConfig) {
throw new Error("Model config not found");
}
// set up agent
const agent = await loadAgent(state.agentName);
const agent = await loadAgent(state.agentName!);
// set up tools
const tools = await buildTools(agent);
@ -436,9 +513,16 @@ export async function* streamAgent(state: AgentState): AsyncGenerator<z.infer<ty
const model = provider.languageModel(agent.model || modelConfig.defaults.model);
let loopCounter = 0;
console.log('here');
async function pendingMsgs() {
const pendingMsgs = [];
}
while (true) {
// console.error(`loop counter: ${loopCounter++}`)
// if last response is from assistant and text, so exit
// if last response is from assistant and text, get any pending msgs
const lastMessage = state.messages[state.messages.length - 1];
if (lastMessage
&& lastMessage.role === "assistant"
@ -446,8 +530,28 @@ export async function* streamAgent(state: AgentState): AsyncGenerator<z.infer<ty
|| !lastMessage.content.some(part => part.type === "tool-call")
)
) {
// console.error("Nothing to do, exiting (a.)")
return;
let pending = 0;
while(true) {
const msg = await messageQueue.dequeue(runId);
if (!msg) {
break;
}
pending++;
yield *processEvent({
runId,
type: "message",
messageId: msg.messageId,
message: {
role: "user",
content: msg.message,
},
subflow: [],
});
}
// if no msgs found, return
if (!pending) {
return;
}
}
// execute any pending tool calls
@ -461,7 +565,9 @@ export async function* streamAgent(state: AgentState): AsyncGenerator<z.infer<ty
// if tool has been denied, deny
if (state.deniedToolCallIds[toolCallId]) {
yield* state.ingestAndLogAndYield({
yield *processEvent({
runId,
messageId: await idGenerator.next(),
type: "message",
message: {
role: "tool",
@ -480,7 +586,8 @@ export async function* streamAgent(state: AgentState): AsyncGenerator<z.infer<ty
}
// execute approved tool
yield* state.ingestAndLogAndYield({
yield *processEvent({
runId,
type: "tool-invocation",
toolName: toolCall.toolName,
input: JSON.stringify(toolCall.arguments),
@ -489,8 +596,14 @@ export async function* streamAgent(state: AgentState): AsyncGenerator<z.infer<ty
let result: any = null;
if (agent.tools![toolCall.toolName].type === "agent") {
let subflowState = state.subflowStates[toolCallId];
for await (const event of streamAgent(subflowState)) {
yield* state.ingestAndLogAndYield({
for await (const event of streamAgent({
state: subflowState,
idGenerator,
runId,
messageQueue,
modelConfigRepo,
})) {
yield *processEvent({
...event,
subflow: [toolCallId, ...event.subflow],
});
@ -508,13 +621,16 @@ export async function* streamAgent(state: AgentState): AsyncGenerator<z.infer<ty
toolCallId: toolCall.toolCallId,
toolName: toolCall.toolName,
};
yield* state.ingestAndLogAndYield({
yield* processEvent({
runId,
type: "tool-result",
toolName: toolCall.toolName,
result: result,
subflow: [],
});
yield* state.ingestAndLogAndYield({
yield *processEvent({
runId,
messageId: await idGenerator.next(),
type: "message",
message: resultMsg,
subflow: [],
@ -529,10 +645,29 @@ export async function* streamAgent(state: AgentState): AsyncGenerator<z.infer<ty
}
// if current message state isn't runnable, exit
/*
if (state.messages.length === 0 || state.messages[state.messages.length - 1].role === "assistant") {
// console.error("current message state isn't runnable, exiting (c.)")
return;
}
*/
while(true) {
const msg = await messageQueue.dequeue(runId);
if (!msg) {
break;
}
yield *processEvent({
runId,
type: "message",
messageId: msg.messageId,
message: {
role: "user",
content: msg.message,
},
subflow: [],
});
}
// run one LLM turn.
// stream agent response and build message
@ -544,7 +679,8 @@ export async function* streamAgent(state: AgentState): AsyncGenerator<z.infer<ty
tools,
)) {
messageBuilder.ingest(event);
yield* state.ingestAndLogAndYield({
yield *processEvent({
runId,
type: "llm-stream-event",
event: event,
subflow: [],
@ -553,7 +689,9 @@ export async function* streamAgent(state: AgentState): AsyncGenerator<z.infer<ty
// build and emit final message from agent response
const message = messageBuilder.get();
yield* state.ingestAndLogAndYield({
yield *processEvent({
runId,
messageId: await idGenerator.next(),
type: "message",
message,
subflow: [],
@ -565,7 +703,8 @@ export async function* streamAgent(state: AgentState): AsyncGenerator<z.infer<ty
if (part.type === "tool-call") {
const underlyingTool = agent.tools![part.toolName];
if (underlyingTool.type === "builtin" && underlyingTool.name === "ask-human") {
yield* state.ingestAndLogAndYield({
yield *processEvent({
runId,
type: "ask-human-request",
toolCallId: part.toolCallId,
query: part.arguments.question,
@ -575,7 +714,8 @@ export async function* streamAgent(state: AgentState): AsyncGenerator<z.infer<ty
if (underlyingTool.type === "builtin" && underlyingTool.name === "executeCommand") {
// if command is blocked, then seek permission
if (isBlocked(part.arguments.command)) {
yield* state.ingestAndLogAndYield({
yield *processEvent({
runId,
type: "tool-permission-request",
toolCall: part,
subflow: [],
@ -583,13 +723,16 @@ export async function* streamAgent(state: AgentState): AsyncGenerator<z.infer<ty
}
}
if (underlyingTool.type === "agent" && underlyingTool.name) {
yield* state.ingestAndLogAndYield({
yield *processEvent({
runId,
type: "spawn-subflow",
agentName: underlyingTool.name,
toolCallId: part.toolCallId,
subflow: [],
});
yield* state.ingestAndLogAndYield({
yield *processEvent({
runId,
messageId: await idGenerator.next(),
type: "message",
message: {
role: "user",

View file

@ -1,41 +1,22 @@
import { AgentState, streamAgent } from "./application/lib/agent.js";
import { AgentState, streamAgent } from "./agents/runtime.js";
import { StreamRenderer } from "./application/lib/stream-renderer.js";
import { stdin as input, stdout as output } from "node:process";
import fs from "fs";
import { promises as fsp } from "fs";
import path from "path";
import { WorkDir, getModelConfig, updateModelConfig } from "./application/config/config.js";
import { RunEvent } from "./application/entities/run-events.js";
import { WorkDir } from "./config/config.js";
import { RunEvent } from "./entities/run-events.js";
import { createInterface, Interface } from "node:readline/promises";
import { ToolCallPart } from "./application/entities/message.js";
import { Agent } from "./application/entities/agent.js";
import { McpServerConfig, McpServerDefinition } from "./application/entities/mcp.js";
import { Example } from "./application/entities/example.js";
import { ToolCallPart } from "./entities/message.js";
import { Agent } from "./agents/agents.js";
import { McpServerConfig } from "./mcp/mcp.js";
import { McpServerDefinition } from "./mcp/mcp.js";
import { Example } from "./entities/example.js";
import { z } from "zod";
import { Flavor } from "./application/entities/models.js";
import { Flavor } from "./models/models.js";
import { examples } from "./examples/index.js";
import { modelMessageSchema } from "ai";
export async function updateState(agent: string, runId: string) {
const state = new AgentState(agent, runId);
// If running in a TTY, read run events from stdin line-by-line
if (!input.isTTY) {
return;
}
const rl = createInterface({ input, crlfDelay: Infinity });
try {
for await (const line of rl) {
if (line.trim() === "") {
continue;
}
const event = RunEvent.parse(JSON.parse(line));
state.ingestAndLog(event);
}
} finally {
rl.close();
}
}
import container from "./di/container.js";
import { IModelConfigRepo } from "./models/repo.js";
function renderGreeting() {
const logo = `
@ -61,12 +42,8 @@ export async function app(opts: {
input?: string;
noInteractive?: boolean;
}) {
// check if model config is required
const c = await getModelConfig();
if (!c) {
await modelConfig();
}
throw new Error("Not implemented");
/*
const renderer = new StreamRenderer();
const state = new AgentState(opts.agent, opts.runId);
@ -172,6 +149,7 @@ export async function app(opts: {
} finally {
rl?.close();
}
*/
}
async function getToolCallPermission(
@ -219,7 +197,8 @@ async function getUserInput(
export async function modelConfig() {
// load existing model config
const config = await getModelConfig();
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
const config = await repo.getConfig();
const rl = createInterface({ input, output });
try {
@ -333,14 +312,7 @@ export async function modelConfig() {
);
const model = modelAns.trim() || modelDefault;
const newConfig = {
providers: { ...(config?.providers || {}) },
defaults: {
provider: providerName!,
model,
},
};
await updateModelConfig(newConfig as any);
await repo.setDefault(providerName!, model);
console.log(`Model configuration updated. Provider set to '${providerName}'.`);
return;
}
@ -391,24 +363,13 @@ export async function modelConfig() {
);
const model = modelAns.trim() || modelDefault;
const mergedProviders = {
...(config?.providers || {}),
[providerName]: {
flavor: selectedFlavor,
...(apiKey ? { apiKey } : {}),
...(baseURL ? { baseURL } : {}),
...(headers ? { headers } : {}),
},
};
const newConfig = {
providers: mergedProviders,
defaults: {
provider: providerName,
model,
},
};
await updateModelConfig(newConfig as any);
await repo.upsert(providerName, {
flavor: selectedFlavor,
apiKey,
baseURL,
headers,
});
await repo.setDefault(providerName, model);
renderCurrentModel(providerName, selectedFlavor, model);
console.log(`Configuration written to ${WorkDir}/config/models.json. You can also edit this file manually`);
} finally {

View file

@ -1,4 +1,4 @@
import { Agent, ToolAttachment } from "../entities/agent.js";
import { Agent, ToolAttachment } from "../../agents/agents.js";
import z from "zod";
import { CopilotInstructions } from "./instructions.js";
import { BuiltinTools } from "../lib/builtin-tools.js";

View file

@ -1,5 +1,5 @@
import { skillCatalog } from "./skills/index.js";
import { WorkDir as BASE_DIR } from "../config/config.js";
import { WorkDir as BASE_DIR } from "../../config/config.js";
export const CopilotInstructions = `You are an intelligent workflow assistant helping users manage their workflows in ${BASE_DIR}. You can also help the user with general tasks.

View file

@ -1,63 +0,0 @@
import path from "path";
import fs from "fs";
import { McpServerConfig } from "../entities/mcp.js";
import { ModelConfig } from "../entities/models.js";
import { z } from "zod";
import { homedir } from "os";
// Resolve app root relative to compiled file location (dist/...)
export const WorkDir = path.join(homedir(), ".rowboat");
let modelConfig: z.infer<typeof ModelConfig> | null = null;
const baseMcpConfig: z.infer<typeof McpServerConfig> = {
mcpServers: {
}
};
function ensureMcpConfig() {
const configPath = path.join(WorkDir, "config", "mcp.json");
if (!fs.existsSync(configPath)) {
fs.writeFileSync(configPath, JSON.stringify(baseMcpConfig, null, 2));
}
}
function ensureDirs() {
const ensure = (p: string) => { if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }); };
ensure(WorkDir);
ensure(path.join(WorkDir, "agents"));
ensure(path.join(WorkDir, "config"));
ensureMcpConfig();
}
function loadMcpServerConfig(): z.infer<typeof McpServerConfig> {
const configPath = path.join(WorkDir, "config", "mcp.json");
if (!fs.existsSync(configPath)) return { mcpServers: {} };
const config = fs.readFileSync(configPath, "utf8");
return McpServerConfig.parse(JSON.parse(config));
}
export async function getModelConfig(): Promise<z.infer<typeof ModelConfig> | null> {
if (modelConfig) {
return modelConfig;
}
const configPath = path.join(WorkDir, "config", "models.json");
try {
const config = await fs.promises.readFile(configPath, "utf8");
modelConfig = ModelConfig.parse(JSON.parse(config));
return modelConfig;
} catch (error) {
console.error(`Warning! model config not found!`);
return null;
}
}
export async function updateModelConfig(config: z.infer<typeof ModelConfig>) {
modelConfig = config;
const configPath = path.join(WorkDir, "config", "models.json");
await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2));
}
ensureDirs();
const { mcpServers } = loadMcpServerConfig();
export const McpServers = mcpServers;

View file

@ -1,20 +0,0 @@
import { z } from "zod";
export const StdioMcpServerConfig = z.object({
type: z.literal("stdio").optional(),
command: z.string(),
args: z.array(z.string()).optional(),
env: z.record(z.string(), z.string()).optional(),
});
export const HttpMcpServerConfig = z.object({
type: z.literal("http").optional(),
url: z.string(),
headers: z.record(z.string(), z.string()).optional(),
});
export const McpServerDefinition = z.union([StdioMcpServerConfig, HttpMcpServerConfig]);
export const McpServerConfig = z.object({
mcpServers: z.record(z.string(), McpServerDefinition),
});

View file

@ -1,27 +0,0 @@
import z from "zod";
export const Flavor = z.enum([
"rowboat [free]",
"aigateway",
"anthropic",
"google",
"ollama",
"openai",
"openai-compatible",
"openrouter",
]);
export const Provider = z.object({
flavor: Flavor,
apiKey: z.string().optional(),
baseURL: z.string().optional(),
headers: z.record(z.string(), z.string()).optional(),
});
export const ModelConfig = z.object({
providers: z.record(z.string(), Provider),
defaults: z.object({
provider: z.string(),
model: z.string(),
}),
});

View file

@ -1,14 +1,13 @@
import { z, ZodType } from "zod";
import * as fs from "fs/promises";
import * as path from "path";
import { WorkDir as BASE_DIR } from "../config/config.js";
import { WorkDir as BASE_DIR } from "../../config/config.js";
import { executeCommand } from "./command-executor.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import { Client } from "@modelcontextprotocol/sdk/client";
import { resolveSkill, availableSkills } from "../assistant/skills/index.js";
import { McpServerDefinition, McpServerConfig } from "../entities/mcp.js";
import { executeTool, listServers, listTools } from "../../mcp/mcp.js";
import container from "../../di/container.js";
import { IMcpConfigRepo } from "../..//mcp/repo.js";
import { McpServerDefinition } from "../../mcp/mcp.js";
const BuiltinToolsSchema = z.record(z.string(), z.object({
description: z.string(),
@ -310,109 +309,33 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
description: 'Add or update an MCP server in the configuration with validation. This ensures the server definition is valid before saving.',
inputSchema: z.object({
serverName: z.string().describe('Name/alias for the MCP server'),
serverType: z.enum(['stdio', 'http']).describe('Type of MCP server: "stdio" for command-based or "http" for HTTP/SSE-based'),
command: z.string().optional().describe('Command to execute (required for stdio type, e.g., "npx", "python", "node")'),
args: z.array(z.string()).optional().describe('Command arguments (optional, for stdio type)'),
env: z.record(z.string(), z.string()).optional().describe('Environment variables (optional, for stdio type)'),
url: z.string().optional().describe('HTTP/SSE endpoint URL (required for http type)'),
headers: z.record(z.string(), z.string()).optional().describe('HTTP headers (optional, for http type)'),
config: McpServerDefinition,
}),
execute: async ({ serverName, serverType, command, args, env, url, headers }: {
execute: async ({ serverName, config }: {
serverName: string;
serverType: 'stdio' | 'http';
command?: string;
args?: string[];
env?: Record<string, string>;
url?: string;
headers?: Record<string, string>;
config: z.infer<typeof McpServerDefinition>;
}) => {
try {
// Build server definition based on type
let serverDef: any;
if (serverType === 'stdio') {
if (!command) {
return {
success: false,
message: 'For stdio type servers, "command" is required. Example: "npx" or "python"',
validationErrors: ['Missing required field: command'],
};
}
serverDef = {
type: 'stdio',
command,
...(args ? { args } : {}),
...(env ? { env } : {}),
};
} else if (serverType === 'http') {
if (!url) {
return {
success: false,
message: 'For http type servers, "url" is required. Example: "http://localhost:3000/sse"',
validationErrors: ['Missing required field: url'],
};
}
serverDef = {
type: 'http',
url,
...(headers ? { headers } : {}),
};
} else {
return {
success: false,
message: `Invalid serverType: ${serverType}. Must be "stdio" or "http"`,
validationErrors: [`Invalid serverType: ${serverType}`],
};
}
// Validate against Zod schema
const validationResult = McpServerDefinition.safeParse(serverDef);
const validationResult = McpServerDefinition.safeParse(config);
if (!validationResult.success) {
return {
success: false,
message: 'Server definition failed validation. Check the errors below.',
validationErrors: validationResult.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`),
providedDefinition: serverDef,
providedDefinition: config,
};
}
// Read existing config
const configPath = path.join(BASE_DIR, 'config', 'mcp.json');
let currentConfig: z.infer<typeof McpServerConfig> = { mcpServers: {} };
try {
const content = await fs.readFile(configPath, 'utf-8');
currentConfig = McpServerConfig.parse(JSON.parse(content));
} catch (error: any) {
if (error?.code !== 'ENOENT') {
return {
success: false,
message: `Failed to read existing MCP config: ${error.message}`,
};
}
// File doesn't exist, use empty config
}
// Check if server already exists
const isUpdate = !!currentConfig.mcpServers[serverName];
// Add/update server
currentConfig.mcpServers[serverName] = validationResult.data;
// Write back to file
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(configPath, JSON.stringify(currentConfig, null, 2), 'utf-8');
const repo = container.resolve<IMcpConfigRepo>('mcpConfigRepo');
await repo.upsert(serverName, config);
return {
success: true,
message: `MCP server '${serverName}' ${isUpdate ? 'updated' : 'added'} successfully`,
serverName,
serverType,
isUpdate,
configuration: validationResult.data,
};
} catch (error) {
return {
success: false,
message: `Failed to add MCP server: ${error instanceof Error ? error.message : 'Unknown error'}`,
error: `Failed to update MCP server: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
},
@ -421,47 +344,17 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
listMcpServers: {
description: 'List all available MCP servers from the configuration',
inputSchema: z.object({}),
execute: async (): Promise<{ success: boolean, servers: any[], count: number, message: string }> => {
execute: async () => {
try {
const configPath = path.join(BASE_DIR, 'config', 'mcp.json');
// Check if config exists
try {
await fs.access(configPath);
} catch {
return {
success: true,
servers: [],
count: 0,
message: 'No MCP servers configured yet',
};
}
const content = await fs.readFile(configPath, 'utf-8');
const config = JSON.parse(content);
const servers = Object.keys(config.mcpServers || {}).map(name => {
const server = config.mcpServers[name];
return {
name,
type: 'command' in server ? 'stdio' : 'http',
command: server.command,
url: server.url,
};
});
const result = await listServers();
return {
success: true,
servers,
count: servers.length,
message: `Found ${servers.length} MCP server(s)`,
result,
count: Object.keys(result.mcpServers).length,
};
} catch (error) {
return {
success: false,
servers: [],
count: 0,
message: `Failed to list MCP servers: ${error instanceof Error ? error.message : 'Unknown error'}`,
error: `Failed to list MCP servers: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
},
@ -471,69 +364,19 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
description: 'List all available tools from a specific MCP server',
inputSchema: z.object({
serverName: z.string().describe('Name of the MCP server to query'),
cursor: z.string().optional(),
}),
execute: async ({ serverName }: { serverName: string }) => {
execute: async ({ serverName, cursor }: { serverName: string, cursor?: string }) => {
try {
const configPath = path.join(BASE_DIR, 'config', 'mcp.json');
const content = await fs.readFile(configPath, 'utf-8');
const config = JSON.parse(content);
const mcpConfig = config.mcpServers[serverName];
if (!mcpConfig) {
return {
success: false,
message: `MCP server '${serverName}' not found in configuration`,
};
}
// Create transport based on config type
let transport;
if ('command' in mcpConfig) {
transport = new StdioClientTransport({
command: mcpConfig.command,
args: mcpConfig.args || [],
env: mcpConfig.env || {},
});
} else {
try {
transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url));
} catch {
transport = new SSEClientTransport(new URL(mcpConfig.url));
}
}
// Create and connect client
const client = new Client({
name: 'rowboat-copilot',
version: '1.0.0',
});
await client.connect(transport);
// List available tools
const toolsList = await client.listTools();
// Close connection
client.close();
transport.close();
const tools = toolsList.tools.map((t: any) => ({
name: t.name,
description: t.description || 'No description',
inputSchema: t.inputSchema,
}));
const result = await listTools(serverName, cursor);
return {
success: true,
serverName,
tools,
count: tools.length,
message: `Found ${tools.length} tool(s) in MCP server '${serverName}'`,
result,
count: result.tools.length,
};
} catch (error) {
return {
success: false,
message: `Failed to list MCP tools: ${error instanceof Error ? error.message : 'Unknown error'}`,
error: `Failed to list MCP tools: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
},
@ -547,108 +390,19 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
arguments: z.record(z.string(), z.any()).optional().describe('Arguments to pass to the tool (as key-value pairs matching the tool\'s input schema). MUST include all required parameters from the tool\'s inputSchema.'),
}),
execute: async ({ serverName, toolName, arguments: args = {} }: { serverName: string, toolName: string, arguments?: Record<string, any> }) => {
let transport: any;
let client: any;
try {
const configPath = path.join(BASE_DIR, 'config', 'mcp.json');
const content = await fs.readFile(configPath, 'utf-8');
const config = JSON.parse(content);
const mcpConfig = config.mcpServers[serverName];
if (!mcpConfig) {
return {
success: false,
message: `MCP server '${serverName}' not found in configuration. Use listMcpServers to see available servers.`,
};
}
// Create transport based on config type
if ('command' in mcpConfig) {
transport = new StdioClientTransport({
command: mcpConfig.command,
args: mcpConfig.args || [],
env: mcpConfig.env || {},
});
} else {
try {
transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url));
} catch {
transport = new SSEClientTransport(new URL(mcpConfig.url));
}
}
// Create and connect client
client = new Client({
name: 'rowboat-copilot',
version: '1.0.0',
});
await client.connect(transport);
// Get tool list to validate the tool exists and check schema
const toolsList = await client.listTools();
const toolDef = toolsList.tools.find((t: any) => t.name === toolName);
if (!toolDef) {
await client.close();
transport.close();
return {
success: false,
message: `Tool '${toolName}' not found in server '${serverName}'. Use listMcpTools to see available tools.`,
availableTools: toolsList.tools.map((t: any) => t.name),
};
}
// Validate required parameters
const inputSchema = toolDef.inputSchema;
if (inputSchema && inputSchema.required && Array.isArray(inputSchema.required)) {
const missingParams = inputSchema.required.filter((param: string) => !(param in args));
if (missingParams.length > 0) {
await client.close();
transport.close();
return {
success: false,
message: `Missing required parameters: ${missingParams.join(', ')}`,
requiredParameters: inputSchema.required,
providedArguments: Object.keys(args),
toolSchema: inputSchema,
hint: `Use listMcpTools to see the full schema for '${toolName}' and ensure all required parameters are included in the arguments field.`,
};
}
}
// Call the tool
const result = await client.callTool({
name: toolName,
arguments: args,
});
// Close connection
await client.close();
transport.close();
const result = await executeTool(serverName, toolName, args);
return {
success: true,
serverName,
toolName,
result: result.content,
result,
message: `Successfully executed tool '${toolName}' from server '${serverName}'`,
};
} catch (error) {
// Ensure cleanup
try {
if (client) await client.close();
if (transport) transport.close();
} catch (cleanupError) {
// Ignore cleanup errors
}
return {
success: false,
message: `Failed to execute MCP tool: ${error instanceof Error ? error.message : 'Unknown error'}`,
serverName,
toolName,
error: `Failed to execute MCP tool: ${error instanceof Error ? error.message : 'Unknown error'}`,
hint: 'Use listMcpTools to verify the tool exists and check its schema. Ensure all required parameters are provided in the arguments field.',
};
}

View file

@ -0,0 +1,38 @@
import { RunEvent } from "../../entities/run-events.js";
import z from "zod";
export interface IBus {
publish(event: z.infer<typeof RunEvent>): Promise<void>;
// subscribe accepts a handler to handle events
// and returns a function to unsubscribe
subscribe(runId: string, handler: (event: z.infer<typeof RunEvent>) => Promise<void>): Promise<() => void>;
}
export class InMemoryBus implements IBus {
private subscribers: Map<string, ((event: z.infer<typeof RunEvent>) => Promise<void>)[]> = new Map();
async publish(event: z.infer<typeof RunEvent>): Promise<void> {
console.log(this.subscribers);
const pending: Promise<void>[] = [];
for (const subscriber of this.subscribers.get(event.runId) || []) {
pending.push(subscriber(event));
}
for (const subscriber of this.subscribers.get('*') || []) {
pending.push(subscriber(event));
}
console.log(pending.length);
await Promise.all(pending);
}
async subscribe(runId: string, handler: (event: z.infer<typeof RunEvent>) => Promise<void>): Promise<() => void> {
if (!this.subscribers.has(runId)) {
this.subscribers.set(runId, []);
}
this.subscribers.get(runId)!.push(handler);
console.log(this.subscribers);
return () => {
this.subscribers.get(runId)!.splice(this.subscribers.get(runId)!.indexOf(handler), 1);
};
}
}

View file

@ -1,6 +1,6 @@
import { exec, execSync } from 'child_process';
import { promisify } from 'util';
import { getSecurityAllowList, SECURITY_CONFIG_PATH } from '../config/security.js';
import { getSecurityAllowList, SECURITY_CONFIG_PATH } from '../../config/security.js';
const execPromise = promisify(exec);
const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n)/;

View file

@ -1,53 +1,10 @@
import { ToolAttachment } from "../entities/agent.js";
import { ToolAttachment } from "../../agents/agents.js";
import { z } from "zod";
import { McpServers } from "../config/config.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import { Client } from "@modelcontextprotocol/sdk/client";
import { BuiltinTools } from "./builtin-tools.js";
import { executeTool } from "../../mcp/mcp.js";
async function execMcpTool(agentTool: z.infer<typeof ToolAttachment> & { type: "mcp" }, input: any): Promise<any> {
// load mcp configuration from the tool
const mcpConfig = McpServers[agentTool.mcpServerName];
if (!mcpConfig) {
throw new Error(`MCP server ${agentTool.mcpServerName} not found`);
}
// create transport
let transport: Transport;
if ("command" in mcpConfig) {
transport = new StdioClientTransport({
command: mcpConfig.command,
args: mcpConfig.args,
env: mcpConfig.env,
});
} else {
// first try streamable http transport
try {
transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url));
} catch (error) {
// if that fails, try sse transport
transport = new SSEClientTransport(new URL(mcpConfig.url));
}
}
if (!transport) {
throw new Error(`No transport found for ${agentTool.mcpServerName}`);
}
// create client
const client = new Client({
name: 'rowboatx',
version: '1.0.0',
});
await client.connect(transport);
// call tool
const result = await client.callTool({ name: agentTool.name, arguments: input });
client.close();
transport.close();
const result = await executeTool(agentTool.mcpServerName, agentTool.name, input);
return result;
}

View file

@ -1,19 +1,23 @@
class RunIdGenerator {
export interface IMonotonicallyIncreasingIdGenerator {
next(): Promise<string>;
}
export class IdGen implements IMonotonicallyIncreasingIdGenerator {
private lastMs = 0;
private seq = 0;
private readonly pid: string;
private readonly hostTag: string;
constructor(hostTag: string = "") {
constructor() {
this.pid = String(process.pid).padStart(7, "0");
this.hostTag = hostTag ? `-${hostTag}` : "";
this.hostTag = "";
}
/**
* Returns an ISO8601-based, lexicographically sortable id string.
* Example: 2025-11-11T04-36-29Z-0001234-h1-000
*/
next(): string {
async next(): Promise<string> {
const now = Date.now();
const ms = now >= this.lastMs ? now : this.lastMs; // monotonic clamp
this.seq = ms === this.lastMs ? this.seq + 1 : 0;
@ -27,6 +31,4 @@ class RunIdGenerator {
const seqStr = String(this.seq).padStart(3, "0");
return `${iso}-${this.pid}${this.hostTag}-${seqStr}`;
}
}
export const runIdGenerator = new RunIdGenerator();
}

View file

@ -1,31 +0,0 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
export async function getMcpClient(serverUrl: string, serverName: string): Promise<Client> {
let client: Client | undefined = undefined;
const baseUrl = new URL(serverUrl);
// Try to connect using Streamable HTTP transport
try {
client = new Client({
name: 'streamable-http-client',
version: '1.0.0'
});
const transport = new StreamableHTTPClientTransport(baseUrl);
await client.connect(transport);
console.log(`[MCP] Connected using Streamable HTTP transport to ${serverName}`);
return client;
} catch (error) {
// If that fails with a 4xx error, try the older SSE transport
console.log(`[MCP] Streamable HTTP connection failed, falling back to SSE transport for ${serverName}`);
client = new Client({
name: 'sse-client',
version: '1.0.0'
});
const sseTransport = new SSEClientTransport(baseUrl);
await client.connect(sseTransport);
console.log(`[MCP] Connected using SSE transport to ${serverName}`);
return client;
}
}

View file

@ -0,0 +1,44 @@
import z from "zod";
import { IMonotonicallyIncreasingIdGenerator } from "./id-gen.js";
const EnqueuedMessage = z.object({
messageId: z.string(),
message: z.string(),
});
export interface IMessageQueue {
enqueue(runId: string, message: string): Promise<string>;
dequeue(runId: string): Promise<z.infer<typeof EnqueuedMessage> | null>;
}
export class InMemoryMessageQueue implements IMessageQueue {
private store: Record<string, z.infer<typeof EnqueuedMessage>[]> = {};
private idGenerator: IMonotonicallyIncreasingIdGenerator;
constructor({
idGenerator,
}: {
idGenerator: IMonotonicallyIncreasingIdGenerator;
}) {
this.idGenerator = idGenerator;
}
async enqueue(runId: string, message: string): Promise<string> {
if (!this.store[runId]) {
this.store[runId] = [];
}
const id = await this.idGenerator.next();
this.store[runId].push({
messageId: id,
message,
});
return id;
}
async dequeue(runId: string): Promise<z.infer<typeof EnqueuedMessage> | null> {
if (!this.store[runId]) {
return null;
}
return this.store[runId].shift() ?? null;
}
}

View file

@ -1,13 +0,0 @@
import { MessageList } from "../entities/message.js";
import { LlmStepStreamEvent } from "../entities/llm-step-events.js";
import { z } from "zod";
import { ToolAttachment } from "../entities/agent.js";
export type StepInputT = z.infer<typeof MessageList>;
export type StepOutputT = AsyncGenerator<z.infer<typeof LlmStepStreamEvent>, void, unknown>;
export interface Step {
execute(input: StepInputT): StepOutputT;
tools(): Record<string, z.infer<typeof ToolAttachment>>;
}

View file

@ -1,6 +1,6 @@
import { z } from "zod";
import { RunEvent } from "../entities/run-events.js";
import { LlmStepStreamEvent } from "../entities/llm-step-events.js";
import { RunEvent } from "../../entities/run-events.js";
import { LlmStepStreamEvent } from "../../entities/llm-step-events.js";
export interface StreamRendererOptions {
showHeaders?: boolean;

View file

@ -0,0 +1,15 @@
import path from "path";
import fs from "fs";
import { homedir } from "os";
// Resolve app root relative to compiled file location (dist/...)
export const WorkDir = path.join(homedir(), ".rowboat");
function ensureDirs() {
const ensure = (p: string) => { if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }); };
ensure(WorkDir);
ensure(path.join(WorkDir, "agents"));
ensure(path.join(WorkDir, "config"));
}
ensureDirs();

View file

@ -0,0 +1,30 @@
import { asClass, createContainer, InjectionMode } from "awilix";
import { FSModelConfigRepo, IModelConfigRepo } from "../models/repo.js";
import { FSMcpConfigRepo, IMcpConfigRepo } from "../mcp/repo.js";
import { FSAgentsRepo, IAgentsRepo } from "../agents/repo.js";
import { FSRunsRepo, IRunsRepo } from "../runs/repo.js";
import { IMonotonicallyIncreasingIdGenerator, IdGen } from "../application/lib/id-gen.js";
import { IMessageQueue, InMemoryMessageQueue } from "../application/lib/message-queue.js";
import { IBus, InMemoryBus } from "../application/lib/bus.js";
import { IRunsLock, InMemoryRunsLock } from "../runs/lock.js";
import { IAgentRuntime, AgentRuntime } from "../agents/runtime.js";
const container = createContainer({
injectionMode: InjectionMode.PROXY,
strict: true,
});
container.register({
idGenerator: asClass<IMonotonicallyIncreasingIdGenerator>(IdGen).singleton(),
messageQueue: asClass<IMessageQueue>(InMemoryMessageQueue).singleton(),
bus: asClass<IBus>(InMemoryBus).singleton(),
runsLock: asClass<IRunsLock>(InMemoryRunsLock).singleton(),
agentRuntime: asClass<IAgentRuntime>(AgentRuntime).singleton(),
mcpConfigRepo: asClass<IMcpConfigRepo>(FSMcpConfigRepo).singleton(),
modelConfigRepo: asClass<IModelConfigRepo>(FSModelConfigRepo).singleton(),
agentsRepo: asClass<IAgentsRepo>(FSAgentsRepo).singleton(),
runsRepo: asClass<IRunsRepo>(FSRunsRepo).singleton(),
});
export default container;

View file

@ -1,6 +1,6 @@
import z from "zod"
import { Agent } from "./agent.js"
import { McpServerDefinition } from "./mcp.js"
import { Agent } from "../agents/agents.js"
import { McpServerDefinition } from "../mcp/mcp.js";
export const Example = z.object({
id: z.string(),

View file

@ -1,16 +1,15 @@
import { LlmStepStreamEvent } from "./llm-step-events.js";
import { Message, ToolCallPart } from "./message.js";
import { Agent } from "./agent.js";
import z from "zod";
const BaseRunEvent = z.object({
runId: z.string(),
ts: z.iso.datetime().optional(),
subflow: z.array(z.string()),
});
export const StartEvent = BaseRunEvent.extend({
type: z.literal("start"),
runId: z.string(),
agentName: z.string(),
});
@ -27,6 +26,7 @@ export const LlmStreamEvent = BaseRunEvent.extend({
export const MessageEvent = BaseRunEvent.extend({
type: z.literal("message"),
messageId: z.string(),
message: Message,
});

View file

@ -1,5 +1,5 @@
import twitterPodcast from './twitter-podcast.json' with { type: 'json' };
import { Example } from '../application/entities/example.js';
import { Example } from '../entities/example.js';
import z from 'zod';
export const examples: Record<string, z.infer<typeof Example>> = {

174
apps/cli/src/mcp/mcp.ts Normal file
View file

@ -0,0 +1,174 @@
import container from "../di/container.js";
import { Client } from "@modelcontextprotocol/sdk/client";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import z from "zod";
import { IMcpConfigRepo } from "./repo.js";
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
export const StdioMcpServerConfig = z.object({
type: z.literal("stdio").optional(),
command: z.string(),
args: z.array(z.string()).optional(),
env: z.record(z.string(), z.string()).optional(),
});
export const HttpMcpServerConfig = z.object({
type: z.literal("http").optional(),
url: z.string(),
headers: z.record(z.string(), z.string()).optional(),
});
export const McpServerDefinition = z.union([StdioMcpServerConfig, HttpMcpServerConfig]);
export const McpServerConfig = z.object({
mcpServers: z.record(z.string(), McpServerDefinition),
});
const connectionState = z.enum(["disconnected", "connected", "error"]);
export const McpServerList = z.object({
mcpServers: z.record(z.string(), z.object({
config: McpServerDefinition,
state: connectionState,
error: z.string().nullable(),
})),
});
/*
inputSchema: {
[x: string]: unknown;
type: "object";
properties?: Record<string, object> | undefined;
required?: string[] | undefined;
};
*/
export const Tool = z.object({
name: z.string(),
description: z.string().optional(),
inputSchema: z.object({
type: z.literal("object"),
properties: z.record(z.string(), z.any()).optional(),
required: z.array(z.string()).optional(),
}),
outputSchema: z.object({
type: z.literal("object"),
properties: z.record(z.string(), z.any()).optional(),
required: z.array(z.string()).optional(),
}).optional(),
})
export const ListToolsResponse = z.object({
tools: z.array(Tool),
nextCursor: z.string().optional(),
});
type mcpState = {
state: z.infer<typeof connectionState>,
client: Client | null,
error: string | null,
};
const clients: Record<string, mcpState> = {};
async function getClient(serverName: string): Promise<Client> {
if (clients[serverName] && clients[serverName].state === "connected") {
return clients[serverName].client!;
}
const repo = container.resolve<IMcpConfigRepo>('mcpConfigRepo');
const { mcpServers } = await repo.getConfig();
const config = mcpServers[serverName];
if (!config) {
throw new Error(`MCP server ${serverName} not found`);
}
let transport: Transport | undefined = undefined;
try {
// create transport
if ("command" in config) {
transport = new StdioClientTransport({
command: config.command,
args: config.args,
env: config.env,
});
} else {
try {
transport = new StreamableHTTPClientTransport(new URL(config.url));
} catch (error) {
// if that fails, try sse transport
transport = new SSEClientTransport(new URL(config.url));
}
}
if (!transport) {
throw new Error(`No transport found for ${serverName}`);
}
// create client
const client = new Client({
name: 'rowboatx',
version: '1.0.0',
});
await client.connect(transport);
// store
clients[serverName] = {
state: "connected",
client,
error: null,
};
return client;
} catch (error) {
clients[serverName] = {
state: "error",
client: null,
error: error instanceof Error ? error.message : "Unknown error",
};
transport?.close();
throw error;
}
}
export async function cleanup() {
for (const [serverName, { client }] of Object.entries(clients)) {
await client?.transport?.close();
await client?.close();
delete clients[serverName];
}
}
export async function listServers(): Promise<z.infer<typeof McpServerList>> {
const repo = container.resolve<IMcpConfigRepo>('mcpConfigRepo');
const { mcpServers } = await repo.getConfig();
const result: z.infer<typeof McpServerList> = {
mcpServers: {},
};
for (const [serverName, config] of Object.entries(mcpServers)) {
const state = clients[serverName];
result.mcpServers[serverName] = {
config,
state: state ? state.state : "disconnected",
error: state ? state.error : null,
};
}
return result;
}
export async function listTools(serverName: string, cursor?: string): Promise<z.infer<typeof ListToolsResponse>> {
const client = await getClient(serverName);
const { tools, nextCursor } = await client.listTools({
cursor,
});
return {
tools,
nextCursor,
}
}
export async function executeTool(serverName: string, toolName: string, input: any): Promise<unknown> {
const client = await getClient(serverName);
const result = await client.callTool({
name: toolName,
arguments: input,
});
return result;
}

45
apps/cli/src/mcp/repo.ts Normal file
View file

@ -0,0 +1,45 @@
import { WorkDir } from "../config/config.js";
import { McpServerConfig } from "./mcp.js";
import { McpServerDefinition } from "./mcp.js";
import fs from "fs/promises";
import path from "path";
import z from "zod";
export interface IMcpConfigRepo {
getConfig(): Promise<z.infer<typeof McpServerConfig>>;
upsert(serverName: string, config: z.infer<typeof McpServerDefinition>): Promise<void>;
delete(serverName: string): Promise<void>;
}
export class FSMcpConfigRepo implements IMcpConfigRepo {
private readonly configPath = path.join(WorkDir, "config", "mcp.json");
constructor() {
this.ensureDefaultConfig();
}
private async ensureDefaultConfig(): Promise<void> {
try {
await fs.access(this.configPath);
} catch (error) {
await fs.writeFile(this.configPath, JSON.stringify({ mcpServers: {} }, null, 2));
}
}
async getConfig(): Promise<z.infer<typeof McpServerConfig>> {
const config = await fs.readFile(this.configPath, "utf8");
return McpServerConfig.parse(JSON.parse(config));
}
async upsert(serverName: string, config: z.infer<typeof McpServerDefinition>): Promise<void> {
const conf = await this.getConfig();
conf.mcpServers[serverName] = config;
await fs.writeFile(this.configPath, JSON.stringify(conf, null, 2));
}
async delete(serverName: string): Promise<void> {
const conf = await this.getConfig();
delete conf.mcpServers[serverName];
await fs.writeFile(this.configPath, JSON.stringify(conf, null, 2));
}
}

View file

@ -6,13 +6,42 @@ import { createAnthropic } from "@ai-sdk/anthropic";
import { createOllama } from "ollama-ai-provider-v2";
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import { getModelConfig } from "../config/config.js";
import { IModelConfigRepo } from "./repo.js";
import container from "../di/container.js";
import z from "zod";
export const Flavor = z.enum([
"rowboat [free]",
"aigateway",
"anthropic",
"google",
"ollama",
"openai",
"openai-compatible",
"openrouter",
]);
export const Provider = z.object({
flavor: Flavor,
apiKey: z.string().optional(),
baseURL: z.string().optional(),
headers: z.record(z.string(), z.string()).optional(),
});
export const ModelConfig = z.object({
providers: z.record(z.string(), Provider),
defaults: z.object({
provider: z.string(),
model: z.string(),
}),
});
const providerMap: Record<string, ProviderV2> = {};
export async function getProvider(name: string = ""): Promise<ProviderV2> {
// get model conf
const modelConfig = await getModelConfig();
const repo = container.resolve<IModelConfigRepo>("modelConfigRepo");
const modelConfig = await repo.getConfig();
if (!modelConfig) {
throw new Error("Model config not found");
}

View file

@ -0,0 +1,70 @@
import { ModelConfig, Provider } from "./models.js";
import { WorkDir } from "../config/config.js";
import fs from "fs/promises";
import path from "path";
import z from "zod";
export interface IModelConfigRepo {
getConfig(): Promise<z.infer<typeof ModelConfig>>;
upsert(providerName: string, config: z.infer<typeof Provider>): Promise<void>;
delete(providerName: string): Promise<void>;
setDefault(providerName: string, model: string): Promise<void>;
}
const defaultConfig: z.infer<typeof ModelConfig> = {
providers: {
"openai": {
flavor: "openai",
}
},
defaults: {
provider: "openai",
model: "gpt-5.1",
}
};
export class FSModelConfigRepo implements IModelConfigRepo {
private readonly configPath = path.join(WorkDir, "config", "models.json");
constructor() {
this.ensureDefaultConfig();
}
private async ensureDefaultConfig(): Promise<void> {
try {
await fs.access(this.configPath);
} catch (error) {
await fs.writeFile(this.configPath, JSON.stringify(defaultConfig, null, 2));
}
}
async getConfig(): Promise<z.infer<typeof ModelConfig>> {
const config = await fs.readFile(this.configPath, "utf8");
return ModelConfig.parse(JSON.parse(config));
}
private async setConfig(config: z.infer<typeof ModelConfig>): Promise<void> {
await fs.writeFile(this.configPath, JSON.stringify(config, null, 2));
}
async upsert(providerName: string, config: z.infer<typeof Provider>): Promise<void> {
const conf = await this.getConfig();
conf.providers[providerName] = config;
await this.setConfig(conf);
}
async delete(providerName: string): Promise<void> {
const conf = await this.getConfig();
delete conf.providers[providerName];
await this.setConfig(conf);
}
async setDefault(providerName: string, model: string): Promise<void> {
const conf = await this.getConfig();
conf.defaults = {
provider: providerName,
model,
};
await this.setConfig(conf);
}
}

20
apps/cli/src/runs/lock.ts Normal file
View file

@ -0,0 +1,20 @@
export interface IRunsLock {
lock(runId: string): Promise<boolean>;
release(runId: string): Promise<void>;
}
export class InMemoryRunsLock implements IRunsLock {
private locks: Record<string, boolean> = {};
async lock(runId: string): Promise<boolean> {
if (this.locks[runId]) {
return false;
}
this.locks[runId] = true;
return true;
}
async release(runId: string): Promise<void> {
delete this.locks[runId];
}
}

79
apps/cli/src/runs/repo.ts Normal file
View file

@ -0,0 +1,79 @@
import { Run } from "./runs.js";
import z from "zod";
import { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js";
import { WorkDir } from "../config/config.js";
import path from "path";
import fsp from "fs/promises";
import { RunEvent, StartEvent } from "../entities/run-events.js";
export const ListRunsResponse = z.object({
runs: z.array(Run.pick({
id: true,
createdAt: true,
agentId: true,
})),
nextCursor: z.string().optional(),
});
export const CreateRunOptions = Run.pick({
agentId: true,
});
export interface IRunsRepo {
create(options: z.infer<typeof CreateRunOptions>): Promise<z.infer<typeof Run>>;
fetch(id: string): Promise<z.infer<typeof Run>>;
appendEvents(runId: string, events: z.infer<typeof RunEvent>[]): Promise<void>;
}
export class FSRunsRepo implements IRunsRepo {
private idGenerator: IMonotonicallyIncreasingIdGenerator;
constructor({
idGenerator,
}: {
idGenerator: IMonotonicallyIncreasingIdGenerator;
}) {
this.idGenerator = idGenerator;
}
async appendEvents(runId: string, events: z.infer<typeof RunEvent>[]): Promise<void> {
await fsp.appendFile(
path.join(WorkDir, 'runs', `${runId}.jsonl`),
events.map(event => JSON.stringify(event)).join("\n") + "\n"
);
}
async create(options: z.infer<typeof CreateRunOptions>): Promise<z.infer<typeof Run>> {
const runId = await this.idGenerator.next();
const ts = new Date().toISOString();
const start: z.infer<typeof StartEvent> = {
type: "start",
runId,
agentName: options.agentId,
subflow: [],
ts,
};
await this.appendEvents(runId, [start]);
return {
id: runId,
createdAt: ts,
agentId: options.agentId,
log: [start],
};
}
async fetch(id: string): Promise<z.infer<typeof Run>> {
const contents = await fsp.readFile(path.join(WorkDir, 'runs', `${id}.jsonl`), 'utf8');
const events = contents.split('\n')
.filter(line => line.trim() !== '')
.map(line => RunEvent.parse(JSON.parse(line)));
if (events.length === 0 || events[0].type !== 'start') {
throw new Error('Corrupt run data');
}
return {
id,
createdAt: events[0].ts!,
agentId: events[0].agentName,
log: events,
};
}
}

70
apps/cli/src/runs/runs.ts Normal file
View file

@ -0,0 +1,70 @@
import z from "zod";
import container from "../di/container.js";
import { IMessageQueue } from "../application/lib/message-queue.js";
import { AskHumanResponseEvent, RunEvent, ToolPermissionResponseEvent } from "../entities/run-events.js";
import { CreateRunOptions, IRunsRepo } from "./repo.js";
import { IAgentRuntime } from "../agents/runtime.js";
import { IBus } from "../application/lib/bus.js";
export const ToolPermissionAuthorizePayload = ToolPermissionResponseEvent.pick({
subflow: true,
toolCallId: true,
response: true,
});
export const AskHumanResponsePayload = AskHumanResponseEvent.pick({
subflow: true,
toolCallId: true,
response: true,
});
export const Run = z.object({
id: z.string(),
createdAt: z.iso.datetime(),
agentId: z.string(),
log: z.array(RunEvent),
});
export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise<z.infer<typeof Run>> {
const repo = container.resolve<IRunsRepo>('runsRepo');
const bus = container.resolve<IBus>('bus');
const run = await repo.create(opts);
await bus.publish(run.log[0]);
return run;
}
export async function createMessage(runId: string, message: string): Promise<string> {
const queue = container.resolve<IMessageQueue>('messageQueue');
const id = await queue.enqueue(runId, message);
const runtime = container.resolve<IAgentRuntime>('agentRuntime');
runtime.trigger(runId);
return id;
}
export async function authorizePermission(runId: string, ev: z.infer<typeof ToolPermissionAuthorizePayload>): Promise<void> {
const repo = container.resolve<IRunsRepo>('runsRepo');
const event: z.infer<typeof ToolPermissionResponseEvent> = {
...ev,
runId,
type: "tool-permission-response",
};
await repo.appendEvents(runId, [event]);
const runtime = container.resolve<IAgentRuntime>('agentRuntime');
runtime.trigger(runId);
}
export async function replyToHumanInputRequest(runId: string, ev: z.infer<typeof AskHumanResponsePayload>): Promise<void> {
const repo = container.resolve<IRunsRepo>('runsRepo');
const event: z.infer<typeof AskHumanResponseEvent> = {
...ev,
runId,
type: "ask-human-response",
};
await repo.appendEvents(runId, [event]);
const runtime = container.resolve<IAgentRuntime>('agentRuntime');
runtime.trigger(runId);
}
export async function stop(runId: string): Promise<void> {
throw new Error('Not implemented');
}

653
apps/cli/src/server.ts Normal file
View file

@ -0,0 +1,653 @@
import { Hono } from 'hono';
import { serve } from '@hono/node-server'
import { streamSSE } from 'hono/streaming'
import { describeRoute, validator, resolver, openAPIRouteHandler } from "hono-openapi"
import z from 'zod';
import container from './di/container.js';
import { executeTool, listServers, listTools, ListToolsResponse, McpServerList } from "./mcp/mcp.js";
import { McpServerDefinition } from "./mcp/mcp.js";
import { IMcpConfigRepo } from './mcp/repo.js';
import { IModelConfigRepo } from './models/repo.js';
import { ModelConfig, Provider } from "./models/models.js";
import { IAgentsRepo } from "./agents/repo.js";
import { Agent } from "./agents/agents.js";
import { AskHumanResponsePayload, authorizePermission, createMessage, createRun, replyToHumanInputRequest, Run, stop, ToolPermissionAuthorizePayload } from './runs/runs.js';
import { IRunsRepo, ListRunsResponse, CreateRunOptions } from './runs/repo.js';
import { IBus } from './application/lib/bus.js';
import { RunEvent } from './entities/run-events.js';
let id = 0;
const routes = new Hono()
.get(
'/health',
describeRoute({
summary: 'Health check',
description: 'Check if the server is running',
responses: {
200: {
description: 'Server is running',
content: {
'application/json': {
schema: resolver(z.object({
status: z.literal("ok"),
})),
},
},
},
},
}),
async (c) => {
return c.json({ status: 'ok' });
}
)
.get(
'/mcp',
describeRoute({
summary: 'List MCP servers',
description: 'List the MCP servers',
responses: {
200: {
description: 'Server list',
content: {
'application/json': {
schema: resolver(McpServerList),
},
},
},
},
}),
async (c) => {
return c.json(await listServers());
}
)
.put(
'/mcp/:serverName',
describeRoute({
summary: 'Upsert MCP server',
description: 'Add or edit MCP server',
responses: {
200: {
description: 'MCP server added / updated',
content: {
'application/json': {
schema: resolver(z.object({
success: z.literal(true),
})),
},
},
},
},
}),
validator('param', z.object({
serverName: z.string(),
})),
validator('json', McpServerDefinition),
async (c) => {
const repo = container.resolve<IMcpConfigRepo>('mcpConfigRepo');
await repo.upsert(c.req.valid('param').serverName, c.req.valid('json'));
return c.json({ success: true });
}
)
.delete(
'/mcp/:serverName',
describeRoute({
summary: 'Delete MCP server',
description: 'Delete a MCP server',
responses: {
200: {
description: 'MCP server deleted',
content: {
'application/json': {
schema: resolver(z.object({
success: z.literal(true),
})),
},
},
},
},
}),
validator('param', z.object({
serverName: z.string(),
})),
async (c) => {
const repo = container.resolve<IMcpConfigRepo>('mcpConfigRepo');
await repo.delete(c.req.valid('param').serverName);
return c.json({ success: true });
}
)
.get(
'/mcp/:serverName/tools',
describeRoute({
summary: 'Get MCP tools',
description: 'Get the MCP tools',
responses: {
200: {
description: 'MCP tools',
content: {
'application/json': {
schema: resolver(ListToolsResponse),
},
},
},
},
}),
validator('query', z.object({
cursor: z.string().optional(),
})),
validator('param', z.object({
serverName: z.string(),
})),
async (c) => {
const result = await listTools(c.req.valid('param').serverName, c.req.valid('query').cursor);
return c.json(result);
}
)
.post(
'/mcp/:serverName/tools/:toolName/execute',
describeRoute({
summary: 'Execute MCP tool',
description: 'Execute a MCP tool',
responses: {
200: {
description: 'Tool executed',
content: {
'application/json': {
schema: resolver(z.object({
result: z.any(),
})),
},
},
},
},
}),
validator('param', z.object({
serverName: z.string(),
toolName: z.string(),
})),
validator('json', z.object({
input: z.any(),
})),
async (c) => {
const result = await executeTool(
c.req.valid('param').serverName,
c.req.valid('param').toolName,
c.req.valid('json').input
);
return c.json(result);
}
)
.get(
'/models',
describeRoute({
summary: 'Get model config',
description: 'Get the current model and provider configuration',
responses: {
200: {
description: 'Model config',
content: {
'application/json': {
schema: resolver(ModelConfig),
},
},
},
},
}),
async (c) => {
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
const config = await repo.getConfig();
return c.json(config);
}
)
.put(
'/models/providers/:providerName',
describeRoute({
summary: 'Upsert provider config',
description: 'Add or update a provider configuration',
responses: {
200: {
description: 'Provider upserted',
content: {
'application/json': {
schema: resolver(z.object({
success: z.literal(true),
})),
},
},
},
},
}),
validator('param', z.object({
providerName: z.string(),
})),
validator('json', Provider),
async (c) => {
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
await repo.upsert(c.req.valid('param').providerName, c.req.valid('json'));
return c.json({ success: true });
}
)
.delete(
'/models/providers/:providerName',
describeRoute({
summary: 'Delete provider config',
description: 'Delete a provider configuration',
responses: {
200: {
description: 'Provider deleted',
content: {
'application/json': {
schema: resolver(z.object({
success: z.literal(true),
})),
},
},
},
},
}),
validator('param', z.object({
providerName: z.string(),
})),
async (c) => {
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
await repo.delete(c.req.valid('param').providerName);
return c.json({ success: true });
}
)
.put(
'/models/default',
describeRoute({
summary: 'Set default model',
description: 'Set the default provider and model',
responses: {
200: {
description: 'Default set',
content: {
'application/json': {
schema: resolver(z.object({
success: z.literal(true),
})),
},
},
},
},
}),
validator('json', z.object({
provider: z.string(),
model: z.string(),
})),
async (c) => {
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
const body = c.req.valid('json');
await repo.setDefault(body.provider, body.model);
return c.json({ success: true });
}
)
// GET /agents
.get(
'/agents',
describeRoute({
summary: 'List agents',
description: 'List all configured agents',
responses: {
200: {
description: 'Agents list',
content: {
'application/json': {
schema: resolver(z.array(Agent)),
},
},
},
},
}),
async (c) => {
const repo = container.resolve<IAgentsRepo>('agentsRepo');
const agents = await repo.list();
return c.json(agents);
}
)
// POST /agents/new
.post(
'/agents/new',
describeRoute({
summary: 'Create agent',
description: 'Create a new agent',
responses: {
200: {
description: 'Agent created',
content: {
'application/json': {
schema: resolver(z.object({
success: z.literal(true),
})),
},
},
},
},
}),
validator('json', Agent),
async (c) => {
const repo = container.resolve<IAgentsRepo>('agentsRepo');
await repo.create(c.req.valid('json'));
return c.json({ success: true });
}
)
// GET /agents/<id>
.get(
'/agents/:id',
describeRoute({
summary: 'Get agent',
description: 'Fetch a specific agent by id',
responses: {
200: {
description: 'Agent',
content: {
'application/json': {
schema: resolver(Agent),
},
},
},
},
}),
validator('param', z.object({
id: z.string(),
})),
async (c) => {
const repo = container.resolve<IAgentsRepo>('agentsRepo');
const agent = await repo.fetch(c.req.valid('param').id);
return c.json(agent);
}
)
// PUT /agents/<id>
.put(
'/agents/:id',
describeRoute({
summary: 'Update agent',
description: 'Update an existing agent',
responses: {
200: {
description: 'Agent updated',
content: {
'application/json': {
schema: resolver(z.object({
success: z.literal(true),
})),
},
},
},
},
}),
validator('param', z.object({
id: z.string(),
})),
validator('json', Agent),
async (c) => {
const repo = container.resolve<IAgentsRepo>('agentsRepo');
await repo.update(c.req.valid('param').id, c.req.valid('json'));
return c.json({ success: true });
}
)
// DELETE /agents/<id>
.delete(
'/agents/:id',
describeRoute({
summary: 'Delete agent',
description: 'Delete an agent by id',
responses: {
200: {
description: 'Agent deleted',
content: {
'application/json': {
schema: resolver(z.object({
success: z.literal(true),
})),
},
},
},
},
}),
validator('param', z.object({
id: z.string(),
})),
async (c) => {
const repo = container.resolve<IAgentsRepo>('agentsRepo');
await repo.delete(c.req.valid('param').id);
return c.json({ success: true });
}
)
.get(
'/runs/:runId',
describeRoute({
summary: 'Get run',
description: 'Get a run by id',
responses: {
200: {
description: 'Run',
content: {
'application/json': {
schema: resolver(Run),
},
},
},
},
}),
validator('param', z.object({
runId: z.string(),
})),
async (c) => {
const repo = container.resolve<IRunsRepo>('runsRepo');
const run = await repo.fetch(c.req.valid('param').runId);
return c.json(run);
}
)
.post(
'/runs/new',
describeRoute({
summary: 'Create run',
description: 'Create a new run',
responses: {
200: {
description: 'Run created',
content: {
'application/json': {
schema: resolver(Run),
},
},
},
},
}),
validator('json', CreateRunOptions),
async (c) => {
const run = await createRun(c.req.valid('json'));
return c.json(run);
}
)
.post(
'/runs/:runId/messages/new',
describeRoute({
summary: 'Create a new message',
description: 'Create a new message',
responses: {
200: {
description: 'Message created',
content: {
'application/json': {
schema: resolver(z.object({
messageId: z.string(),
})),
},
},
},
},
}),
validator('param', z.object({
runId: z.string(),
})),
validator('json', z.object({
message: z.string(),
})),
async (c) => {
const messageId = await createMessage(c.req.valid('param').runId, c.req.valid('json').message);
return c.json({
messageId,
});
}
)
.post(
'/runs/:runId/permissions/authorize',
describeRoute({
summary: 'Authorize permission',
description: 'Authorize a permission',
responses: {
200: {
description: 'Permission authorized',
content: {
'application/json': {
schema: resolver(z.object({
success: z.literal(true),
})),
},
}
},
},
}),
validator('param', z.object({
runId: z.string(),
})),
validator('json', ToolPermissionAuthorizePayload),
async (c) => {
const response = await authorizePermission(
c.req.valid('param').runId,
c.req.valid('json')
);
return c.json({
success: true,
});
}
)
.post(
'/runs/:runId/human-input-requests/:requestId/reply',
describeRoute({
summary: 'Reply to human input request',
description: 'Reply to a human input request',
responses: {
200: {
description: 'Human input request replied',
},
},
}),
validator('param', z.object({
runId: z.string(),
})),
validator('json', AskHumanResponsePayload),
async (c) => {
const response = await replyToHumanInputRequest(
c.req.valid('param').runId,
c.req.valid('json')
);
return c.json({
success: true,
});
}
)
.post(
'/runs/:runId/stop',
describeRoute({
summary: 'Stop run',
description: 'Stop a run',
responses: {
200: {
description: 'Run stopped',
},
},
}),
validator('param', z.object({
runId: z.string(),
})),
async (c) => {
const response = await stop(c.req.valid('param').runId);
return c.json({
success: true,
});
}
)
.get(
'/stream',
async (c) => {
return streamSSE(c, async (stream) => {
const bus = container.resolve<IBus>('bus');
let id = 0;
let unsub: (() => void) | null = null;
let aborted = false;
stream.onAbort(() => {
aborted = true;
if (unsub) {
unsub();
}
});
// Subscribe to your bus
unsub = await bus.subscribe('*', async (event) => {
if (aborted) return;
console.log('got ev', event);
await stream.writeSSE({
data: JSON.stringify(event),
event: "message",
id: String(id++),
});
});
// Keep the function alive until the client disconnects
while (!aborted) {
await stream.sleep(1000); // any interval is fine
}
});
}
)
.get('/sse', async (c) => {
return streamSSE(c, async (stream) => {
while (true) {
const message = `It is ${new Date().toISOString()}`
await stream.writeSSE({
data: message,
event: 'time-update',
id: String(id++),
})
await stream.sleep(1000)
}
})
})
;
const app = new Hono()
.route("/", routes)
.get(
"/openapi.json",
openAPIRouteHandler(routes, {
documentation: {
info: {
title: "Hono",
version: "1.0.0",
description: "RowboatX API",
},
},
}),
);
// export default app;
serve({
fetch: app.fetch,
port: Number(process.env.PORT) || 3000,
});
// GET /skills
// POST /skills/new
// GET /skills/<id>
// PUT /skills/<id>
// DELETE /skills/<id>
// GET /sse