feat: add CLI subprocess chat, Agent Teams API, and real background tasks

Three new modules completing the server-side functionality:

1. ConversationService: spawns CLI as subprocess with --input-format
   stream-json, forwards WebSocket messages to CLI stdin/stdout.
   CLI handles all AI communication, tool execution, and permissions.
   Graceful fallback to echo mode when CLI unavailable.

2. Agent Teams API: GET/DELETE /api/teams, member listing with status
   derivation (running/idle/completed), transcript reading from
   CLI-generated JSONL files. Teams created by CLI, API is read-only.

3. TaskService: replaces placeholder with real task file scanning
   from ~/.claude/tasks/, supports nested team directories.

229 tests passing (49 new: 12 conversations + 27 teams + 10 tasks).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-04-05 14:05:22 +08:00
parent f1c310a004
commit 29c9fb8f4a
10 changed files with 1780 additions and 36 deletions

View File

@ -0,0 +1,257 @@
/**
* Tests for ConversationService and WebSocket chat integration
*
* ConversationService CLI
* WebSocket CLI
*/
import { describe, it, expect, beforeAll, afterAll } from 'bun:test'
import * as fs from 'fs/promises'
import * as path from 'path'
import * as os from 'os'
import { ConversationService } from '../services/conversationService.js'
// ============================================================================
// ConversationService unit tests
// ============================================================================
describe('ConversationService', () => {
it('should report no session for unknown ID', () => {
const svc = new ConversationService()
const sid = crypto.randomUUID()
expect(svc.hasSession(sid)).toBe(false)
})
it('should track active sessions as empty initially', () => {
const svc = new ConversationService()
expect(svc.getActiveSessions()).toEqual([])
})
it('should return false when sending message to non-existent session', () => {
const svc = new ConversationService()
const result = svc.sendMessage('no-such-session', 'hello')
expect(result).toBe(false)
})
it('should return false when responding to permission for non-existent session', () => {
const svc = new ConversationService()
const result = svc.respondToPermission('no-such-session', 'req-1', true)
expect(result).toBe(false)
})
it('should return false when sending interrupt to non-existent session', () => {
const svc = new ConversationService()
const result = svc.sendInterrupt('no-such-session')
expect(result).toBe(false)
})
it('should not throw when stopping non-existent session', () => {
const svc = new ConversationService()
expect(() => svc.stopSession('no-such-session')).not.toThrow()
})
it('should not throw when registering callback for non-existent session', () => {
const svc = new ConversationService()
expect(() => svc.onOutput('no-such-session', () => {})).not.toThrow()
})
})
// ============================================================================
// WebSocket integration tests (with real server, CLI falls back to echo)
// ============================================================================
describe('WebSocket Chat Integration', () => {
let server: ReturnType<typeof Bun.serve>
let baseUrl: string
let wsUrl: string
let tmpDir: string
beforeAll(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-conv-'))
process.env.CLAUDE_CONFIG_DIR = tmpDir
await fs.mkdir(path.join(tmpDir, 'projects'), { recursive: true })
const port = 15000 + Math.floor(Math.random() * 1000)
const { startServer } = await import('../index.js')
server = startServer(port, '127.0.0.1')
baseUrl = `http://127.0.0.1:${port}`
wsUrl = `ws://127.0.0.1:${port}`
})
afterAll(async () => {
server?.stop()
if (tmpDir) {
await fs.rm(tmpDir, { recursive: true, force: true })
}
delete process.env.CLAUDE_CONFIG_DIR
})
it('should connect and receive connected event', async () => {
const messages: any[] = []
const ws = new WebSocket(`${wsUrl}/ws/chat-test-1`)
await new Promise<void>((resolve) => {
ws.onmessage = (e) => {
messages.push(JSON.parse(e.data as string))
if (messages.length >= 1) {
ws.close()
resolve()
}
}
ws.onerror = () => {
ws.close()
resolve()
}
setTimeout(() => {
ws.close()
resolve()
}, 3000)
})
expect(messages[0].type).toBe('connected')
expect(messages[0].sessionId).toBe('chat-test-1')
})
it('should handle stop_generation and return idle status', async () => {
const messages: any[] = []
const ws = new WebSocket(`${wsUrl}/ws/chat-test-2`)
await new Promise<void>((resolve) => {
ws.onmessage = (e) => {
const msg = JSON.parse(e.data as string)
messages.push(msg)
if (msg.type === 'connected') {
ws.send(JSON.stringify({ type: 'stop_generation' }))
}
if (msg.type === 'status' && msg.state === 'idle') {
ws.close()
resolve()
}
}
ws.onerror = () => {
ws.close()
resolve()
}
setTimeout(() => {
ws.close()
resolve()
}, 3000)
})
expect(messages.some((m) => m.type === 'status' && m.state === 'idle')).toBe(true)
})
it('should send user_message and receive fallback echo response', async () => {
const messages: any[] = []
const ws = new WebSocket(`${wsUrl}/ws/chat-test-3`)
await new Promise<void>((resolve) => {
ws.onmessage = (e) => {
const msg = JSON.parse(e.data as string)
messages.push(msg)
if (msg.type === 'connected') {
ws.send(
JSON.stringify({ type: 'user_message', content: 'Hello from test' })
)
}
// Wait until we receive idle status after the echo
if (
msg.type === 'status' &&
msg.state === 'idle' &&
messages.length > 3
) {
ws.close()
resolve()
}
}
ws.onerror = () => {
ws.close()
resolve()
}
setTimeout(() => {
ws.close()
resolve()
}, 5000)
})
const types = messages.map((m) => m.type)
expect(types).toContain('connected')
expect(types).toContain('status')
// Fallback echo produces content_start, content_delta, message_complete, status
expect(types).toContain('content_start')
expect(types).toContain('content_delta')
expect(types).toContain('message_complete')
// Verify thinking was first status
const statusMsgs = messages.filter((m) => m.type === 'status')
expect(statusMsgs[0].state).toBe('thinking')
})
it('should handle permission_response without error', async () => {
const messages: any[] = []
const ws = new WebSocket(`${wsUrl}/ws/chat-test-4`)
await new Promise<void>((resolve) => {
ws.onmessage = (e) => {
const msg = JSON.parse(e.data as string)
messages.push(msg)
if (msg.type === 'connected') {
// Send a permission response (no active session, should not crash)
ws.send(
JSON.stringify({
type: 'permission_response',
requestId: 'test-req-1',
allowed: true,
})
)
// Give a moment then close
setTimeout(() => {
ws.close()
resolve()
}, 500)
}
}
ws.onerror = () => {
ws.close()
resolve()
}
setTimeout(() => {
ws.close()
resolve()
}, 3000)
})
// Should have received connected and no error
expect(messages[0].type).toBe('connected')
expect(messages.some((m) => m.type === 'error')).toBe(false)
})
it('should handle ping/pong', async () => {
const messages: any[] = []
const ws = new WebSocket(`${wsUrl}/ws/chat-test-5`)
await new Promise<void>((resolve) => {
ws.onmessage = (e) => {
const msg = JSON.parse(e.data as string)
messages.push(msg)
if (msg.type === 'connected') {
ws.send(JSON.stringify({ type: 'ping' }))
}
if (msg.type === 'pong') {
ws.close()
resolve()
}
}
ws.onerror = () => {
ws.close()
resolve()
}
setTimeout(() => {
ws.close()
resolve()
}, 3000)
})
expect(messages.some((m) => m.type === 'pong')).toBe(true)
})
})

