Merge branch 'feat/server-refactor' into feat/server-add-session
This commit is contained in:
commit
a62989e245
|
|
@ -5983,7 +5983,7 @@ async function run(): Promise<CommanderCommand> {
|
||||||
});
|
});
|
||||||
await sessionManager.init();
|
await sessionManager.init();
|
||||||
|
|
||||||
const server = startServer(config, sessionManager);
|
const server = startServer(config, sessionManager, eventBus);
|
||||||
const actualPort = server.port ?? config.port;
|
const actualPort = server.port ?? config.port;
|
||||||
printBanner(config, undefined, actualPort);
|
printBanner(config, undefined, actualPort);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ describe('SessionHandle', () => {
|
||||||
unsub()
|
unsub()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('prompt throws if not running', async () => {
|
test('prompt throws if stopped', async () => {
|
||||||
const bus = createMockEventBus()
|
const bus = createMockEventBus()
|
||||||
const handle = new SessionHandle({
|
const handle = new SessionHandle({
|
||||||
sessionId: 'test-id',
|
sessionId: 'test-id',
|
||||||
|
|
@ -92,7 +92,8 @@ describe('SessionHandle', () => {
|
||||||
execPath: 'bun',
|
execPath: 'bun',
|
||||||
scriptArgs: [],
|
scriptArgs: [],
|
||||||
})
|
})
|
||||||
expect(handle.prompt('hello')).rejects.toThrow('not running')
|
handle.kill()
|
||||||
|
expect(handle.prompt('hello')).rejects.toThrow('stopped')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('getPendingPermissions returns empty initially', () => {
|
test('getPendingPermissions returns empty initially', () => {
|
||||||
|
|
@ -132,6 +133,31 @@ describe('SessionHandle', () => {
|
||||||
expect(handle.status).toBe('stopped')
|
expect(handle.status).toBe('stopped')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('waitReady throws if stopped', async () => {
|
||||||
|
const bus = createMockEventBus()
|
||||||
|
const handle = new SessionHandle({
|
||||||
|
sessionId: 'test-id',
|
||||||
|
cwd: '/tmp',
|
||||||
|
eventBus: bus,
|
||||||
|
execPath: 'bun',
|
||||||
|
scriptArgs: [],
|
||||||
|
})
|
||||||
|
handle.kill()
|
||||||
|
expect(handle.waitReady(100)).rejects.toThrow('stopped')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ready returns false initially', () => {
|
||||||
|
const bus = createMockEventBus()
|
||||||
|
const handle = new SessionHandle({
|
||||||
|
sessionId: 'test-id',
|
||||||
|
cwd: '/tmp',
|
||||||
|
eventBus: bus,
|
||||||
|
execPath: 'bun',
|
||||||
|
scriptArgs: [],
|
||||||
|
})
|
||||||
|
expect(handle.ready).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
test('usage returns token counts', () => {
|
test('usage returns token counts', () => {
|
||||||
const bus = createMockEventBus()
|
const bus = createMockEventBus()
|
||||||
const handle = new SessionHandle({
|
const handle = new SessionHandle({
|
||||||
|
|
|
||||||
|
|
@ -25,13 +25,18 @@ export class EventBus {
|
||||||
const client: SSEClient = { id, writer, rawWrite: null, sessionIdFilter }
|
const client: SSEClient = { id, writer, rawWrite: null, sessionIdFilter }
|
||||||
this.clients.set(id, client)
|
this.clients.set(id, client)
|
||||||
|
|
||||||
void writer.writeSSE({
|
const sendAndCleanup = (opts: { event: string; data: string }) =>
|
||||||
|
writer.writeSSE(opts).catch(() => {
|
||||||
|
this.clients.delete(id)
|
||||||
|
})
|
||||||
|
|
||||||
|
void sendAndCleanup({
|
||||||
event: 'connected',
|
event: 'connected',
|
||||||
data: JSON.stringify({ type: 'server.connected' }),
|
data: JSON.stringify({ type: 'server.connected' }),
|
||||||
})
|
})
|
||||||
|
|
||||||
for (const buffered of this.buffer) {
|
for (const buffered of this.buffer) {
|
||||||
void writer.writeSSE({
|
void sendAndCleanup({
|
||||||
event: buffered.event,
|
event: buffered.event,
|
||||||
data: JSON.stringify(buffered.data),
|
data: JSON.stringify(buffered.data),
|
||||||
})
|
})
|
||||||
|
|
@ -51,6 +56,7 @@ export class EventBus {
|
||||||
this.buffer.shift()
|
this.buffer.shift()
|
||||||
}
|
}
|
||||||
const payload = JSON.stringify(data)
|
const payload = JSON.stringify(data)
|
||||||
|
const deadIds: string[] = []
|
||||||
for (const client of this.clients.values()) {
|
for (const client of this.clients.values()) {
|
||||||
if (client.writer) {
|
if (client.writer) {
|
||||||
if (client.sessionIdFilter) {
|
if (client.sessionIdFilter) {
|
||||||
|
|
@ -59,7 +65,14 @@ export class EventBus {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
void client.writer.writeSSE({ event, data: payload })
|
client.writer.writeSSE({ event, data: payload }).catch(() => {
|
||||||
|
deadIds.push(client.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (deadIds.length > 0) {
|
||||||
|
for (const id of deadIds) {
|
||||||
|
this.clients.delete(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,14 +14,23 @@ export function createPermissionRoutes(sessionManager: SessionManager): Hono {
|
||||||
if (!found) throw notFound('permission request not found')
|
if (!found) throw notFound('permission request not found')
|
||||||
|
|
||||||
const body = await c.req.json<{
|
const body = await c.req.json<{
|
||||||
behavior: 'allow' | 'deny'
|
behavior?: 'allow' | 'deny' | 'once' | 'always' | 'reject'
|
||||||
updated_input?: Record<string, unknown>
|
updated_input?: Record<string, unknown>
|
||||||
|
updated_permissions?: Record<string, unknown>[]
|
||||||
message?: string
|
message?: string
|
||||||
|
interrupt?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
found.handle.replyPermission(requestId, body.behavior, {
|
const mappedBehavior: 'allow' | 'deny' =
|
||||||
|
body.behavior === 'reject' ? 'deny' :
|
||||||
|
body.behavior === 'once' || body.behavior === 'always' ? 'allow' :
|
||||||
|
body.behavior === 'deny' ? 'deny' : 'allow'
|
||||||
|
|
||||||
|
found.handle.replyPermission(requestId, mappedBehavior, {
|
||||||
updatedInput: body.updated_input,
|
updatedInput: body.updated_input,
|
||||||
|
updatedPermissions: body.updated_permissions,
|
||||||
message: body.message,
|
message: body.message,
|
||||||
|
interrupt: body.interrupt,
|
||||||
})
|
})
|
||||||
|
|
||||||
return c.json({ resolved: true })
|
return c.json({ resolved: true })
|
||||||
|
|
|
||||||
|
|
@ -114,34 +114,45 @@ export function createSessionRoutes(
|
||||||
const body = await c.req.json<{
|
const body = await c.req.json<{
|
||||||
cwd?: string
|
cwd?: string
|
||||||
permission_mode?: string
|
permission_mode?: string
|
||||||
|
permission?: Array<{ permission: string; pattern: string; action: string }>
|
||||||
model?: string
|
model?: string
|
||||||
system_prompt?: string
|
system_prompt?: string
|
||||||
resume_session_id?: string
|
resume_session_id?: string
|
||||||
|
resume_session_at?: string
|
||||||
|
hooks?: Record<string, unknown>
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
let permissionMode = body.permission_mode
|
||||||
|
if (!permissionMode && body.permission) {
|
||||||
|
const hasDeny = body.permission.some(r => r.action === 'deny')
|
||||||
|
const hasAsk = body.permission.some(r => r.action === 'ask')
|
||||||
|
if (!hasDeny && !hasAsk) {
|
||||||
|
permissionMode = 'bypassPermissions'
|
||||||
|
} else if (hasAsk) {
|
||||||
|
permissionMode = 'default'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const handle = await sessionManager.createSession({
|
const handle = await sessionManager.createSession({
|
||||||
cwd: body.cwd,
|
cwd: body.cwd,
|
||||||
model: body.model,
|
model: body.model,
|
||||||
permissionMode: body.permission_mode,
|
permissionMode,
|
||||||
systemPrompt: body.system_prompt,
|
systemPrompt: body.system_prompt,
|
||||||
resumeSessionId: body.resume_session_id,
|
resumeSessionId: body.resume_session_id,
|
||||||
|
resumeSessionAt: body.resume_session_at,
|
||||||
|
hooks: body.hooks,
|
||||||
execPath: process.execPath,
|
execPath: process.execPath,
|
||||||
scriptArgs: getScriptArgsForChild(),
|
scriptArgs: getScriptArgsForChild(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const initData = handle.initData
|
const info = handle.getInfo()
|
||||||
|
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
session_id: handle.sessionId,
|
session_id: handle.sessionId,
|
||||||
status: handle.status,
|
status: handle.status,
|
||||||
cwd: handle.cwd,
|
cwd: handle.cwd,
|
||||||
created_at: handle.getInfo().created_at,
|
created_at: info.created_at,
|
||||||
commands: initData?.commands ?? [],
|
|
||||||
agents: initData?.agents ?? [],
|
|
||||||
models: initData?.models ?? [],
|
|
||||||
account: initData?.account ?? {},
|
|
||||||
},
|
},
|
||||||
201,
|
201,
|
||||||
)
|
)
|
||||||
|
|
@ -294,6 +305,7 @@ export function createSessionRoutes(
|
||||||
parts?: Array<{ type: string; text?: string }>
|
parts?: Array<{ type: string; text?: string }>
|
||||||
files?: string[]
|
files?: string[]
|
||||||
images?: unknown[]
|
images?: unknown[]
|
||||||
|
model?: { providerID?: string; modelID?: string }
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const content = body.content ?? body.parts
|
const content = body.content ?? body.parts
|
||||||
|
|
@ -302,6 +314,11 @@ export function createSessionRoutes(
|
||||||
.join('\n') ?? ''
|
.join('\n') ?? ''
|
||||||
if (!content) throw badRequest('content is required')
|
if (!content) throw badRequest('content is required')
|
||||||
if (handle.prompting) throw conflict('session is already processing a prompt')
|
if (handle.prompting) throw conflict('session is already processing a prompt')
|
||||||
|
|
||||||
|
if (body.model?.modelID) {
|
||||||
|
try { await handle.setModel(body.model.modelID) } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
return ssePrompt(handle, id, content, c)
|
return ssePrompt(handle, id, content, c)
|
||||||
})
|
})
|
||||||
.post('/session/:sessionID/prompt_async', async c => {
|
.post('/session/:sessionID/prompt_async', async c => {
|
||||||
|
|
@ -314,6 +331,7 @@ export function createSessionRoutes(
|
||||||
parts?: Array<{ type: string; text?: string }>
|
parts?: Array<{ type: string; text?: string }>
|
||||||
files?: string[]
|
files?: string[]
|
||||||
images?: unknown[]
|
images?: unknown[]
|
||||||
|
model?: { providerID?: string; modelID?: string }
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const content = body.content ?? body.parts
|
const content = body.content ?? body.parts
|
||||||
|
|
@ -323,7 +341,12 @@ export function createSessionRoutes(
|
||||||
if (!content) throw badRequest('content is required')
|
if (!content) throw badRequest('content is required')
|
||||||
if (handle.prompting) throw conflict('session is already processing a prompt')
|
if (handle.prompting) throw conflict('session is already processing a prompt')
|
||||||
|
|
||||||
handle.prompt(content).catch(() => {})
|
void (async () => {
|
||||||
|
if (body.model?.modelID) {
|
||||||
|
try { await handle.setModel(body.model.modelID) } catch {}
|
||||||
|
}
|
||||||
|
handle.prompt(content).catch(() => {})
|
||||||
|
})()
|
||||||
|
|
||||||
return new Response(null, { status: 204 })
|
return new Response(null, { status: 204 })
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,8 @@ import type { ServerConfig } from './types.js'
|
||||||
export function startServer(
|
export function startServer(
|
||||||
config: ServerConfig,
|
config: ServerConfig,
|
||||||
sessionManager: SessionManager,
|
sessionManager: SessionManager,
|
||||||
|
eventBus: EventBus,
|
||||||
): BunServer & { port?: number } {
|
): BunServer & { port?: number } {
|
||||||
const eventBus = new EventBus()
|
|
||||||
eventBus.startHeartbeat()
|
eventBus.startHeartbeat()
|
||||||
|
|
||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
|
|
|
||||||
|
|
@ -55,10 +55,14 @@ export type SessionHandleOptions = {
|
||||||
permissionMode?: string
|
permissionMode?: string
|
||||||
systemPrompt?: string
|
systemPrompt?: string
|
||||||
resumeSessionId?: string
|
resumeSessionId?: string
|
||||||
|
resumeSessionAt?: string
|
||||||
|
hooks?: Record<string, unknown>
|
||||||
eventBus: EventBus
|
eventBus: EventBus
|
||||||
execPath: string
|
execPath: string
|
||||||
scriptArgs: string[]
|
scriptArgs: string[]
|
||||||
verbose?: boolean
|
verbose?: boolean
|
||||||
|
silent?: boolean
|
||||||
|
onInit?: (data: InitData) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getScriptArgsForChild(): string[] {
|
export function getScriptArgsForChild(): string[] {
|
||||||
|
|
@ -133,7 +137,9 @@ export class SessionHandle {
|
||||||
private _initData: InitData | null = null
|
private _initData: InitData | null = null
|
||||||
private initRequestId: string | null = null
|
private initRequestId: string | null = null
|
||||||
private initResolve: ((data: InitData) => void) | null = null
|
private initResolve: ((data: InitData) => void) | null = null
|
||||||
|
private initReject: ((reason: unknown) => void) | null = null
|
||||||
private _prompting = false
|
private _prompting = false
|
||||||
|
private _lastMessageUuid: string | null = null
|
||||||
|
|
||||||
get status(): SessionState {
|
get status(): SessionState {
|
||||||
return this._status
|
return this._status
|
||||||
|
|
@ -169,6 +175,36 @@ export class SessionHandle {
|
||||||
return this._prompting
|
return this._prompting
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get lastMessageUuid(): string | null {
|
||||||
|
return this._lastMessageUuid
|
||||||
|
}
|
||||||
|
|
||||||
|
get ready(): boolean {
|
||||||
|
return this._status === 'running'
|
||||||
|
}
|
||||||
|
|
||||||
|
get silent(): boolean {
|
||||||
|
return this.opts.silent ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitReady(timeoutMs = 30000): Promise<void> {
|
||||||
|
if (this._status === 'running') return
|
||||||
|
if (this._status === 'stopped') {
|
||||||
|
throw new Error(`Session ${this.sessionId} is stopped`)
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
reject(new Error(`Session ${this.sessionId} init timed out`))
|
||||||
|
}, timeoutMs)
|
||||||
|
const origResolve = this.initResolve
|
||||||
|
this.initResolve = (data: InitData) => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
origResolve?.(data)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
constructor(private opts: SessionHandleOptions) {
|
constructor(private opts: SessionHandleOptions) {
|
||||||
this.sessionId = opts.sessionId
|
this.sessionId = opts.sessionId
|
||||||
this.cwd = opts.cwd
|
this.cwd = opts.cwd
|
||||||
|
|
@ -178,12 +214,17 @@ export class SessionHandle {
|
||||||
this.verbose = opts.verbose ?? false
|
this.verbose = opts.verbose ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private emitEvent(event: string, data: Record<string, unknown>): void {
|
||||||
|
if (this.opts.silent) return
|
||||||
|
this.eventBus.publishSessionEvent(this.sessionId, event, data)
|
||||||
|
}
|
||||||
|
|
||||||
onMessage(listener: MessageListener): () => void {
|
onMessage(listener: MessageListener): () => void {
|
||||||
this.listeners.add(listener)
|
this.listeners.add(listener)
|
||||||
return () => { this.listeners.delete(listener) }
|
return () => { this.listeners.delete(listener) }
|
||||||
}
|
}
|
||||||
|
|
||||||
async start(): Promise<void> {
|
spawn(): void {
|
||||||
const printArgs = [
|
const printArgs = [
|
||||||
'--print',
|
'--print',
|
||||||
'--input-format',
|
'--input-format',
|
||||||
|
|
@ -194,11 +235,19 @@ export class SessionHandle {
|
||||||
this.sessionId,
|
this.sessionId,
|
||||||
...(this.opts.model ? ['--model', this.opts.model] : []),
|
...(this.opts.model ? ['--model', this.opts.model] : []),
|
||||||
...(this.opts.permissionMode
|
...(this.opts.permissionMode
|
||||||
? ['--permission-mode', this.opts.permissionMode]
|
? [
|
||||||
|
'--permission-mode',
|
||||||
|
this.opts.permissionMode,
|
||||||
|
'--permission-prompt-tool',
|
||||||
|
'stdio',
|
||||||
|
]
|
||||||
: []),
|
: []),
|
||||||
...(this.opts.resumeSessionId
|
...(this.opts.resumeSessionId
|
||||||
? ['--resume', this.opts.resumeSessionId]
|
? ['--resume', this.opts.resumeSessionId]
|
||||||
: []),
|
: []),
|
||||||
|
...(this.opts.resumeSessionAt
|
||||||
|
? ['--resume-session-at', this.opts.resumeSessionAt]
|
||||||
|
: []),
|
||||||
'--verbose',
|
'--verbose',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -229,7 +278,7 @@ export class SessionHandle {
|
||||||
this.child.on('close', (code, signal) => {
|
this.child.on('close', (code, signal) => {
|
||||||
if (this._status !== 'stopped') {
|
if (this._status !== 'stopped') {
|
||||||
this._status = 'stopped'
|
this._status = 'stopped'
|
||||||
this.eventBus.publishSessionEvent(this.sessionId, 'deleted', {
|
this.emitEvent('deleted', {
|
||||||
status: 'stopped',
|
status: 'stopped',
|
||||||
exit_code: code,
|
exit_code: code,
|
||||||
signal: signal ?? null,
|
signal: signal ?? null,
|
||||||
|
|
@ -255,43 +304,58 @@ export class SessionHandle {
|
||||||
})
|
})
|
||||||
|
|
||||||
this.initRequestId = crypto.randomUUID()
|
this.initRequestId = crypto.randomUUID()
|
||||||
const initPromise = new Promise<InitData>((resolve, reject) => {
|
|
||||||
this.initResolve = resolve
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
this.initResolve = null
|
|
||||||
reject(new Error('Init handshake timed out (30s)'))
|
|
||||||
}, 30000)
|
|
||||||
const originalResolve = resolve
|
|
||||||
this.initResolve = (data: InitData) => {
|
|
||||||
clearTimeout(timeout)
|
|
||||||
originalResolve(data)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
this.sendInitialize()
|
this.sendInitialize()
|
||||||
|
|
||||||
try {
|
const timeout = setTimeout(() => {
|
||||||
this._initData = await initPromise
|
this.initResolve = null
|
||||||
} catch (err) {
|
this.initReject = null
|
||||||
this._status = 'stopped'
|
this._status = 'stopped'
|
||||||
throw err
|
this.emitEvent('deleted', {
|
||||||
}
|
status: 'stopped',
|
||||||
|
reason: 'init_timeout',
|
||||||
|
})
|
||||||
|
}, 30000)
|
||||||
|
|
||||||
this._status = 'running'
|
this.initResolve = (data: InitData) => {
|
||||||
this.lastActiveAt = Date.now()
|
clearTimeout(timeout)
|
||||||
this.eventBus.publishSessionEvent(this.sessionId, 'ready', {
|
this.initReject = null
|
||||||
status: 'running',
|
}
|
||||||
model: this._model,
|
this.initReject = (_err: unknown) => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
this.initResolve = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
if (!this.child) {
|
||||||
|
this.spawn()
|
||||||
|
}
|
||||||
|
if (this._status === 'running') return
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const origResolve = this.initResolve
|
||||||
|
const origReject = this.initReject
|
||||||
|
this.initResolve = (data: InitData) => {
|
||||||
|
origResolve?.(data)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
this.initReject = (err: unknown) => {
|
||||||
|
origReject?.(err)
|
||||||
|
reject(err)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private sendInitialize(): void {
|
private sendInitialize(): void {
|
||||||
|
const request: Record<string, unknown> = {
|
||||||
|
subtype: 'initialize',
|
||||||
|
}
|
||||||
|
if (this.opts.hooks) {
|
||||||
|
request.hooks = this.opts.hooks
|
||||||
|
}
|
||||||
const msg = jsonStringify({
|
const msg = jsonStringify({
|
||||||
type: 'control_request',
|
type: 'control_request',
|
||||||
request_id: this.initRequestId,
|
request_id: this.initRequestId,
|
||||||
request: {
|
request,
|
||||||
subtype: 'initialize',
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
this.writeStdin(msg)
|
this.writeStdin(msg)
|
||||||
}
|
}
|
||||||
|
|
@ -347,8 +411,17 @@ export class SessionHandle {
|
||||||
const first = (initData.models as Array<Record<string, string>>)[0]
|
const first = (initData.models as Array<Record<string, string>>)[0]
|
||||||
this._model = first?.value ?? first?.name
|
this._model = first?.value ?? first?.name
|
||||||
}
|
}
|
||||||
this.initResolve(initData)
|
this._status = 'running'
|
||||||
|
this.lastActiveAt = Date.now()
|
||||||
|
const resolve = this.initResolve
|
||||||
this.initResolve = null
|
this.initResolve = null
|
||||||
|
this.initReject = null
|
||||||
|
resolve?.(initData)
|
||||||
|
this.emitEvent('ready', {
|
||||||
|
status: 'running',
|
||||||
|
model: this._model,
|
||||||
|
})
|
||||||
|
this.opts.onInit?.(initData)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -372,14 +445,13 @@ export class SessionHandle {
|
||||||
this.emitMessage(msg)
|
this.emitMessage(msg)
|
||||||
|
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'system': {
|
|
||||||
this.eventBus.publishSessionEvent(this.sessionId, 'message', msg)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'assistant': {
|
case 'assistant': {
|
||||||
this.lastActiveAt = Date.now()
|
this.lastActiveAt = Date.now()
|
||||||
this._status = 'running'
|
this._status = 'running'
|
||||||
this.eventBus.publishSessionEvent(this.sessionId, 'message', msg)
|
if (msg.uuid && typeof msg.uuid === 'string') {
|
||||||
|
this._lastMessageUuid = msg.uuid
|
||||||
|
}
|
||||||
|
this.emitEvent('message', msg)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'result': {
|
case 'result': {
|
||||||
|
|
@ -392,7 +464,7 @@ export class SessionHandle {
|
||||||
if (cost) this._costUsd += cost
|
if (cost) this._costUsd += cost
|
||||||
if (usage?.input_tokens) this._inputTokens += usage.input_tokens
|
if (usage?.input_tokens) this._inputTokens += usage.input_tokens
|
||||||
if (usage?.output_tokens) this._outputTokens += usage.output_tokens
|
if (usage?.output_tokens) this._outputTokens += usage.output_tokens
|
||||||
this.eventBus.publishSessionEvent(this.sessionId, 'result', msg)
|
this.emitEvent('result', msg)
|
||||||
if (this.promptResolve) {
|
if (this.promptResolve) {
|
||||||
this.promptResolve({ done: true })
|
this.promptResolve({ done: true })
|
||||||
this.promptResolve = null
|
this.promptResolve = null
|
||||||
|
|
@ -414,11 +486,7 @@ export class SessionHandle {
|
||||||
description: `Execute ${request.tool_name}`,
|
description: `Execute ${request.tool_name}`,
|
||||||
}
|
}
|
||||||
this.pendingPermissions.set(requestId, perm)
|
this.pendingPermissions.set(requestId, perm)
|
||||||
this.eventBus.publishSessionEvent(
|
this.emitEvent('control_request', { request_id: requestId, request })
|
||||||
this.sessionId,
|
|
||||||
'control_request',
|
|
||||||
{ request_id: requestId, request },
|
|
||||||
)
|
|
||||||
} else if (request?.subtype === 'elicitation') {
|
} else if (request?.subtype === 'elicitation') {
|
||||||
const question: PendingQuestion = {
|
const question: PendingQuestion = {
|
||||||
requestId,
|
requestId,
|
||||||
|
|
@ -430,22 +498,24 @@ export class SessionHandle {
|
||||||
(request.requested_schema as Record<string, unknown>) ?? {},
|
(request.requested_schema as Record<string, unknown>) ?? {},
|
||||||
}
|
}
|
||||||
this.pendingQuestions.set(requestId, question)
|
this.pendingQuestions.set(requestId, question)
|
||||||
this.eventBus.publishSessionEvent(
|
this.emitEvent('control_request', { request_id: requestId, request })
|
||||||
this.sessionId,
|
} else if (request?.subtype === 'hook_callback') {
|
||||||
'control_request',
|
this.emitEvent('control_request', { request_id: requestId, request })
|
||||||
{ request_id: requestId, request },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case 'control_cancel_request': {
|
||||||
|
const cancelRequestId = msg.request_id as string
|
||||||
|
this.pendingPermissions.delete(cancelRequestId)
|
||||||
|
this.pendingQuestions.delete(cancelRequestId)
|
||||||
|
break
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
this.eventBus.publishSessionEvent(this.sessionId, 'message', msg)
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _outputTokens = 0
|
|
||||||
private pendingControlResponses = new Map<
|
private pendingControlResponses = new Map<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
|
|
@ -503,11 +573,14 @@ export class SessionHandle {
|
||||||
}
|
}
|
||||||
|
|
||||||
async prompt(content: string): Promise<{ done: boolean; error?: string }> {
|
async prompt(content: string): Promise<{ done: boolean; error?: string }> {
|
||||||
if (this._status !== 'running') {
|
if (this._status === 'stopped') {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Session ${this.sessionId} is not running (status: ${this._status})`,
|
`Session ${this.sessionId} is stopped`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (this._status !== 'running') {
|
||||||
|
await this.waitReady()
|
||||||
|
}
|
||||||
if (this._prompting) {
|
if (this._prompting) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Session ${this.sessionId} is already processing a prompt`,
|
`Session ${this.sessionId} is already processing a prompt`,
|
||||||
|
|
@ -518,8 +591,16 @@ export class SessionHandle {
|
||||||
|
|
||||||
const userMsg = jsonStringify({
|
const userMsg = jsonStringify({
|
||||||
type: 'user',
|
type: 'user',
|
||||||
|
content,
|
||||||
|
uuid: crypto.randomUUID(),
|
||||||
|
session_id: this.sessionId,
|
||||||
message: { role: 'user', content },
|
message: { role: 'user', content },
|
||||||
parent_tool_use_id: null,
|
parent_tool_use_id: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
this.emitEvent('message', {
|
||||||
|
type: 'user',
|
||||||
|
content,
|
||||||
session_id: this.sessionId,
|
session_id: this.sessionId,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -565,7 +646,12 @@ export class SessionHandle {
|
||||||
replyPermission(
|
replyPermission(
|
||||||
requestId: string,
|
requestId: string,
|
||||||
behavior: 'allow' | 'deny',
|
behavior: 'allow' | 'deny',
|
||||||
opts?: { updatedInput?: Record<string, unknown>; message?: string },
|
opts?: {
|
||||||
|
updatedInput?: Record<string, unknown>
|
||||||
|
updatedPermissions?: Record<string, unknown>[]
|
||||||
|
message?: string
|
||||||
|
interrupt?: boolean
|
||||||
|
},
|
||||||
): void {
|
): void {
|
||||||
const response = {
|
const response = {
|
||||||
type: 'control_response',
|
type: 'control_response',
|
||||||
|
|
@ -574,8 +660,18 @@ export class SessionHandle {
|
||||||
request_id: requestId,
|
request_id: requestId,
|
||||||
response:
|
response:
|
||||||
behavior === 'allow'
|
behavior === 'allow'
|
||||||
? { behavior: 'allow', updatedInput: opts?.updatedInput }
|
? {
|
||||||
: { behavior: 'deny', message: opts?.message ?? 'Denied' },
|
behavior: 'allow',
|
||||||
|
updatedInput: opts?.updatedInput,
|
||||||
|
...(opts?.updatedPermissions
|
||||||
|
? { updatedPermissions: opts.updatedPermissions }
|
||||||
|
: {}),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
behavior: 'deny',
|
||||||
|
message: opts?.message ?? 'Denied',
|
||||||
|
...(opts?.interrupt ? { interrupt: true } : {}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
this.writeStdin(jsonStringify(response))
|
this.writeStdin(jsonStringify(response))
|
||||||
|
|
@ -659,12 +755,16 @@ export class SessionHandle {
|
||||||
cost_usd: this._costUsd,
|
cost_usd: this._costUsd,
|
||||||
input_tokens: this._inputTokens,
|
input_tokens: this._inputTokens,
|
||||||
output_tokens: this._outputTokens,
|
output_tokens: this._outputTokens,
|
||||||
|
last_message_uuid: this._lastMessageUuid,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private writeStdin(data: string): void {
|
private writeStdin(data: string): void {
|
||||||
if (this.child?.stdin && !this.child.stdin.destroyed) {
|
if (this.child?.stdin && !this.child.stdin.destroyed) {
|
||||||
this.child.stdin.write(data + '\n')
|
const flushed = this.child.stdin.write(data + '\n')
|
||||||
|
if (!flushed) {
|
||||||
|
this.child.stdin.once('drain', () => {})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { readFile, writeFile, mkdir } from 'fs/promises'
|
import { readFile, writeFile, mkdir } from 'fs/promises'
|
||||||
import { existsSync } from 'fs'
|
import { existsSync, statSync } from 'fs'
|
||||||
import { join } from 'path'
|
import { join, resolve, isAbsolute } from 'path'
|
||||||
import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
|
import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
|
||||||
import { logError } from '../utils/log.js'
|
import { logError } from '../utils/log.js'
|
||||||
import type { EventBus } from './eventBus.js'
|
import type { EventBus } from './eventBus.js'
|
||||||
|
|
@ -11,6 +11,7 @@ const INDEX_FILE = 'server-sessions.json'
|
||||||
|
|
||||||
export class SessionManager {
|
export class SessionManager {
|
||||||
private sessions = new Map<string, SessionHandle>()
|
private sessions = new Map<string, SessionHandle>()
|
||||||
|
private loadedIndex = new Map<string, SessionIndexEntry>()
|
||||||
private eventBus: EventBus
|
private eventBus: EventBus
|
||||||
private maxSessions: number
|
private maxSessions: number
|
||||||
private idleTimeoutMs: number
|
private idleTimeoutMs: number
|
||||||
|
|
@ -49,8 +50,8 @@ export class SessionManager {
|
||||||
try {
|
try {
|
||||||
const raw = await readFile(path, 'utf-8')
|
const raw = await readFile(path, 'utf-8')
|
||||||
const index: SessionIndex = JSON.parse(raw)
|
const index: SessionIndex = JSON.parse(raw)
|
||||||
for (const [, entry] of Object.entries(index)) {
|
for (const [key, entry] of Object.entries(index)) {
|
||||||
entry
|
this.loadedIndex.set(key, entry)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logError(err as Error)
|
logError(err as Error)
|
||||||
|
|
@ -148,17 +149,26 @@ export class SessionManager {
|
||||||
permissionMode?: string
|
permissionMode?: string
|
||||||
systemPrompt?: string
|
systemPrompt?: string
|
||||||
resumeSessionId?: string
|
resumeSessionId?: string
|
||||||
|
resumeSessionAt?: string
|
||||||
|
hooks?: Record<string, unknown>
|
||||||
execPath: string
|
execPath: string
|
||||||
scriptArgs: string[]
|
scriptArgs: string[]
|
||||||
|
silent?: boolean
|
||||||
}): Promise<SessionHandle> {
|
}): Promise<SessionHandle> {
|
||||||
if (this.sessions.size >= this.maxSessions) {
|
if (this.maxSessions > 0 && this.sessions.size >= this.maxSessions) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Maximum concurrent sessions reached (${this.maxSessions})`,
|
`Maximum concurrent sessions reached (${this.maxSessions})`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionId = crypto.randomUUID()
|
const sessionId = crypto.randomUUID()
|
||||||
const cwd = opts.cwd || this.defaultWorkspace || process.cwd()
|
let cwd = opts.cwd || this.defaultWorkspace || process.cwd()
|
||||||
|
if (!isAbsolute(cwd)) {
|
||||||
|
cwd = resolve(process.cwd(), cwd)
|
||||||
|
}
|
||||||
|
if (!existsSync(cwd) || !statSync(cwd).isDirectory()) {
|
||||||
|
throw new Error(`Working directory does not exist: ${cwd}`)
|
||||||
|
}
|
||||||
|
|
||||||
const handle = new SessionHandle({
|
const handle = new SessionHandle({
|
||||||
sessionId,
|
sessionId,
|
||||||
|
|
@ -167,29 +177,34 @@ export class SessionManager {
|
||||||
permissionMode: opts.permissionMode,
|
permissionMode: opts.permissionMode,
|
||||||
systemPrompt: opts.systemPrompt,
|
systemPrompt: opts.systemPrompt,
|
||||||
resumeSessionId: opts.resumeSessionId,
|
resumeSessionId: opts.resumeSessionId,
|
||||||
|
resumeSessionAt: opts.resumeSessionAt,
|
||||||
|
hooks: opts.hooks,
|
||||||
eventBus: this.eventBus,
|
eventBus: this.eventBus,
|
||||||
execPath: opts.execPath,
|
execPath: opts.execPath,
|
||||||
scriptArgs: opts.scriptArgs,
|
scriptArgs: opts.scriptArgs,
|
||||||
|
silent: opts.silent,
|
||||||
|
onInit: (data) => {
|
||||||
|
this._cachedInitData = data
|
||||||
|
this._resolveInitDataReady?.()
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
this.sessions.set(sessionId, handle)
|
this.sessions.set(sessionId, handle)
|
||||||
this.eventBus.publishSessionEvent(sessionId, 'created', {
|
if (!opts.silent) {
|
||||||
status: 'starting',
|
this.eventBus.publishSessionEvent(sessionId, 'created', {
|
||||||
})
|
status: 'starting',
|
||||||
|
})
|
||||||
|
}
|
||||||
this.scheduleIndexSave()
|
this.scheduleIndexSave()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await handle.start()
|
handle.spawn()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.sessions.delete(sessionId)
|
this.sessions.delete(sessionId)
|
||||||
this.scheduleIndexSave()
|
this.scheduleIndexSave()
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
|
|
||||||
if (handle.initData) {
|
|
||||||
this._cachedInitData = handle.initData
|
|
||||||
}
|
|
||||||
|
|
||||||
return handle
|
return handle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -221,10 +236,12 @@ export class SessionManager {
|
||||||
cwd: opts.cwd,
|
cwd: opts.cwd,
|
||||||
execPath: opts.execPath,
|
execPath: opts.execPath,
|
||||||
scriptArgs: opts.scriptArgs,
|
scriptArgs: opts.scriptArgs,
|
||||||
|
silent: true,
|
||||||
})
|
})
|
||||||
this._probeHandle = probe
|
this._probeHandle = probe
|
||||||
this._probeHandle = null
|
await probe.start()
|
||||||
await this.deleteSession(probe.sessionId)
|
await this.deleteSession(probe.sessionId)
|
||||||
|
this._probeHandle = null
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this._probeHandle?.forceKill()
|
this._probeHandle?.forceKill()
|
||||||
this._probeHandle = null
|
this._probeHandle = null
|
||||||
|
|
@ -244,9 +261,12 @@ export class SessionManager {
|
||||||
async deleteSession(id: string): Promise<boolean> {
|
async deleteSession(id: string): Promise<boolean> {
|
||||||
const handle = this.sessions.get(id)
|
const handle = this.sessions.get(id)
|
||||||
if (!handle) return false
|
if (!handle) return false
|
||||||
|
const silent = handle.silent
|
||||||
handle.forceKill()
|
handle.forceKill()
|
||||||
this.sessions.delete(id)
|
this.sessions.delete(id)
|
||||||
this.eventBus.publishSessionEvent(id, 'deleted', { status: 'stopped' })
|
if (!silent) {
|
||||||
|
this.eventBus.publishSessionEvent(id, 'deleted', { status: 'stopped' })
|
||||||
|
}
|
||||||
this.scheduleIndexSave()
|
this.scheduleIndexSave()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user