feat(server): opencode-compatible question/permission with AskUserQuestion popup support
- emit opencode-compatible SSE events for questions and permissions (flat shape with top-level session_id for wrapEventStream routing) - adapt question API (/question, /question/:id/reply, /question/:id/reject) for AskUserQuestion and elicitation workflows - fix AskUserQuestion tool name matching and guard against double-wrapped events - allow requiresUserInteraction tools to ask user even in bypassPermissions mode - avoid blocking on session init to prevent cold-start timeout - add /global/event SSE endpoint and cwdFilter support - add question routes and session handle tests Co-authored-by: CoStrict <zgsm@sangfor.com.cn>
This commit is contained in:
parent
59d3460554
commit
009e9f0c49
|
|
@ -93,13 +93,13 @@ describe('createQuestionRoutes', () => {
|
|||
expect(body[0].questions[0].custom).toBe(true)
|
||||
})
|
||||
|
||||
test('returns AskUserQuestionTool permissions as questions', async () => {
|
||||
test('returns AskUserQuestion permissions as questions', async () => {
|
||||
const mgr = createMockSessionManager({
|
||||
permissions: [
|
||||
{
|
||||
requestId: 'perm-1',
|
||||
sessionId: 'sess-1',
|
||||
toolName: 'AskUserQuestionTool',
|
||||
toolName: 'AskUserQuestion',
|
||||
toolUseId: 'tu-1',
|
||||
input: {
|
||||
questions: [
|
||||
|
|
@ -114,7 +114,7 @@ describe('createQuestionRoutes', () => {
|
|||
},
|
||||
],
|
||||
},
|
||||
title: 'AskUserQuestionTool',
|
||||
title: 'AskUserQuestion',
|
||||
description: '',
|
||||
suggestions: [],
|
||||
},
|
||||
|
|
@ -200,7 +200,7 @@ describe('createQuestionRoutes', () => {
|
|||
)
|
||||
})
|
||||
|
||||
test('replies to AskUserQuestionTool permission', async () => {
|
||||
test('replies to AskUserQuestion permission', async () => {
|
||||
const handle = createMockHandle()
|
||||
const mgr = createMockSessionManager({
|
||||
findPermission: {
|
||||
|
|
@ -208,7 +208,7 @@ describe('createQuestionRoutes', () => {
|
|||
perm: {
|
||||
requestId: 'perm-1',
|
||||
sessionId: 'sess-1',
|
||||
toolName: 'AskUserQuestionTool',
|
||||
toolName: 'AskUserQuestion',
|
||||
toolUseId: 'tu-1',
|
||||
input: {
|
||||
questions: [
|
||||
|
|
@ -266,14 +266,14 @@ describe('createQuestionRoutes', () => {
|
|||
expect(handle.replyQuestion).toHaveBeenCalledWith('req-1', 'decline')
|
||||
})
|
||||
|
||||
test('rejects AskUserQuestionTool permission', async () => {
|
||||
test('rejects AskUserQuestion permission', async () => {
|
||||
const handle = createMockHandle()
|
||||
const mgr = createMockSessionManager({
|
||||
findPermission: {
|
||||
handle,
|
||||
perm: {
|
||||
requestId: 'perm-1',
|
||||
toolName: 'AskUserQuestionTool',
|
||||
toolName: 'AskUserQuestion',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,20 +2,24 @@ import { describe, expect, test, mock } from 'bun:test'
|
|||
import { SessionHandle } from '../sessionHandle.js'
|
||||
import type { EventBus } from '../eventBus.js'
|
||||
|
||||
function createMockEventBus(): EventBus {
|
||||
function createMockEventBus(): EventBus & { published: Array<{ event: string; data: Record<string, unknown> }> } {
|
||||
const events: Array<{ event: string; data: Record<string, unknown> }> = []
|
||||
const published: Array<{ event: string; data: Record<string, unknown> }> = []
|
||||
return {
|
||||
publishSessionEvent(sessionId: string, event: string, data: Record<string, unknown>) {
|
||||
events.push({ event, data: { session_id: sessionId, ...data } })
|
||||
},
|
||||
publish() {},
|
||||
publish(event: string, data: unknown) {
|
||||
published.push({ event, data: data as Record<string, unknown> })
|
||||
},
|
||||
addClient() { return '' },
|
||||
removeClient() {},
|
||||
startHeartbeat() {},
|
||||
stopHeartbeat() {},
|
||||
clientCount() { return 0 },
|
||||
destroy() {},
|
||||
} as unknown as EventBus
|
||||
get published() { return published },
|
||||
} as unknown as EventBus & { published: Array<{ event: string; data: Record<string, unknown> }> }
|
||||
}
|
||||
|
||||
describe('SessionHandle', () => {
|
||||
|
|
@ -170,4 +174,138 @@ describe('SessionHandle', () => {
|
|||
const usage = handle.usage
|
||||
expect(usage).toEqual({ input_tokens: 0, output_tokens: 0 })
|
||||
})
|
||||
|
||||
test('emits question.asked opencode event for AskUserQuestion can_use_tool', () => {
|
||||
const bus = createMockEventBus()
|
||||
const handle = new SessionHandle({
|
||||
sessionId: 'sess-1',
|
||||
cwd: '/tmp',
|
||||
eventBus: bus,
|
||||
execPath: 'bun',
|
||||
scriptArgs: [],
|
||||
})
|
||||
|
||||
// Simulate child process sending can_use_tool for AskUserQuestion
|
||||
;(handle as unknown as Record<string, (msg: Record<string, unknown>) => void>).routeMessage({
|
||||
type: 'control_request',
|
||||
request_id: 'req-1',
|
||||
request: {
|
||||
subtype: 'can_use_tool',
|
||||
tool_name: 'AskUserQuestion',
|
||||
tool_use_id: 'tu-1',
|
||||
input: {
|
||||
questions: [
|
||||
{
|
||||
question: 'Which library?',
|
||||
header: 'Library',
|
||||
options: [
|
||||
{ label: 'A', description: 'Option A' },
|
||||
{ label: 'B', description: 'Option B' },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
permission_suggestions: [],
|
||||
},
|
||||
})
|
||||
|
||||
// Permission should be recorded
|
||||
const perms = handle.getPendingPermissions()
|
||||
expect(perms).toHaveLength(1)
|
||||
expect(perms[0].toolName).toBe('AskUserQuestion')
|
||||
expect(perms[0].requestId).toBe('req-1')
|
||||
|
||||
// question.asked opencode event should be published (flat shape for wrapEventStream)
|
||||
const askedEvent = bus.published.find(e => e.event === 'question.asked')
|
||||
expect(askedEvent).toBeDefined()
|
||||
const askedData = askedEvent!.data as Record<string, unknown>
|
||||
expect(askedData.session_id).toBe('sess-1')
|
||||
expect(askedData.sessionID).toBe('sess-1')
|
||||
expect(askedData.id).toBe('req-1')
|
||||
expect(askedData.questions).toMatchObject([
|
||||
{
|
||||
question: 'Which library?',
|
||||
header: 'Library',
|
||||
options: [
|
||||
{ label: 'A', description: 'Option A' },
|
||||
{ label: 'B', description: 'Option B' },
|
||||
],
|
||||
multiple: false,
|
||||
custom: false,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('emits permission.asked opencode event for regular tool can_use_tool', () => {
|
||||
const bus = createMockEventBus()
|
||||
const handle = new SessionHandle({
|
||||
sessionId: 'sess-1',
|
||||
cwd: '/tmp',
|
||||
eventBus: bus,
|
||||
execPath: 'bun',
|
||||
scriptArgs: [],
|
||||
})
|
||||
|
||||
;(handle as unknown as Record<string, (msg: Record<string, unknown>) => void>).routeMessage({
|
||||
type: 'control_request',
|
||||
request_id: 'req-2',
|
||||
request: {
|
||||
subtype: 'can_use_tool',
|
||||
tool_name: 'Bash',
|
||||
tool_use_id: 'tu-2',
|
||||
input: { command: 'echo hello' },
|
||||
permission_suggestions: [],
|
||||
},
|
||||
})
|
||||
|
||||
const perms = handle.getPendingPermissions()
|
||||
expect(perms).toHaveLength(1)
|
||||
expect(perms[0].toolName).toBe('Bash')
|
||||
|
||||
const askedEvent = bus.published.find(e => e.event === 'permission.asked')
|
||||
expect(askedEvent).toBeDefined()
|
||||
const permData = askedEvent!.data as Record<string, unknown>
|
||||
expect(permData.session_id).toBe('sess-1')
|
||||
expect(permData.sessionID).toBe('sess-1')
|
||||
expect(permData.permission).toBe('bash')
|
||||
})
|
||||
|
||||
test('emits question.replied opencode event when AskUserQuestion is allowed', () => {
|
||||
const bus = createMockEventBus()
|
||||
const handle = new SessionHandle({
|
||||
sessionId: 'sess-1',
|
||||
cwd: '/tmp',
|
||||
eventBus: bus,
|
||||
execPath: 'bun',
|
||||
scriptArgs: [],
|
||||
})
|
||||
|
||||
// First inject the permission
|
||||
;(handle as unknown as Record<string, (msg: Record<string, unknown>) => void>).routeMessage({
|
||||
type: 'control_request',
|
||||
request_id: 'req-3',
|
||||
request: {
|
||||
subtype: 'can_use_tool',
|
||||
tool_name: 'AskUserQuestion',
|
||||
tool_use_id: 'tu-3',
|
||||
input: { questions: [{ question: 'Q1?', header: 'H', options: [], multiSelect: false }] },
|
||||
permission_suggestions: [],
|
||||
},
|
||||
})
|
||||
|
||||
expect(handle.getPendingPermissions()).toHaveLength(1)
|
||||
|
||||
// Now reply allow
|
||||
handle.replyPermission('req-3', 'allow', { updatedInput: { answers: { 'Q1?': 'A' } } })
|
||||
|
||||
expect(handle.getPendingPermissions()).toHaveLength(0)
|
||||
|
||||
const repliedEvent = bus.published.find(e => e.event === 'question.replied')
|
||||
expect(repliedEvent).toBeDefined()
|
||||
const repliedData = repliedEvent!.data as Record<string, unknown>
|
||||
expect(repliedData.session_id).toBe('sess-1')
|
||||
expect(repliedData.sessionID).toBe('sess-1')
|
||||
expect(repliedData.requestID).toBe('req-3')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -21,10 +21,10 @@ describe('SessionManager', () => {
|
|||
expect(mgr.getAllSessions()).toEqual([])
|
||||
})
|
||||
|
||||
test('getSessionStatuses returns empty object', () => {
|
||||
test('getSessionStatuses returns empty object', async () => {
|
||||
const bus = new EventBus()
|
||||
const mgr = new SessionManager({ eventBus: bus })
|
||||
expect(mgr.getSessionStatuses()).toEqual({})
|
||||
expect(await mgr.getSessionStatuses()).toEqual({})
|
||||
})
|
||||
|
||||
test('getAllPendingPermissions returns empty', () => {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ type SSEClient = {
|
|||
writer: SSEWriter | null
|
||||
rawWrite: ((event: string, data: unknown) => void) | null
|
||||
sessionIdFilter?: string
|
||||
cwdFilter?: string
|
||||
}
|
||||
|
||||
type BusEvent = {
|
||||
|
|
@ -19,10 +20,19 @@ export class EventBus {
|
|||
private buffer: BusEvent[] = []
|
||||
private bufferSize = 100
|
||||
private heartbeatInterval: ReturnType<typeof setInterval> | null = null
|
||||
private sessionCwds = new Map<string, string>()
|
||||
|
||||
addClient(writer: SSEWriter, sessionIdFilter?: string): string {
|
||||
registerSessionCwd(sessionId: string, cwd: string): void {
|
||||
this.sessionCwds.set(sessionId, cwd)
|
||||
}
|
||||
|
||||
unregisterSessionCwd(sessionId: string): void {
|
||||
this.sessionCwds.delete(sessionId)
|
||||
}
|
||||
|
||||
addClient(writer: SSEWriter, sessionIdFilter?: string, cwdFilter?: string): string {
|
||||
const id = crypto.randomUUID()
|
||||
const client: SSEClient = { id, writer, rawWrite: null, sessionIdFilter }
|
||||
const client: SSEClient = { id, writer, rawWrite: null, sessionIdFilter, cwdFilter }
|
||||
this.clients.set(id, client)
|
||||
|
||||
const sendAndCleanup = (opts: { event: string; data: string }) =>
|
||||
|
|
@ -65,6 +75,16 @@ export class EventBus {
|
|||
continue
|
||||
}
|
||||
}
|
||||
if (client.cwdFilter) {
|
||||
const dataObj = data as Record<string, unknown> | undefined
|
||||
const sid = dataObj?.session_id ?? dataObj?.sessionID
|
||||
if (typeof sid === 'string') {
|
||||
const sessionCwd = this.sessionCwds.get(sid)
|
||||
if (sessionCwd && sessionCwd !== client.cwdFilter) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
client.writer.writeSSE({ event, data: payload }).catch(() => {
|
||||
deadIds.push(client.id)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,19 +1,36 @@
|
|||
import { Hono } from 'hono'
|
||||
import { streamSSE } from 'hono/streaming'
|
||||
import type { EventBus } from '../eventBus.js'
|
||||
import { canonicalizePath } from '../../utils/sessionStoragePortable.js'
|
||||
|
||||
export function createEventRoutes(eventBus: EventBus): Hono {
|
||||
return new Hono().get('/event', async c => {
|
||||
const sessionIdFilter = c.req.query('session_id') ?? undefined
|
||||
return streamSSE(c, async stream => {
|
||||
const clientId = eventBus.addClient(stream, sessionIdFilter)
|
||||
stream.onAbort(() => {
|
||||
eventBus.removeClient(clientId)
|
||||
})
|
||||
return new Hono()
|
||||
.get('/event', async c => {
|
||||
const sessionIdFilter = c.req.query('session_id') ?? undefined
|
||||
const headerDir = c.req.header('x-csc-directory')
|
||||
const cwdFilter = headerDir ? await canonicalizePath(decodeURIComponent(headerDir)) : undefined
|
||||
return streamSSE(c, async stream => {
|
||||
const clientId = eventBus.addClient(stream, sessionIdFilter, cwdFilter)
|
||||
stream.onAbort(() => {
|
||||
eventBus.removeClient(clientId)
|
||||
})
|
||||
|
||||
while (true) {
|
||||
await stream.sleep(30000)
|
||||
}
|
||||
while (true) {
|
||||
await stream.sleep(30000)
|
||||
}
|
||||
})
|
||||
})
|
||||
.get('/global/event', async c => {
|
||||
const sessionIdFilter = c.req.query('session_id') ?? undefined
|
||||
return streamSSE(c, async stream => {
|
||||
const clientId = eventBus.addClient(stream, sessionIdFilter)
|
||||
stream.onAbort(() => {
|
||||
eventBus.removeClient(clientId)
|
||||
})
|
||||
|
||||
while (true) {
|
||||
await stream.sleep(30000)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,8 +80,27 @@ export function createPermissionRoutes(sessionManager: SessionManager): Hono {
|
|||
body.behavior === 'once' || body.behavior === 'always' ? 'allow' :
|
||||
body.behavior === 'deny' ? 'deny' : 'allow'
|
||||
|
||||
const updatedPermissions = body.updated_permissions
|
||||
let updatedPermissions = body.updated_permissions
|
||||
?? (isAlways ? found.perm.suggestions : undefined)
|
||||
// Fallback: if user chose "always" but no suggestions were generated,
|
||||
// construct a default allow rule for this tool so the decision persists.
|
||||
if (
|
||||
isAlways &&
|
||||
(!updatedPermissions || (updatedPermissions as Record<string, unknown>[]).length === 0)
|
||||
) {
|
||||
updatedPermissions = [
|
||||
{
|
||||
type: 'addRules',
|
||||
destination: 'session',
|
||||
behavior: 'allow',
|
||||
rules: [
|
||||
{
|
||||
toolName: found.perm.toolName,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
found.handle.replyPermission(requestId, mappedBehavior, {
|
||||
updatedInput: body.updated_input ?? {},
|
||||
|
|
|
|||
|
|
@ -146,9 +146,9 @@ export function createQuestionRoutes(sessionManager: SessionManager): Hono {
|
|||
))
|
||||
}
|
||||
|
||||
// 2. AskUserQuestionTool permissions exposed as questions
|
||||
// 2. AskUserQuestion permissions exposed as questions
|
||||
for (const p of sessionManager.getAllPendingPermissions()) {
|
||||
if (p.toolName === 'AskUserQuestionTool') {
|
||||
if (p.toolName === 'AskUserQuestion') {
|
||||
result.push(convertAskUserQuestionToOpencode(
|
||||
p.requestId,
|
||||
p.sessionId,
|
||||
|
|
@ -171,9 +171,9 @@ export function createQuestionRoutes(sessionManager: SessionManager): Hono {
|
|||
return c.json({ resolved: true })
|
||||
}
|
||||
|
||||
// 2. Try AskUserQuestionTool permission
|
||||
// 2. Try AskUserQuestion permission
|
||||
const pFound = sessionManager.findPermissionAcrossSessions(requestId)
|
||||
if (pFound && pFound.perm.toolName === 'AskUserQuestionTool') {
|
||||
if (pFound && pFound.perm.toolName === 'AskUserQuestion') {
|
||||
const body = await c.req.json<QuestionReplyBody>()
|
||||
const input = pFound.perm.input as {
|
||||
questions: Array<{ question: string }>
|
||||
|
|
@ -208,9 +208,9 @@ export function createQuestionRoutes(sessionManager: SessionManager): Hono {
|
|||
return c.json({ resolved: true })
|
||||
}
|
||||
|
||||
// 2. Try AskUserQuestionTool permission
|
||||
// 2. Try AskUserQuestion permission
|
||||
const pFound = sessionManager.findPermissionAcrossSessions(requestId)
|
||||
if (pFound && pFound.perm.toolName === 'AskUserQuestionTool') {
|
||||
if (pFound && pFound.perm.toolName === 'AskUserQuestion') {
|
||||
pFound.handle.replyPermission(requestId, 'deny')
|
||||
return c.json({ resolved: true })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -161,9 +161,7 @@ export function createSessionRoutes(
|
|||
scriptArgs: getScriptArgsForChild(),
|
||||
})
|
||||
|
||||
// 等待子进程初始化完成,确保 ready 后再返回,避免立即发 prompt 时子进程还未就绪
|
||||
await handle.waitReady(30000)
|
||||
|
||||
// 不阻塞等待 ready:子进程冷启动可能 >30s 撞上反代超时;prompt() 内部已有 waitReady 兜底
|
||||
const info = handle.getInfo()
|
||||
return c.json(
|
||||
{
|
||||
|
|
@ -212,10 +210,19 @@ export function createSessionRoutes(
|
|||
const handle = handleMap.get(s.sessionId)
|
||||
const info = handle?.getInfo()
|
||||
return {
|
||||
id: s.sessionId,
|
||||
session_id: s.sessionId,
|
||||
slug: info?.slug ?? s.sessionId,
|
||||
projectID: info?.projectID ?? '',
|
||||
status: info?.status ?? 'stopped',
|
||||
directory: info?.directory ?? s.cwd ?? '',
|
||||
cwd: info?.cwd ?? s.cwd ?? '',
|
||||
title: (info?.title ?? s.customTitle ?? s.firstPrompt ?? s.summary) ?? '',
|
||||
version: info?.version ?? '',
|
||||
time: info?.time ?? {
|
||||
created: s.createdAt ?? 0,
|
||||
updated: s.lastModified ?? 0,
|
||||
},
|
||||
model: info?.model,
|
||||
permission_mode: info?.permission_mode,
|
||||
created_at: s.createdAt ?? info?.created_at ?? 0,
|
||||
|
|
@ -255,47 +262,13 @@ export function createSessionRoutes(
|
|||
filtered.sort((a, b) => (b.last_active_at ?? 0) - (a.last_active_at ?? 0))
|
||||
|
||||
const sessions = filtered.slice(offset, offset + limit)
|
||||
return c.json({ sessions })
|
||||
return c.json(sessions)
|
||||
})
|
||||
.get('/session/status', async c => {
|
||||
// 内存中活跃 session 的状态
|
||||
const activeStatuses = sessionManager.getSessionStatuses()
|
||||
|
||||
// 从请求头或 query 取目录过滤
|
||||
const headerDir = c.req.header('x-csc-directory')
|
||||
const statusDir = (headerDir ? decodeURIComponent(headerDir) : undefined)
|
||||
?? c.req.query('dir')
|
||||
?? undefined
|
||||
|
||||
// 补充磁盘历史 session(全部视为 idle)
|
||||
let historySessions: Awaited<ReturnType<typeof listSessionsImpl>> = []
|
||||
try {
|
||||
historySessions = await listSessionsImpl({ dir: statusDir, limit: 200 })
|
||||
} catch {}
|
||||
|
||||
const sessions: Record<string, { status: string; state: string; has_pending_permission: boolean; type: string }> = {}
|
||||
|
||||
// 先把历史 session 全部标为 idle/stopped
|
||||
for (const s of historySessions) {
|
||||
sessions[s.sessionId] = {
|
||||
status: 'stopped',
|
||||
state: 'stopped',
|
||||
has_pending_permission: false,
|
||||
type: 'idle',
|
||||
}
|
||||
}
|
||||
|
||||
// 用内存中活跃的 handle 状态覆盖
|
||||
for (const [id, st] of Object.entries(activeStatuses)) {
|
||||
sessions[id] = {
|
||||
status: st.status,
|
||||
state: st.status,
|
||||
has_pending_permission: st.has_pending_permission,
|
||||
type: st.prompting ? 'busy' : 'idle',
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ sessions })
|
||||
const dir = headerDir ? decodeURIComponent(headerDir) : undefined
|
||||
const statuses = sessionManager.getSessionStatuses(dir)
|
||||
return c.json(statuses)
|
||||
})
|
||||
.get('/session/:sessionID', async c => {
|
||||
const id = c.req.param('sessionID')
|
||||
|
|
@ -315,10 +288,19 @@ export function createSessionRoutes(
|
|||
const s = history.find(h => h.sessionId === id)
|
||||
if (s) {
|
||||
return c.json({
|
||||
id: s.sessionId,
|
||||
session_id: s.sessionId,
|
||||
slug: s.sessionId,
|
||||
projectID: '',
|
||||
status: 'stopped',
|
||||
directory: s.cwd ?? '',
|
||||
cwd: s.cwd ?? '',
|
||||
title: (s.customTitle ?? s.firstPrompt ?? s.summary) ?? '',
|
||||
version: '',
|
||||
time: {
|
||||
created: s.createdAt ?? 0,
|
||||
updated: s.lastModified ?? 0,
|
||||
},
|
||||
model: undefined,
|
||||
permission_mode: undefined,
|
||||
created_at: s.createdAt ?? 0,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,13 @@ import { createInterface } from 'readline'
|
|||
import { jsonParse, jsonStringify } from '../utils/slowOperations.js'
|
||||
import { logError } from '../utils/log.js'
|
||||
import type { EventBus } from './eventBus.js'
|
||||
import type { SessionState } from './types.js'
|
||||
import type { SessionBusyStatus, SessionState } from './types.js'
|
||||
|
||||
// 子进程冷启动 + MCP 加载 + 鉴权可能远超 30s;env 可调
|
||||
const INIT_TIMEOUT_MS = (() => {
|
||||
const raw = Number(process.env.CSC_SERVE_INIT_TIMEOUT_MS)
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : 120000
|
||||
})()
|
||||
|
||||
type StdoutMessage = {
|
||||
type: string
|
||||
|
|
@ -119,6 +125,7 @@ export class SessionHandle {
|
|||
private _spawnCwd: string | undefined
|
||||
private child: ChildProcess | null = null
|
||||
private _status: SessionState = 'starting'
|
||||
private _busyStatus: SessionBusyStatus = { type: 'idle' }
|
||||
private _model?: string
|
||||
private _providerId?: string
|
||||
private _permissionMode?: string
|
||||
|
|
@ -148,6 +155,9 @@ export class SessionHandle {
|
|||
get status(): SessionState {
|
||||
return this._status
|
||||
}
|
||||
get busyStatus(): SessionBusyStatus {
|
||||
return this._busyStatus
|
||||
}
|
||||
get model(): string | undefined {
|
||||
return this._model
|
||||
}
|
||||
|
|
@ -195,7 +205,7 @@ export class SessionHandle {
|
|||
return this._spawnCwd ?? this.cwd
|
||||
}
|
||||
|
||||
async waitReady(timeoutMs = 30000): Promise<void> {
|
||||
async waitReady(timeoutMs = INIT_TIMEOUT_MS): Promise<void> {
|
||||
if (this._status === 'running') return
|
||||
if (this._status === 'stopped') {
|
||||
throw new Error(`Session ${this.sessionId} is stopped`)
|
||||
|
|
@ -233,6 +243,60 @@ export class SessionHandle {
|
|||
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 {
|
||||
this.listeners.add(listener)
|
||||
return () => { this.listeners.delete(listener) }
|
||||
|
|
@ -290,6 +354,8 @@ export class SessionHandle {
|
|||
this.child.on('close', (code, signal) => {
|
||||
if (this._status !== 'stopped') {
|
||||
this._status = 'stopped'
|
||||
this._busyStatus = { type: 'idle' }
|
||||
this.emitBusyStatus()
|
||||
this.emitEvent('deleted', {
|
||||
status: 'stopped',
|
||||
exit_code: code,
|
||||
|
|
@ -311,6 +377,8 @@ export class SessionHandle {
|
|||
this.child.on('error', err => {
|
||||
logError(err)
|
||||
this._status = 'stopped'
|
||||
this._busyStatus = { type: 'idle' }
|
||||
this.emitBusyStatus()
|
||||
if (this.initReject) {
|
||||
const reject = this.initReject
|
||||
this.initResolve = null
|
||||
|
|
@ -337,7 +405,7 @@ export class SessionHandle {
|
|||
})
|
||||
reject(new Error(`Session ${this.sessionId} init timed out`))
|
||||
}
|
||||
}, 30000)
|
||||
}, INIT_TIMEOUT_MS)
|
||||
|
||||
this.initResolve = (data: InitData) => {
|
||||
clearTimeout(timeout)
|
||||
|
|
@ -504,6 +572,8 @@ export class SessionHandle {
|
|||
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 })
|
||||
|
|
@ -516,10 +586,11 @@ export class SessionHandle {
|
|||
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: (request.tool_name as string) ?? 'Unknown',
|
||||
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)}`,
|
||||
|
|
@ -529,6 +600,43 @@ export class SessionHandle {
|
|||
}
|
||||
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,
|
||||
|
|
@ -541,6 +649,19 @@ export class SessionHandle {
|
|||
}
|
||||
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
|
||||
|
|
@ -580,8 +701,22 @@ export class SessionHandle {
|
|||
}
|
||||
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': {
|
||||
|
|
@ -666,6 +801,8 @@ export class SessionHandle {
|
|||
)
|
||||
}
|
||||
this._prompting = true
|
||||
this._busyStatus = { type: 'busy' }
|
||||
this.emitBusyStatus()
|
||||
this.lastActiveAt = Date.now()
|
||||
|
||||
const userMsg = jsonStringify({
|
||||
|
|
@ -709,6 +846,9 @@ export class SessionHandle {
|
|||
})
|
||||
this.writeStdin(interrupt)
|
||||
|
||||
this._busyStatus = { type: 'idle' }
|
||||
this.emitBusyStatus()
|
||||
|
||||
if (!this.promptResolve) return
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
|
|
@ -761,11 +901,25 @@ export class SessionHandle {
|
|||
},
|
||||
}
|
||||
this.writeStdin(jsonStringify(response))
|
||||
const perm = this.getPendingPermission(requestId)
|
||||
const toolName = perm?.toolName
|
||||
this.pendingPermissions.delete(requestId)
|
||||
this.emitEvent('permission_replied', {
|
||||
request_id: requestId,
|
||||
behavior,
|
||||
})
|
||||
if (toolName === 'AskUserQuestion') {
|
||||
const eventType = behavior === 'allow' ? 'question.replied' : 'question.rejected'
|
||||
this.emitOpencodeEvent(eventType, {
|
||||
sessionID: this.sessionId,
|
||||
requestID: requestId,
|
||||
})
|
||||
} else {
|
||||
this.emitOpencodeEvent('permission.replied', {
|
||||
sessionID: this.sessionId,
|
||||
requestID: requestId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
replyQuestion(
|
||||
|
|
@ -790,6 +944,11 @@ export class SessionHandle {
|
|||
request_id: requestId,
|
||||
action,
|
||||
})
|
||||
const eventType = action === 'accept' ? 'question.replied' : 'question.rejected'
|
||||
this.emitOpencodeEvent(eventType, {
|
||||
sessionID: this.sessionId,
|
||||
requestID: requestId,
|
||||
})
|
||||
}
|
||||
|
||||
kill(): void {
|
||||
|
|
@ -838,10 +997,19 @@ export class SessionHandle {
|
|||
|
||||
getInfo() {
|
||||
return {
|
||||
id: this.sessionId,
|
||||
session_id: this.sessionId,
|
||||
status: this._status,
|
||||
slug: this.sessionId,
|
||||
projectID: '',
|
||||
directory: this.cwd,
|
||||
cwd: this.cwd,
|
||||
title: this._title,
|
||||
version: '',
|
||||
time: {
|
||||
created: this.createdAt,
|
||||
updated: this.lastActiveAt,
|
||||
},
|
||||
status: this._status,
|
||||
model: this._model,
|
||||
permission_mode: this._permissionMode,
|
||||
created_at: this.createdAt,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
|
|||
import { logError } from '../utils/log.js'
|
||||
import type { EventBus } from './eventBus.js'
|
||||
import { SessionHandle, type InitData } from './sessionHandle.js'
|
||||
import type { SessionIndex, SessionIndexEntry, SessionState } from './types.js'
|
||||
import type { SessionIndex, SessionIndexEntry, SessionBusyStatus, SessionState } from './types.js'
|
||||
import { canonicalizePath } from '../utils/sessionStoragePortable.js'
|
||||
|
||||
const INDEX_FILE = 'server-sessions.json'
|
||||
|
||||
|
|
@ -108,6 +109,7 @@ export class SessionManager {
|
|||
reason: 'idle_timeout',
|
||||
})
|
||||
this.sessions.delete(id)
|
||||
this.eventBus.unregisterSessionCwd(id)
|
||||
this.scheduleIndexSave()
|
||||
}
|
||||
}
|
||||
|
|
@ -126,20 +128,15 @@ export class SessionManager {
|
|||
return [...this.sessions.values()]
|
||||
}
|
||||
|
||||
getSessionStatuses(): Record<
|
||||
string,
|
||||
{ status: SessionState; has_pending_permission: boolean; prompting: boolean }
|
||||
> {
|
||||
const result: Record<
|
||||
string,
|
||||
{ status: SessionState; has_pending_permission: boolean; prompting: boolean }
|
||||
> = {}
|
||||
async getSessionStatuses(cwd?: string): Promise<Record<string, SessionBusyStatus>> {
|
||||
const canonicalCwd = cwd ? await canonicalizePath(cwd) : undefined
|
||||
const result: Record<string, SessionBusyStatus> = {}
|
||||
for (const [id, handle] of this.sessions) {
|
||||
result[id] = {
|
||||
status: handle.status,
|
||||
has_pending_permission: handle.getPendingPermissions().length > 0,
|
||||
prompting: handle.prompting,
|
||||
if (canonicalCwd) {
|
||||
const handleCwd = await canonicalizePath(handle.cwd)
|
||||
if (handleCwd !== canonicalCwd) continue
|
||||
}
|
||||
result[id] = handle.busyStatus
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
@ -193,6 +190,9 @@ export class SessionManager {
|
|||
|
||||
this.sessions.set(sessionId, handle)
|
||||
if (!opts.silent) {
|
||||
void canonicalizePath(cwd).then(canonical => {
|
||||
this.eventBus.registerSessionCwd(sessionId, canonical)
|
||||
})
|
||||
this.eventBus.publishSessionEvent(sessionId, 'created', {
|
||||
status: 'starting',
|
||||
})
|
||||
|
|
@ -203,6 +203,7 @@ export class SessionManager {
|
|||
handle.spawn()
|
||||
} catch (err) {
|
||||
this.sessions.delete(sessionId)
|
||||
this.eventBus.unregisterSessionCwd(sessionId)
|
||||
this.scheduleIndexSave()
|
||||
throw err
|
||||
}
|
||||
|
|
@ -266,6 +267,7 @@ export class SessionManager {
|
|||
const silent = handle.silent
|
||||
handle.forceKill()
|
||||
this.sessions.delete(id)
|
||||
this.eventBus.unregisterSessionCwd(id)
|
||||
if (!silent) {
|
||||
this.eventBus.publishSessionEvent(id, 'deleted', { status: 'stopped' })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,11 @@ export type SessionState =
|
|||
| 'stopping'
|
||||
| 'stopped'
|
||||
|
||||
export type SessionBusyStatus =
|
||||
| { type: 'idle' }
|
||||
| { type: 'busy' }
|
||||
| { type: 'retry'; attempt: number; message: string; next: number }
|
||||
|
||||
export type SessionInfo = {
|
||||
id: string
|
||||
status: SessionState
|
||||
|
|
|
|||
|
|
@ -1291,7 +1291,9 @@ async function hasPermissionsToUseToolInner(
|
|||
appState.toolPermissionContext.mode === 'bypassPermissions' ||
|
||||
(appState.toolPermissionContext.mode === 'plan' &&
|
||||
appState.toolPermissionContext.isBypassPermissionsModeAvailable)
|
||||
if (shouldBypassPermissions) {
|
||||
// requiresUserInteraction tools (e.g. AskUserQuestion) must always ask
|
||||
// the user, even in bypassPermissions mode.
|
||||
if (shouldBypassPermissions && !tool.requiresUserInteraction?.()) {
|
||||
return {
|
||||
behavior: 'allow',
|
||||
updatedInput: getUpdatedInputOrFallback(toolPermissionResult, input),
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user