View File

@ -0,0 +1,171 @@
/**
* Unit tests for TaskService and Tasks API
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
import * as fs from 'fs/promises'
import * as path from 'path'
import * as os from 'os'
import { TaskService } from '../services/taskService.js'
// ============================================================================
// TaskService unit tests
// ============================================================================
describe('TaskService', () => {
let tmpDir: string
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-tasks-'))
process.env.CLAUDE_CONFIG_DIR = tmpDir
})
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true })
delete process.env.CLAUDE_CONFIG_DIR
})
it('should return empty list when no tasks dir', async () => {
const svc = new TaskService()
const tasks = await svc.listTasks()
expect(tasks).toEqual([])
})
it('should list tasks from JSON files', async () => {
const tasksDir = path.join(tmpDir, 'tasks')
await fs.mkdir(tasksDir, { recursive: true })
await fs.writeFile(path.join(tasksDir, 'task-001.json'), JSON.stringify({
id: 'task-001',
type: 'local_agent',
status: 'completed',
name: 'code-review',
description: 'Review PR #42',
createdAt: Date.now() - 60000,
completedAt: Date.now(),
}))
await fs.writeFile(path.join(tasksDir, 'task-002.json'), JSON.stringify({
id: 'task-002',
type: 'in_process_teammate',
status: 'running',
name: 'frontend-dev',
teamName: 'ui-team',
createdAt: Date.now(),
}))
const svc = new TaskService()
const tasks = await svc.listTasks()
expect(tasks.length).toBe(2)
// 按 createdAt 倒序
expect(tasks[0].id).toBe('task-002')
expect(tasks[1].id).toBe('task-001')
})
it('should scan nested team task directories', async () => {
const teamDir = path.join(tmpDir, 'tasks', 'my-team')
await fs.mkdir(teamDir, { recursive: true })
await fs.writeFile(path.join(teamDir, 'member-1.json'), JSON.stringify({
id: 'member-1',
type: 'in_process_teammate',
status: 'completed',
teamName: 'my-team',
}))
const svc = new TaskService()
const tasks = await svc.listTasks()
expect(tasks.length).toBe(1)
expect(tasks[0].teamName).toBe('my-team')
})
it('should get single task by ID', async () => {
const tasksDir = path.join(tmpDir, 'tasks')
await fs.mkdir(tasksDir, { recursive: true })
await fs.writeFile(path.join(tasksDir, 'abc.json'), JSON.stringify({
id: 'abc',
type: 'local_shell',
status: 'failed',
name: 'build',
}))
const svc = new TaskService()
const task = await svc.getTask('abc')
expect(task).toBeDefined()
expect(task!.status).toBe('failed')
})
it('should return null for unknown task', async () => {
const svc = new TaskService()
const task = await svc.getTask('nonexistent')
expect(task).toBeNull()
})
it('should skip invalid JSON files gracefully', async () => {
const tasksDir = path.join(tmpDir, 'tasks')
await fs.mkdir(tasksDir, { recursive: true })
await fs.writeFile(path.join(tasksDir, 'bad.json'), 'not json {{{')
const svc = new TaskService()
const tasks = await svc.listTasks()
expect(tasks).toEqual([])
})
})
// ============================================================================
// Tasks API integration tests
// ============================================================================
describe('Tasks API', () => {
let server: any
let baseUrl: string
let tmpDir: string
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-tasks-api-'))
process.env.CLAUDE_CONFIG_DIR = tmpDir
await fs.mkdir(path.join(tmpDir, 'projects'), { recursive: true })
const port = 15500 + Math.floor(Math.random() * 500)
const { startServer } = await import('../../server/index.js')
server = startServer(port, '127.0.0.1')
baseUrl = `http://127.0.0.1:${port}`
})
afterEach(async () => {
server?.stop()
await fs.rm(tmpDir, { recursive: true, force: true })
delete process.env.CLAUDE_CONFIG_DIR
})
it('should return empty tasks list', async () => {
const res = await fetch(`${baseUrl}/api/tasks`)
const data = await res.json()
expect(res.status).toBe(200)
expect(data.tasks).toEqual([])
})
it('should return tasks when files exist', async () => {
const tasksDir = path.join(tmpDir, 'tasks')
await fs.mkdir(tasksDir, { recursive: true })
await fs.writeFile(path.join(tasksDir, 'test.json'), JSON.stringify({
id: 'test', type: 'local_agent', status: 'completed', name: 'test-task',
}))
const res = await fetch(`${baseUrl}/api/tasks`)
const data = await res.json()
expect(data.tasks.length).toBe(1)
expect(data.tasks[0].name).toBe('test-task')
})
it('should return 404 for unknown task', async () => {
const res = await fetch(`${baseUrl}/api/tasks/nonexistent`)
expect(res.status).toBe(404)
})
it('should reject non-GET methods', async () => {
const res = await fetch(`${baseUrl}/api/tasks`, { method: 'POST' })
expect(res.status).toBe(405)
})
})

View File

@ -0,0 +1,484 @@
/**
* Unit tests for TeamService and Teams API
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import * as os from 'node:os'
import { TeamService } from '../services/teamService.js'
// ============================================================================
// Test helpers
// ============================================================================
let tmpDir: string
let service: TeamService
async function setupTmpConfigDir(): Promise<string> {
tmpDir = path.join(
os.tmpdir(),
`claude-teams-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
)
await fs.mkdir(path.join(tmpDir, 'teams'), { recursive: true })
await fs.mkdir(path.join(tmpDir, 'projects'), { recursive: true })
process.env.CLAUDE_CONFIG_DIR = tmpDir
return tmpDir
}
async function cleanupTmpDir(): Promise<void> {
if (tmpDir) {
await fs.rm(tmpDir, { recursive: true, force: true })
}
delete process.env.CLAUDE_CONFIG_DIR
}
/** Write a team config.json to the temp directory. */
async function writeTeamConfig(
teamName: string,
config: Record<string, unknown>,
): Promise<string> {
const teamDir = path.join(tmpDir, 'teams', teamName)
await fs.mkdir(teamDir, { recursive: true })
const configPath = path.join(teamDir, 'config.json')
await fs.writeFile(configPath, JSON.stringify(config), 'utf-8')
return configPath
}
/** Write a mock JSONL transcript file under projects. */
async function writeTranscriptFile(
projectDir: string,
sessionId: string,
entries: Record<string, unknown>[],
): Promise<string> {
const dir = path.join(tmpDir, 'projects', projectDir)
await fs.mkdir(dir, { recursive: true })
const filePath = path.join(dir, `${sessionId}.jsonl`)
const content = entries.map((e) => JSON.stringify(e)).join('\n') + '\n'
await fs.writeFile(filePath, content, 'utf-8')
return filePath
}
/** Create a standard team config for testing. */
function makeTeamConfig(overrides?: Record<string, unknown>) {
return {
name: 'test-team',
description: 'A test team',
createdAt: 1700000000000,
leadAgentId: 'agent-lead',
members: [
{
agentId: 'agent-lead',
name: 'Lead Agent',
agentType: 'lead',
model: 'claude-opus-4-6',
color: '#ff0000',
joinedAt: 1700000000000,
tmuxPaneId: '%0',
cwd: '/tmp/project',
sessionId: 'session-lead-001',
isActive: true,
},
{
agentId: 'agent-worker',
name: 'Worker Agent',
agentType: 'worker',
model: 'claude-sonnet-4-20250514',
color: '#00ff00',
joinedAt: 1700000001000,
tmuxPaneId: '%1',
cwd: '/tmp/project/src',
sessionId: 'session-worker-001',
isActive: false,
},
],
...overrides,
}
}
// ============================================================================
// TeamService tests
// ============================================================================
describe('TeamService', () => {
beforeEach(async () => {
await setupTmpConfigDir()
service = new TeamService()
})
afterEach(async () => {
await cleanupTmpDir()
})
// --------------------------------------------------------------------------
// listTeams
// --------------------------------------------------------------------------
it('should return empty list when no teams exist', async () => {
const teams = await service.listTeams()
expect(teams).toEqual([])
})
it('should return empty list when teams directory does not exist', async () => {
await fs.rm(path.join(tmpDir, 'teams'), { recursive: true, force: true })
const teams = await service.listTeams()
expect(teams).toEqual([])
})
it('should list teams from config files', async () => {
await writeTeamConfig('alpha', makeTeamConfig({ name: 'alpha' }))
await writeTeamConfig('beta', makeTeamConfig({ name: 'beta', description: 'Beta team' }))
const teams = await service.listTeams()
expect(teams).toHaveLength(2)
const names = teams.map((t) => t.name).sort()
expect(names).toEqual(['alpha', 'beta'])
})
it('should compute memberCount and activeMemberCount', async () => {
await writeTeamConfig('gamma', makeTeamConfig({ name: 'gamma' }))
const teams = await service.listTeams()
expect(teams).toHaveLength(1)
expect(teams[0]!.memberCount).toBe(2)
expect(teams[0]!.activeMemberCount).toBe(1) // only lead is active
})
it('should skip malformed team directories', async () => {
// Create a team dir with invalid JSON
const badDir = path.join(tmpDir, 'teams', 'bad-team')
await fs.mkdir(badDir, { recursive: true })
await fs.writeFile(path.join(badDir, 'config.json'), 'not json', 'utf-8')
// Also create a valid team
await writeTeamConfig('good-team', makeTeamConfig({ name: 'good-team' }))
const teams = await service.listTeams()
expect(teams).toHaveLength(1)
expect(teams[0]!.name).toBe('good-team')
})
// --------------------------------------------------------------------------
// getTeam
// --------------------------------------------------------------------------
it('should return team detail with members', async () => {
await writeTeamConfig('detail-team', makeTeamConfig({ name: 'detail-team' }))
const detail = await service.getTeam('detail-team')
expect(detail.name).toBe('detail-team')
expect(detail.leadAgentId).toBe('agent-lead')
expect(detail.members).toHaveLength(2)
expect(detail.members[0]!.agentId).toBe('agent-lead')
expect(detail.members[1]!.agentId).toBe('agent-worker')
})
it('should derive running status for active member', async () => {
await writeTeamConfig('status-team', makeTeamConfig({ name: 'status-team' }))
const detail = await service.getTeam('status-team')
const lead = detail.members.find((m) => m.agentId === 'agent-lead')!
expect(lead.status).toBe('running')
})
it('should derive idle status for inactive member', async () => {
await writeTeamConfig('status-team', makeTeamConfig({ name: 'status-team' }))
const detail = await service.getTeam('status-team')
const worker = detail.members.find((m) => m.agentId === 'agent-worker')!
expect(worker.status).toBe('idle')
})
it('should derive running status when isActive is undefined', async () => {
const config = makeTeamConfig({ name: 'undef-team' })
// Remove isActive from the first member to simulate undefined
delete (config.members[0] as Record<string, unknown>).isActive
await writeTeamConfig('undef-team', config)
const detail = await service.getTeam('undef-team')
const lead = detail.members.find((m) => m.agentId === 'agent-lead')!
expect(lead.status).toBe('running')
})
it('should throw 404 for non-existent team', async () => {
expect(service.getTeam('nonexistent')).rejects.toThrow('Team not found')
})
// --------------------------------------------------------------------------
// getMemberTranscript
// --------------------------------------------------------------------------
it('should return transcript messages for a member', async () => {
await writeTeamConfig('transcript-team', makeTeamConfig({ name: 'transcript-team' }))
// Write a mock transcript JSONL for the lead session
await writeTranscriptFile('-tmp-project', 'session-lead-001', [
{
type: 'file-history-snapshot',
messageId: 'snap-1',
snapshot: {},
},
{
type: 'user',
uuid: 'msg-user-1',
message: { role: 'user', content: 'Hello team' },
timestamp: '2026-01-01T00:01:00.000Z',
},
{
type: 'assistant',
uuid: 'msg-asst-1',
message: {
role: 'assistant',
content: [{ type: 'text', text: 'Hi! Ready to help.' }],
},
timestamp: '2026-01-01T00:02:00.000Z',
},
])
const messages = await service.getMemberTranscript(
'transcript-team',
'agent-lead',
)
expect(messages).toHaveLength(2)
expect(messages[0]!.type).toBe('user')
expect(messages[0]!.id).toBe('msg-user-1')
expect(messages[1]!.type).toBe('assistant')
})
it('should return empty array when member has no sessionId', async () => {
const config = makeTeamConfig({ name: 'no-session-team' })
delete (config.members[0] as Record<string, unknown>).sessionId
await writeTeamConfig('no-session-team', config)
const messages = await service.getMemberTranscript(
'no-session-team',
'agent-lead',
)
expect(messages).toEqual([])
})
it('should return empty array when transcript file not found', async () => {
await writeTeamConfig('no-file-team', makeTeamConfig({ name: 'no-file-team' }))
// Don't write any transcript file
const messages = await service.getMemberTranscript(
'no-file-team',
'agent-lead',
)
expect(messages).toEqual([])
})
it('should throw 404 for unknown member', async () => {
await writeTeamConfig('member-team', makeTeamConfig({ name: 'member-team' }))
expect(
service.getMemberTranscript('member-team', 'nonexistent-agent'),
).rejects.toThrow('Member not found')
})
it('should skip meta entries in transcript', async () => {
await writeTeamConfig('meta-team', makeTeamConfig({ name: 'meta-team' }))
await writeTranscriptFile('-tmp-project', 'session-lead-001', [
{
type: 'user',
uuid: 'msg-meta',
message: { role: 'user', content: 'internal meta' },
isMeta: true,
timestamp: '2026-01-01T00:00:30.000Z',
},
{
type: 'user',
uuid: 'msg-real',
message: { role: 'user', content: 'Real message' },
timestamp: '2026-01-01T00:01:00.000Z',
},
])
const messages = await service.getMemberTranscript('meta-team', 'agent-lead')
expect(messages).toHaveLength(1)
expect(messages[0]!.id).toBe('msg-real')
})
// --------------------------------------------------------------------------
// deleteTeam
// --------------------------------------------------------------------------
it('should delete a team with no active members', async () => {
const config = makeTeamConfig({ name: 'deletable' })
// Set all members to inactive
for (const member of config.members) {
;(member as Record<string, unknown>).isActive = false
}
await writeTeamConfig('deletable', config)
await service.deleteTeam('deletable')
// Team dir should be gone
const teamDir = path.join(tmpDir, 'teams', 'deletable')
expect(fs.access(teamDir)).rejects.toThrow()
})
it('should refuse to delete a team with active members', async () => {
await writeTeamConfig('active-team', makeTeamConfig({ name: 'active-team' }))
expect(service.deleteTeam('active-team')).rejects.toThrow(
'has active members',
)
})
it('should throw 404 when deleting non-existent team', async () => {
expect(service.deleteTeam('ghost')).rejects.toThrow('Team not found')
})
})
// ============================================================================
// Teams API integration tests
// ============================================================================
describe('Teams API', () => {
let baseUrl: string
let server: ReturnType<typeof Bun.serve> | null = null
beforeEach(async () => {
await setupTmpConfigDir()
service = new TeamService()
const { handleTeamsApi } = await import('../api/teams.js')
const port = 40000 + Math.floor(Math.random() * 10000)
baseUrl = `http://127.0.0.1:${port}`
server = Bun.serve({
port,
hostname: '127.0.0.1',
async fetch(req) {
const url = new URL(req.url)
const segments = url.pathname.split('/').filter(Boolean)
if (segments[0] === 'api' && segments[1] === 'teams') {
return handleTeamsApi(req, url, segments)
}
return new Response('Not Found', { status: 404 })
},
})
})
afterEach(async () => {
if (server) {
server.stop(true)
server = null
}
await cleanupTmpDir()
})
it('GET /api/teams should return empty list', async () => {
const res = await fetch(`${baseUrl}/api/teams`)
expect(res.status).toBe(200)
const body = (await res.json()) as { teams: unknown[] }
expect(body.teams).toEqual([])
})
it('GET /api/teams should list teams', async () => {
await writeTeamConfig('api-team', makeTeamConfig({ name: 'api-team' }))
const res = await fetch(`${baseUrl}/api/teams`)
expect(res.status).toBe(200)
const body = (await res.json()) as { teams: Array<{ name: string }> }
expect(body.teams).toHaveLength(1)
expect(body.teams[0]!.name).toBe('api-team')
})
it('GET /api/teams/:name should return team detail', async () => {
await writeTeamConfig('detail', makeTeamConfig({ name: 'detail' }))
const res = await fetch(`${baseUrl}/api/teams/detail`)
expect(res.status).toBe(200)
const body = (await res.json()) as {
name: string
leadAgentId: string
members: Array<{ agentId: string }>
}
expect(body.name).toBe('detail')
expect(body.leadAgentId).toBe('agent-lead')
expect(body.members).toHaveLength(2)
})
it('GET /api/teams/:name should 404 for unknown team', async () => {
const res = await fetch(`${baseUrl}/api/teams/nonexistent`)
expect(res.status).toBe(404)
})
it('GET /api/teams/:name/members/:id/transcript should return messages', async () => {
await writeTeamConfig('t-team', makeTeamConfig({ name: 't-team' }))
await writeTranscriptFile('-tmp-project', 'session-lead-001', [
{
type: 'user',
uuid: 'u1',
message: { role: 'user', content: 'Hello' },
timestamp: '2026-01-01T00:01:00.000Z',
},
])
const res = await fetch(
`${baseUrl}/api/teams/t-team/members/agent-lead/transcript`,
)
expect(res.status).toBe(200)
const body = (await res.json()) as {
messages: Array<{ id: string; type: string }>
}
expect(body.messages).toHaveLength(1)
expect(body.messages[0]!.type).toBe('user')
})
it('GET /api/teams/:name/members/:id/transcript should 404 for unknown member', async () => {
await writeTeamConfig('t2-team', makeTeamConfig({ name: 't2-team' }))
const res = await fetch(
`${baseUrl}/api/teams/t2-team/members/unknown-agent/transcript`,
)
expect(res.status).toBe(404)
})
it('DELETE /api/teams/:name should delete team', async () => {
const config = makeTeamConfig({ name: 'del-team' })
for (const member of (config as { members: Array<Record<string, unknown>> }).members) {
member.isActive = false
}
await writeTeamConfig('del-team', config)
const res = await fetch(`${baseUrl}/api/teams/del-team`, {
method: 'DELETE',
})
expect(res.status).toBe(200)
const body = (await res.json()) as { ok: boolean }
expect(body.ok).toBe(true)
// Verify it's gone
const res2 = await fetch(`${baseUrl}/api/teams/del-team`)
expect(res2.status).toBe(404)
})
it('DELETE /api/teams/:name should 409 when team has active members', async () => {
await writeTeamConfig('active', makeTeamConfig({ name: 'active' }))
const res = await fetch(`${baseUrl}/api/teams/active`, {
method: 'DELETE',
})
expect(res.status).toBe(409)
})
it('POST /api/teams should return 405', async () => {
const res = await fetch(`${baseUrl}/api/teams`, { method: 'POST' })
expect(res.status).toBe(405)
})
})

