Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/channels/channel-identity.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export type ChannelIdentity = {
platform: "slack" | "telegram";
platform: "slack" | "telegram" | "web";
botId?: string;
accountId?: string;
conversationKind: "dm" | "channel" | "group" | "topic";
Expand Down
2 changes: 2 additions & 0 deletions src/channels/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import type { RuntimeChannel } from "../control/runtime-health-store.ts";
import type { ChannelPlugin } from "./channel-plugin.ts";
import { slackChannelPlugin } from "./slack/plugin.ts";
import { telegramChannelPlugin } from "./telegram/plugin.ts";
import { webChannelPlugin } from "./web/plugin.ts";

const CHANNEL_PLUGINS: ChannelPlugin[] = [
slackChannelPlugin,
telegramChannelPlugin,
webChannelPlugin,
];

export function listChannelPlugins() {
Expand Down
6 changes: 4 additions & 2 deletions src/channels/rendering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export type ChannelRenderedMessageState = {
};

export function buildRenderedMessageState(params: {
platform: "slack" | "telegram";
platform: "slack" | "telegram" | "web";
status: "queued" | "running" | "completed" | "timeout" | "detached" | "error";
snapshot: string;
queuePosition?: number;
Expand Down Expand Up @@ -45,7 +45,7 @@ export function buildRenderedMessageState(params: {
}

export function renderPlatformInteraction(params: {
platform: "slack" | "telegram";
platform: "slack" | "telegram" | "web";
status: "queued" | "running" | "completed" | "timeout" | "detached" | "error";
content: string;
maxChars: number;
Expand All @@ -54,6 +54,8 @@ export function renderPlatformInteraction(params: {
allowTranscriptInspection?: boolean;
responsePolicy?: "all" | "final";
}) {
// Web channel receives raw markdown — no platform rendering needed.
if (params.platform === "web") return params.content;
return params.platform === "telegram"
? renderTelegramInteraction(params)
: renderSlackInteraction(params);
Expand Down
78 changes: 78 additions & 0 deletions src/channels/web/history-reader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";
import { join } from "node:path";
import { homedir } from "node:os";
import type { HistoryMessage } from "./transport.ts";

/**
* Reads chat history from the Claude Code session JSONL file.
* Returns last `limit` user/assistant message pairs.
*/
export async function readSessionHistory(params: {
sessionId: string;
workspacePath: string;
limit?: number;
}): Promise<HistoryMessage[]> {
const { sessionId, workspacePath, limit = 50 } = params;

// Derive project dir: replace / and . with -
const projectDir = workspacePath.replace(/[/.]/g, "-");
const jsonlPath = join(homedir(), ".claude", "projects", projectDir, `${sessionId}.jsonl`);

const messages: HistoryMessage[] = [];

try {
const stream = createReadStream(jsonlPath, { encoding: "utf-8" });
const rl = createInterface({ input: stream, crlfDelay: Infinity });

for await (const line of rl) {
if (!line.trim()) continue;
let entry: Record<string, unknown>;
try { entry = JSON.parse(line); } catch { continue; }

const type = entry.type as string;
if (type !== "user" && type !== "assistant") continue;

const msg = entry.message as Record<string, unknown> | undefined;
if (!msg) continue;

const role = msg.role as string;
if (role !== "user" && role !== "assistant") continue;

const content = msg.content;
let text = "";

if (typeof content === "string") {
text = content;
} else if (Array.isArray(content)) {
text = content
.filter((c): c is Record<string, unknown> => typeof c === "object" && c !== null)
.filter((c) => c.type === "text")
.map((c) => c.text as string)
.join("\n");
}

// Strip context prefix injected by clients (everything before [USER_INPUT] marker).
text = text.trim();
if (!text) continue;
const markerIdx = text.indexOf('\n[USER_INPUT]\n');
if (markerIdx !== -1) {
text = text.slice(markerIdx + '\n[USER_INPUT]\n'.length).trim();
if (!text) continue;
}

// Strip [[CHANNEL_EVENT:...]] annotations from historical assistant messages.
text = text.replace(/\[\[CHANNEL_EVENT:[\s\S]*?\]\]/gi, "").trim();
text = text.replace(/\[\[CHANNEL_EVENT:[\s\S]*$/i, "").trim();
if (!text) continue;

messages.push({ role: role as "user" | "assistant", text });
}
} catch {
// File not found or unreadable — return empty
return [];
}

// Return last N messages
return messages.slice(-limit);
}
93 changes: 93 additions & 0 deletions src/channels/web/mcp-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { spawn } from "node:child_process";
import { createInterface } from "node:readline";

export interface McpServerConfig {
command: string;
args: string[];
env?: Record<string, string>;
}

interface Pending {
resolve: (v: unknown) => void;
reject: (e: Error) => void;
}

export interface McpClient {
callTool(tool: string, args: Record<string, unknown>): Promise<unknown>;
close(): void;
}

export function createMcpClient(config: McpServerConfig): McpClient {
const proc = spawn(config.command, config.args, {
env: { ...process.env, ...(config.env ?? {}) },
stdio: ["pipe", "pipe", "inherit"],
});

const pending = new Map<number, Pending>();
let msgId = 1;
let initResolve: (() => void) | null = null;
const initPromise = new Promise<void>((r) => { initResolve = r; });
let initialized = false;

const send = (msg: object) => {
proc.stdin.write(JSON.stringify(msg) + "\n");
};

const rl = createInterface({ input: proc.stdout! });
rl.on("line", (line) => {
let msg: { id?: number; result?: unknown; error?: { message: string } };
try { msg = JSON.parse(line); } catch { return; }
if (msg.id !== undefined) {
const p = pending.get(msg.id);
if (p) {
pending.delete(msg.id);
if (msg.error) p.reject(new Error(msg.error.message));
else p.resolve(msg.result);
}
if (!initialized) {
initialized = true;
initResolve?.();
}
}
});

proc.on("exit", () => {
for (const p of pending.values()) p.reject(new Error("MCP process exited"));
pending.clear();
});

// Perform initialize handshake
const initId = msgId++;
send({
jsonrpc: "2.0", id: initId,
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "clisbot-web", version: "1.0.0" },
},
});
initPromise.then(() => {
send({ jsonrpc: "2.0", method: "notifications/initialized", params: {} });
});

return {
async callTool(tool, args) {
await initPromise;
const id = msgId++;
const result = await new Promise<unknown>((resolve, reject) => {
pending.set(id, { resolve, reject });
send({ jsonrpc: "2.0", id, method: "tools/call", params: { name: tool, arguments: args } });
});
// Unwrap MCP content envelope: { content: [{ type: "text", text: "..." }] }
const content = (result as { content?: Array<{ type: string; text: string }> }).content;
if (content?.[0]?.type === "text") {
try { return JSON.parse(content[0].text); } catch { return content[0].text; }
}
return result;
},
close() {
try { proc.stdin.end(); proc.kill(); } catch { /* already dead */ }
},
};
}
57 changes: 57 additions & 0 deletions src/channels/web/plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { ChannelPlugin } from "../channel-plugin.ts";
import type { WebBotConfig } from "./service.ts";
import { WebRuntimeService } from "./service.ts";

function getWebConfig(loadedConfig: Parameters<ChannelPlugin["isEnabled"]>[0]): WebBotConfig | null {
const raw = loadedConfig.raw as {
bots?: {
web?: {
apiKey?: string; port?: number; agentId?: string; ownerId?: string;
senderName?: string;
mcpServers?: Record<string, { command: string; args: string[]; env?: Record<string, string> }>;
};
};
};
const web = raw.bots?.web;
if (!web?.apiKey) return null;
return {
port: web.port ?? 3099,
apiKey: web.apiKey,
agentId: web.agentId ?? loadedConfig.raw.agents.defaults.defaultAgentId ?? "default",
ownerId: web.ownerId,
senderName: web.senderName,
mcpServers: web.mcpServers,
};
}

export const webChannelPlugin: ChannelPlugin = {
id: "web",
isEnabled: (loadedConfig) => !!getWebConfig(loadedConfig),
listBots: (loadedConfig) => {
const config = getWebConfig(loadedConfig);
if (!config) return [];
return [{ botId: "default", config }];
},
createRuntimeService: (context, bot) =>
new WebRuntimeService(
context.loadedConfig,
context.agentService,
bot.config as WebBotConfig,
context.reportLifecycle,
),
renderHealthSummary: (state) => {
switch (state) {
case "starting": return "Web channel is starting.";
case "disabled": return "Web channel is disabled (no apiKey configured).";
case "stopped": return "Web channel is stopped.";
}
},
renderActiveHealthSummary: () => "Web channel WebSocket server active.",
markStartupFailure: async (store, error) => {
await store.markWebFailure(error);
},
runMessageCommand: async (_loadedConfig, _command) => {
return { botId: "default", result: { ok: false, error: "Web channel does not support CLI message commands." } };
},
resolveMessageReplyTarget: () => null,
};
Loading