kimi-code/apps/kimi-web/dev/stub-daemon.mjs
qer f5a7f21c68 feat(web): land P3 — goal / swarm / subagent + terminal + view split
Implements the locked P3 design end-to-end:
- subagent lifecycle projection (spawned→started→suspended→completed/failed) +
  inline Agent / AgentGroup cards; swarm progress card (multi-column) derived
  from swarmIndex; goal dock strip (expandable) from goal.updated; plan/goal/
  swarm activation badges in the composer status line.
- terminal as a view (xterm + WS terminal_* frames with since_seq replay) and a
  tab/view-dimension split (usePaneLayout tree + ViewGroup + SplitLayout, VSCode
  editor-group style), persisted to localStorage.
Adds swarm-groups / subagent-goal / agent-group-turns unit tests and stub-daemon
seeds. 98 tests pass; vue-tsc + oxlint clean; production build OK.

Accepted by review (see reports/web-p3-acceptance.md); no blocking issues.
2026-06-13 22:53:20 +08:00

2113 lines
78 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// Local stub daemon for Kimi Web development.
//
// This is NOT the real backend. It is a throwaway dev server that speaks the
// daemon REST + WS wire protocol (envelope, snake_case, event frames) closely
// enough for the Web UI to be fully clickable before the real daemon exists.
// When the real daemon ships, point VITE_KIMI_SERVER_HTTP_URL at it instead and
// stop running this.
//
// node dev/stub-daemon.mjs # listens on 127.0.0.1:7878
// PORT=9000 node dev/stub-daemon.mjs
//
import http from 'node:http';
import { WebSocketServer } from 'ws';
const PORT = Number(process.env.PORT) || 7878;
const STARTED_AT = new Date().toISOString();
const now = () => new Date().toISOString();
const expires60 = () => new Date(Date.now() + 60_000).toISOString();
// Simple ULID-ish: time-prefix + random. Good enough for a stub.
function ulid(prefix = '') {
const t = Date.now().toString(36).padStart(10, '0');
const r = Math.random().toString(36).slice(2, 12).padEnd(10, '0');
return `${prefix}${t}${r}`;
}
const ok = (data) =>
JSON.stringify({ code: 0, msg: 'success', data, request_id: ulid('req_') });
const fail = (code, msg, data = null) =>
JSON.stringify({ code, msg, data, request_id: ulid('req_') });
// ---- PRESUMED: in-memory models + providers ----
// PRESUMED — not in current daemon docs; endpoints isolated here, swap when backend defines them.
const seedProviders = [
{
id: 'prov_moonshot',
type: 'moonshot',
base_url: undefined,
default_model: 'moonshot-v1-128k',
has_api_key: true,
status: 'connected',
models: ['moonshot-v1-128k', 'moonshot-v1-32k'],
},
{
id: 'prov_anthropic',
type: 'anthropic',
base_url: undefined,
default_model: undefined,
has_api_key: false,
status: 'unconfigured',
models: [],
},
{
id: 'prov_openai',
type: 'openai',
base_url: undefined,
default_model: undefined,
has_api_key: false,
status: 'unconfigured',
models: [],
},
];
const seedModels = [
{ provider: 'prov_moonshot', model: 'moonshot-v1-128k', display_name: 'Moonshot 128K', max_context_size: 131072, capabilities: [] },
{ provider: 'prov_moonshot', model: 'moonshot-v1-32k', display_name: 'Moonshot 32K', max_context_size: 32768, capabilities: [] },
{ provider: 'prov_moonshot', model: 'moonshot-v1-8k', display_name: 'Moonshot 8K', max_context_size: 8192, capabilities: [] },
{ provider: 'prov_anthropic', model: 'claude-sonnet-4-6', display_name: 'Claude Sonnet 4.6', max_context_size: 200000, capabilities: ['thinking'] },
{ provider: 'prov_anthropic', model: 'claude-opus-4-5', display_name: 'Claude Opus 4.5', max_context_size: 200000, capabilities: ['thinking'] },
{ provider: 'prov_openai', model: 'gpt-4o', display_name: 'GPT-4o', max_context_size: 128000, capabilities: [] },
];
// Mutable arrays so POST/DELETE update them live
const providers = [...seedProviders];
const models = [...seedModels];
// ---- Real OAuth singleton state ----
let loggedIn = process.env.STUB_LOGGED_IN === '1';
let currentFlow = null; // { flow_id, provider, status, user_code, expires_in, interval, ... }
// ---- in-memory state ----
function mkUsage(ctx = 38000, turns = 2) {
return {
input_tokens: 1200 + ctx * 2,
output_tokens: 600,
cache_read_tokens: 0,
cache_creation_tokens: 0,
total_cost_usd: +(ctx * 0.000002).toFixed(4),
context_tokens: ctx,
context_limit: 200000,
turn_count: turns,
};
}
function mkSession(id, title, status = 'idle', ctx = 38000, turns = 2) {
return {
id,
title,
created_at: now(),
updated_at: now(),
status,
metadata: { cwd: '/Users/moonshot/code/kimi-code-web' },
agent_config: { model: 'moonshot-v1-128k', tools: ['read', 'bash', 'edit', 'write'] },
usage: mkUsage(ctx, turns),
permission_rules: [],
message_count: 0,
last_seq: 0,
};
}
// ---- seed sessions ----
const ses1 = mkSession('ses_1', '重构 API client 超时配置', 'idle', 52000, 4);
const ses2 = mkSession('ses_2', '修复 TUI 渲染抖动', 'idle', 29000, 2);
const ses3 = mkSession('ses_3', '登录态错误归一化', 'idle', 18000, 1);
const ses4 = mkSession('ses_4', '新功能:文件搜索高亮', 'idle', 8000, 0);
const sessions = [ses1, ses2, ses3, ses4];
// ---- seed workspaces + folder browser (demo) ----
// A wd_<slug>_<hash12> id, matching the real daemon's workspace id shape.
function wdId(root) {
const slug = (root.split('/').filter(Boolean).pop() || 'root')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'root';
let h = 0;
for (let i = 0; i < root.length; i++) h = (h * 31 + root.charCodeAt(i)) >>> 0;
const hash = (h.toString(36) + '000000000000').slice(0, 12);
return `wd_${slug}_${hash}`;
}
function mkWorkspace(root, name) {
return {
id: wdId(root),
root,
name: name || root.split('/').filter(Boolean).pop() || root,
is_git_repo: true,
branch: 'main',
created_at: now(),
last_opened_at: now(),
session_count: sessions.filter((s) => s.metadata?.cwd === root).length,
};
}
// Derive one workspace from the seeded session cwd, plus a couple of demo ones.
const workspaces = [
mkWorkspace('/Users/moonshot/code/kimi-code-web', 'kimi-code-web'),
mkWorkspace('/Users/moonshot/code/kimi-cli', 'kimi-cli'),
mkWorkspace('/Users/moonshot/code/paseo', 'paseo'),
];
const FS_HOME = '/Users/moonshot';
const FS_RECENT = [
'/Users/moonshot/code/kimi-code-web',
'/Users/moonshot/code/kimi-cli',
];
// A tiny in-memory folder tree for the demo folder browser. Maps an absolute
// dir → its immediate subdirs (name + whether it's a git repo + branch).
const FS_TREE = {
'/': [{ name: 'Users', git: false }],
'/Users': [{ name: 'moonshot', git: false }],
'/Users/moonshot': [
{ name: 'code', git: false },
{ name: 'Documents', git: false },
{ name: 'Downloads', git: false },
],
'/Users/moonshot/code': [
{ name: 'kimi-code-web', git: true, branch: 'main' },
{ name: 'kimi-cli', git: true, branch: 'dev' },
{ name: 'paseo', git: true, branch: 'main' },
{ name: 'scratch', git: false },
],
'/Users/moonshot/code/kimi-code-web': [
{ name: 'apps', git: false },
{ name: 'packages', git: false },
{ name: 'docs', git: false },
],
'/Users/moonshot/code/kimi-cli': [{ name: 'src', git: false }],
'/Users/moonshot/code/paseo': [{ name: 'src', git: false }],
};
function browseDir(dirPath) {
const kids = FS_TREE[dirPath] || [];
const parent = dirPath === '/' ? null : (dirPath.split('/').slice(0, -1).join('/') || '/');
return {
path: dirPath,
parent,
entries: kids.map((k) => ({
name: k.name,
path: dirPath === '/' ? `/${k.name}` : `${dirPath}/${k.name}`,
is_dir: true,
is_git_repo: !!k.git,
...(k.branch ? { branch: k.branch } : {}),
})),
};
}
// ---- seed messages ----
const t = (text) => ({ type: 'text', text });
const thinking = (text) => ({
type: 'thinking',
thinking: text,
signature: 'sig_stub_' + Math.random().toString(36).slice(2, 8),
});
const toolUse = (id, name, input) => ({ type: 'tool_use', tool_call_id: id, tool_name: name, input });
const toolResult = (id, output, is_error = false) => ({
type: 'tool_result',
tool_call_id: id,
output,
is_error,
});
function mkMsg(id, session_id, role, content, prompt_id) {
return { id, session_id, role, content, created_at: now(), prompt_id };
}
const messages = {
ses_1: [
mkMsg('msg_s1_1', 'ses_1', 'user', [t('把 api client 的超时改成可配置,然后跑测试')], 'pr_s1_1'),
mkMsg('msg_s1_2', 'ses_1', 'assistant', [
thinking(
'用户想让我修改 API client把硬编码的超时时间改成可通过参数配置的方式。\n' +
'我需要先读取现有代码,了解超时目前是如何硬编码的,然后:\n' +
'1. 修改 createClient 函数签名,加入 timeoutMs 可选参数\n' +
'2. 更新相关测试\n让我先读一下文件。'
),
t('好的,我先看一下 `api/client.ts` 的现有实现:'),
toolUse('tc_s1_a', 'read', { path: 'packages/api/src/client.ts' }),
], 'pr_s1_1'),
mkMsg('msg_s1_3', 'ses_1', 'assistant', [
toolResult('tc_s1_a',
'export function createClient(baseUrl: string) {\n' +
' const timeout = 30_000; // hardcoded\n' +
' return { baseUrl, timeout };\n' +
'}\n', false),
], 'pr_s1_1'),
mkMsg('msg_s1_4', 'ses_1', 'assistant', [
t('找到了,超时是硬编码的 `30_000`。现在修改成可配置:'),
toolUse('tc_s1_b', 'edit', {
path: 'packages/api/src/client.ts',
old_string: 'export function createClient(baseUrl: string) {\n const timeout = 30_000; // hardcoded',
new_string: 'export function createClient(baseUrl: string, timeoutMs = 30_000) {\n const timeout = timeoutMs;',
}),
], 'pr_s1_1'),
mkMsg('msg_s1_5', 'ses_1', 'assistant', [
toolResult('tc_s1_b', 'File edited successfully.', false),
t('修改完成,我先让两个子代理并行检查风险,然后跑测试确认没有回归:'),
toolUse('tc_s1_agent', 'agent_swarm', { description: 'Review timeout configuration change', count: 2 }),
toolUse('tc_s1_c', 'bash', { command: 'pnpm --filter @kimi-code/api test --run' }),
], 'pr_s1_1'),
mkMsg('msg_s1_6', 'ses_1', 'assistant', [
toolResult('tc_s1_c',
'PASS packages/api/src/__tests__/client.test.ts\n' +
'Test Suites: 1 passed, 1 total\nTests: 3 passed, 3 total\n', false),
], 'pr_s1_1'),
mkMsg('msg_s1_7', 'ses_1', 'assistant', [
t(
'## 完成\n\n' +
'**变更摘要**\n\n' +
'- 给 `createClient` 加了 `timeoutMs` 可选参数,默认保持 `30_000` 不变\n' +
'- 现有调用方无需修改(向后兼容)\n\n' +
'所有 3 个测试通过,没有回归。'
),
], 'pr_s1_1'),
],
ses_2: [
mkMsg('msg_s2_1', 'ses_2', 'user', [t('TUI 偶尔抖动,看下渲染循环是否有问题')], 'pr_s2_1'),
mkMsg('msg_s2_2', 'ses_2', 'assistant', [
thinking(
'用户报告 TUI 渲染抖动。可能原因:\n' +
'1. 渲染节流不够导致频繁重绘\n' +
'2. 状态更新与渲染循环冲突\n' +
'先读渲染主循环。'
),
t('先看一下 TUI 渲染循环的实现:'),
toolUse('tc_s2_a', 'read', { path: 'apps/kimi-tui/src/renderer/loop.ts' }),
], 'pr_s2_1'),
mkMsg('msg_s2_3', 'ses_2', 'assistant', [
toolResult('tc_s2_a',
'export class RenderLoop {\n' +
' private dirty = false;\n' +
' schedule() { this.dirty = true; this.flush(); }\n' +
' flush() { if (!this.dirty) return; this.dirty = false; this.render(); }\n' +
'}\n', false),
], 'pr_s2_1'),
mkMsg('msg_s2_4', 'ses_2', 'assistant', [
t(
'问题找到了。`schedule()` 在标记 `dirty` 之后立即 `flush()`' +
'所以同一帧内多次 `schedule()` 调用会触发多次 `render()`,造成抖动。\n\n' +
'修复方案:用 `setImmediate` 把 flush 推迟到当前 tick 末尾。'
),
toolUse('tc_s2_b', 'edit', {
path: 'apps/kimi-tui/src/renderer/loop.ts',
old_string: ' schedule() { this.dirty = true; this.flush(); }',
new_string: ' schedule() { if (this.dirty) return; this.dirty = true; setImmediate(() => this.flush()); }',
}),
], 'pr_s2_1'),
mkMsg('msg_s2_5', 'ses_2', 'assistant', [
toolResult('tc_s2_b', 'File edited successfully.', false),
t('修复完成。现在 `schedule()` 在同一 tick 内只会排队一次 flush消除了重复渲染。'),
], 'pr_s2_1'),
],
ses_3: [
mkMsg('msg_s3_1', 'ses_3', 'user', [t('登录态报错太乱了,帮我归一化一下错误处理')], 'pr_s3_1'),
mkMsg('msg_s3_2', 'ses_3', 'assistant', [
t('好的,我先搜索一下目前登录相关的错误处理分散在哪些文件:'),
toolUse('tc_s3_a', 'bash', {
command: 'rg "AuthError|loginError|auth_error" --type ts -l apps/kimi-cli/src/',
}),
], 'pr_s3_1'),
mkMsg('msg_s3_3', 'ses_3', 'assistant', [
toolResult('tc_s3_a',
'apps/kimi-cli/src/auth/login.ts\n' +
'apps/kimi-cli/src/auth/refresh.ts\n' +
'apps/kimi-cli/src/commands/auth.ts\n', false),
t(
'错误处理散落在 3 个文件里。建议统一到 `auth/errors.ts` 里定义一个 `AuthError` 类。\n\n' +
'你希望我直接动手实施这个方案,还是先看看具体代码再决定?'
),
], 'pr_s3_1'),
],
ses_4: [],
};
// Update message_count on sessions
for (const s of sessions) {
s.message_count = (messages[s.id] || []).length;
}
// ---- seed tasks ----
const tasks = {
ses_1: [
{
id: 'task_1', session_id: 'ses_1', kind: 'subagent', description: 'pnpm build --filter @kimi-code/api',
status: 'running', created_at: now(), started_at: now(),
output_preview: '$ pnpm build --filter @kimi-code/api\nvite v5.2.1 building for production...',
output_bytes: 128,
subagent_phase: 'working',
subagent_type: 'coder',
parent_tool_call_id: 'tc_s1_agent',
swarm_index: 1,
},
{
id: 'task_4', session_id: 'ses_1', kind: 'subagent', description: 'Review timeout defaults',
status: 'completed', created_at: now(), started_at: now(), completed_at: now(),
output_preview: 'Default timeout remains backward compatible.',
output_bytes: 96,
subagent_phase: 'completed',
subagent_type: 'reviewer',
parent_tool_call_id: 'tc_s1_agent',
swarm_index: 2,
},
{
id: 'task_2', session_id: 'ses_1', kind: 'bash', description: 'eslint packages/api/src',
status: 'running', created_at: now(), started_at: now(),
output_preview: 'Running ESLint on packages/api/src...',
output_bytes: 48,
},
{
id: 'task_3', session_id: 'ses_1', kind: 'tool', description: 'Generate docs',
status: 'completed', created_at: now(), started_at: now(), completed_at: now(),
output_preview: 'Docs generated: docs/api/client.md\n0 errors, 0 warnings',
output_bytes: 512,
},
],
ses_2: [],
ses_3: [],
ses_4: [],
};
// ---- seed active goals ----
const goals = {
ses_1: {
goal_id: 'goal_s1',
objective: 'Make API client timeout configurable without breaking existing callers.',
completion_criterion: 'Code accepts a timeout override, existing defaults stay compatible, and tests pass.',
status: 'active',
turns_used: 4,
tokens_used: 18400,
wall_clock_ms: 245000,
budget: {
token_budget: 50000,
remaining_tokens: 31600,
turn_budget: 8,
remaining_turns: 4,
wall_clock_budget_ms: 600000,
remaining_wall_clock_ms: 355000,
over_budget: false,
},
},
};
const terminals = {};
const terminalSinks = new Map();
function terminalKey(sessionId, terminalId) {
return `${sessionId}\0${terminalId}`;
}
function publicTerminal(record) {
const { buffer, next_seq, ...terminal } = record;
void buffer;
void next_seq;
return terminal;
}
function terminalList(sessionId) {
terminals[sessionId] = terminals[sessionId] || [];
return terminals[sessionId];
}
function emitTerminalOutput(record, data) {
record.next_seq = (record.next_seq || 0) + 1;
const frame = {
type: 'terminal_output',
seq: record.next_seq,
session_id: record.session_id,
terminal_id: record.id,
timestamp: now(),
payload: { data },
};
record.buffer = [...(record.buffer || []), frame].slice(-200);
const sinks = terminalSinks.get(terminalKey(record.session_id, record.id)) || new Set();
for (const ws of sinks) {
if (ws.readyState === 1) ws.send(JSON.stringify(frame));
}
}
function emitTerminalExit(record, exitCode) {
record.status = 'exited';
record.exited_at = now();
record.exit_code = exitCode;
const frame = {
type: 'terminal_exit',
session_id: record.session_id,
terminal_id: record.id,
timestamp: now(),
payload: { exit_code: exitCode },
};
record.buffer = [...(record.buffer || []), frame].slice(-200);
const sinks = terminalSinks.get(terminalKey(record.session_id, record.id)) || new Set();
for (const ws of sinks) {
if (ws.readyState === 1) ws.send(JSON.stringify(frame));
}
}
// ---- sequence counters ----
const seqBySession = { ses_1: 8, ses_2: 5, ses_3: 2, ses_4: 0 };
// ---- pending continuations keyed by session_id ----
const pendingContinuation = {};
const pendingApproval = {};
const pendingQuestion = {};
// ---- WS broadcast ----
const sockets = new Set();
function broadcast(type, sessionId, payload) {
const seq = (seqBySession[sessionId] = (seqBySession[sessionId] || 0) + 1);
const session = sessions.find((s) => s.id === sessionId);
if (session) session.last_seq = seq;
const frame = JSON.stringify({ type, seq, session_id: sessionId, timestamp: now(), payload });
for (const ws of sockets) if (ws.readyState === 1) ws.send(frame);
return seq;
}
function sendEvent(ws, type, sessionId, payload) {
const seq = (seqBySession[sessionId] = (seqBySession[sessionId] || 0) + 1);
const session = sessions.find((s) => s.id === sessionId);
if (session) session.last_seq = seq;
if (ws.readyState === 1) {
ws.send(JSON.stringify({ type, seq, session_id: sessionId, timestamp: now(), payload }));
}
return seq;
}
function sendGoalSnapshots(ws, sessionIds) {
for (const sessionId of sessionIds) {
if (goals[sessionId]) sendEvent(ws, 'event.goal.updated', sessionId, { snapshot: goals[sessionId] });
}
}
// ---- raw mode flag ----
// Set STUB_RAW_EVENTS=1 to emit raw agent-core events instead of projected event.* frames.
const RAW_EVENTS_MODE = process.env.STUB_RAW_EVENTS === '1';
// ---- scripted reply flows ----
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function streamMarkdown(sessionId, msgId, contentIndex, text, chunkSize = 40) {
const chunks = [];
for (let i = 0; i < text.length; i += chunkSize) {
chunks.push(text.slice(i, i + chunkSize));
}
for (const chunk of chunks) {
await delay(80 + Math.random() * 60);
broadcast('event.assistant.delta', sessionId, {
message_id: msgId,
content_index: contentIndex,
delta: { text: chunk },
});
}
}
async function simulateToolUse(sessionId, parentMsgId, toolCallId, toolName, input, outputText) {
broadcast('event.assistant.tool_use_started', sessionId, {
message_id: parentMsgId,
tool_call_id: toolCallId,
tool_name: toolName,
content_index: 1,
});
await delay(200);
const inputStr = JSON.stringify(input);
const chunkSize = 20;
for (let i = 0; i < inputStr.length; i += chunkSize) {
await delay(40);
broadcast('event.assistant.tool_use_delta', sessionId, {
message_id: parentMsgId,
tool_call_id: toolCallId,
input_delta: inputStr.slice(i, i + chunkSize),
});
}
broadcast('event.assistant.tool_use_completed', sessionId, {
message_id: parentMsgId,
tool_call_id: toolCallId,
input,
});
await delay(100);
broadcast('event.tool.started', sessionId, {
tool_call_id: toolCallId,
tool_name: toolName,
input,
parent_message_id: parentMsgId,
});
const lines = outputText.split('\n');
for (const line of lines) {
await delay(50 + Math.random() * 80);
broadcast('event.tool.output', sessionId, {
tool_call_id: toolCallId,
chunk: line + '\n',
stream: 'stdout',
});
}
await delay(100);
broadcast('event.tool.completed', sessionId, {
tool_call_id: toolCallId,
output: outputText,
is_error: false,
duration_ms: 210 + Math.floor(Math.random() * 300),
});
}
async function simulateDefaultReply(sessionId, userText) {
const promptId = ulid('pr_');
const session = sessions.find((s) => s.id === sessionId);
broadcast('event.session.status_changed', sessionId, {
status: 'running',
previous_status: 'idle',
current_prompt_id: promptId,
});
if (session) session.status = 'running';
await delay(80);
const userMsgId = ulid('msg_');
const userMsg = mkMsg(userMsgId, sessionId, 'user', [t(userText)], promptId);
(messages[sessionId] = messages[sessionId] || []).push(userMsg);
broadcast('event.message.created', sessionId, { message: userMsg });
await delay(150);
const aMsgId = ulid('msg_');
const aMsg = mkMsg(aMsgId, sessionId, 'assistant', [t('')], promptId);
(messages[sessionId]).push(aMsg);
broadcast('event.message.created', sessionId, { message: { ...aMsg, status: 'pending' } });
await delay(300);
const mdText =
'## 分析结果\n\n' +
'我检查了相关代码,发现以下问题:\n\n' +
'- **超时配置** 目前硬编码在多处,应该统一到配置文件\n' +
'- **重试逻辑** 缺少指数退避exponential backoff\n\n' +
'我先读取 `src/api/client.ts` 确认当前实现:';
await streamMarkdown(sessionId, aMsgId, 0, mdText);
const readCallId = ulid('tc_');
await simulateToolUse(
sessionId, aMsgId, readCallId, 'read',
{ path: 'src/api/client.ts' },
'import { fetch } from "node-fetch";\n\nconst TIMEOUT = 5000;\n\nexport async function apiGet(url) {\n return fetch(url, { signal: AbortSignal.timeout(TIMEOUT) });\n}\n'
);
await delay(200);
const preWriteText = '\n\n找到了超时硬编码为 `5000`。现在我来更新这个文件:';
await streamMarkdown(sessionId, aMsgId, 0, preWriteText);
await delay(200);
const writeCallId = ulid('tc_');
const approvalId = ulid('apv_');
broadcast('event.assistant.tool_use_started', sessionId, {
message_id: aMsgId,
tool_call_id: writeCallId,
tool_name: 'edit',
content_index: 1,
});
broadcast('event.assistant.tool_use_completed', sessionId, {
message_id: aMsgId,
tool_call_id: writeCallId,
input: {
path: 'src/api/client.ts',
old_string: 'const TIMEOUT = 5000;',
new_string: 'const TIMEOUT = Number(process.env.API_TIMEOUT_MS ?? 5000);',
},
});
await delay(150);
broadcast('event.tool.started', sessionId, {
tool_call_id: writeCallId,
tool_name: 'edit',
input: {
path: 'src/api/client.ts',
old_string: 'const TIMEOUT = 5000;',
new_string: 'const TIMEOUT = Number(process.env.API_TIMEOUT_MS ?? 5000);',
},
parent_message_id: aMsgId,
});
await delay(100);
broadcast('event.session.status_changed', sessionId, {
status: 'awaiting_approval',
previous_status: 'running',
current_prompt_id: promptId,
});
if (session) session.status = 'awaiting_approval';
broadcast('event.approval.requested', sessionId, {
approval_id: approvalId,
session_id: sessionId,
tool_call_id: writeCallId,
tool_name: 'edit',
action: 'Edit file src/api/client.ts',
display: {
kind: 'diff',
path: 'src/api/client.ts',
old_text:
'import { fetch } from "node-fetch";\n\nconst TIMEOUT = 5000;\n\nexport async function apiGet(url) {\n return fetch(url, { signal: AbortSignal.timeout(TIMEOUT) });\n}\n',
new_text:
'import { fetch } from "node-fetch";\n\nconst TIMEOUT = Number(process.env.API_TIMEOUT_MS ?? 5000);\n\nexport async function apiGet(url) {\n return fetch(url, { signal: AbortSignal.timeout(TIMEOUT) });\n}\n',
summary: 'Replace hardcoded timeout with environment-variable-controlled value',
},
expires_at: expires60(),
created_at: now(),
});
pendingApproval[sessionId] = approvalId;
pendingContinuation[sessionId] = async () => {
delete pendingContinuation[sessionId];
delete pendingApproval[sessionId];
broadcast('event.session.status_changed', sessionId, {
status: 'running',
previous_status: 'awaiting_approval',
current_prompt_id: promptId,
});
if (session) session.status = 'running';
await delay(100);
broadcast('event.tool.completed', sessionId, {
tool_call_id: writeCallId,
output: 'File edited successfully.',
is_error: false,
duration_ms: 34,
});
const toolResultMsgId = ulid('msg_');
const trMsg = mkMsg(toolResultMsgId, sessionId, 'assistant',
[toolResult(writeCallId, 'File edited successfully.')], promptId);
messages[sessionId].push(trMsg);
broadcast('event.message.created', sessionId, { message: trMsg });
await delay(200);
const conclusionText =
'\n\n文件已更新\n\n' +
'现在 `TIMEOUT` 会读取 `API_TIMEOUT_MS` 环境变量,不设置时回退到默认值 `5000`。\n\n' +
'需要我同时更新一下 `.env.example` 文件中的说明吗?';
await streamMarkdown(sessionId, aMsgId, 0, conclusionText);
await delay(100);
broadcast('event.assistant.completed', sessionId, {
message_id: aMsgId,
finish_reason: 'stop',
});
const finalContent = [t(mdText + preWriteText + conclusionText)];
broadcast('event.message.updated', sessionId, {
message_id: aMsgId,
content: finalContent,
status: 'completed',
});
const aEntry = messages[sessionId].find((m) => m.id === aMsgId);
if (aEntry) { aEntry.content = finalContent; aEntry.status = 'completed'; }
await delay(100);
const newCtx = (session?.usage?.context_tokens || 52000) + 8000;
const newUsage = mkUsage(newCtx, (session?.usage?.turn_count || 0) + 1);
if (session) session.usage = newUsage;
broadcast('event.session.usage_updated', sessionId, {
usage: newUsage,
delta: {
input_tokens: 2400,
output_tokens: 800,
cache_read_tokens: 400,
cache_creation_tokens: 0,
cost_usd: 0.0032,
},
});
await delay(80);
broadcast('event.session.status_changed', sessionId, {
status: 'idle',
previous_status: 'running',
});
if (session) session.status = 'idle';
broadcastTaskProgress(sessionId);
};
}
async function simulateQuestionReply(sessionId, userText) {
const promptId = ulid('pr_');
const session = sessions.find((s) => s.id === sessionId);
broadcast('event.session.status_changed', sessionId, {
status: 'running',
previous_status: 'idle',
current_prompt_id: promptId,
});
if (session) session.status = 'running';
await delay(80);
const userMsgId = ulid('msg_');
const userMsg = mkMsg(userMsgId, sessionId, 'user', [t(userText)], promptId);
(messages[sessionId] = messages[sessionId] || []).push(userMsg);
broadcast('event.message.created', sessionId, { message: userMsg });
await delay(200);
const aMsgId = ulid('msg_');
const aMsg = mkMsg(aMsgId, sessionId, 'assistant', [t('')], promptId);
messages[sessionId].push(aMsg);
broadcast('event.message.created', sessionId, { message: { ...aMsg, status: 'pending' } });
await delay(250);
await streamMarkdown(sessionId, aMsgId, 0, '在开始之前,我需要了解一下你的偏好:');
await delay(200);
const questionId = ulid('qst_');
broadcast('event.session.status_changed', sessionId, {
status: 'awaiting_question',
previous_status: 'running',
current_prompt_id: promptId,
});
if (session) session.status = 'awaiting_question';
broadcast('event.question.requested', sessionId, {
question_id: questionId,
session_id: sessionId,
questions: [
{
id: 'q_1',
question: '你更倾向于哪种代码风格?',
header: '代码风格',
body: '影响注释、命名和函数长度等方面的偏好。',
options: [
{ id: 'opt_1a', label: '简洁优先', description: '短函数、少注释、精炼命名' },
{ id: 'opt_1b', label: '可读性优先', description: '长注释、描述性命名、函数分层' },
{ id: 'opt_1c', label: '性能优先', description: '尽量减少抽象和开销' },
],
multi_select: false,
allow_other: false,
},
{
id: 'q_2',
question: '这次修改应该同时处理哪些子任务?',
header: '范围选择',
options: [
{ id: 'opt_2a', label: '更新单元测试' },
{ id: 'opt_2b', label: '更新文档注释' },
{ id: 'opt_2c', label: '更新 CHANGELOG' },
{ id: 'opt_2d', label: '更新 .env.example' },
],
multi_select: true,
allow_other: true,
other_label: '其他',
other_description: '如有特殊要求请填写',
},
],
expires_at: expires60(),
created_at: now(),
});
pendingQuestion[sessionId] = questionId;
pendingContinuation[sessionId] = async () => {
delete pendingContinuation[sessionId];
delete pendingQuestion[sessionId];
broadcast('event.session.status_changed', sessionId, {
status: 'running',
previous_status: 'awaiting_question',
current_prompt_id: promptId,
});
if (session) session.status = 'running';
await delay(200);
const replyText = '好的,已记录你的偏好,按照你的选择来实施修改。稍等……';
await streamMarkdown(sessionId, aMsgId, 0, '\n\n' + replyText);
await delay(100);
broadcast('event.assistant.completed', sessionId, { message_id: aMsgId, finish_reason: 'stop' });
broadcast('event.message.updated', sessionId, {
message_id: aMsgId,
content: [t(replyText)],
status: 'completed',
});
await delay(100);
const newCtx = (session?.usage?.context_tokens || 20000) + 3000;
const newUsage = mkUsage(newCtx, (session?.usage?.turn_count || 0) + 1);
if (session) session.usage = newUsage;
broadcast('event.session.usage_updated', sessionId, {
usage: newUsage,
delta: { input_tokens: 800, output_tokens: 200, cache_read_tokens: 0, cache_creation_tokens: 0, cost_usd: 0.0008 },
});
await delay(80);
broadcast('event.session.status_changed', sessionId, { status: 'idle', previous_status: 'running' });
if (session) session.status = 'idle';
};
}
function broadcastTaskProgress(sessionId) {
const sessionTasks = (tasks[sessionId] || []).filter((t) => t.status === 'running');
if (!sessionTasks.length) return;
const intervals = [];
for (const task of sessionTasks) {
const lines = [
'Building TypeScript sources...',
'Resolving entry points...',
'Bundling 42 modules...',
'Emitting declaration files...',
'Running post-build checks...',
'Done.',
];
let lineIdx = 0;
const iv = setInterval(() => {
if (lineIdx < lines.length) {
broadcast('event.task.progress', sessionId, {
task_id: task.id,
output_chunk: lines[lineIdx] + '\n',
stream: 'stdout',
});
lineIdx++;
} else {
clearInterval(iv);
task.status = 'completed';
task.completed_at = now();
task.output_preview += '\nDone.';
broadcast('event.task.completed', sessionId, {
task_id: task.id,
status: 'completed',
output_preview: task.output_preview,
output_bytes: (task.output_bytes || 0) + 64,
});
}
}, 600);
intervals.push(iv);
}
return intervals;
}
// ---- raw agent-core event simulation (STUB_RAW_EVENTS=1) ----
// Emits the real daemon's raw event shapes instead of projected event.* frames.
// This lets us verify the client-side agentEventProjector end-to-end.
function broadcastRaw(type, sessionId, payload) {
const seq = (seqBySession[sessionId] = (seqBySession[sessionId] || 0) + 1);
const session = sessions.find((s) => s.id === sessionId);
if (session) session.last_seq = seq;
const frame = JSON.stringify({ type, seq, session_id: sessionId, timestamp: now(), payload: { type, ...payload } });
for (const ws of sockets) if (ws.readyState === 1) ws.send(frame);
return seq;
}
async function simulateRawReply(sessionId, userText) {
const session = sessions.find((s) => s.id === sessionId);
const promptId = ulid('pr_');
const turnId = Math.floor(Math.random() * 100000);
// Store user message
const userMsgId = ulid('msg_');
const userMsg = mkMsg(userMsgId, sessionId, 'user', [{ type: 'text', text: userText }], promptId);
(messages[sessionId] = messages[sessionId] || []).push(userMsg);
await delay(80);
// turn.started
broadcastRaw('turn.started', sessionId, {
turnId,
origin: { kind: 'user' },
agentId: 'main',
sessionId,
});
await delay(100);
// turn.step.started
broadcastRaw('turn.step.started', sessionId, {
turnId,
step: 0,
stepId: ulid('step_'),
agentId: 'main',
sessionId,
});
await delay(120);
// thinking.delta
const thinkingText = '分析用户输入:「' + userText + '」,准备回复。';
for (let i = 0; i < thinkingText.length; i += 10) {
await delay(40);
broadcastRaw('thinking.delta', sessionId, {
turnId,
delta: thinkingText.slice(i, i + 10),
agentId: 'main',
sessionId,
});
}
await delay(100);
// assistant.delta — stream a reply in chunks
const replyText =
'你好!我是 Kimi你的 AI 助手。\n\n' +
'你发送了:**' + userText + '**\n\n' +
'我已经收到你的消息,正在处理中。';
const chunkSize = 8;
for (let i = 0; i < replyText.length; i += chunkSize) {
await delay(60 + Math.random() * 40);
broadcastRaw('assistant.delta', sessionId, {
turnId,
delta: replyText.slice(i, i + chunkSize),
agentId: 'main',
sessionId,
});
}
await delay(100);
// turn.step.completed
broadcastRaw('turn.step.completed', sessionId, {
turnId,
step: 0,
stepId: ulid('step_'),
usage: {
inputOther: 1200,
output: 80,
inputCacheRead: 400,
inputCacheCreation: 0,
},
finishReason: 'end_turn',
agentId: 'main',
sessionId,
});
await delay(80);
// agent.status.updated
const newCtx = (session?.usage?.context_tokens || 8000) + 2000;
broadcastRaw('agent.status.updated', sessionId, {
model: session?.agent_config?.model || 'moonshot-v1-128k',
contextTokens: newCtx,
maxContextTokens: 131072,
contextUsage: newCtx / 131072,
planMode: false,
permission: 'auto',
usage: {
byModel: {},
total: { inputOther: 1200, output: 80, inputCacheRead: 400, inputCacheCreation: 0 },
currentTurn: { inputOther: 1200, output: 80, inputCacheRead: 400, inputCacheCreation: 0 },
},
agentId: 'main',
sessionId,
});
if (session) session.usage.context_tokens = newCtx;
await delay(80);
// turn.ended
broadcastRaw('turn.ended', sessionId, {
turnId,
reason: 'completed',
agentId: 'main',
sessionId,
});
await delay(50);
// prompt.completed
broadcastRaw('prompt.completed', sessionId, {
agentId: 'main',
sessionId,
promptId,
});
if (session) session.status = 'idle';
}
function simulateReply(sessionId, userText) {
if (RAW_EVENTS_MODE) {
simulateRawReply(sessionId, userText).catch(console.error);
return;
}
const lower = userText.toLowerCase();
const isQuestion = lower.includes('问') || lower.includes('ask') || lower.includes('?') || lower.includes('');
if (isQuestion) {
simulateQuestionReply(sessionId, userText).catch(console.error);
} else {
simulateDefaultReply(sessionId, userText).catch(console.error);
}
}
// ---- REST ----
const server = http.createServer((req, res) => {
const { url = '', method = 'GET' } = req;
const path = url.split('?')[0];
// Permissive CORS so a browser dev server on another port can read responses.
res.setHeader('access-control-allow-origin', req.headers.origin || '*');
res.setHeader('access-control-allow-methods', 'GET,POST,PATCH,DELETE,OPTIONS');
res.setHeader('access-control-allow-headers', 'content-type,x-request-id,authorization');
res.setHeader('access-control-allow-credentials', 'true');
res.setHeader('access-control-max-age', '86400');
if (method === 'OPTIONS') { res.statusCode = 204; return res.end(); }
res.setHeader('content-type', 'application/json; charset=utf-8');
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
const json = () => { try { return JSON.parse(body || '{}'); } catch { return {}; } };
// Segments after stripping /api/v1 prefix
// path: /api/v1/sessions/ses_1/messages → stripped: /sessions/ses_1/messages
// We route on the stripped path for clarity.
const isApiV1 = path.startsWith('/api/v1');
const stripped = isApiV1 ? path.slice('/api/v1'.length) : path;
const seg = stripped.split('/').filter(Boolean);
// seg[0]: resource group (sessions, providers, models, auth, …)
// seg[1]: first id
// seg[2]: sub-resource or action
const sid = seg[0] === 'sessions' ? seg[1] : undefined;
// ---- healthz / meta ----
if (stripped === '/healthz' || path === '/healthz') {
return res.end(ok({ ok: true }));
}
if (stripped === '/meta' || path === '/meta') {
return res.end(ok({
server_version: '0.0.0-stub',
capabilities: { websocket: true, file_upload: false, fs_query: false, mcp: false, background_tasks: true },
server_id: 'stub',
started_at: STARTED_AT,
}));
}
// Require /api/v1 prefix for everything below
if (!isApiV1) {
return res.end(ok({}));
}
// ---- sessions collection ----
if (stripped === '/sessions' && method === 'GET') {
const sp = new URLSearchParams(url.split('?')[1] || '');
const pageSize = Math.min(Number(sp.get('page_size') || '20'), 100);
const status = sp.get('status');
let items = [...sessions];
if (status) items = items.filter((s) => s.status === status);
items = items.slice(0, pageSize);
return res.end(ok({ items, has_more: false }));
}
if (stripped === '/sessions' && method === 'POST') {
const b = json();
const s = mkSession(ulid('ses_'), b.title || '新会话', 'idle', 8000, 0);
if (b.metadata) Object.assign(s.metadata, b.metadata);
if (b.agent_config) Object.assign(s.agent_config, b.agent_config);
sessions.unshift(s);
messages[s.id] = [];
tasks[s.id] = [];
seqBySession[s.id] = 0;
broadcast('event.session.created', s.id, { session: s });
return res.end(ok(s));
}
// ---- sessions/{id}:compact ----
// Mirrors the real daemon: 200 {} immediately, then raw agent-core
// compaction.* frames over WS. After ~1.2s the stored history is rewritten
// to [summary message with origin compaction_summary] + recent suffix, so
// a later snapshot reload shows the compacted view like the real server.
if (seg[0] === 'sessions' && seg.length === 2 && method === 'POST' && seg[1].endsWith(':compact')) {
const realSid = seg[1].slice(0, -':compact'.length);
const session = sessions.find((s) => s.id === realSid);
if (!session) return res.end(fail(40401, `session ${realSid} does not exist`));
const msgs = messages[realSid] || [];
if (msgs.length < 2) return res.end(fail(40910, 'No prefix that can be compacted in current history.'));
const b = json();
const instruction = typeof b.instruction === 'string' && b.instruction.trim() ? b.instruction.trim() : undefined;
broadcast('compaction.started', realSid, { trigger: 'manual', instruction });
setTimeout(() => {
const all = messages[realSid] || [];
const keep = all.slice(-2);
const compactedCount = all.length - keep.length;
const summaryText =
'(stub) Summary of the earlier conversation: the user asked to make the API ' +
'client timeout configurable; the assistant edited createClient, ran the tests ' +
'(3 passed) and confirmed the change.' +
(instruction ? `\n\nInstruction honoured: ${instruction}` : '');
const summaryMsg = mkMsg(ulid('msg_'), realSid, 'assistant', [t(summaryText)], undefined);
summaryMsg.metadata = { origin: { kind: 'compaction_summary' } };
messages[realSid] = [summaryMsg, ...keep];
broadcast('compaction.completed', realSid, {
result: { summary: summaryText, compactedCount, tokensBefore: 90000, tokensAfter: 12000 },
});
}, 1200);
return res.end(ok({}));
}
// ---- sessions/{id} ----
if (seg[0] === 'sessions' && sid && seg.length === 2) {
const session = sessions.find((s) => s.id === sid);
if (method === 'GET') {
if (!session) return res.end(fail(40401, `session ${sid} does not exist`));
return res.end(ok(session));
}
if (method === 'PATCH') {
if (!session) return res.end(fail(40401, `session ${sid} does not exist`));
const b = json();
if (b.title !== undefined) session.title = b.title;
if (b.metadata !== undefined) Object.assign(session.metadata, b.metadata);
if (b.agent_config !== undefined) Object.assign(session.agent_config, b.agent_config);
if (b.permission_rules !== undefined) session.permission_rules = b.permission_rules;
session.updated_at = now();
broadcast('event.session.updated', sid, { session, changed_fields: Object.keys(b) });
return res.end(ok(session));
}
if (method === 'DELETE') {
if (!session) return res.end(fail(40401, `session ${sid} does not exist`));
const idx = sessions.findIndex((s) => s.id === sid);
if (idx >= 0) sessions.splice(idx, 1);
broadcast('event.session.deleted', sid, { session_id: sid });
return res.end(ok({ deleted: true }));
}
}
// ---- snapshot (v2 initial sync: GET /sessions/{id}/snapshot) ----
if (seg[0] === 'sessions' && seg[2] === 'snapshot' && seg.length === 3 && method === 'GET') {
const session = sessions.find((s) => s.id === sid);
if (!session) return res.end(fail(40401, `session ${sid} does not exist`));
return res.end(ok({
as_of_seq: seqBySession[sid] || 0,
epoch: 'ep_stub',
session,
messages: { items: messages[sid] || [], has_more: false },
in_flight_turn: null,
pending_approvals: [],
pending_questions: [],
}));
}
// ---- messages ----
if (seg[0] === 'sessions' && seg[2] === 'messages' && seg.length === 3 && method === 'GET') {
const sp = new URLSearchParams(url.split('?')[1] || '');
const pageSize = Math.min(Number(sp.get('page_size') || '50'), 100);
const items = (messages[sid] || []).slice(-pageSize);
return res.end(ok({ items, has_more: false }));
}
if (seg[0] === 'sessions' && seg[2] === 'messages' && seg.length === 4 && method === 'GET') {
const msgId = seg[3];
const msg = (messages[sid] || []).find((m) => m.id === msgId);
if (!msg) return res.end(fail(40403, `message ${msgId} does not exist`));
return res.end(ok(msg));
}
// ---- tasks ----
if (seg[0] === 'sessions' && seg[2] === 'tasks' && seg.length === 3 && method === 'GET') {
const sp = new URLSearchParams(url.split('?')[1] || '');
const status = sp.get('status');
let items = tasks[sid] || [];
if (status) items = items.filter((t) => t.status === status);
return res.end(ok({ items }));
}
if (seg[0] === 'sessions' && seg[2] === 'tasks' && seg.length === 4 && method === 'GET') {
const taskId = seg[3];
const task = (tasks[sid] || []).find((t) => t.id === taskId);
if (!task) return res.end(fail(40406, `task ${taskId} does not exist`));
return res.end(ok(task));
}
if (seg[0] === 'sessions' && seg[2] === 'tasks' && seg.length === 4 && method === 'POST' && seg[3].endsWith(':cancel')) {
const taskId = seg[3].replace(':cancel', '');
const task = (tasks[sid] || []).find((t) => t.id === taskId);
if (!task) return res.end(fail(40406, `task ${taskId} does not exist`));
if (task.status !== 'running') return res.end(fail(40904, 'task already finished'));
task.status = 'cancelled';
task.completed_at = now();
return res.end(ok({ cancelled: true }));
}
// ---- terminals ----
if (seg[0] === 'sessions' && seg[2] === 'terminals' && seg.length === 3 && method === 'GET') {
return res.end(ok({ items: terminalList(sid).map(publicTerminal) }));
}
if (seg[0] === 'sessions' && seg[2] === 'terminals' && seg.length === 3 && method === 'POST') {
const session = sessions.find((s) => s.id === sid);
if (!session) return res.end(fail(40401, `session ${sid} does not exist`));
const b = json();
const record = {
id: ulid('term_'),
session_id: sid,
cwd: session.metadata?.cwd || '/tmp',
shell: b.shell || '/bin/zsh',
cols: b.cols || 80,
rows: b.rows || 24,
status: 'running',
created_at: now(),
buffer: [],
next_seq: 0,
};
terminalList(sid).push(record);
return res.end(ok(publicTerminal(record)));
}
if (seg[0] === 'sessions' && seg[2] === 'terminals' && seg.length === 4 && method === 'GET') {
const termId = seg[3];
const record = terminalList(sid).find((item) => item.id === termId);
if (!record) return res.end(fail(40407, `terminal ${termId} does not exist`));
return res.end(ok(publicTerminal(record)));
}
if (seg[0] === 'sessions' && seg[2] === 'terminals' && seg.length === 4 && method === 'POST' && seg[3].endsWith(':close')) {
const termId = seg[3].replace(':close', '');
const record = terminalList(sid).find((item) => item.id === termId);
if (!record) return res.end(fail(40407, `terminal ${termId} does not exist`));
if (record.status !== 'exited') emitTerminalExit(record, 0);
return res.end(ok({ closed: true }));
}
// ---- prompts ----
if (seg[0] === 'sessions' && seg[2] === 'prompts' && seg.length === 3 && method === 'POST') {
const b = json();
const content = b.content || [];
// Extract text from text-type parts only; tolerate image and other part types without crashing.
const userText = content.filter((c) => c.type === 'text').map((c) => c.text).filter(Boolean).join('');
const imageCount = content.filter((c) => c.type === 'image').length;
const promptId = ulid('pr_');
const userMsgId = ulid('msg_');
const effectiveText = userText || (imageCount > 0 ? `[${imageCount} image(s) attached]` : '你好');
setTimeout(() => simulateReply(sid, effectiveText), 100);
return res.end(ok({ prompt_id: promptId, user_message_id: userMsgId, status: 'running' }));
}
// POST /sessions/{sid}/prompts:steer — steer queued prompts into the
// active turn. The stub has no real queue: acknowledge so the web client's
// submit→steer two-step can be exercised end-to-end.
if (seg[0] === 'sessions' && seg[2] === 'prompts:steer' && seg.length === 3 && method === 'POST') {
const b = json();
const ids = Array.isArray(b.prompt_ids) ? b.prompt_ids : [];
if (ids.length === 0) return res.end(fail(40001, 'prompt_ids required'));
return res.end(ok({ steered: true, prompt_ids: ids }));
}
if (seg[0] === 'sessions' && seg[2] === 'prompts' && seg.length === 4 && method === 'POST' && seg[3].endsWith(':abort')) {
return res.end(fail(40903, 'prompt already completed', { aborted: false }));
}
// ---- approvals ----
if (seg[0] === 'sessions' && seg[2] === 'approvals' && seg.length === 4 && method === 'POST') {
const approvalId = seg[3];
const b = json();
const resolvedAt = now();
broadcast('event.approval.resolved', sid, {
approval_id: approvalId,
decision: b.decision || 'approved',
scope: b.scope,
feedback: b.feedback,
resolved_by: 'user',
resolved_at: resolvedAt,
});
if (pendingContinuation[sid]) {
const cont = pendingContinuation[sid];
setTimeout(() => cont(), 200);
}
return res.end(ok({ resolved: true, resolved_at: resolvedAt }));
}
// ---- questions ----
if (seg[0] === 'sessions' && seg[2] === 'questions' && seg.length === 4 && method === 'POST') {
const questionIdRaw = seg[3];
if (questionIdRaw.endsWith(':dismiss')) {
const questionId = questionIdRaw.replace(':dismiss', '');
const dismissedAt = now();
broadcast('event.question.dismissed', sid, {
question_id: questionId,
dismissed_by: 'user',
dismissed_at: dismissedAt,
});
if (pendingContinuation[sid]) {
const cont = pendingContinuation[sid];
setTimeout(() => cont(), 200);
}
return res.end(fail(40909, 'question.dismissed', { dismissed: true, dismissed_at: dismissedAt }));
}
const questionId = questionIdRaw;
const b = json();
const resolvedAt = now();
broadcast('event.question.answered', sid, {
question_id: questionId,
answers: b.answers || {},
method: b.method,
note: b.note,
resolved_by: 'user',
resolved_at: resolvedAt,
});
if (pendingContinuation[sid]) {
const cont = pendingContinuation[sid];
setTimeout(() => cont(), 200);
}
return res.end(ok({ resolved: true, resolved_at: resolvedAt }));
}
// ---- PRESUMED: models ----
// PRESUMED — not in current daemon docs; swap when backend defines them.
if (stripped === '/models' && method === 'GET') {
return res.end(ok({ items: models }));
}
// ---- PRESUMED: providers ----
// PRESUMED — not in current daemon docs; swap when backend defines them.
if (stripped === '/providers' && method === 'GET') {
return res.end(ok({ items: providers }));
}
if (stripped === '/providers' && method === 'POST') {
const b = json();
const newId = ulid('prov_');
const newProv = {
id: newId,
type: b.type || 'custom',
base_url: b.base_url || undefined,
default_model: b.default_model || undefined,
has_api_key: !!b.api_key,
status: 'connected',
models: [],
};
providers.push(newProv);
models.push(
{ provider: newId, model: `${b.type || 'custom'}-default`, display_name: `${b.type || 'custom'} Default`, max_context_size: 128000, capabilities: [] },
);
newProv.models = [`${b.type || 'custom'}-default`];
return res.end(ok(newProv));
}
if (seg[0] === 'providers' && seg.length === 2 && method === 'DELETE') {
const provId = seg[1];
const idx = providers.findIndex((p) => p.id === provId);
if (idx < 0) return res.end(fail(40401, `provider ${provId} not found`));
providers.splice(idx, 1);
const toRemove = models.filter((m) => m.provider === provId);
for (const m of toRemove) {
const mi = models.indexOf(m);
if (mi >= 0) models.splice(mi, 1);
}
return res.end(ok({ deleted: true }));
}
if (seg[0] === 'providers' && seg.length === 2 && method === 'POST' && seg[1].endsWith(':refresh')) {
const provId = seg[1].replace(':refresh', '');
const prov = providers.find((p) => p.id === provId);
if (!prov) return res.end(fail(40401, `provider ${provId} not found`));
prov.status = 'connected';
return res.end(ok(prov));
}
// ---- workspaces + daemon folder browser (demo) ----
// GET /api/v1/workspaces — derived from seeded session cwds + a couple demo
// workspaces, each with a wd_<slug>_<hash12> id (matches the real shape).
if (stripped === '/workspaces' && method === 'GET') {
return res.end(ok({ items: workspaces }));
}
// POST /api/v1/workspaces { root, name? } — echo a wd_ workspace (idempotent per root)
if (stripped === '/workspaces' && method === 'POST') {
const b = json();
const root = String(b.root || '').replace(/\/+$/, '') || '/';
const existing = workspaces.find((w) => w.root === root);
if (existing) return res.end(ok(existing));
const ws = mkWorkspace(root, b.name);
workspaces.unshift(ws);
return res.end(ok(ws));
}
// PATCH /api/v1/workspaces/{id} { name }
if (seg[0] === 'workspaces' && seg.length === 2 && method === 'PATCH') {
const ws = workspaces.find((w) => w.id === seg[1]);
if (!ws) return res.end(fail(40401, `workspace ${seg[1]} not found`));
const b = json();
if (b.name !== undefined) ws.name = b.name;
return res.end(ok(ws));
}
// DELETE /api/v1/workspaces/{id} (registry only)
if (seg[0] === 'workspaces' && seg.length === 2 && method === 'DELETE') {
const idx = workspaces.findIndex((w) => w.id === seg[1]);
if (idx < 0) return res.end(fail(40401, `workspace ${seg[1]} not found`));
workspaces.splice(idx, 1);
return res.end(ok({ deleted: true }));
}
// GET /api/v1/fs:home — picker start dir + recent roots
if (stripped === '/fs:home' && method === 'GET') {
return res.end(ok({ home: FS_HOME, recent_roots: FS_RECENT }));
}
// GET /api/v1/fs:browse?path=<abs> — subdirs only
if (stripped === '/fs:browse' && method === 'GET') {
const sp = new URLSearchParams(url.split('?')[1] || '');
const reqPath = (sp.get('path') || FS_HOME).replace(/\/+$/, '') || '/';
return res.end(ok(browseDir(reqPath)));
}
// ---- REAL auth endpoints ----
// GET /api/v1/auth — readiness check
if (stripped === '/auth' && method === 'GET') {
return res.end(ok({
ready: loggedIn,
providers_count: loggedIn ? 1 : 0,
default_model: loggedIn ? 'kimi-code/kimi-for-coding' : null,
managed_provider: loggedIn ? { status: 'authenticated' } : null,
}));
}
// POST /api/v1/oauth/login — start singleton device flow
if (stripped === '/oauth/login' && method === 'POST') {
const flowId = ulid('flow_');
const userCode = 'DEMO-1234';
const expiresIn = 1800;
const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString();
currentFlow = {
flow_id: flowId,
provider: 'managed:kimi-code',
verification_uri: 'https://www.kimi.com/code/authorize_device',
verification_uri_complete: `https://www.kimi.com/code/authorize_device?user_code=${userCode}`,
user_code: userCode,
expires_in: expiresIn,
interval: 2,
status: 'pending',
expires_at: expiresAt,
};
// Auto-flip to 'authenticated' after 5 seconds
const capturedFlowId = flowId;
setTimeout(() => {
if (currentFlow && currentFlow.flow_id === capturedFlowId && currentFlow.status === 'pending') {
currentFlow.status = 'authenticated';
currentFlow.resolved_at = now();
loggedIn = true;
}
}, 5000);
return res.end(ok({ ...currentFlow }));
}
// GET /api/v1/oauth/login — poll current singleton flow
if (stripped === '/oauth/login' && method === 'GET') {
return res.end(ok(currentFlow));
}
// DELETE /api/v1/oauth/login — cancel current flow
if (stripped === '/oauth/login' && method === 'DELETE') {
if (currentFlow) {
currentFlow.status = 'cancelled';
currentFlow.resolved_at = now();
}
const wasCancelled = currentFlow !== null;
currentFlow = null;
return res.end(ok({ cancelled: wasCancelled, status: 'cancelled' }));
}
// POST /api/v1/oauth/logout — logout
if (stripped === '/oauth/logout' && method === 'POST') {
loggedIn = false;
currentFlow = null;
return res.end(ok({ logged_out: true }));
}
// ---- fs:git_status ----
// POST /api/v1/sessions/{id}/fs:git_status
if (seg[0] === 'sessions' && seg[2] === 'fs:git_status' && seg.length === 3 && method === 'POST') {
const session = sessions.find((s) => s.id === sid);
if (!session) return res.end(fail(40401, `session ${sid} does not exist`));
// Return realistic git status keyed by session
const gitStatusBySid = {
ses_1: {
branch: 'feat/web',
ahead: 1,
behind: 0,
entries: {
'apps/kimi-code/package.json': 'modified',
'packages/daemon/src/middleware/schema.ts': 'added',
'apps/kimi-web/src/composables/useKimiWebClient.ts': 'modified',
},
},
ses_2: {
branch: 'fix/tui-render',
ahead: 0,
behind: 2,
entries: {
'apps/kimi-tui/src/renderer/loop.ts': 'modified',
},
},
ses_3: {
branch: 'refactor/auth-errors',
ahead: 3,
behind: 0,
entries: {
'apps/kimi-cli/src/auth/errors.ts': 'added',
'apps/kimi-cli/src/auth/login.ts': 'modified',
'apps/kimi-cli/src/auth/refresh.ts': 'modified',
'apps/kimi-cli/src/commands/auth.ts': 'modified',
},
},
ses_4: {
branch: 'feat/file-search',
ahead: 0,
behind: 0,
entries: {},
},
};
const gs = gitStatusBySid[sid] ?? {
branch: 'main',
ahead: 0,
behind: 0,
entries: {},
};
return res.end(ok(gs));
}
// ---- fs:diff ----
// POST /api/v1/sessions/{id}/fs:diff body: { path }
if (seg[0] === 'sessions' && seg[2] === 'fs:diff' && seg.length === 3 && method === 'POST') {
const session = sessions.find((s) => s.id === sid);
if (!session) return res.end(fail(40401, `session ${sid} does not exist`));
const b = json();
if (!b.path) return res.end(fail(40001, 'path: required'));
// Deterministic fake unified diff so the Changed → diff flow is demoable.
const diff = [
`diff --git a/${b.path} b/${b.path}`,
'index 1111111..2222222 100644',
`--- a/${b.path}`,
`+++ b/${b.path}`,
'@@ -1,7 +1,9 @@',
` // ${b.path}`,
' ',
'-export function oldImpl(input) {',
'- return null;',
'+export function newImpl(input) {',
'+ // handle the empty-state branch the old impl missed',
'+ if (!input) return fallback();',
'+ return compute(input);',
' }',
' ',
' export const VERSION = 2;',
'',
].join('\n');
return res.end(ok({ path: b.path, diff, truncated: false }));
}
// ---- fs:list ----
// POST /api/v1/sessions/{id}/fs:list body: { path, depth?, include_git_status? }
if (seg[0] === 'sessions' && seg[2] === 'fs:list' && seg.length === 3 && method === 'POST') {
const session = sessions.find((s) => s.id === sid);
if (!session) return res.end(fail(40401, `session ${sid} does not exist`));
const b = json();
const reqPath = (b.path || '.').replace(/^\.\//, '').replace(/\/$/, '') || '.';
const modifiedNow = now();
// Nested stub filesystem tree
const fsTree = {
'.': [
{ path: 'src', name: 'src', kind: 'directory', modified_at: modifiedNow, etag: 'etag_src', child_count: 5 },
{ path: 'docs', name: 'docs', kind: 'directory', modified_at: modifiedNow, etag: 'etag_docs', child_count: 2 },
{ path: 'packages', name: 'packages', kind: 'directory', modified_at: modifiedNow, etag: 'etag_pkg', child_count: 3 },
{ path: 'package.json', name: 'package.json', kind: 'file', modified_at: modifiedNow, etag: 'etag_pkgjson', size: 892, mime: 'application/json', language_id: 'json' },
{ path: 'README.md', name: 'README.md', kind: 'file', modified_at: modifiedNow, etag: 'etag_readme', size: 1240, mime: 'text/markdown', language_id: 'markdown' },
{ path: 'tsconfig.json', name: 'tsconfig.json', kind: 'file', modified_at: modifiedNow, etag: 'etag_tsconfig', size: 320, mime: 'application/json', language_id: 'json' },
{ path: 'logo.png', name: 'logo.png', kind: 'file', modified_at: modifiedNow, etag: 'etag_logo', size: 68, mime: 'image/png', is_binary: true },
],
'src': [
{ path: 'src/components', name: 'components', kind: 'directory', modified_at: modifiedNow, etag: 'etag_comp', child_count: 4 },
{ path: 'src/api', name: 'api', kind: 'directory', modified_at: modifiedNow, etag: 'etag_api', child_count: 2 },
{ path: 'src/main.ts', name: 'main.ts', kind: 'file', modified_at: modifiedNow, etag: 'etag_main', size: 420, mime: 'text/typescript', language_id: 'typescript' },
{ path: 'src/App.vue', name: 'App.vue', kind: 'file', modified_at: modifiedNow, etag: 'etag_app', size: 3200, mime: 'text/x-vue', language_id: 'vue' },
{ path: 'src/style.css', name: 'style.css', kind: 'file', modified_at: modifiedNow, etag: 'etag_style', size: 680, mime: 'text/css', language_id: 'css', git_status: 'modified' },
],
'src/components': [
{ path: 'src/components/TabBar.vue', name: 'TabBar.vue', kind: 'file', modified_at: modifiedNow, etag: 'etag_tabbar', size: 1800, mime: 'text/x-vue', language_id: 'vue' },
{ path: 'src/components/FileTree.vue', name: 'FileTree.vue', kind: 'file', modified_at: modifiedNow, etag: 'etag_filetree', size: 4200, mime: 'text/x-vue', language_id: 'vue', git_status: 'added' },
{ path: 'src/components/FilePreview.vue', name: 'FilePreview.vue', kind: 'file', modified_at: modifiedNow, etag: 'etag_filepreview', size: 3900, mime: 'text/x-vue', language_id: 'vue', git_status: 'added' },
{ path: 'src/components/DiffView.vue', name: 'DiffView.vue', kind: 'file', modified_at: modifiedNow, etag: 'etag_diffview', size: 2800, mime: 'text/x-vue', language_id: 'vue' },
],
'src/api': [
{ path: 'src/api/types.ts', name: 'types.ts', kind: 'file', modified_at: modifiedNow, etag: 'etag_types', size: 5200, mime: 'text/typescript', language_id: 'typescript' },
{ path: 'src/api/client.ts', name: 'client.ts', kind: 'file', modified_at: modifiedNow, etag: 'etag_client', size: 1800, mime: 'text/typescript', language_id: 'typescript', git_status: 'modified' },
],
'docs': [
{ path: 'docs/api.md', name: 'api.md', kind: 'file', modified_at: modifiedNow, etag: 'etag_apidoc', size: 2400, mime: 'text/markdown', language_id: 'markdown', git_status: 'modified' },
{ path: 'docs/CHANGELOG.md', name: 'CHANGELOG.md', kind: 'file', modified_at: modifiedNow, etag: 'etag_changelog', size: 5600, mime: 'text/markdown', language_id: 'markdown' },
],
'packages': [
{ path: 'packages/daemon', name: 'daemon', kind: 'directory', modified_at: modifiedNow, etag: 'etag_daemon', child_count: 8 },
{ path: 'packages/api', name: 'api', kind: 'directory', modified_at: modifiedNow, etag: 'etag_api_pkg', child_count: 5 },
{ path: 'packages/types', name: 'types', kind: 'directory', modified_at: modifiedNow, etag: 'etag_types_pkg', child_count: 3 },
],
'packages/daemon': [
{ path: 'packages/daemon/src', name: 'src', kind: 'directory', modified_at: modifiedNow, etag: 'etag_dsrc', child_count: 6 },
{ path: 'packages/daemon/package.json', name: 'package.json', kind: 'file', modified_at: modifiedNow, etag: 'etag_dpkg', size: 640, mime: 'application/json', language_id: 'json' },
],
'packages/api': [
{ path: 'packages/api/src', name: 'src', kind: 'directory', modified_at: modifiedNow, etag: 'etag_asrc', child_count: 3 },
{ path: 'packages/api/package.json', name: 'package.json', kind: 'file', modified_at: modifiedNow, etag: 'etag_apkg', size: 420, mime: 'application/json', language_id: 'json' },
],
};
const items = fsTree[reqPath] || [];
return res.end(ok({ items, truncated: false }));
}
// ---- fs:read ----
// POST /api/v1/sessions/{id}/fs:read body: { path, offset?, length? }
if (seg[0] === 'sessions' && seg[2] === 'fs:read' && seg.length === 3 && method === 'POST') {
const session = sessions.find((s) => s.id === sid);
if (!session) return res.end(fail(40401, `session ${sid} does not exist`));
const b = json();
const reqPath = b.path || 'README.md';
// Tiny 1×1 transparent PNG (base64)
const PNG_1X1 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
const fileContents = {
'README.md': {
content:
'# Kimi Code Web\n\n' +
'A browser-based workspace client for the Kimi Code daemon.\n\n' +
'## Features\n\n' +
'- **~/chat** — Conversational AI interface with tool calls and approvals\n' +
'- **~/diff** — Real-time git status and changed-file tracking\n' +
'- **~/tasks** — Background task monitoring (subagents, bash, tools)\n' +
'- **~/files** — Workspace file browser with lazy tree and preview\n\n' +
'## Quick Start\n\n' +
'```bash\n' +
'pnpm install\n' +
'pnpm -C apps/kimi-web run dev:stub # start stub daemon\n' +
'pnpm -C apps/kimi-web run dev # start Vite dev server\n' +
'```\n\n' +
'## Architecture\n\n' +
'The web client is a Vue 3 + TypeScript SPA. All daemon calls go through\n' +
'`useKimiWebClient` composable, which owns the reactive state and exposes\n' +
'typed action functions to components.\n\n' +
'> **Note:** The stub daemon (`dev/stub-daemon.mjs`) speaks the real wire\n' +
'> protocol closely enough for all UI features to be fully demoable.\n',
encoding: 'utf-8',
mime: 'text/markdown',
language_id: 'markdown',
size: 1240,
line_count: 35,
is_binary: false,
etag: 'etag_readme',
truncated: false,
},
'package.json': {
content: JSON.stringify({
name: '@moonshot-ai/kimi-web',
version: '0.1.1',
private: true,
license: 'MIT',
type: 'module',
scripts: {
dev: 'vite',
'dev:stub': 'node dev/stub-daemon.mjs',
build: 'vite build',
typecheck: 'vue-tsc --noEmit',
test: 'vitest run',
},
dependencies: { marked: '^14.1.4', vue: '^3.5.35' },
devDependencies: {
'@vitejs/plugin-vue': '^5.2.4',
'@vue/test-utils': '^2.4.6',
typescript: '6.0.2',
vite: '^6.3.3',
vitest: '^2.1.8',
},
}, null, 2),
encoding: 'utf-8',
mime: 'application/json',
language_id: 'json',
size: 892,
line_count: 28,
is_binary: false,
etag: 'etag_pkgjson',
truncated: false,
},
'tsconfig.json': {
content: JSON.stringify({
compilerOptions: {
target: 'ES2022',
module: 'ESNext',
moduleResolution: 'bundler',
strict: true,
jsx: 'preserve',
lib: ['ES2022', 'DOM'],
},
include: ['src/**/*'],
exclude: ['node_modules'],
}, null, 2),
encoding: 'utf-8',
mime: 'application/json',
language_id: 'json',
size: 320,
line_count: 14,
is_binary: false,
etag: 'etag_tsconfig',
truncated: false,
},
'src/api/client.ts': {
content:
'// src/api/client.ts\n' +
'// Daemon HTTP + WS client — maps wire protocol to app types.\n\n' +
'import type { KimiWebApi } from \'./types\';\n\n' +
'const DEFAULT_TIMEOUT_MS = Number(process.env.API_TIMEOUT_MS ?? 30_000);\n\n' +
'export function createApiClient(baseUrl: string, timeoutMs = DEFAULT_TIMEOUT_MS): KimiWebApi {\n' +
' // Implementation elided for brevity in the stub.\n' +
' return {} as KimiWebApi;\n' +
'}\n',
encoding: 'utf-8',
mime: 'text/typescript',
language_id: 'typescript',
size: 1800,
line_count: 11,
is_binary: false,
etag: 'etag_client',
truncated: false,
},
'src/style.css': {
content:
'@import "tailwindcss";\n\n' +
':root {\n' +
' --ink: #14171c;\n' +
' --text: #3f454d;\n' +
' --dim: #697079;\n' +
' --muted: #8b929b;\n' +
' --line: #e7eaee;\n' +
' --panel: #fafbfc;\n' +
' --bg: #ffffff;\n' +
' --blue: #1565c0;\n' +
' --blue2: #0d4f9e;\n' +
' --soft: #e9f0fa;\n' +
' --mono: "SF Mono", ui-monospace, Menlo, Consolas, monospace;\n' +
'}\n\n' +
'body {\n' +
' font-family: var(--mono);\n' +
' color: var(--text);\n' +
' background: var(--bg);\n' +
' font-size: 12.5px;\n' +
' line-height: 1.55;\n' +
'}\n',
encoding: 'utf-8',
mime: 'text/css',
language_id: 'css',
size: 680,
line_count: 24,
is_binary: false,
etag: 'etag_style',
truncated: false,
},
'docs/api.md': {
content:
'# API Reference\n\n' +
'## File System Endpoints\n\n' +
'### `POST /api/v1/sessions/{id}/fs:list`\n\n' +
'List directory contents.\n\n' +
'**Request body:**\n' +
'```json\n' +
'{ "path": "src", "include_git_status": true }\n' +
'```\n\n' +
'**Response:**\n' +
'```json\n' +
'{ "items": [...], "truncated": false }\n' +
'```\n\n' +
'### `POST /api/v1/sessions/{id}/fs:read`\n\n' +
'Read file content.\n\n' +
'**Request body:**\n' +
'```json\n' +
'{ "path": "README.md" }\n' +
'```\n\n' +
'**Response:**\n' +
'```json\n' +
'{ "content": "...", "encoding": "utf-8", "mime": "text/markdown", ... }\n' +
'```\n',
encoding: 'utf-8',
mime: 'text/markdown',
language_id: 'markdown',
size: 2400,
line_count: 42,
is_binary: false,
etag: 'etag_apidoc',
truncated: false,
},
'logo.png': {
content: PNG_1X1,
encoding: 'base64',
mime: 'image/png',
size: 68,
is_binary: true,
etag: 'etag_logo',
truncated: false,
},
};
const fileData = fileContents[reqPath];
if (fileData) {
return res.end(ok({ path: reqPath, ...fileData }));
}
// Generic fallback for any unknown path
const ext = reqPath.split('.').pop() || '';
const mimeMap = {
ts: 'text/typescript', vue: 'text/x-vue', js: 'text/javascript',
json: 'application/json', md: 'text/markdown', css: 'text/css',
html: 'text/html', sh: 'text/x-sh', txt: 'text/plain',
};
const langMap = {
ts: 'typescript', vue: 'vue', js: 'javascript', json: 'json',
md: 'markdown', css: 'css', html: 'html', sh: 'shellscript',
};
const mime = mimeMap[ext] || 'text/plain';
const lang = langMap[ext] || ext;
const fallbackContent = `// ${reqPath}\n// (stub: content not seeded for this path)\n`;
return res.end(ok({
path: reqPath,
content: fallbackContent,
encoding: 'utf-8',
mime,
language_id: lang || undefined,
size: fallbackContent.length,
line_count: 2,
is_binary: false,
etag: 'etag_fallback_' + reqPath.replace(/[^a-z0-9]/gi, '_'),
truncated: false,
}));
}
// ---- file upload ----
// POST /api/v1/files (multipart/form-data: file, name?, expires_in_sec?)
// The stub does not parse multipart fully — it just returns a synthesised FileMeta.
if (stripped === '/files' && method === 'POST') {
const fileId = ulid('file_');
// Try to extract filename from Content-Disposition if possible; fall back to generic name.
const nameMatch = body.match(/filename="([^"]+)"/);
const fileName = nameMatch ? nameMatch[1] : 'upload.png';
const fileMeta = {
id: fileId,
name: fileName,
media_type: 'image/png',
size: body.length,
created_at: now(),
};
return res.end(ok(fileMeta));
}
// ---- tools / mcp ----
if (stripped === '/tools' && method === 'GET') {
return res.end(ok({
tools: [
{ name: 'read', description: 'Read a file from disk', input_schema: {}, source: 'builtin' },
{ name: 'bash', description: 'Run a shell command', input_schema: {}, source: 'builtin' },
{ name: 'edit', description: 'Edit a file with old/new string replacement', input_schema: {}, source: 'builtin' },
{ name: 'write', description: 'Write a new file', input_schema: {}, source: 'builtin' },
{ name: 'ls', description: 'List directory contents', input_schema: {}, source: 'builtin' },
{ name: 'grep', description: 'Search file contents with regex', input_schema: {}, source: 'builtin' },
],
}));
}
if (stripped === '/mcp/servers' && method === 'GET') {
return res.end(ok({ servers: [] }));
}
// Fallback
return res.end(ok({}));
});
});
// ---- WS ----
const wss = new WebSocketServer({ server, path: '/api/v1/ws' });
wss.on('connection', (ws) => {
sockets.add(ws);
ws.send(JSON.stringify({
type: 'server_hello',
timestamp: now(),
payload: {
server_id: 'stub',
heartbeat_ms: 30000,
max_event_buffer_size: 1000,
capabilities: { event_batching: false, compression: false },
},
}));
const ping = setInterval(() => {
if (ws.readyState === 1) {
ws.send(JSON.stringify({ type: 'ping', timestamp: now(), payload: { nonce: ulid('n_') } }));
}
}, 30000);
ws.on('message', (raw) => {
let m;
try { m = JSON.parse(String(raw)); } catch { return; }
if (m.type === 'client_hello') {
const acceptedSubscriptions = m.payload?.subscriptions || [];
ws.send(JSON.stringify({
type: 'ack', id: m.id, code: 0, msg: 'success',
payload: {
accepted_subscriptions: acceptedSubscriptions,
resync_required: [],
},
}));
sendGoalSnapshots(ws, acceptedSubscriptions);
}
if (m.type === 'subscribe') {
const accepted = m.payload?.session_ids || [];
ws.send(JSON.stringify({
type: 'ack', id: m.id, code: 0, msg: 'success',
payload: {
accepted,
not_found: [],
resync_required: [],
},
}));
sendGoalSnapshots(ws, accepted);
}
if (m.type === 'unsubscribe') {
ws.send(JSON.stringify({ type: 'ack', id: m.id, code: 0, msg: 'success', payload: {} }));
}
if (m.type === 'abort') {
ws.send(JSON.stringify({
type: 'ack', id: m.id, code: 0, msg: 'success',
payload: { aborted: false },
}));
}
if (m.type === 'terminal_attach') {
const { session_id, terminal_id, since_seq } = m.payload || {};
const record = terminalList(session_id).find((item) => item.id === terminal_id);
if (!record) {
ws.send(JSON.stringify({ type: 'ack', id: m.id, code: 40407, msg: 'terminal not found', payload: {} }));
return;
}
const key = terminalKey(session_id, terminal_id);
const sinks = terminalSinks.get(key) || new Set();
sinks.add(ws);
terminalSinks.set(key, sinks);
const replay = (record.buffer || []).filter((frame) => frame.type !== 'terminal_output' || frame.seq > (since_seq || 0));
for (const frame of replay) {
if (ws.readyState === 1) ws.send(JSON.stringify(frame));
}
if ((record.buffer || []).length === 0 && record.status === 'running') {
emitTerminalOutput(record, `stub terminal ${record.id}\r\n${record.cwd} $ `);
}
ws.send(JSON.stringify({
type: 'ack', id: m.id, code: 0, msg: 'success',
payload: { attached: true, replayed: replay.length },
}));
}
if (m.type === 'terminal_detach') {
const { session_id, terminal_id } = m.payload || {};
terminalSinks.get(terminalKey(session_id, terminal_id))?.delete(ws);
ws.send(JSON.stringify({ type: 'ack', id: m.id, code: 0, msg: 'success', payload: { detached: true } }));
}
if (m.type === 'terminal_input') {
const { session_id, terminal_id, data } = m.payload || {};
const record = terminalList(session_id).find((item) => item.id === terminal_id);
if (!record || record.status === 'exited') {
ws.send(JSON.stringify({ type: 'ack', id: m.id, code: 40407, msg: 'terminal not found', payload: {} }));
return;
}
emitTerminalOutput(record, data);
if (typeof data === 'string' && (data.includes('\r') || data.includes('\n'))) {
emitTerminalOutput(record, `${record.cwd} $ `);
}
ws.send(JSON.stringify({ type: 'ack', id: m.id, code: 0, msg: 'success', payload: { accepted: true } }));
}
if (m.type === 'terminal_resize') {
const { session_id, terminal_id, cols, rows } = m.payload || {};
const record = terminalList(session_id).find((item) => item.id === terminal_id);
if (record) {
record.cols = cols || record.cols;
record.rows = rows || record.rows;
}
ws.send(JSON.stringify({ type: 'ack', id: m.id, code: 0, msg: 'success', payload: { resized: true } }));
}
if (m.type === 'terminal_close') {
const { session_id, terminal_id } = m.payload || {};
const record = terminalList(session_id).find((item) => item.id === terminal_id);
if (record && record.status !== 'exited') emitTerminalExit(record, 0);
ws.send(JSON.stringify({ type: 'ack', id: m.id, code: 0, msg: 'success', payload: { closed: true } }));
}
if (m.type === 'pong') {
// heartbeat response — no-op
}
if (m.type === 'watch_fs_add' || m.type === 'watch_fs_remove') {
ws.send(JSON.stringify({
type: 'ack', id: m.id, code: 0, msg: 'success',
payload: { watched_paths: m.payload?.paths || [] },
}));
}
});
ws.on('close', () => {
clearInterval(ping);
sockets.delete(ws);
for (const sinks of terminalSinks.values()) sinks.delete(ws);
});
});
server.listen(PORT, '127.0.0.1', () => {
console.log(`[stub-daemon] REST+WS on http://127.0.0.1:${PORT} (Ctrl+C to stop)`);
console.log(`[stub-daemon] Routes: /api/v1/* (healthz, models, providers, auth/login, auth/logout, auth/status)`);
console.log(`[stub-daemon] WS path: /api/v1/ws`);
console.log(`[stub-daemon] Event mode: ${RAW_EVENTS_MODE ? 'RAW agent-core events (STUB_RAW_EVENTS=1)' : 'projected event.* protocol (default)'}`);
console.log(`[stub-daemon] Seeded ${sessions.length} sessions, ${Object.values(messages).flat().length} messages`);
});