View File

@ -7,11 +7,12 @@
* PUT /api/agents/:name Agent
* DELETE /api/agents/:name Agent
*
* GET /api/tasks (placeholder)
* GET /api/tasks/:id (placeholder)
* GET /api/tasks
* GET /api/tasks/:id
*/
import { AgentService } from '../services/agentService.js'
import { taskService } from '../services/taskService.js'
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
const agentService = new AgentService()
@ -25,7 +26,7 @@ export async function handleAgentsApi(
const resource = segments[1] // 'agents' | 'tasks'
if (resource === 'tasks') {
return await handleTasksPlaceholder(req, segments)
return await handleTasksApi(req, segments)
}
return await handleAgents(req, segments)
@ -96,36 +97,34 @@ async function handleAgents(
)
}
// ─── Tasks placeholder ──────────────────────────────────────────────────────
// ─── Tasks API ─────────────────────────────────────────────────────────────
async function handleTasksPlaceholder(
async function handleTasksApi(
req: Request,
segments: string[],
): Promise<Response> {
const method = req.method
const taskId = segments[2]
if (method !== 'GET') {
if (req.method !== 'GET') {
throw new ApiError(
405,
`Method ${method} not allowed on /api/tasks`,
`Method ${req.method} not allowed on /api/tasks`,
'METHOD_NOT_ALLOWED',
)
}
// GET /api/tasks/:id
const taskId = segments[2]
if (taskId) {
return Response.json({
task: {
id: taskId,
status: 'pending',
message: 'Background tasks are not yet implemented',
},
})
// GET /api/tasks/:id
const task = await taskService.getTask(taskId)
if (!task) {
throw ApiError.notFound(`Task not found: ${taskId}`)
}
return Response.json({ task })
}
// GET /api/tasks
return Response.json({ tasks: [] })
const tasks = await taskService.listTasks()
return Response.json({ tasks })
}
// ─── Helpers ────────────────────────────────────────────────────────────────

61
src/server/api/teams.ts Normal file
View File

@ -0,0 +1,61 @@
/**
* Teams REST API
*
* GET /api/teams
* GET /api/teams/:name
* GET /api/teams/:name/members/:id/transcript transcript
* DELETE /api/teams/:name
*/
import { teamService } from '../services/teamService.js'
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
export async function handleTeamsApi(
req: Request,
_url: URL,
segments: string[],
): Promise<Response> {
try {
const method = req.method
const teamName = segments[2] ? decodeURIComponent(segments[2]) : undefined
// ── GET /api/teams ────────────────────────────────────────────────────
if (method === 'GET' && !teamName) {
const teams = await teamService.listTeams()
return Response.json({ teams })
}
// ── GET /api/teams/:name/members/:id/transcript ───────────────────────
if (
method === 'GET' &&
teamName &&
segments[3] === 'members' &&
segments[4] &&
segments[5] === 'transcript'
) {
const agentId = decodeURIComponent(segments[4])
const messages = await teamService.getMemberTranscript(teamName, agentId)
return Response.json({ messages })
}
// ── GET /api/teams/:name ──────────────────────────────────────────────
if (method === 'GET' && teamName) {
const team = await teamService.getTeam(teamName)
return Response.json(team)
}
// ── DELETE /api/teams/:name ───────────────────────────────────────────
if (method === 'DELETE' && teamName) {
await teamService.deleteTeam(teamName)
return Response.json({ ok: true })
}
throw new ApiError(
405,
`Method ${method} not allowed on /api/teams${teamName ? `/${teamName}` : ''}`,
'METHOD_NOT_ALLOWED',
)
} catch (error) {
return errorResponse(error)
}
}

View File

@ -10,6 +10,7 @@ import { handleSearchApi } from './api/search.js'
import { handleAgentsApi } from './api/agents.js'
import { handleStatusApi } from './api/status.js'
import { handleConversationsApi } from './api/conversations.js'
import { handleTeamsApi } from './api/teams.js'
export async function handleApiRequest(req: Request, url: URL): Promise<Response> {
const path = url.pathname
@ -54,6 +55,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise<Response
case 'status':
return handleStatusApi(req, url, segments)
case 'teams':
return handleTeamsApi(req, url, segments)
default:
return Response.json(
{ error: 'Not Found', message: `Unknown API resource: ${resource}` },

View File

@ -0,0 +1,254 @@
/**
* ConversationService CLI subprocess manager
*
* sessionId CLI stdin/stdout
* stream-json (NDJSON)
*
*
* WebSocket stdin (user message)
* stdout (assistant message) WebSocket
*/
import * as path from 'path'
type SessionProcess = {
proc: ReturnType<typeof Bun.spawn>
outputCallbacks: Array<(msg: any) => void>
workDir: string
}
export class ConversationService {
private sessions = new Map<string, SessionProcess>()
/**
* CLI
* sessionId
*/
async startSession(sessionId: string, workDir: string): Promise<void> {
if (this.sessions.has(sessionId)) return
// 找到 CLI 入口点
const cliPath = path.resolve(import.meta.dir, '../../entrypoints/cli.tsx')
const proc = Bun.spawn(
[
'bun',
cliPath,
'--print',
'--verbose',
'--input-format',
'stream-json',
'--output-format',
'stream-json',
'--session-id',
sessionId,
],
{
cwd: workDir,
env: { ...process.env },
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
}
)
const session: SessionProcess = {
proc,
outputCallbacks: [],
workDir,
}
this.sessions.set(sessionId, session)
// 读取 stdoutNDJSON 格式,每行一个 JSON 对象)
this.readOutputStream(sessionId, proc)
// 读取 stderr 用于调试
this.readErrorStream(sessionId, proc)
// 进程退出时清理
proc.exited.then((code) => {
console.log(
`[ConversationService] CLI process for ${sessionId} exited with code ${code}`
)
this.sessions.delete(sessionId)
})
// 等待一小段时间,检测进程是否立即崩溃退出。
// 使用 Promise.race 直接竞争 proc.exited比检查 sessions map 更可靠。
const STARTUP_GRACE_MS = 1000
const earlyExitCode = await Promise.race([
proc.exited,
new Promise<null>((resolve) => setTimeout(() => resolve(null), STARTUP_GRACE_MS)),
])
if (earlyExitCode !== null) {
// 进程在启动窗口内退出了,确保清理并抛出错误
this.sessions.delete(sessionId)
throw new Error(
`CLI process for ${sessionId} exited immediately with code ${earlyExitCode}`
)
}
}
private async readOutputStream(
sessionId: string,
proc: ReturnType<typeof Bun.spawn>
): Promise<void> {
if (!proc.stdout) return
const reader = (proc.stdout as ReadableStream).getReader()
const decoder = new TextDecoder()
let buffer = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || '' // 保留最后不完整的行
for (const line of lines) {
if (!line.trim()) continue
try {
const msg = JSON.parse(line)
const session = this.sessions.get(sessionId)
if (session) {
for (const cb of session.outputCallbacks) {
cb(msg)
}
}
} catch {
// 非 JSON 行忽略
}
}
}
} catch (err) {
console.error(
`[ConversationService] stdout read error for ${sessionId}:`,
err
)
}
}
private async readErrorStream(
sessionId: string,
proc: ReturnType<typeof Bun.spawn>
): Promise<void> {
if (!proc.stderr) return
const reader = (proc.stderr as ReadableStream).getReader()
const decoder = new TextDecoder()
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
const text = decoder.decode(value, { stream: true })
if (text.trim()) {
console.error(`[CLI:${sessionId}] ${text.trim()}`)
}
}
} catch {
// stderr 读取错误不影响主流程
}
}
/**
* stdout CLI JSON
*
*/
onOutput(sessionId: string, callback: (msg: any) => void): void {
const session = this.sessions.get(sessionId)
if (session) {
session.outputCallbacks.push(callback)
}
}
/**
* CLI stdin
* stream-json
*/
sendMessage(sessionId: string, content: string): boolean {
const session = this.sessions.get(sessionId)
if (!session || !session.proc.stdin) return false
const msg =
JSON.stringify({
type: 'user',
message: {
role: 'user',
content: [{ type: 'text', text: content }],
},
parent_tool_use_id: null,
session_id: '',
}) + '\n'
session.proc.stdin.write(msg)
return true
}
/**
* UI /
*/
respondToPermission(
sessionId: string,
requestId: string,
allowed: boolean
): boolean {
const session = this.sessions.get(sessionId)
if (!session || !session.proc.stdin) return false
const response =
JSON.stringify({
type: 'control_response',
response: {
subtype: 'success',
request_id: requestId,
response: allowed
? { behavior: 'allow' }
: { behavior: 'deny', message: 'User denied via UI' },
},
}) + '\n'
session.proc.stdin.write(response)
return true
}
/**
*
*/
sendInterrupt(sessionId: string): boolean {
const session = this.sessions.get(sessionId)
if (!session || !session.proc.stdin) return false
const request =
JSON.stringify({
type: 'control_request',
request_id: crypto.randomUUID(),
request: { subtype: 'interrupt' },
}) + '\n'
session.proc.stdin.write(request)
return true
}
hasSession(sessionId: string): boolean {
return this.sessions.has(sessionId)
}
stopSession(sessionId: string): void {
const session = this.sessions.get(sessionId)
if (session) {
session.proc.kill()
this.sessions.delete(sessionId)
}
}
getActiveSessions(): string[] {
return Array.from(this.sessions.keys())
}
}
// 导出全局单例
export const conversationService = new ConversationService()

