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:
林凯90331 2026-05-11 12:54:44 +08:00
parent 59d3460554
commit 009e9f0c49
12 changed files with 445 additions and 92 deletions

View File

@ -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',
},
},
})

View File

@ -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')
})
})

View File

@ -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', () => {

View File

@ -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)
})

View File

@ -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)
}
})
})
})
}

View File

@ -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 ?? {},

View File

@ -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 })
}

View File

@ -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,

View File

@ -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 加载 + 鉴权可能远超 30senv 可调
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,

View File

@ -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' })
}

View File

@ -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

View File

@ -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),