From 009e9f0c4955dcff74e095629dbf80206787a132 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E5=87=AF90331?= <90331@sangfor.com> Date: Mon, 11 May 2026 12:54:44 +0800 Subject: [PATCH] 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 --- src/server/__tests__/questionRoutes.test.ts | 14 +- src/server/__tests__/sessionHandle.test.ts | 144 +++++++++++++++- src/server/__tests__/sessionManager.test.ts | 4 +- src/server/eventBus.ts | 24 ++- src/server/routes/event.ts | 39 +++-- src/server/routes/permission.ts | 21 ++- src/server/routes/question.ts | 12 +- src/server/routes/session.ts | 64 +++---- src/server/sessionHandle.ts | 178 +++++++++++++++++++- src/server/sessionManager.ts | 28 +-- src/server/types.ts | 5 + src/utils/permissions/permissions.ts | 4 +- 12 files changed, 445 insertions(+), 92 deletions(-) diff --git a/src/server/__tests__/questionRoutes.test.ts b/src/server/__tests__/questionRoutes.test.ts index 41de99310..c8fd6398e 100644 --- a/src/server/__tests__/questionRoutes.test.ts +++ b/src/server/__tests__/questionRoutes.test.ts @@ -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', }, }, }) diff --git a/src/server/__tests__/sessionHandle.test.ts b/src/server/__tests__/sessionHandle.test.ts index 9bf10f50d..bfd0b305e 100644 --- a/src/server/__tests__/sessionHandle.test.ts +++ b/src/server/__tests__/sessionHandle.test.ts @@ -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 }> } { const events: Array<{ event: string; data: Record }> = [] + const published: Array<{ event: string; data: Record }> = [] return { publishSessionEvent(sessionId: string, event: string, data: Record) { events.push({ event, data: { session_id: sessionId, ...data } }) }, - publish() {}, + publish(event: string, data: unknown) { + published.push({ event, data: data as Record }) + }, 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 }> } } 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) => 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 + 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) => 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 + 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) => 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 + expect(repliedData.session_id).toBe('sess-1') + expect(repliedData.sessionID).toBe('sess-1') + expect(repliedData.requestID).toBe('req-3') + }) }) diff --git a/src/server/__tests__/sessionManager.test.ts b/src/server/__tests__/sessionManager.test.ts index 898bf1d13..79392cbf1 100644 --- a/src/server/__tests__/sessionManager.test.ts +++ b/src/server/__tests__/sessionManager.test.ts @@ -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', () => { diff --git a/src/server/eventBus.ts b/src/server/eventBus.ts index ab4e27506..95a0a638f 100644 --- a/src/server/eventBus.ts +++ b/src/server/eventBus.ts @@ -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 | null = null + private sessionCwds = new Map() - 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 | 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) }) diff --git a/src/server/routes/event.ts b/src/server/routes/event.ts index 8c8327c0c..a86e6ab3e 100644 --- a/src/server/routes/event.ts +++ b/src/server/routes/event.ts @@ -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) + } + }) }) - }) } diff --git a/src/server/routes/permission.ts b/src/server/routes/permission.ts index d7af5e8a4..aa0b31046 100644 --- a/src/server/routes/permission.ts +++ b/src/server/routes/permission.ts @@ -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[]).length === 0) + ) { + updatedPermissions = [ + { + type: 'addRules', + destination: 'session', + behavior: 'allow', + rules: [ + { + toolName: found.perm.toolName, + }, + ], + }, + ] + } found.handle.replyPermission(requestId, mappedBehavior, { updatedInput: body.updated_input ?? {}, diff --git a/src/server/routes/question.ts b/src/server/routes/question.ts index e116bdd2e..57d8feb45 100644 --- a/src/server/routes/question.ts +++ b/src/server/routes/question.ts @@ -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() 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 }) } diff --git a/src/server/routes/session.ts b/src/server/routes/session.ts index d76e328a4..e275731fe 100644 --- a/src/server/routes/session.ts +++ b/src/server/routes/session.ts @@ -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> = [] - try { - historySessions = await listSessionsImpl({ dir: statusDir, limit: 200 }) - } catch {} - - const sessions: Record = {} - - // 先把历史 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, diff --git a/src/server/sessionHandle.ts b/src/server/sessionHandle.ts index c45cee4e5..3e663cd56 100644 --- a/src/server/sessionHandle.ts +++ b/src/server/sessionHandle.ts @@ -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 { + async waitReady(timeoutMs = INIT_TIMEOUT_MS): Promise { 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): 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) + 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 = { + 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[] { + 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 | 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) ?? {}, 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) ?? {} + const questions = (input.questions as Array>) ?? [] + 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(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, diff --git a/src/server/sessionManager.ts b/src/server/sessionManager.ts index 76618f464..07d558882 100644 --- a/src/server/sessionManager.ts +++ b/src/server/sessionManager.ts @@ -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> { + const canonicalCwd = cwd ? await canonicalizePath(cwd) : undefined + const result: Record = {} 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' }) } diff --git a/src/server/types.ts b/src/server/types.ts index b305bc8fa..d1cd910a5 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -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 diff --git a/src/utils/permissions/permissions.ts b/src/utils/permissions/permissions.ts index 435f13e65..13392ace6 100644 --- a/src/utils/permissions/permissions.ts +++ b/src/utils/permissions/permissions.ts @@ -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),