View File

@ -0,0 +1,99 @@
/**
* TaskService
*
* ~/.claude/tasks/ JSON
* team/project
*/
import * as fs from 'fs/promises'
import * as path from 'path'
import * as os from 'os'
export type TaskInfo = {
id: string
type: 'local_shell' | 'local_agent' | 'in_process_teammate' | 'remote_agent' | 'workflow'
status: 'running' | 'completed' | 'failed' | 'pending'
name?: string
description?: string
createdAt?: number
completedAt?: number
teamName?: string
agentName?: string
metadata?: Record<string, unknown>
}
export class TaskService {
private getConfigDir(): string {
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
}
private getTasksDir(): string {
return path.join(this.getConfigDir(), 'tasks')
}
/** 列出所有持久化任务 */
async listTasks(): Promise<TaskInfo[]> {
const tasksDir = this.getTasksDir()
try {
const result: TaskInfo[] = []
await this.scanTasksRecursive(tasksDir, result)
return result.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0))
} catch (err: any) {
if (err.code === 'ENOENT') return []
throw err
}
}
/** 递归扫描任务目录 */
private async scanTasksRecursive(dir: string, result: TaskInfo[]): Promise<void> {
let entries
try {
entries = await fs.readdir(dir, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
await this.scanTasksRecursive(fullPath, result)
} else if (entry.name.endsWith('.json')) {
try {
const raw = await fs.readFile(fullPath, 'utf-8')
const data = JSON.parse(raw)
const task = this.parseTaskFile(entry.name, data)
if (task) result.push(task)
} catch {
// 跳过无法解析的文件
}
}
}
}
/** 解析单个任务文件 */
private parseTaskFile(filename: string, data: any): TaskInfo | null {
if (!data || typeof data !== 'object') return null
const id = filename.replace('.json', '')
return {
id: data.id || id,
type: data.type || 'local_agent',
status: data.status || 'completed',
name: data.name || data.title,
description: data.description || data.prompt,
createdAt: data.createdAt,
completedAt: data.completedAt,
teamName: data.teamName,
agentName: data.agentName || data.name,
metadata: data.metadata,
}
}
/** 获取单个任务详情 */
async getTask(taskId: string): Promise<TaskInfo | null> {
const tasks = await this.listTasks()
return tasks.find(t => t.id === taskId) || null
}
}
export const taskService = new TaskService()

