refactor(server): 拆分 SessionHandle 为三个子模块
- childSpawn.ts: spawn 参数构建、INIT_TIMEOUT_MS、save/loadChildSpawnPrefix - sessionControlChannel.ts: control response pending 管理 - sessionMessageRouter.ts: stdout 消息路由 + opencode 事件发射 - sessionHandle.ts: 精简为编排层,委托给子模块 - types.ts: 统一导出 InitData/PendingPermission/PendingQuestion
This commit is contained in:
parent
7c3d9da000
commit
3ebfb2c05d
50
src/server/childSpawn.ts
Normal file
50
src/server/childSpawn.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
export const INIT_TIMEOUT_MS = (() => {
|
||||||
|
const raw = Number(process.env.CSC_SERVE_INIT_TIMEOUT_MS)
|
||||||
|
return Number.isFinite(raw) && raw > 0 ? raw : 120000
|
||||||
|
})()
|
||||||
|
|
||||||
|
export function getScriptArgsForChild(): string[] {
|
||||||
|
const argv1 = process.argv[1]
|
||||||
|
if (!argv1) return []
|
||||||
|
if (argv1.endsWith('.ts') || argv1.endsWith('.tsx') || argv1.includes('/') || argv1.includes('\\')) {
|
||||||
|
return [argv1]
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChildSpawnArgs(): { execPath: string; scriptArgs: string[] } {
|
||||||
|
const execPath = process.execPath
|
||||||
|
const scriptArgs = getScriptArgsForChild()
|
||||||
|
return { execPath, scriptArgs }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveChildSpawnPrefix(): Promise<void> {
|
||||||
|
if (process.env._CSC_CHILD_SPAWN_PREFIX) return
|
||||||
|
const { execPath, scriptArgs } = getChildSpawnArgs()
|
||||||
|
let defineArgs: string[] = []
|
||||||
|
let featureArgs: string[] = []
|
||||||
|
try {
|
||||||
|
const definesMod = await import('../../scripts/defines.js') as { getMacroDefines: () => Record<string, string>; DEFAULT_BUILD_FEATURES: readonly string[] }
|
||||||
|
const defines = definesMod.getMacroDefines()
|
||||||
|
defineArgs = Object.entries(defines).flatMap(([k, v]) => ['-d', `${k}:${v}`])
|
||||||
|
const features = definesMod.DEFAULT_BUILD_FEATURES
|
||||||
|
featureArgs = features.flatMap((f: string) => ['--feature', f])
|
||||||
|
} catch {}
|
||||||
|
const envFeatures = Object.entries(process.env)
|
||||||
|
.filter(([k]) => k.startsWith('FEATURE_') && k.slice(8))
|
||||||
|
.map(([k]) => ['--feature', k.slice(8)] as [string, string])
|
||||||
|
.flat()
|
||||||
|
const allFeatureArgs = [...featureArgs, ...envFeatures]
|
||||||
|
const prefix = JSON.stringify({ execPath, scriptArgs, defineArgs, featureArgs: allFeatureArgs })
|
||||||
|
process.env._CSC_CHILD_SPAWN_PREFIX = prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadChildSpawnPrefix(): { execPath: string; scriptArgs: string[]; defineArgs?: string[]; featureArgs?: string[] } | null {
|
||||||
|
const raw = process.env._CSC_CHILD_SPAWN_PREFIX
|
||||||
|
if (!raw) return null
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as { execPath: string; scriptArgs: string[]; defineArgs?: string[]; featureArgs?: string[] }
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/server/sessionControlChannel.ts
Normal file
43
src/server/sessionControlChannel.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
type PendingEntry = {
|
||||||
|
resolve: (response: Record<string, unknown>) => void
|
||||||
|
reject: (error: Error) => void
|
||||||
|
timeout: ReturnType<typeof setTimeout>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ControlChannel {
|
||||||
|
private pending = new Map<string, PendingEntry>()
|
||||||
|
|
||||||
|
register(requestId: string, timeoutMs = 10000): Promise<Record<string, unknown>> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
this.pending.delete(requestId)
|
||||||
|
reject(new Error(`Control response timed out for ${requestId}`))
|
||||||
|
}, timeoutMs)
|
||||||
|
this.pending.set(requestId, { resolve, reject, timeout })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
tryResolve(msg: { type: string; response?: unknown }): boolean {
|
||||||
|
if (msg.type !== 'control_response') return false
|
||||||
|
const response = msg.response as Record<string, unknown> | undefined
|
||||||
|
const requestId = response?.request_id as string | undefined
|
||||||
|
if (!requestId || !this.pending.has(requestId)) return false
|
||||||
|
const entry = this.pending.get(requestId)!
|
||||||
|
this.pending.delete(requestId)
|
||||||
|
clearTimeout(entry.timeout)
|
||||||
|
if (response?.subtype === 'error') {
|
||||||
|
entry.reject(new Error((response.error as string) ?? 'Unknown error'))
|
||||||
|
} else {
|
||||||
|
entry.resolve((response?.response ?? {}) as Record<string, unknown>)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectAll(error: Error): void {
|
||||||
|
for (const [, entry] of this.pending) {
|
||||||
|
clearTimeout(entry.timeout)
|
||||||
|
entry.reject(error)
|
||||||
|
}
|
||||||
|
this.pending.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,57 +3,17 @@ import { createInterface } from 'readline'
|
||||||
import { jsonParse, jsonStringify } from '../utils/slowOperations.js'
|
import { jsonParse, jsonStringify } from '../utils/slowOperations.js'
|
||||||
import { logError } from '../utils/log.js'
|
import { logError } from '../utils/log.js'
|
||||||
import type { EventBus } from './eventBus.js'
|
import type { EventBus } from './eventBus.js'
|
||||||
import type { SessionBusyStatus, SessionState } from './types.js'
|
import { INIT_TIMEOUT_MS, getScriptArgsForChild, loadChildSpawnPrefix } from './childSpawn.js'
|
||||||
// 子进程冷启动 + MCP 加载 + 鉴权可能远超 30s;env 可调
|
import { ControlChannel } from './sessionControlChannel.js'
|
||||||
const INIT_TIMEOUT_MS = (() => {
|
import { routeMessage, type StdoutMessage, type MessageRouterCtx } from './sessionMessageRouter.js'
|
||||||
const raw = Number(process.env.CSC_SERVE_INIT_TIMEOUT_MS)
|
import type { SessionBusyStatus, SessionState, InitData, PendingPermission, PendingQuestion } from './types.js'
|
||||||
return Number.isFinite(raw) && raw > 0 ? raw : 120000
|
|
||||||
})()
|
|
||||||
|
|
||||||
type StdoutMessage = {
|
export type { InitData, PendingPermission, PendingQuestion }
|
||||||
type: string
|
export { getScriptArgsForChild }
|
||||||
[key: string]: unknown
|
export { getChildSpawnArgs, saveChildSpawnPrefix } from './childSpawn.js'
|
||||||
}
|
|
||||||
|
|
||||||
type MessageListener = (msg: StdoutMessage) => void
|
type MessageListener = (msg: StdoutMessage) => void
|
||||||
|
|
||||||
export type InitData = {
|
|
||||||
commands?: Array<{ name: string; description: string; argumentHint?: string }>
|
|
||||||
agents?: Array<{ name: string; description?: string; model?: string }>
|
|
||||||
models?: unknown
|
|
||||||
account?: {
|
|
||||||
email?: string
|
|
||||||
organization?: string
|
|
||||||
subscriptionType?: string
|
|
||||||
tokenSource?: string
|
|
||||||
apiKeySource?: string
|
|
||||||
apiProvider?: string
|
|
||||||
}
|
|
||||||
output_style?: string
|
|
||||||
available_output_styles?: string[]
|
|
||||||
pid?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PendingPermission = {
|
|
||||||
requestId: string
|
|
||||||
sessionId: string
|
|
||||||
toolName: string
|
|
||||||
toolUseId: string
|
|
||||||
input: Record<string, unknown>
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
suggestions: Record<string, unknown>[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PendingQuestion = {
|
|
||||||
requestId: string
|
|
||||||
sessionId: string
|
|
||||||
mcpServerName: string
|
|
||||||
message: string
|
|
||||||
mode: string
|
|
||||||
requestedSchema: Record<string, unknown>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SessionHandleOptions = {
|
export type SessionHandleOptions = {
|
||||||
sessionId: string
|
sessionId: string
|
||||||
cwd: string
|
cwd: string
|
||||||
|
|
@ -71,56 +31,9 @@ export type SessionHandleOptions = {
|
||||||
onInit?: (data: InitData) => void
|
onInit?: (data: InitData) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getScriptArgsForChild(): string[] {
|
|
||||||
const argv1 = process.argv[1]
|
|
||||||
if (!argv1) return []
|
|
||||||
if (argv1.endsWith('.ts') || argv1.endsWith('.tsx') || argv1.includes('/') || argv1.includes('\\')) {
|
|
||||||
return [argv1]
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getChildSpawnArgs(): { execPath: string; scriptArgs: string[] } {
|
|
||||||
const execPath = process.execPath
|
|
||||||
const scriptArgs = getScriptArgsForChild()
|
|
||||||
return { execPath, scriptArgs }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveChildSpawnPrefix(): Promise<void> {
|
|
||||||
if (process.env._CSC_CHILD_SPAWN_PREFIX) return
|
|
||||||
const { execPath, scriptArgs } = getChildSpawnArgs()
|
|
||||||
let defineArgs: string[] = []
|
|
||||||
let featureArgs: string[] = []
|
|
||||||
try {
|
|
||||||
const definesMod = await import('../../scripts/defines.js') as { getMacroDefines: () => Record<string, string>; DEFAULT_BUILD_FEATURES: readonly string[] }
|
|
||||||
const defines = definesMod.getMacroDefines()
|
|
||||||
defineArgs = Object.entries(defines).flatMap(([k, v]) => ['-d', `${k}:${v}`])
|
|
||||||
const features = definesMod.DEFAULT_BUILD_FEATURES
|
|
||||||
featureArgs = features.flatMap((f: string) => ['--feature', f])
|
|
||||||
} catch {}
|
|
||||||
const envFeatures = Object.entries(process.env)
|
|
||||||
.filter(([k]) => k.startsWith('FEATURE_') && k.slice(8))
|
|
||||||
.map(([k]) => ['--feature', k.slice(8)] as [string, string])
|
|
||||||
.flat()
|
|
||||||
const allFeatureArgs = [...featureArgs, ...envFeatures]
|
|
||||||
const prefix = JSON.stringify({ execPath, scriptArgs, defineArgs, featureArgs: allFeatureArgs })
|
|
||||||
process.env._CSC_CHILD_SPAWN_PREFIX = prefix
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadChildSpawnPrefix(): { execPath: string; scriptArgs: string[]; defineArgs?: string[]; featureArgs?: string[] } | null {
|
|
||||||
const raw = process.env._CSC_CHILD_SPAWN_PREFIX
|
|
||||||
if (!raw) return null
|
|
||||||
try {
|
|
||||||
return JSON.parse(raw) as { execPath: string; scriptArgs: string[]; defineArgs?: string[]; featureArgs?: string[] }
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SessionHandle {
|
export class SessionHandle {
|
||||||
readonly sessionId: string
|
readonly sessionId: string
|
||||||
readonly cwd: string
|
readonly cwd: string
|
||||||
/** 子进程实际的工作目录,dev 模式下与 cwd 不同(csc 根目录),用于定位 transcript 文件 */
|
|
||||||
private _spawnCwd: string | undefined
|
private _spawnCwd: string | undefined
|
||||||
private child: ChildProcess | null = null
|
private child: ChildProcess | null = null
|
||||||
private _status: SessionState = 'starting'
|
private _status: SessionState = 'starting'
|
||||||
|
|
@ -150,6 +63,7 @@ export class SessionHandle {
|
||||||
private initReject: ((reason: unknown) => void) | null = null
|
private initReject: ((reason: unknown) => void) | null = null
|
||||||
private _prompting = false
|
private _prompting = false
|
||||||
private _lastMessageUuid: string | null = null
|
private _lastMessageUuid: string | null = null
|
||||||
|
private _controlChannel = new ControlChannel()
|
||||||
|
|
||||||
get status(): SessionState {
|
get status(): SessionState {
|
||||||
return this._status
|
return this._status
|
||||||
|
|
@ -237,65 +151,6 @@ export class SessionHandle {
|
||||||
this.verbose = opts.verbose ?? false
|
this.verbose = opts.verbose ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
private emitEvent(event: string, data: Record<string, unknown>): void {
|
|
||||||
if (this.opts.silent) return
|
|
||||||
this.eventBus.publishSessionEvent(this.sessionId, event, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
private emitOpencodeEvent(event: string, properties: Record<string, unknown>): void {
|
|
||||||
if (this.opts.silent) return
|
|
||||||
// Guard against double-wrapping when runtime code is out of sync
|
|
||||||
if (properties.type === event && 'properties' in properties) {
|
|
||||||
this.eventBus.publish(event, properties as Record<string, unknown>)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Send flat shape with session_id at the top level so cs-cloud's
|
|
||||||
// wrapEventStream can extract it and inject sessionID into properties.
|
|
||||||
// The frontend then uses inferDirectory -> dirForSession to route the
|
|
||||||
// event to the correct workspace child store.
|
|
||||||
this.eventBus.publish(event, {
|
|
||||||
session_id: properties.sessionID,
|
|
||||||
...properties,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private emitBusyStatus(): void {
|
|
||||||
if (this.opts.silent) return
|
|
||||||
this.eventBus.publish('session.status', {
|
|
||||||
sessionID: this.sessionId,
|
|
||||||
status: this._busyStatus,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private toPermissionKey(toolName: string): string {
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
Read: 'read',
|
|
||||||
Edit: 'edit',
|
|
||||||
Write: 'edit',
|
|
||||||
Glob: 'glob',
|
|
||||||
Grep: 'grep',
|
|
||||||
LS: 'list',
|
|
||||||
Bash: 'bash',
|
|
||||||
PowerShell: 'bash',
|
|
||||||
Agent: 'task',
|
|
||||||
WebFetch: 'webfetch',
|
|
||||||
WebSearch: 'websearch',
|
|
||||||
TodoRead: 'todoread',
|
|
||||||
TodoWrite: 'todowrite',
|
|
||||||
}
|
|
||||||
return map[toolName] ?? toolName.toLowerCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
private extractPatterns(input: Record<string, unknown>): string[] {
|
|
||||||
const patterns: string[] = []
|
|
||||||
for (const key of ['file_path', 'path', 'pattern', 'glob'] as const) {
|
|
||||||
const v = typeof input[key] === 'string' ? input[key] : ''
|
|
||||||
if (v) patterns.push(v)
|
|
||||||
}
|
|
||||||
const cmd = typeof input.command === 'string' ? input.command : ''
|
|
||||||
if (cmd) patterns.push(cmd)
|
|
||||||
return patterns
|
|
||||||
}
|
|
||||||
onMessage(listener: MessageListener): () => void {
|
onMessage(listener: MessageListener): () => void {
|
||||||
this.listeners.add(listener)
|
this.listeners.add(listener)
|
||||||
return () => { this.listeners.delete(listener) }
|
return () => { this.listeners.delete(listener) }
|
||||||
|
|
@ -308,7 +163,6 @@ export class SessionHandle {
|
||||||
'stream-json',
|
'stream-json',
|
||||||
'--output-format',
|
'--output-format',
|
||||||
'stream-json',
|
'stream-json',
|
||||||
// resume 时不传 --session-id,csc 会继承历史 session 的 id
|
|
||||||
...(this.opts.resumeSessionId ? [] : ['--session-id', this.sessionId]),
|
...(this.opts.resumeSessionId ? [] : ['--session-id', this.sessionId]),
|
||||||
...(this.opts.model ? ['--model', this.opts.model] : []),
|
...(this.opts.model ? ['--model', this.opts.model] : []),
|
||||||
'--permission-mode',
|
'--permission-mode',
|
||||||
|
|
@ -337,7 +191,7 @@ export class SessionHandle {
|
||||||
const saved = loadChildSpawnPrefix()
|
const saved = loadChildSpawnPrefix()
|
||||||
const defineArgs = saved?.defineArgs ?? []
|
const defineArgs = saved?.defineArgs ?? []
|
||||||
const featureArgs = saved?.featureArgs ?? []
|
const featureArgs = saved?.featureArgs ?? []
|
||||||
const spawnArgs = [...defineArgs, ...featureArgs, ...this.opts.scriptArgs, ...printArgs]
|
const spawnArgs = [...defineArgs, ...featureArgs, ...this.opts.scriptArgs, ...printArgs.filter((x): x is string => x != null)]
|
||||||
|
|
||||||
this._spawnCwd = this.opts.cwd
|
this._spawnCwd = this.opts.cwd
|
||||||
this.child = spawn(this.opts.execPath, spawnArgs, {
|
this.child = spawn(this.opts.execPath, spawnArgs, {
|
||||||
|
|
@ -350,7 +204,7 @@ export class SessionHandle {
|
||||||
this.setupStdout()
|
this.setupStdout()
|
||||||
this.setupStderr()
|
this.setupStderr()
|
||||||
|
|
||||||
this.child.on('close', (code, signal) => {
|
this.child!.on('close', (code, signal) => {
|
||||||
if (this._status !== 'stopped') {
|
if (this._status !== 'stopped') {
|
||||||
this._status = 'stopped'
|
this._status = 'stopped'
|
||||||
this._busyStatus = { type: 'idle' }
|
this._busyStatus = { type: 'idle' }
|
||||||
|
|
@ -373,7 +227,7 @@ export class SessionHandle {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
this.child.on('error', err => {
|
this.child!.on('error', err => {
|
||||||
logError(err)
|
logError(err)
|
||||||
this._status = 'stopped'
|
this._status = 'stopped'
|
||||||
this._busyStatus = { type: 'idle' }
|
this._busyStatus = { type: 'idle' }
|
||||||
|
|
@ -461,7 +315,7 @@ export class SessionHandle {
|
||||||
} catch {
|
} catch {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.routeMessage(msg)
|
routeMessage(msg, this.createRouterCtx())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -479,6 +333,31 @@ export class SessionHandle {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private emitEvent(event: string, data: Record<string, unknown>): void {
|
||||||
|
if (this.opts.silent) return
|
||||||
|
this.eventBus.publishSessionEvent(this.sessionId, event, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitOpencodeEvent(event: string, properties: Record<string, unknown>): void {
|
||||||
|
if (this.opts.silent) return
|
||||||
|
if (properties.type === event && 'properties' in properties) {
|
||||||
|
this.eventBus.publish(event, properties as Record<string, unknown>)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.eventBus.publish(event, {
|
||||||
|
session_id: properties.sessionID,
|
||||||
|
...properties,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitBusyStatus(): void {
|
||||||
|
if (this.opts.silent) return
|
||||||
|
this.eventBus.publish('session.status', {
|
||||||
|
sessionID: this.sessionId,
|
||||||
|
status: this._busyStatus,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private emitMessage(msg: StdoutMessage): void {
|
private emitMessage(msg: StdoutMessage): void {
|
||||||
for (const listener of this.listeners) {
|
for (const listener of this.listeners) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -487,256 +366,61 @@ export class SessionHandle {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private routeMessage(msg: StdoutMessage): void {
|
private writeStdin(data: string): void {
|
||||||
if (
|
if (this.child?.stdin && !this.child.stdin.destroyed) {
|
||||||
this._status === 'starting' &&
|
const flushed = this.child.stdin.write(data + '\n')
|
||||||
msg.type === 'control_response' &&
|
if (!flushed) {
|
||||||
this.initResolve
|
this.child.stdin.once('drain', () => {})
|
||||||
) {
|
|
||||||
const response = msg.response as Record<string, unknown> | undefined
|
|
||||||
if (response?.subtype === 'success' && response?.request_id === this.initRequestId) {
|
|
||||||
const initData = (response.response ?? {}) as InitData
|
|
||||||
this._initData = initData
|
|
||||||
if (Array.isArray(initData.models) && initData.models.length > 0 && !this._model) {
|
|
||||||
const first = (initData.models as Array<Record<string, string>>)[0]
|
|
||||||
this._model = first?.value ?? first?.name
|
|
||||||
}
|
|
||||||
if (initData.account?.apiProvider) {
|
|
||||||
this._providerId = initData.account.apiProvider
|
|
||||||
}
|
|
||||||
this._status = 'running'
|
|
||||||
this.lastActiveAt = Date.now()
|
|
||||||
const resolve = this.initResolve
|
|
||||||
this.initResolve = null
|
|
||||||
this.initReject = null
|
|
||||||
resolve?.(initData)
|
|
||||||
this.emitEvent('ready', {
|
|
||||||
status: 'running',
|
|
||||||
model: this._model,
|
|
||||||
provider_id: this._providerId,
|
|
||||||
})
|
|
||||||
this.opts.onInit?.(initData)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.type === 'control_response') {
|
|
||||||
const response = msg.response as Record<string, unknown> | undefined
|
|
||||||
const requestId = response?.request_id as string | undefined
|
|
||||||
if (requestId && this.pendingControlResponses.has(requestId)) {
|
|
||||||
const pending = this.pendingControlResponses.get(requestId)!
|
|
||||||
this.pendingControlResponses.delete(requestId)
|
|
||||||
clearTimeout(pending.timeout)
|
|
||||||
if (response?.subtype === 'error') {
|
|
||||||
pending.reject(new Error((response.error as string) ?? 'Unknown error'))
|
|
||||||
} else {
|
|
||||||
pending.resolve((response?.response ?? {}) as Record<string, unknown>)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.emitMessage(msg)
|
|
||||||
|
|
||||||
switch (msg.type) {
|
|
||||||
case 'assistant': {
|
|
||||||
this.lastActiveAt = Date.now()
|
|
||||||
this._status = 'running'
|
|
||||||
if (msg.uuid && typeof msg.uuid === 'string') {
|
|
||||||
this._lastMessageUuid = msg.uuid
|
|
||||||
}
|
|
||||||
if (!msg.model && this._model) {
|
|
||||||
msg = { ...msg, model: this._model }
|
|
||||||
}
|
|
||||||
if (!msg.provider_id && this._providerId) {
|
|
||||||
msg = { ...msg, provider_id: this._providerId }
|
|
||||||
}
|
|
||||||
this.emitEvent('message', msg)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'user': {
|
|
||||||
if (msg.isReplay) break
|
|
||||||
const content = typeof msg.content === 'string' ? msg.content : ''
|
|
||||||
if (content.includes('<local-command-stdout>')) break
|
|
||||||
this.emitEvent('message', msg)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'result': {
|
|
||||||
this.lastActiveAt = Date.now()
|
|
||||||
this._status = 'running'
|
|
||||||
const cost = msg.cost_usd as number | undefined
|
|
||||||
const usage = msg.usage as
|
|
||||||
| { input_tokens?: number; output_tokens?: number }
|
|
||||||
| undefined
|
|
||||||
if (cost) this._costUsd += cost
|
|
||||||
if (usage?.input_tokens) this._inputTokens += usage.input_tokens
|
|
||||||
if (usage?.output_tokens) this._outputTokens += usage.output_tokens
|
|
||||||
this._busyStatus = { type: 'idle' }
|
|
||||||
this.emitBusyStatus()
|
|
||||||
this.emitEvent('result', msg)
|
|
||||||
if (this.promptResolve) {
|
|
||||||
this.promptResolve({ done: true })
|
|
||||||
this.promptResolve = null
|
|
||||||
this.promptReject = null
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'control_request': {
|
|
||||||
const request = msg.request as Record<string, unknown> | undefined
|
|
||||||
const requestId = msg.request_id as string
|
|
||||||
if (request?.subtype === 'can_use_tool') {
|
|
||||||
const toolName = (request.tool_name as string) ?? 'Unknown'
|
|
||||||
const perm: PendingPermission = {
|
|
||||||
requestId,
|
|
||||||
sessionId: this.sessionId,
|
|
||||||
toolName,
|
|
||||||
toolUseId: (request.tool_use_id as string) ?? '',
|
|
||||||
input: (request.input as Record<string, unknown>) ?? {},
|
|
||||||
title: `${request.tool_name}: ${JSON.stringify(request.input).slice(0, 80)}`,
|
|
||||||
description: `Execute ${request.tool_name}`,
|
|
||||||
suggestions:
|
|
||||||
(request.permission_suggestions as Record<string, unknown>[]) ?? [],
|
|
||||||
}
|
|
||||||
this.pendingPermissions.set(requestId, perm)
|
|
||||||
this.emitEvent('control_request', { request_id: requestId, request })
|
|
||||||
|
|
||||||
// opencode-compatible events
|
|
||||||
if (toolName === 'AskUserQuestion') {
|
|
||||||
const input = (request.input as Record<string, unknown>) ?? {}
|
|
||||||
const questions = (input.questions as Array<Record<string, unknown>>) ?? []
|
|
||||||
this.emitOpencodeEvent('question.asked', {
|
|
||||||
sessionID: this.sessionId,
|
|
||||||
id: requestId,
|
|
||||||
questions: questions.map(q => ({
|
|
||||||
question: q.question as string,
|
|
||||||
header: (q.header as string) ?? '',
|
|
||||||
options: ((q.options as Array<{ label: string; description: string }>) ?? []).map(o => ({
|
|
||||||
label: o.label,
|
|
||||||
description: o.description,
|
|
||||||
})),
|
|
||||||
multiple: (q.multiSelect as boolean) ?? false,
|
|
||||||
custom: false,
|
|
||||||
})),
|
|
||||||
tool: {
|
|
||||||
messageID: '',
|
|
||||||
callID: perm.toolUseId,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
this.emitOpencodeEvent('permission.asked', {
|
|
||||||
sessionID: this.sessionId,
|
|
||||||
id: requestId,
|
|
||||||
permission: this.toPermissionKey(toolName),
|
|
||||||
patterns: this.extractPatterns(perm.input),
|
|
||||||
metadata: { input: perm.input },
|
|
||||||
always: [] as string[],
|
|
||||||
tool: {
|
|
||||||
messageID: '',
|
|
||||||
callID: perm.toolUseId,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else if (request?.subtype === 'elicitation') {
|
|
||||||
const question: PendingQuestion = {
|
|
||||||
requestId,
|
|
||||||
sessionId: this.sessionId,
|
|
||||||
mcpServerName: (request.mcp_server_name as string) ?? '',
|
|
||||||
message: (request.message as string) ?? '',
|
|
||||||
mode: (request.mode as string) ?? 'form',
|
|
||||||
requestedSchema:
|
|
||||||
(request.requested_schema as Record<string, unknown>) ?? {},
|
|
||||||
}
|
|
||||||
this.pendingQuestions.set(requestId, question)
|
|
||||||
this.emitEvent('control_request', { request_id: requestId, request })
|
|
||||||
|
|
||||||
// opencode-compatible event
|
|
||||||
this.emitOpencodeEvent('question.asked', {
|
|
||||||
sessionID: this.sessionId,
|
|
||||||
id: requestId,
|
|
||||||
questions: [{
|
|
||||||
question: question.message,
|
|
||||||
header: question.mcpServerName || 'MCP',
|
|
||||||
options: [] as Array<{ label: string; description: string }>,
|
|
||||||
multiple: false,
|
|
||||||
custom: true,
|
|
||||||
}],
|
|
||||||
})
|
|
||||||
} else if (request?.subtype === 'hook_callback') {
|
|
||||||
this.emitEvent('control_request', { request_id: requestId, request })
|
|
||||||
const callbackId = request.callback_id as string | undefined
|
|
||||||
if (!callbackId || callbackId === 'AUTO_APPROVE_CALLBACK_ID') {
|
|
||||||
this.writeStdin(jsonStringify({
|
|
||||||
type: 'control_response',
|
|
||||||
response: {
|
|
||||||
subtype: 'success',
|
|
||||||
request_id: requestId,
|
|
||||||
response: {
|
|
||||||
hookSpecificOutput: {
|
|
||||||
hookEventName: 'PreToolUse',
|
|
||||||
permissionDecision: 'allow',
|
|
||||||
permissionDecisionReason: 'Auto-approved by serve',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
} else {
|
|
||||||
this.writeStdin(jsonStringify({
|
|
||||||
type: 'control_response',
|
|
||||||
response: {
|
|
||||||
subtype: 'success',
|
|
||||||
request_id: requestId,
|
|
||||||
response: {
|
|
||||||
hookSpecificOutput: {
|
|
||||||
hookEventName: 'PreToolUse',
|
|
||||||
permissionDecision: 'ask',
|
|
||||||
permissionDecisionReason: 'Forwarding to can_use_tool',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'control_cancel_request': {
|
|
||||||
const cancelRequestId = msg.request_id as string
|
|
||||||
const wasPerm = this.pendingPermissions.has(cancelRequestId)
|
|
||||||
const wasQuestion = this.pendingQuestions.has(cancelRequestId)
|
|
||||||
this.pendingPermissions.delete(cancelRequestId)
|
|
||||||
this.pendingQuestions.delete(cancelRequestId)
|
|
||||||
if (wasQuestion) {
|
|
||||||
this.emitOpencodeEvent('question.rejected', {
|
|
||||||
sessionID: this.sessionId,
|
|
||||||
requestID: cancelRequestId,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (wasPerm) {
|
|
||||||
this.emitOpencodeEvent('permission.replied', {
|
|
||||||
sessionID: this.sessionId,
|
|
||||||
requestID: cancelRequestId,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'stream_event': {
|
|
||||||
this.lastActiveAt = Date.now()
|
|
||||||
this.emitEvent('stream_event', msg)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private pendingControlResponses = new Map<
|
private createRouterCtx(): MessageRouterCtx {
|
||||||
string,
|
return {
|
||||||
{
|
sessionId: this.sessionId,
|
||||||
resolve: (response: Record<string, unknown>) => void
|
silent: this.opts.silent ?? false,
|
||||||
reject: (error: Error) => void
|
getStatus: () => this._status,
|
||||||
timeout: ReturnType<typeof setTimeout>
|
getBusyStatus: () => this._busyStatus,
|
||||||
|
getModel: () => this._model,
|
||||||
|
getProviderId: () => this._providerId,
|
||||||
|
getInitRequestId: () => this.initRequestId,
|
||||||
|
getLastMessageUuid: () => this._lastMessageUuid,
|
||||||
|
setStatus: (s) => { this._status = s },
|
||||||
|
setBusyStatus: (s) => { this._busyStatus = s },
|
||||||
|
setModel: (m) => { this._model = m },
|
||||||
|
setProviderId: (id) => { this._providerId = id },
|
||||||
|
setInitData: (d) => { this._initData = d },
|
||||||
|
setLastMessageUuid: (u) => { this._lastMessageUuid = u },
|
||||||
|
setLastActiveAt: (t) => { this.lastActiveAt = t },
|
||||||
|
addCost: (c) => { this._costUsd += c },
|
||||||
|
addInputTokens: (t) => { this._inputTokens += t },
|
||||||
|
addOutputTokens: (t) => { this._outputTokens += t },
|
||||||
|
getControlChannel: () => this._controlChannel,
|
||||||
|
getPendingPermissions: () => this.pendingPermissions,
|
||||||
|
getPendingQuestions: () => this.pendingQuestions,
|
||||||
|
resolveInit: (data) => {
|
||||||
|
const resolve = this.initResolve
|
||||||
|
this.initResolve = null
|
||||||
|
this.initReject = null
|
||||||
|
resolve?.(data)
|
||||||
|
this.opts.onInit?.(data)
|
||||||
|
},
|
||||||
|
resolvePrompt: (value) => {
|
||||||
|
this._busyStatus = { type: 'idle' }
|
||||||
|
this.emitBusyStatus()
|
||||||
|
if (this.promptResolve) {
|
||||||
|
this.promptResolve(value)
|
||||||
|
this.promptResolve = null
|
||||||
|
this.promptReject = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
emitEvent: (event, data) => this.emitEvent(event, data),
|
||||||
|
emitOpencodeEvent: (event, props) => this.emitOpencodeEvent(event, props),
|
||||||
|
emitBusyStatus: () => this.emitBusyStatus(),
|
||||||
|
emitMessage: (msg) => this.emitMessage(msg),
|
||||||
|
writeStdin: (data) => this.writeStdin(data),
|
||||||
}
|
}
|
||||||
>()
|
}
|
||||||
|
|
||||||
setTitle(title: string): void {
|
setTitle(title: string): void {
|
||||||
this._title = title
|
this._title = title
|
||||||
|
|
@ -754,7 +438,7 @@ export class SessionHandle {
|
||||||
request,
|
request,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
return this.waitForControlResponse(requestId, timeoutMs)
|
return this._controlChannel.register(requestId, timeoutMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
async setModel(model: string): Promise<void> {
|
async setModel(model: string): Promise<void> {
|
||||||
|
|
@ -772,20 +456,7 @@ export class SessionHandle {
|
||||||
return (response.mcpServers as unknown[]) ?? []
|
return (response.mcpServers as unknown[]) ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
private waitForControlResponse(
|
async prompt(content: string, _opts?: { parts?: Array<Record<string, unknown>> }): Promise<{ done: boolean; error?: string }> {
|
||||||
requestId: string,
|
|
||||||
timeoutMs = 10000,
|
|
||||||
): Promise<Record<string, unknown>> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
this.pendingControlResponses.delete(requestId)
|
|
||||||
reject(new Error(`Control response timed out for ${requestId}`))
|
|
||||||
}, timeoutMs)
|
|
||||||
this.pendingControlResponses.set(requestId, { resolve, reject, timeout })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async prompt(content: string): Promise<{ done: boolean; error?: string }> {
|
|
||||||
if (this._status === 'stopped') {
|
if (this._status === 'stopped') {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Session ${this.sessionId} is stopped`,
|
`Session ${this.sessionId} is stopped`,
|
||||||
|
|
@ -959,7 +630,7 @@ export class SessionHandle {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this._status = 'stopped'
|
this._status = 'stopped'
|
||||||
this.rejectAllPendingControlResponses(new Error('Session killed'))
|
this._controlChannel.rejectAll(new Error('Session killed'))
|
||||||
}
|
}
|
||||||
|
|
||||||
forceKill(): void {
|
forceKill(): void {
|
||||||
|
|
@ -971,7 +642,7 @@ export class SessionHandle {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this._status = 'stopped'
|
this._status = 'stopped'
|
||||||
this.rejectAllPendingControlResponses(new Error('Session force killed'))
|
this._controlChannel.rejectAll(new Error('Session force killed'))
|
||||||
}
|
}
|
||||||
|
|
||||||
getPendingPermissions(): PendingPermission[] {
|
getPendingPermissions(): PendingPermission[] {
|
||||||
|
|
@ -1019,21 +690,4 @@ export class SessionHandle {
|
||||||
last_message_uuid: this._lastMessageUuid,
|
last_message_uuid: this._lastMessageUuid,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private writeStdin(data: string): void {
|
|
||||||
if (this.child?.stdin && !this.child.stdin.destroyed) {
|
|
||||||
const flushed = this.child.stdin.write(data + '\n')
|
|
||||||
if (!flushed) {
|
|
||||||
this.child.stdin.once('drain', () => {})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private rejectAllPendingControlResponses(error: Error): void {
|
|
||||||
for (const [id, pending] of this.pendingControlResponses) {
|
|
||||||
clearTimeout(pending.timeout)
|
|
||||||
pending.reject(error)
|
|
||||||
}
|
|
||||||
this.pendingControlResponses.clear()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
320
src/server/sessionMessageRouter.ts
Normal file
320
src/server/sessionMessageRouter.ts
Normal file
|
|
@ -0,0 +1,320 @@
|
||||||
|
import { jsonStringify } from '../utils/slowOperations.js'
|
||||||
|
import type { ControlChannel } from './sessionControlChannel.js'
|
||||||
|
import type { SessionBusyStatus, SessionState, InitData, PendingPermission, PendingQuestion } from './types.js'
|
||||||
|
|
||||||
|
export type StdoutMessage = {
|
||||||
|
type: string
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MessageRouterCtx = {
|
||||||
|
readonly sessionId: string
|
||||||
|
readonly silent: boolean
|
||||||
|
|
||||||
|
getStatus(): SessionState
|
||||||
|
getBusyStatus(): SessionBusyStatus
|
||||||
|
getModel(): string | undefined
|
||||||
|
getProviderId(): string | undefined
|
||||||
|
getInitRequestId(): string | null
|
||||||
|
getLastMessageUuid(): string | null
|
||||||
|
|
||||||
|
setStatus(status: SessionState): void
|
||||||
|
setBusyStatus(status: SessionBusyStatus): void
|
||||||
|
setModel(model: string): void
|
||||||
|
setProviderId(id: string): void
|
||||||
|
setInitData(data: InitData): void
|
||||||
|
setLastMessageUuid(uuid: string): void
|
||||||
|
setLastActiveAt(ts: number): void
|
||||||
|
|
||||||
|
getControlChannel(): ControlChannel
|
||||||
|
getPendingPermissions(): Map<string, PendingPermission>
|
||||||
|
getPendingQuestions(): Map<string, PendingQuestion>
|
||||||
|
|
||||||
|
resolveInit(data: InitData): void
|
||||||
|
resolvePrompt(value: { done: boolean; error?: string }): void
|
||||||
|
|
||||||
|
addCost(cost: number): void
|
||||||
|
addInputTokens(tokens: number): void
|
||||||
|
addOutputTokens(tokens: number): void
|
||||||
|
|
||||||
|
emitEvent(event: string, data: Record<string, unknown>): void
|
||||||
|
emitOpencodeEvent(event: string, properties: Record<string, unknown>): void
|
||||||
|
emitBusyStatus(): void
|
||||||
|
emitMessage(msg: StdoutMessage): void
|
||||||
|
writeStdin(data: string): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function routeMessage(msg: StdoutMessage, ctx: MessageRouterCtx): void {
|
||||||
|
if (
|
||||||
|
ctx.getStatus() === 'starting' &&
|
||||||
|
msg.type === 'control_response'
|
||||||
|
) {
|
||||||
|
const response = msg.response as Record<string, unknown> | undefined
|
||||||
|
if (response?.subtype === 'success' && response?.request_id === ctx.getInitRequestId()) {
|
||||||
|
handleInitResponse(response, ctx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'control_response') {
|
||||||
|
if (ctx.getControlChannel().tryResolve(msg)) return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.emitMessage(msg)
|
||||||
|
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'assistant': {
|
||||||
|
handleAssistantMessage(msg, ctx)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'user': {
|
||||||
|
handleUserMessage(msg, ctx)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'result': {
|
||||||
|
handleResultMessage(msg, ctx)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'control_request': {
|
||||||
|
handleControlRequest(msg, ctx)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'control_cancel_request': {
|
||||||
|
handleCancelRequest(msg, ctx)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'stream_event': {
|
||||||
|
ctx.setLastActiveAt(Date.now())
|
||||||
|
ctx.emitEvent('stream_event', msg)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInitResponse(response: Record<string, unknown>, ctx: MessageRouterCtx): void {
|
||||||
|
const initData = (response.response ?? {}) as InitData
|
||||||
|
ctx.setInitData(initData)
|
||||||
|
if (Array.isArray(initData.models) && initData.models.length > 0 && !ctx.getModel()) {
|
||||||
|
const first = (initData.models as Array<Record<string, string>>)[0]
|
||||||
|
ctx.setModel(first?.value ?? first?.name)
|
||||||
|
}
|
||||||
|
if (initData.account?.apiProvider) {
|
||||||
|
ctx.setProviderId(initData.account.apiProvider)
|
||||||
|
}
|
||||||
|
ctx.setStatus('running')
|
||||||
|
ctx.setLastActiveAt(Date.now())
|
||||||
|
ctx.resolveInit(initData)
|
||||||
|
ctx.emitEvent('ready', {
|
||||||
|
status: 'running',
|
||||||
|
model: ctx.getModel(),
|
||||||
|
provider_id: ctx.getProviderId(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAssistantMessage(msg: StdoutMessage, ctx: MessageRouterCtx): void {
|
||||||
|
ctx.setLastActiveAt(Date.now())
|
||||||
|
ctx.setStatus('running')
|
||||||
|
if (msg.uuid && typeof msg.uuid === 'string') {
|
||||||
|
ctx.setLastMessageUuid(msg.uuid)
|
||||||
|
}
|
||||||
|
let enriched = msg
|
||||||
|
if (!msg.model && ctx.getModel()) {
|
||||||
|
enriched = { ...enriched, model: ctx.getModel() }
|
||||||
|
}
|
||||||
|
if (!msg.provider_id && ctx.getProviderId()) {
|
||||||
|
enriched = { ...enriched, provider_id: ctx.getProviderId() }
|
||||||
|
}
|
||||||
|
ctx.emitEvent('message', enriched)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUserMessage(msg: StdoutMessage, ctx: MessageRouterCtx): void {
|
||||||
|
if (msg.isReplay) return
|
||||||
|
const content = typeof msg.content === 'string' ? msg.content : ''
|
||||||
|
if (content.includes('<local-command-stdout>')) return
|
||||||
|
ctx.emitEvent('message', msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResultMessage(msg: StdoutMessage, ctx: MessageRouterCtx): void {
|
||||||
|
ctx.setLastActiveAt(Date.now())
|
||||||
|
ctx.setStatus('running')
|
||||||
|
const cost = msg.cost_usd as number | undefined
|
||||||
|
const usage = msg.usage as
|
||||||
|
| { input_tokens?: number; output_tokens?: number }
|
||||||
|
| undefined
|
||||||
|
if (cost) ctx.addCost(cost)
|
||||||
|
if (usage?.input_tokens) ctx.addInputTokens(usage.input_tokens)
|
||||||
|
if (usage?.output_tokens) ctx.addOutputTokens(usage.output_tokens)
|
||||||
|
ctx.setBusyStatus({ type: 'idle' })
|
||||||
|
ctx.emitBusyStatus()
|
||||||
|
ctx.emitEvent('result', msg)
|
||||||
|
ctx.resolvePrompt({ done: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleControlRequest(msg: StdoutMessage, ctx: MessageRouterCtx): void {
|
||||||
|
const request = msg.request as Record<string, unknown> | undefined
|
||||||
|
const requestId = msg.request_id as string
|
||||||
|
|
||||||
|
if (request?.subtype === 'can_use_tool') {
|
||||||
|
const toolName = (request.tool_name as string) ?? 'Unknown'
|
||||||
|
const perm: PendingPermission = {
|
||||||
|
requestId,
|
||||||
|
sessionId: ctx.sessionId,
|
||||||
|
toolName,
|
||||||
|
toolUseId: (request.tool_use_id as string) ?? '',
|
||||||
|
input: (request.input as Record<string, unknown>) ?? {},
|
||||||
|
title: `${request.tool_name}: ${JSON.stringify(request.input).slice(0, 80)}`,
|
||||||
|
description: `Execute ${request.tool_name}`,
|
||||||
|
suggestions:
|
||||||
|
(request.permission_suggestions as Record<string, unknown>[]) ?? [],
|
||||||
|
}
|
||||||
|
ctx.getPendingPermissions().set(requestId, perm)
|
||||||
|
ctx.emitEvent('control_request', { request_id: requestId, request })
|
||||||
|
|
||||||
|
if (toolName === 'AskUserQuestion') {
|
||||||
|
const input = (request.input as Record<string, unknown>) ?? {}
|
||||||
|
const questions = (input.questions as Array<Record<string, unknown>>) ?? []
|
||||||
|
ctx.emitOpencodeEvent('question.asked', {
|
||||||
|
sessionID: ctx.sessionId,
|
||||||
|
id: requestId,
|
||||||
|
questions: questions.map(q => ({
|
||||||
|
question: q.question as string,
|
||||||
|
header: (q.header as string) ?? '',
|
||||||
|
options: ((q.options as Array<{ label: string; description: string }>) ?? []).map(o => ({
|
||||||
|
label: o.label,
|
||||||
|
description: o.description,
|
||||||
|
})),
|
||||||
|
multiple: (q.multiSelect as boolean) ?? false,
|
||||||
|
custom: false,
|
||||||
|
})),
|
||||||
|
tool: {
|
||||||
|
messageID: '',
|
||||||
|
callID: perm.toolUseId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
ctx.emitOpencodeEvent('permission.asked', {
|
||||||
|
sessionID: ctx.sessionId,
|
||||||
|
id: requestId,
|
||||||
|
permission: toPermissionKey(toolName),
|
||||||
|
patterns: extractPatterns(perm.input),
|
||||||
|
metadata: { input: perm.input },
|
||||||
|
always: [] as string[],
|
||||||
|
tool: {
|
||||||
|
messageID: '',
|
||||||
|
callID: perm.toolUseId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (request?.subtype === 'elicitation') {
|
||||||
|
const question: PendingQuestion = {
|
||||||
|
requestId,
|
||||||
|
sessionId: ctx.sessionId,
|
||||||
|
mcpServerName: (request.mcp_server_name as string) ?? '',
|
||||||
|
message: (request.message as string) ?? '',
|
||||||
|
mode: (request.mode as string) ?? 'form',
|
||||||
|
requestedSchema:
|
||||||
|
(request.requested_schema as Record<string, unknown>) ?? {},
|
||||||
|
}
|
||||||
|
ctx.getPendingQuestions().set(requestId, question)
|
||||||
|
ctx.emitEvent('control_request', { request_id: requestId, request })
|
||||||
|
|
||||||
|
ctx.emitOpencodeEvent('question.asked', {
|
||||||
|
sessionID: ctx.sessionId,
|
||||||
|
id: requestId,
|
||||||
|
questions: [{
|
||||||
|
question: question.message,
|
||||||
|
header: question.mcpServerName || 'MCP',
|
||||||
|
options: [] as Array<{ label: string; description: string }>,
|
||||||
|
multiple: false,
|
||||||
|
custom: true,
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
} else if (request?.subtype === 'hook_callback') {
|
||||||
|
ctx.emitEvent('control_request', { request_id: requestId, request })
|
||||||
|
const callbackId = request.callback_id as string | undefined
|
||||||
|
if (!callbackId || callbackId === 'AUTO_APPROVE_CALLBACK_ID') {
|
||||||
|
ctx.writeStdin(jsonStringify({
|
||||||
|
type: 'control_response',
|
||||||
|
response: {
|
||||||
|
subtype: 'success',
|
||||||
|
request_id: requestId,
|
||||||
|
response: {
|
||||||
|
hookSpecificOutput: {
|
||||||
|
hookEventName: 'PreToolUse',
|
||||||
|
permissionDecision: 'allow',
|
||||||
|
permissionDecisionReason: 'Auto-approved by serve',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
ctx.writeStdin(jsonStringify({
|
||||||
|
type: 'control_response',
|
||||||
|
response: {
|
||||||
|
subtype: 'success',
|
||||||
|
request_id: requestId,
|
||||||
|
response: {
|
||||||
|
hookSpecificOutput: {
|
||||||
|
hookEventName: 'PreToolUse',
|
||||||
|
permissionDecision: 'ask',
|
||||||
|
permissionDecisionReason: 'Forwarding to can_use_tool',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancelRequest(msg: StdoutMessage, ctx: MessageRouterCtx): void {
|
||||||
|
const cancelRequestId = msg.request_id as string
|
||||||
|
const wasPerm = ctx.getPendingPermissions().has(cancelRequestId)
|
||||||
|
const wasQuestion = ctx.getPendingQuestions().has(cancelRequestId)
|
||||||
|
ctx.getPendingPermissions().delete(cancelRequestId)
|
||||||
|
ctx.getPendingQuestions().delete(cancelRequestId)
|
||||||
|
if (wasQuestion) {
|
||||||
|
ctx.emitOpencodeEvent('question.rejected', {
|
||||||
|
sessionID: ctx.sessionId,
|
||||||
|
requestID: cancelRequestId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (wasPerm) {
|
||||||
|
ctx.emitOpencodeEvent('permission.replied', {
|
||||||
|
sessionID: ctx.sessionId,
|
||||||
|
requestID: cancelRequestId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPermissionKey(toolName: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
Read: 'read',
|
||||||
|
Edit: 'edit',
|
||||||
|
Write: 'edit',
|
||||||
|
Glob: 'glob',
|
||||||
|
Grep: 'grep',
|
||||||
|
LS: 'list',
|
||||||
|
Bash: 'bash',
|
||||||
|
PowerShell: 'bash',
|
||||||
|
Agent: 'task',
|
||||||
|
WebFetch: 'webfetch',
|
||||||
|
WebSearch: 'websearch',
|
||||||
|
TodoRead: 'todoread',
|
||||||
|
TodoWrite: 'todowrite',
|
||||||
|
}
|
||||||
|
return map[toolName] ?? toolName.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPatterns(input: Record<string, unknown>): string[] {
|
||||||
|
const patterns: string[] = []
|
||||||
|
for (const key of ['file_path', 'path', 'pattern', 'glob'] as const) {
|
||||||
|
const v = typeof input[key] === 'string' ? input[key] : ''
|
||||||
|
if (v) patterns.push(v)
|
||||||
|
}
|
||||||
|
const cmd = typeof input.command === 'string' ? input.command : ''
|
||||||
|
if (cmd) patterns.push(cmd)
|
||||||
|
return patterns
|
||||||
|
}
|
||||||
|
|
@ -60,3 +60,40 @@ export type SessionIndexEntry = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionIndex = Record<string, SessionIndexEntry>
|
export type SessionIndex = Record<string, SessionIndexEntry>
|
||||||
|
|
||||||
|
export type InitData = {
|
||||||
|
commands?: Array<{ name: string; description: string; argumentHint?: string }>
|
||||||
|
agents?: Array<{ name: string; description?: string; model?: string }>
|
||||||
|
models?: unknown
|
||||||
|
account?: {
|
||||||
|
email?: string
|
||||||
|
organization?: string
|
||||||
|
subscriptionType?: string
|
||||||
|
tokenSource?: string
|
||||||
|
apiKeySource?: string
|
||||||
|
apiProvider?: string
|
||||||
|
}
|
||||||
|
output_style?: string
|
||||||
|
available_output_styles?: string[]
|
||||||
|
pid?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PendingPermission = {
|
||||||
|
requestId: string
|
||||||
|
sessionId: string
|
||||||
|
toolName: string
|
||||||
|
toolUseId: string
|
||||||
|
input: Record<string, unknown>
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
suggestions: Record<string, unknown>[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PendingQuestion = {
|
||||||
|
requestId: string
|
||||||
|
sessionId: string
|
||||||
|
mcpServerName: string
|
||||||
|
message: string
|
||||||
|
mode: string
|
||||||
|
requestedSchema: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user