View File

@ -0,0 +1,296 @@
/**
* TeamService CLI Agent Teams
*
* Team ~/.claude/teams/{name}/config.json
* transcript JSONL sessionId ~/.claude/projects/
* CLI TeamCreate
*/
import * as fs from 'fs/promises'
import * as path from 'path'
import * as os from 'os'
import { ApiError } from '../middleware/errorHandler.js'
// ─── Types ─────────────────────────────────────────────────────────────────
export type TeamMember = {
agentId: string
name: string
agentType?: string
model?: string
color?: string
status: 'running' | 'completed' | 'idle' | 'failed'
joinedAt: number
cwd: string
sessionId?: string
}
export type TeamSummary = {
name: string
description?: string
createdAt: number
memberCount: number
activeMemberCount: number
}
export type TeamDetail = TeamSummary & {
leadAgentId: string
members: TeamMember[]
}
export type TranscriptMessage = {
id: string
type: 'user' | 'assistant' | 'system' | 'tool_use' | 'tool_result'
content: unknown
timestamp: string
}
/** Raw config.json structure written by CLI */
type TeamFileRaw = {
name: string
description?: string
createdAt: number
leadAgentId: string
leadSessionId?: string
members: Array<{
agentId: string
name: string
agentType?: string
model?: string
prompt?: string
color?: string
joinedAt: number
tmuxPaneId: string
cwd: string
worktreePath?: string
sessionId?: string
isActive?: boolean
mode?: string
}>
}
// ─── Service ───────────────────────────────────────────────────────────────
export class TeamService {
private getConfigDir(): string {
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
}
private getTeamsDir(): string {
return path.join(this.getConfigDir(), 'teams')
}
private getProjectsDir(): string {
return path.join(this.getConfigDir(), 'projects')
}
// ── List all teams ──────────────────────────────────────────────────────
async listTeams(): Promise<TeamSummary[]> {
const teamsDir = this.getTeamsDir()
try {
await fs.access(teamsDir)
} catch {
return []
}
const entries = await fs.readdir(teamsDir, { withFileTypes: true })
const teams: TeamSummary[] = []
for (const entry of entries) {
if (!entry.isDirectory()) continue
try {
const config = await this.loadTeamConfig(entry.name)
teams.push(this.toSummary(config))
} catch {
// Skip malformed team directories
}
}
return teams
}
// ── Get team detail ─────────────────────────────────────────────────────
async getTeam(name: string): Promise<TeamDetail> {
const config = await this.loadTeamConfig(name)
const members: TeamMember[] = config.members.map((m) => ({
agentId: m.agentId,
name: m.name,
agentType: m.agentType,
model: m.model,
color: m.color,
status: this.deriveStatus(m.isActive),
joinedAt: m.joinedAt,
cwd: m.cwd,
sessionId: m.sessionId,
}))
return {
...this.toSummary(config),
leadAgentId: config.leadAgentId,
members,
}
}
// ── Get member transcript ───────────────────────────────────────────────
async getMemberTranscript(
teamName: string,
agentId: string,
): Promise<TranscriptMessage[]> {
const config = await this.loadTeamConfig(teamName)
const member = config.members.find((m) => m.agentId === agentId)
if (!member) {
throw ApiError.notFound(
`Member not found: ${agentId} in team ${teamName}`,
)
}
if (!member.sessionId) {
return []
}
const jsonlPath = await this.findTranscriptFile(member.sessionId)
if (!jsonlPath) {
return []
}
return this.parseTranscriptFile(jsonlPath)
}
// ── Delete team ─────────────────────────────────────────────────────────
async deleteTeam(name: string): Promise<void> {
const config = await this.loadTeamConfig(name)
const hasActive = config.members.some(
(m) => m.isActive === undefined || m.isActive === true,
)
if (hasActive) {
throw ApiError.conflict(
`Cannot delete team "${name}": has active members`,
)
}
const teamDir = path.join(this.getTeamsDir(), name)
await fs.rm(teamDir, { recursive: true, force: true })
}
// ── Internal helpers ────────────────────────────────────────────────────
private async loadTeamConfig(name: string): Promise<TeamFileRaw> {
const configPath = path.join(this.getTeamsDir(), name, 'config.json')
try {
const raw = await fs.readFile(configPath, 'utf-8')
return JSON.parse(raw) as TeamFileRaw
} catch {
throw ApiError.notFound(`Team not found: ${name}`)
}
}
private toSummary(config: TeamFileRaw): TeamSummary {
const activeMemberCount = config.members.filter(
(m) => m.isActive === undefined || m.isActive === true,
).length
return {
name: config.name,
description: config.description,
createdAt: config.createdAt,
memberCount: config.members.length,
activeMemberCount,
}
}
private deriveStatus(
isActive: boolean | undefined,
): 'running' | 'completed' | 'idle' | 'failed' {
if (isActive === false) return 'idle'
// isActive === undefined || isActive === true
return 'running'
}
/** Search ~/.claude/projects/ for a JSONL file matching the sessionId. */
private async findTranscriptFile(
sessionId: string,
): Promise<string | null> {
const projectsDir = this.getProjectsDir()
try {
await fs.access(projectsDir)
} catch {
return null
}
const projectEntries = await fs.readdir(projectsDir, {
withFileTypes: true,
})
for (const entry of projectEntries) {
if (!entry.isDirectory()) continue
const candidate = path.join(projectsDir, entry.name, `${sessionId}.jsonl`)
try {
await fs.access(candidate)
return candidate
} catch {
// Not in this project directory
}
}
return null
}
/** Parse a JSONL transcript file into messages. */
private async parseTranscriptFile(
filePath: string,
): Promise<TranscriptMessage[]> {
const raw = await fs.readFile(filePath, 'utf-8')
const lines = raw.split('\n').filter((line) => line.trim().length > 0)
const messages: TranscriptMessage[] = []
for (const line of lines) {
try {
const entry = JSON.parse(line) as Record<string, unknown>
// Skip non-message entries (snapshots, meta, etc.)
const entryType = entry.type as string | undefined
if (
entryType !== 'user' &&
entryType !== 'assistant' &&
entryType !== 'system' &&
entryType !== 'tool_use' &&
entryType !== 'tool_result'
) {
continue
}
// Skip meta entries
if (entry.isMeta) continue
const message: TranscriptMessage = {
id: (entry.uuid as string) || crypto.randomUUID(),
type: entryType as TranscriptMessage['type'],
content: entry.message ?? entry.content ?? null,
timestamp:
(entry.timestamp as string) || new Date().toISOString(),
}
messages.push(message)
} catch {
// Skip unparseable lines
}
}
return messages
}
}
export const teamService = new TeamService()

View File

@ -2,10 +2,13 @@
* WebSocket connection handler
*
* WebSocket
* CLI stream-json
* CLI stdout ServerMessage WebSocket
*/
import type { ServerWebSocket } from 'bun'
import type { ClientMessage, ServerMessage, WebSocketSession } from './events.js'
import { conversationService } from '../services/conversationService.js'
export type WebSocketData = {
sessionId: string
@ -34,7 +37,9 @@ export const handleWebSocket = {
switch (message.type) {
case 'user_message':
handleUserMessage(ws, message)
handleUserMessage(ws, message).catch((err) => {
console.error(`[WS] Unhandled error in handleUserMessage:`, err)
})
break
case 'permission_response':
@ -61,6 +66,11 @@ export const handleWebSocket = {
const { sessionId } = ws.data
console.log(`[WS] Client disconnected from session: ${sessionId} (${code}: ${reason})`)
activeSessions.delete(sessionId)
// 断开连接时停止对应的 CLI 子进程
if (conversationService.hasSession(sessionId)) {
conversationService.stopSession(sessionId)
}
},
drain(ws: ServerWebSocket<WebSocketData>) {
@ -69,7 +79,7 @@ export const handleWebSocket = {
}
// ============================================================================
// Message handlers (to be implemented by conversationService)
// Message handlers
// ============================================================================
async function handleUserMessage(
@ -81,31 +91,140 @@ async function handleUserMessage(
// Send thinking status
sendMessage(ws, { type: 'status', state: 'thinking', verb: 'Thinking' })
// TODO: Integrate with ConversationService to process the message
// For now, echo back a placeholder response
sendMessage(ws, { type: 'content_start', blockType: 'text' })
sendMessage(ws, {
type: 'content_delta',
text: `[Server] Received message for session ${sessionId}: "${message.content}". Chat integration pending.`,
})
sendMessage(ws, {
type: 'message_complete',
usage: { input_tokens: 0, output_tokens: 0 },
})
sendMessage(ws, { type: 'status', state: 'idle' })
// 启动 CLI 子进程(如果还没有)
if (!conversationService.hasSession(sessionId)) {
try {
const workDir = process.cwd()
await conversationService.startSession(sessionId, workDir)
// 注册 CLI stdout → WebSocket 转发
conversationService.onOutput(sessionId, (cliMsg) => {
const serverMsg = translateCliMessage(cliMsg)
if (serverMsg) {
sendMessage(ws, serverMsg)
}
})
} catch (err) {
console.error(`[WS] CLI start failed for ${sessionId}, falling back to echo`)
// CLI 启动失败时回退到 echo 模式,保证基本可用性
sendFallbackEcho(ws, sessionId, message.content)
return
}
}
// 将用户消息写入 CLI stdin
const sent = conversationService.sendMessage(sessionId, message.content)
if (!sent) {
// 消息发送失败(进程可能已退出),回退到 echo 模式
sendFallbackEcho(ws, sessionId, message.content)
}
}
function handlePermissionResponse(
ws: ServerWebSocket<WebSocketData>,
message: Extract<ClientMessage, { type: 'permission_response' }>
) {
// TODO: Forward permission response to the active tool execution
const { sessionId } = ws.data
conversationService.respondToPermission(sessionId, message.requestId, message.allowed)
console.log(`[WS] Permission response for ${message.requestId}: ${message.allowed}`)
}
function handleStopGeneration(ws: ServerWebSocket<WebSocketData>) {
// TODO: Abort the active generation for this session
console.log(`[WS] Stop generation requested for session: ${ws.data.sessionId}`)
const { sessionId } = ws.data
console.log(`[WS] Stop generation requested for session: ${sessionId}`)
// 向 CLI 子进程发送中断信号
if (conversationService.hasSession(sessionId)) {
conversationService.sendInterrupt(sessionId)
}
sendMessage(ws, { type: 'status', state: 'idle' })
}
// ============================================================================
// CLI message translation
// ============================================================================
/**
* CLI stdout stream-json WebSocket ServerMessage
*
* CLI src/bridge/sessionRunner.ts stream-json
*/
function translateCliMessage(cliMsg: any): ServerMessage | null {
switch (cliMsg.type) {
case 'assistant': {
// 助手消息 - 提取文本内容
if (cliMsg.message?.content) {
const content = cliMsg.message.content
if (Array.isArray(content)) {
for (const block of content) {
if (block.type === 'text') {
return { type: 'content_delta', text: block.text }
}
}
}
}
return null
}
case 'control_request': {
// 权限请求 — CLI 需要用户授权才能执行工具
if (cliMsg.request?.subtype === 'can_use_tool') {
return {
type: 'permission_request',
requestId: cliMsg.request_id,
toolName: cliMsg.request.tool_name || 'Unknown',
input: cliMsg.request.input || {},
description: cliMsg.request.description,
}
}
return null
}
case 'result': {
// 对话完成
if (cliMsg.subtype === 'success') {
return {
type: 'message_complete',
usage: {
input_tokens: cliMsg.usage?.input_tokens || 0,
output_tokens: cliMsg.usage?.output_tokens || 0,
},
}
}
return null
}
case 'system':
return { type: 'status', state: 'idle' }
default:
return null
}
}
// ============================================================================
// Fallback echo (for when CLI is not available)
// ============================================================================
/**
* CLI 使 echo 退
* WebSocket
*/
function sendFallbackEcho(
ws: ServerWebSocket<WebSocketData>,
sessionId: string,
content: string
) {
sendMessage(ws, { type: 'content_start', blockType: 'text' })
sendMessage(ws, {
type: 'content_delta',
text: `[Server] Received message for session ${sessionId}: "${content}". Chat integration pending.`,
})
sendMessage(ws, {
type: 'message_complete',
usage: { input_tokens: 0, output_tokens: 0 },
})
sendMessage(ws, { type: 'status', state: 'idle' })
}