@jacexh/claude-web-console 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +102 -0
- package/bin/cli.js +64 -0
- package/claude-web-console.service +26 -0
- package/client/dist/assets/_baseUniq-Cnoz3HXL.js +1 -0
- package/client/dist/assets/arc-Z_2trbN8.js +1 -0
- package/client/dist/assets/architectureDiagram-Q4EWVU46-BkJc1Y5A.js +36 -0
- package/client/dist/assets/blockDiagram-DXYQGD6D-DajeZur_.js +132 -0
- package/client/dist/assets/c4Diagram-AHTNJAMY-DPrSiSIe.js +10 -0
- package/client/dist/assets/channel-xmJgF8AT.js +1 -0
- package/client/dist/assets/chunk-4BX2VUAB-Ap8VOuEx.js +1 -0
- package/client/dist/assets/chunk-4TB4RGXK-7WUmxI0T.js +206 -0
- package/client/dist/assets/chunk-55IACEB6-gyKf_szG.js +1 -0
- package/client/dist/assets/chunk-EDXVE4YY-Dqsdf8RE.js +1 -0
- package/client/dist/assets/chunk-FMBD7UC4-CFwejjYg.js +15 -0
- package/client/dist/assets/chunk-OYMX7WX6-DjhMJFGF.js +231 -0
- package/client/dist/assets/chunk-QZHKN3VN-BZl3lFpB.js +1 -0
- package/client/dist/assets/chunk-YZCP3GAM-Bgp9VGoJ.js +1 -0
- package/client/dist/assets/classDiagram-6PBFFD2Q-CUsL9OeO.js +1 -0
- package/client/dist/assets/classDiagram-v2-HSJHXN6E-CUsL9OeO.js +1 -0
- package/client/dist/assets/clone-YPoNuUWg.js +1 -0
- package/client/dist/assets/cose-bilkent-S5V4N54A-BhMgNxzB.js +1 -0
- package/client/dist/assets/cytoscape.esm-BQaXIfA_.js +331 -0
- package/client/dist/assets/dagre-KV5264BT-Im4xvruI.js +4 -0
- package/client/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/client/dist/assets/diagram-5BDNPKRD-C_LUH1t_.js +10 -0
- package/client/dist/assets/diagram-G4DWMVQ6-ovGPIIZa.js +24 -0
- package/client/dist/assets/diagram-MMDJMWI5-Cc_S6QYf.js +43 -0
- package/client/dist/assets/diagram-TYMM5635-DNtJ4zRJ.js +24 -0
- package/client/dist/assets/erDiagram-SMLLAGMA-mjVqYF51.js +85 -0
- package/client/dist/assets/flowDiagram-DWJPFMVM-gfeU3vbm.js +162 -0
- package/client/dist/assets/ganttDiagram-T4ZO3ILL-DHzKAwPu.js +292 -0
- package/client/dist/assets/gitGraphDiagram-UUTBAWPF-NmVr9v2T.js +106 -0
- package/client/dist/assets/graph-DWZBkQul.js +1 -0
- package/client/dist/assets/index-Bs3edPh7.js +329 -0
- package/client/dist/assets/index-BvGI0U64.css +1 -0
- package/client/dist/assets/infoDiagram-42DDH7IO-uZQs2FWg.js +2 -0
- package/client/dist/assets/init-Gi6I4Gst.js +1 -0
- package/client/dist/assets/ishikawaDiagram-UXIWVN3A-CVqWYfyh.js +70 -0
- package/client/dist/assets/journeyDiagram-VCZTEJTY-BM-S2Uek.js +139 -0
- package/client/dist/assets/kanban-definition-6JOO6SKY-DtfrMWsW.js +89 -0
- package/client/dist/assets/katex-DkKDou_j.js +257 -0
- package/client/dist/assets/layout-dcq7fgvs.js +1 -0
- package/client/dist/assets/linear-B-FGkSzh.js +1 -0
- package/client/dist/assets/mermaid.core-DDFJ1A5Z.js +309 -0
- package/client/dist/assets/min-DD4RQeGA.js +1 -0
- package/client/dist/assets/mindmap-definition-QFDTVHPH-B1E5NbCo.js +96 -0
- package/client/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/client/dist/assets/pieDiagram-DEJITSTG-Ciel1XRV.js +30 -0
- package/client/dist/assets/quadrantDiagram-34T5L4WZ-D1jxP0fH.js +7 -0
- package/client/dist/assets/requirementDiagram-MS252O5E-CTXKAocB.js +84 -0
- package/client/dist/assets/sankeyDiagram-XADWPNL6-Dj-i0BnN.js +10 -0
- package/client/dist/assets/sequenceDiagram-FGHM5R23-kCI4qbes.js +157 -0
- package/client/dist/assets/stateDiagram-FHFEXIEX-CiE-lplM.js +1 -0
- package/client/dist/assets/stateDiagram-v2-QKLJ7IA2-ZPI5JmoG.js +1 -0
- package/client/dist/assets/timeline-definition-GMOUNBTQ-MoAQpvF_.js +120 -0
- package/client/dist/assets/vennDiagram-DHZGUBPP-BqTjHn6B.js +34 -0
- package/client/dist/assets/wardley-RL74JXVD-C3OshQuH.js +162 -0
- package/client/dist/assets/wardleyDiagram-NUSXRM2D-Dl-s9oxJ.js +20 -0
- package/client/dist/assets/xychartDiagram-5P7HB3ND-BqyQEiVY.js +7 -0
- package/client/dist/index.html +16 -0
- package/package.json +57 -0
- package/server/src/index.ts +52 -0
- package/server/src/session-manager.ts +765 -0
- package/server/src/types.ts +295 -0
- package/server/src/ws-handler.ts +355 -0
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
import {
|
|
2
|
+
unstable_v2_createSession,
|
|
3
|
+
unstable_v2_resumeSession,
|
|
4
|
+
listSessions,
|
|
5
|
+
getSessionMessages,
|
|
6
|
+
getSubagentMessages as sdkGetSubagentMessages,
|
|
7
|
+
renameSession as sdkRenameSession,
|
|
8
|
+
forkSession as sdkForkSession,
|
|
9
|
+
type SDKSession,
|
|
10
|
+
type SDKSessionOptions,
|
|
11
|
+
type SDKMessage,
|
|
12
|
+
type PermissionResult,
|
|
13
|
+
} from '@anthropic-ai/claude-agent-sdk'
|
|
14
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
15
|
+
import { join } from 'node:path'
|
|
16
|
+
import { homedir } from 'node:os'
|
|
17
|
+
import type { SessionInfo, EffortLevel } from './types.js'
|
|
18
|
+
|
|
19
|
+
type PermissionResolver = {
|
|
20
|
+
resolve: (approved: boolean, reason?: string, updatedPermissions?: import('@anthropic-ai/claude-agent-sdk').PermissionUpdate[]) => void
|
|
21
|
+
sessionId: string
|
|
22
|
+
suggestions?: import('@anthropic-ai/claude-agent-sdk').PermissionUpdate[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type ElicitationResult = {
|
|
26
|
+
action: 'accept' | 'decline' | 'cancel'
|
|
27
|
+
content?: Record<string, string | number | boolean | string[]>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type PermissionMeta = {
|
|
31
|
+
agentId?: string
|
|
32
|
+
title?: string
|
|
33
|
+
description?: string
|
|
34
|
+
hasSuggestions?: boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type SessionListener = {
|
|
38
|
+
id: string
|
|
39
|
+
onMessage: (sessionId: string, msg: SDKMessage) => void
|
|
40
|
+
onPermissionRequest: (sessionId: string, toolUseId: string, toolName: string, input: Record<string, unknown>, meta: PermissionMeta) => void
|
|
41
|
+
onEnd: (sessionId: string) => void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Strip inherited Claude Code env vars to prevent SDK from connecting
|
|
45
|
+
// to the parent CC session instead of spawning a fresh process
|
|
46
|
+
function cleanEnv(cwd?: string): Record<string, string | undefined> {
|
|
47
|
+
const env = { ...process.env }
|
|
48
|
+
delete env.CLAUDE_CODE_SSE_PORT
|
|
49
|
+
delete env.CLAUDECODE
|
|
50
|
+
delete env.CLAUDE_CODE_ENTRYPOINT
|
|
51
|
+
if (cwd) {
|
|
52
|
+
env.CC_CLAUDE_CWD = cwd
|
|
53
|
+
}
|
|
54
|
+
return env
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const CLAUDE_EXECUTABLE = process.env.CLAUDE_PATH ?? '/home/xuhao/.local/bin/claude'
|
|
58
|
+
|
|
59
|
+
// Read ~/.claude/settings.json and resolve enabled plugins to local --plugin-dir paths
|
|
60
|
+
function getPluginDirArgs(): string[] {
|
|
61
|
+
const home = homedir()
|
|
62
|
+
try {
|
|
63
|
+
const settings = JSON.parse(readFileSync(join(home, '.claude', 'settings.json'), 'utf-8'))
|
|
64
|
+
const enabled = settings.enabledPlugins as Record<string, boolean> | undefined
|
|
65
|
+
if (!enabled) return []
|
|
66
|
+
const args: string[] = []
|
|
67
|
+
const pluginsBase = join(home, '.claude', 'plugins')
|
|
68
|
+
for (const [key, value] of Object.entries(enabled)) {
|
|
69
|
+
if (!value) continue
|
|
70
|
+
// key format: "plugin-name@marketplace-id"
|
|
71
|
+
const atIdx = key.indexOf('@')
|
|
72
|
+
if (atIdx === -1) continue
|
|
73
|
+
const pluginName = key.slice(0, atIdx)
|
|
74
|
+
const marketplaceId = key.slice(atIdx + 1)
|
|
75
|
+
// Try marketplace subdirs: plugins/, external_plugins/, then root
|
|
76
|
+
const candidates = [
|
|
77
|
+
join(pluginsBase, 'marketplaces', marketplaceId, 'plugins', pluginName),
|
|
78
|
+
join(pluginsBase, 'marketplaces', marketplaceId, 'external_plugins', pluginName),
|
|
79
|
+
join(pluginsBase, 'marketplaces', marketplaceId, pluginName),
|
|
80
|
+
]
|
|
81
|
+
let resolved = candidates.find((p) => { try { return statSync(p).isDirectory() } catch { return false } })
|
|
82
|
+
// Fallback to cache: pick the latest version directory
|
|
83
|
+
if (!resolved) {
|
|
84
|
+
const cacheDir = join(pluginsBase, 'cache', marketplaceId, pluginName)
|
|
85
|
+
try {
|
|
86
|
+
const versions = readdirSync(cacheDir).sort()
|
|
87
|
+
if (versions.length > 0) {
|
|
88
|
+
resolved = join(cacheDir, versions[versions.length - 1])
|
|
89
|
+
}
|
|
90
|
+
} catch { /* no cache entry */ }
|
|
91
|
+
}
|
|
92
|
+
if (resolved) {
|
|
93
|
+
args.push('--plugin-dir', resolved)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return args
|
|
97
|
+
} catch {
|
|
98
|
+
return []
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Check if a session is being used by an external process (e.g. CLI) */
|
|
103
|
+
function isSessionLockedExternally(sessionId: string): boolean {
|
|
104
|
+
const sessionsDir = join(homedir(), '.claude', 'sessions')
|
|
105
|
+
try {
|
|
106
|
+
const files = readdirSync(sessionsDir).filter((f) => f.endsWith('.json'))
|
|
107
|
+
for (const file of files) {
|
|
108
|
+
try {
|
|
109
|
+
const data = JSON.parse(readFileSync(join(sessionsDir, file), 'utf-8'))
|
|
110
|
+
if (data.sessionId !== sessionId) continue
|
|
111
|
+
const pid = data.pid as number
|
|
112
|
+
// Check if process is still alive
|
|
113
|
+
try { process.kill(pid, 0); return true } catch { /* dead process, stale lock */ }
|
|
114
|
+
} catch { /* skip malformed files */ }
|
|
115
|
+
}
|
|
116
|
+
} catch { /* sessions dir doesn't exist */ }
|
|
117
|
+
return false
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export class SessionManager {
|
|
121
|
+
private sessions = new Map<string, SDKSession>()
|
|
122
|
+
private pendingPermissions = new Map<string, PermissionResolver>()
|
|
123
|
+
private pendingElicitations = new Map<string, { resolve: (result: ElicitationResult) => void; sessionId: string }>()
|
|
124
|
+
private runningSessionIds = new Set<string>()
|
|
125
|
+
private closedSessionIds = new Set<string>()
|
|
126
|
+
private streamingSessionIds = new Set<string>()
|
|
127
|
+
private sessionListeners = new Map<string, Set<SessionListener>>()
|
|
128
|
+
private idleTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
129
|
+
private pendingRemaps = new Map<string, { sessionIdRef: { current: string } }>()
|
|
130
|
+
// Track cwd for each session so we can resume in the correct project
|
|
131
|
+
private sessionCwds = new Map<string, string>()
|
|
132
|
+
// Cache commands extracted from SDK init messages
|
|
133
|
+
private sessionCommands = new Map<string, { name: string; description: string }[]>()
|
|
134
|
+
// Active stream (Query) references for control requests like setModel
|
|
135
|
+
private activeQueries = new Map<string, AsyncGenerator<SDKMessage, void>>()
|
|
136
|
+
|
|
137
|
+
// --- Pub/Sub methods ---
|
|
138
|
+
|
|
139
|
+
subscribe(sessionId: string, listener: SessionListener): void {
|
|
140
|
+
let listeners = this.sessionListeners.get(sessionId)
|
|
141
|
+
if (!listeners) {
|
|
142
|
+
listeners = new Set()
|
|
143
|
+
this.sessionListeners.set(sessionId, listeners)
|
|
144
|
+
}
|
|
145
|
+
// Deduplicate by listener.id: remove existing listener with same id
|
|
146
|
+
for (const existing of listeners) {
|
|
147
|
+
if (existing.id === listener.id) {
|
|
148
|
+
listeners.delete(existing)
|
|
149
|
+
break
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
listeners.add(listener)
|
|
153
|
+
|
|
154
|
+
// Cancel any pending idle timer since we now have a listener
|
|
155
|
+
const timer = this.idleTimers.get(sessionId)
|
|
156
|
+
if (timer) {
|
|
157
|
+
clearTimeout(timer)
|
|
158
|
+
this.idleTimers.delete(sessionId)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
unsubscribe(sessionId: string, listenerId: string): void {
|
|
163
|
+
const listeners = this.sessionListeners.get(sessionId)
|
|
164
|
+
if (!listeners) return
|
|
165
|
+
for (const l of listeners) {
|
|
166
|
+
if (l.id === listenerId) {
|
|
167
|
+
listeners.delete(l)
|
|
168
|
+
break
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (listeners.size === 0) {
|
|
172
|
+
this.scheduleIdleClose(sessionId)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
unsubscribeAll(listenerId: string): void {
|
|
177
|
+
for (const [sessionId, listeners] of this.sessionListeners) {
|
|
178
|
+
for (const l of listeners) {
|
|
179
|
+
if (l.id === listenerId) {
|
|
180
|
+
listeners.delete(l)
|
|
181
|
+
break
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (listeners.size === 0) {
|
|
185
|
+
this.scheduleIdleClose(sessionId)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private broadcast(sessionId: string, fn: (listener: SessionListener) => void): void {
|
|
191
|
+
const listeners = this.sessionListeners.get(sessionId)
|
|
192
|
+
if (!listeners) return
|
|
193
|
+
for (const l of listeners) {
|
|
194
|
+
try { fn(l) } catch (err) {
|
|
195
|
+
console.error('[SessionManager] Listener error:', err)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private scheduleIdleClose(sessionId: string): void {
|
|
201
|
+
// Don't schedule if there's already a timer
|
|
202
|
+
if (this.idleTimers.has(sessionId)) return
|
|
203
|
+
const timer = setTimeout(() => {
|
|
204
|
+
this.idleTimers.delete(sessionId)
|
|
205
|
+
// Only close if still zero listeners
|
|
206
|
+
const listeners = this.sessionListeners.get(sessionId)
|
|
207
|
+
if (!listeners || listeners.size === 0) {
|
|
208
|
+
this.closeSession(sessionId)
|
|
209
|
+
}
|
|
210
|
+
}, 60_000) // 1 minute
|
|
211
|
+
this.idleTimers.set(sessionId, timer)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// --- Core methods ---
|
|
215
|
+
|
|
216
|
+
private buildOnElicitation(sessionIdRef: { current: string }) {
|
|
217
|
+
return async (request: { serverName: string; message: string; mode?: string; url?: string; requestedSchema?: Record<string, unknown> }, _options: { signal: AbortSignal }): Promise<ElicitationResult> => {
|
|
218
|
+
const id = `elicit-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
219
|
+
return new Promise<ElicitationResult>((resolve) => {
|
|
220
|
+
this.pendingElicitations.set(id, { resolve, sessionId: sessionIdRef.current })
|
|
221
|
+
this.broadcast(sessionIdRef.current, (l) => l.onMessage(sessionIdRef.current, {
|
|
222
|
+
type: 'elicitation_request',
|
|
223
|
+
id,
|
|
224
|
+
serverName: request.serverName,
|
|
225
|
+
message: request.message,
|
|
226
|
+
mode: request.mode,
|
|
227
|
+
requestedSchema: request.requestedSchema,
|
|
228
|
+
url: request.url,
|
|
229
|
+
} as unknown as SDKMessage))
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private buildCanUseTool(
|
|
235
|
+
sessionIdRef: { current: string },
|
|
236
|
+
): SDKSessionOptions['canUseTool'] {
|
|
237
|
+
return async (toolName, input, { toolUseID, agentID, suggestions, title, description }) => {
|
|
238
|
+
// Auto-allow non-dangerous tools that don't need user approval
|
|
239
|
+
const autoAllow = new Set(['TaskCreate', 'TaskUpdate', 'TaskGet', 'TaskList'])
|
|
240
|
+
if (autoAllow.has(toolName)) {
|
|
241
|
+
return { behavior: 'allow' as const, updatedInput: input as Record<string, unknown> }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// AskUserQuestion: keep canUseTool pending (SDK waits for answer)
|
|
245
|
+
// but don't send permission_request (no PermissionCard).
|
|
246
|
+
// The user's answer comes via resolvePermission() from the frontend.
|
|
247
|
+
const isQuestion = toolName === 'AskUserQuestion'
|
|
248
|
+
|
|
249
|
+
return new Promise<PermissionResult>((resolve) => {
|
|
250
|
+
this.pendingPermissions.set(toolUseID, {
|
|
251
|
+
resolve: (approved: boolean, reason?: string, updatedPermissions?) => {
|
|
252
|
+
if (approved) {
|
|
253
|
+
resolve({
|
|
254
|
+
behavior: 'allow',
|
|
255
|
+
updatedInput: input as Record<string, unknown>,
|
|
256
|
+
...(updatedPermissions ? { updatedPermissions } : {}),
|
|
257
|
+
})
|
|
258
|
+
} else {
|
|
259
|
+
resolve({ behavior: 'deny', message: reason ?? 'User denied' })
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
sessionId: sessionIdRef.current,
|
|
263
|
+
suggestions,
|
|
264
|
+
})
|
|
265
|
+
// Only send permission_request for real permission prompts, not questions
|
|
266
|
+
if (!isQuestion) {
|
|
267
|
+
this.broadcast(sessionIdRef.current, (l) =>
|
|
268
|
+
l.onPermissionRequest(sessionIdRef.current, toolUseID, toolName, input as Record<string, unknown>, {
|
|
269
|
+
agentId: agentID,
|
|
270
|
+
title,
|
|
271
|
+
description,
|
|
272
|
+
hasSuggestions: (suggestions?.length ?? 0) > 0,
|
|
273
|
+
}),
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async createSession(
|
|
281
|
+
options?: { model?: string; cwd?: string; permissionMode?: string; executableArgs?: string[]; env?: Record<string, string> },
|
|
282
|
+
): Promise<string> {
|
|
283
|
+
const cwd = options?.cwd ?? process.env.CC_WEB_CONSOLE_CWD ?? process.env.HOME ?? '/'
|
|
284
|
+
const sessionIdRef = { current: '' }
|
|
285
|
+
const pluginArgs = getPluginDirArgs()
|
|
286
|
+
const userArgs = options?.executableArgs ?? []
|
|
287
|
+
const sessionOptions = {
|
|
288
|
+
...(options?.model ? { model: options.model } : {}),
|
|
289
|
+
permissionMode: options?.permissionMode ?? 'default',
|
|
290
|
+
canUseTool: this.buildCanUseTool(sessionIdRef),
|
|
291
|
+
onElicitation: this.buildOnElicitation(sessionIdRef),
|
|
292
|
+
env: { ...cleanEnv(cwd), ...options?.env },
|
|
293
|
+
pathToClaudeCodeExecutable: CLAUDE_EXECUTABLE,
|
|
294
|
+
executableArgs: [...pluginArgs, ...userArgs],
|
|
295
|
+
} as SDKSessionOptions
|
|
296
|
+
|
|
297
|
+
const originalCwd = process.cwd()
|
|
298
|
+
try { process.chdir(cwd) } catch { /* ignore */ }
|
|
299
|
+
const session = unstable_v2_createSession(sessionOptions)
|
|
300
|
+
try { process.chdir(originalCwd) } catch { /* ignore */ }
|
|
301
|
+
|
|
302
|
+
// New sessions need send() before sessionId is available.
|
|
303
|
+
// Use a temporary ID, then remap when real sessionId arrives.
|
|
304
|
+
const tempId = `pending-${Date.now()}`
|
|
305
|
+
this.sessions.set(tempId, session)
|
|
306
|
+
this.sessionCwds.set(tempId, cwd)
|
|
307
|
+
|
|
308
|
+
// Store tempId in pendingRemaps for remap inside consumeStream
|
|
309
|
+
this.pendingRemaps.set(tempId, { sessionIdRef })
|
|
310
|
+
|
|
311
|
+
// For new sessions, start stream immediately — SDK may emit init messages
|
|
312
|
+
this.startStreamConsumer(tempId, session)
|
|
313
|
+
|
|
314
|
+
return tempId
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private fetchAndBroadcastModels(sessionId: string, session: SDKSession, currentModel?: string): void {
|
|
318
|
+
const query = (session as unknown as { query: { supportedModels(): Promise<{ value: string; displayName: string; description: string }[]> } }).query
|
|
319
|
+
if (!query?.supportedModels) return
|
|
320
|
+
query.supportedModels().then((models) => {
|
|
321
|
+
if (models.length > 0) {
|
|
322
|
+
this.broadcast(sessionId, (l) => l.onMessage(sessionId, {
|
|
323
|
+
type: 'models_updated', models, currentModel,
|
|
324
|
+
} as unknown as SDKMessage))
|
|
325
|
+
}
|
|
326
|
+
}).catch((err) => {
|
|
327
|
+
console.error('[SessionManager] Failed to fetch models:', err)
|
|
328
|
+
})
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private startStreamConsumer(
|
|
332
|
+
sessionId: string,
|
|
333
|
+
session: SDKSession,
|
|
334
|
+
): void {
|
|
335
|
+
if (this.streamingSessionIds.has(sessionId)) return
|
|
336
|
+
this.streamingSessionIds.add(sessionId)
|
|
337
|
+
this.consumeStream(sessionId, session)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private async consumeStream(
|
|
341
|
+
initialSessionId: string,
|
|
342
|
+
session: SDKSession,
|
|
343
|
+
): Promise<void> {
|
|
344
|
+
// Track the current sessionId — may change from tempId to real sessionId
|
|
345
|
+
let currentSessionId = initialSessionId
|
|
346
|
+
let modelsFetched = false
|
|
347
|
+
|
|
348
|
+
try {
|
|
349
|
+
// SDK's stream() ends after each turn (on "result" message).
|
|
350
|
+
// Loop to re-enter stream() for subsequent turns.
|
|
351
|
+
while (true) {
|
|
352
|
+
const query = session.stream()
|
|
353
|
+
// Store query reference so control requests (setModel etc.) can use it
|
|
354
|
+
try {
|
|
355
|
+
const sid = session.sessionId
|
|
356
|
+
this.activeQueries.set(sid, query)
|
|
357
|
+
} catch { /* sessionId not yet available, will set after remap */ }
|
|
358
|
+
|
|
359
|
+
for await (const msg of query) {
|
|
360
|
+
const msgAny = msg as Record<string, unknown>
|
|
361
|
+
|
|
362
|
+
// Fetch models once after stream is active (works for both new and resumed sessions)
|
|
363
|
+
if (!modelsFetched) {
|
|
364
|
+
modelsFetched = true
|
|
365
|
+
try {
|
|
366
|
+
const sid = session.sessionId
|
|
367
|
+
// Update query ref now that sessionId is available
|
|
368
|
+
this.activeQueries.set(sid, query)
|
|
369
|
+
this.fetchAndBroadcastModels(sid, session, (msgAny.type === 'system' && msgAny.subtype === 'init') ? (msgAny.model as string) : undefined)
|
|
370
|
+
} catch { /* session not yet initialized */ }
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Extract commands from init message, then enrich with full descriptions
|
|
374
|
+
if (msgAny.type === 'system' && msgAny.subtype === 'init') {
|
|
375
|
+
try {
|
|
376
|
+
const sid = session.sessionId
|
|
377
|
+
const slashCmds = (msgAny.slash_commands as string[]) ?? []
|
|
378
|
+
const skills = (msgAny.skills as string[]) ?? []
|
|
379
|
+
const allNames = new Set([...slashCmds, ...skills])
|
|
380
|
+
// Set basic cache immediately so getCommands() doesn't return empty
|
|
381
|
+
this.sessionCommands.set(sid, Array.from(allNames).map((name) => ({
|
|
382
|
+
name,
|
|
383
|
+
description: skills.includes(name) ? 'skill' : '',
|
|
384
|
+
})))
|
|
385
|
+
// Async: fetch full descriptions from supportedCommands()
|
|
386
|
+
const q = (session as unknown as { query: { supportedCommands(): Promise<{ name: string; description: string }[]> } }).query
|
|
387
|
+
q?.supportedCommands()?.then((cmds: { name: string; description: string }[]) => {
|
|
388
|
+
if (cmds.length > 0) {
|
|
389
|
+
// Merge: use supportedCommands descriptions, but keep init-only skills that supportedCommands missed
|
|
390
|
+
const cmdMap = new Map(cmds.map((c) => [c.name, c]))
|
|
391
|
+
const existing = this.sessionCommands.get(sid) ?? []
|
|
392
|
+
for (const prev of existing) {
|
|
393
|
+
if (!cmdMap.has(prev.name)) {
|
|
394
|
+
cmdMap.set(prev.name, prev)
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
this.sessionCommands.set(sid, Array.from(cmdMap.values()))
|
|
398
|
+
// Notify via broadcast so ws-handler can push updated list
|
|
399
|
+
this.broadcast(sid, (l) => l.onMessage(sid, { type: 'commands_updated' } as unknown as SDKMessage))
|
|
400
|
+
}
|
|
401
|
+
}).catch(() => {})
|
|
402
|
+
} catch { /* session not yet initialized */ }
|
|
403
|
+
}
|
|
404
|
+
if (msgAny.type === 'result') {
|
|
405
|
+
if (msgAny.is_error) {
|
|
406
|
+
console.error('[SessionManager] SDK error result:', JSON.stringify(msg).slice(0, 500))
|
|
407
|
+
} else {
|
|
408
|
+
console.log('[SessionManager] Turn complete, cost:', (msgAny as Record<string, unknown>).total_cost_usd)
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
let sessionId: string
|
|
413
|
+
try {
|
|
414
|
+
sessionId = session.sessionId
|
|
415
|
+
} catch {
|
|
416
|
+
continue
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Remap tempId → real sessionId (O(1) lookup)
|
|
420
|
+
const remap = this.pendingRemaps.get(initialSessionId)
|
|
421
|
+
if (remap && remap.sessionIdRef.current === '' && sessionId && !sessionId.startsWith('pending-')) {
|
|
422
|
+
remap.sessionIdRef.current = sessionId
|
|
423
|
+
const tempId = initialSessionId
|
|
424
|
+
// Move session, cwd, listeners, streaming from tempId to real sessionId
|
|
425
|
+
const s = this.sessions.get(tempId)
|
|
426
|
+
if (s) { this.sessions.delete(tempId); this.sessions.set(sessionId, s) }
|
|
427
|
+
const c = this.sessionCwds.get(tempId)
|
|
428
|
+
if (c) { this.sessionCwds.delete(tempId); this.sessionCwds.set(sessionId, c) }
|
|
429
|
+
const listeners = this.sessionListeners.get(tempId)
|
|
430
|
+
if (listeners) { this.sessionListeners.delete(tempId); this.sessionListeners.set(sessionId, listeners) }
|
|
431
|
+
this.streamingSessionIds.delete(tempId)
|
|
432
|
+
this.streamingSessionIds.add(sessionId)
|
|
433
|
+
const q = this.activeQueries.get(tempId)
|
|
434
|
+
if (q) { this.activeQueries.delete(tempId); this.activeQueries.set(sessionId, q) }
|
|
435
|
+
this.pendingRemaps.delete(tempId)
|
|
436
|
+
// Update our tracking variable
|
|
437
|
+
currentSessionId = sessionId
|
|
438
|
+
// Notify listeners of the remap
|
|
439
|
+
this.broadcast(sessionId, (l) => l.onMessage(sessionId, {
|
|
440
|
+
type: 'session_id_resolved', tempId, sessionId,
|
|
441
|
+
} as unknown as SDKMessage))
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
this.runningSessionIds.add(sessionId)
|
|
445
|
+
this.broadcast(sessionId, (l) => l.onMessage(sessionId, msg))
|
|
446
|
+
}
|
|
447
|
+
// Check if session was closed while we were streaming
|
|
448
|
+
let sessionId: string
|
|
449
|
+
try { sessionId = session.sessionId } catch { break }
|
|
450
|
+
if (this.closedSessionIds.has(sessionId)) break
|
|
451
|
+
// Wait briefly before re-entering stream() for next turn
|
|
452
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
453
|
+
}
|
|
454
|
+
} catch (err) {
|
|
455
|
+
// AbortError is expected when session is closed
|
|
456
|
+
if (!(err instanceof Error && err.name === 'AbortError')) {
|
|
457
|
+
console.error('[SessionManager] Stream error:', err)
|
|
458
|
+
}
|
|
459
|
+
} finally {
|
|
460
|
+
try {
|
|
461
|
+
const sessionId = session.sessionId
|
|
462
|
+
this.runningSessionIds.delete(sessionId)
|
|
463
|
+
this.streamingSessionIds.delete(sessionId)
|
|
464
|
+
this.activeQueries.delete(sessionId)
|
|
465
|
+
this.broadcast(sessionId, (l) => l.onEnd(sessionId))
|
|
466
|
+
} catch {
|
|
467
|
+
// Session never initialized — try with our tracked id
|
|
468
|
+
this.runningSessionIds.delete(currentSessionId)
|
|
469
|
+
this.streamingSessionIds.delete(currentSessionId)
|
|
470
|
+
this.activeQueries.delete(currentSessionId)
|
|
471
|
+
this.broadcast(currentSessionId, (l) => l.onEnd(currentSessionId))
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
hasSession(sessionId: string): boolean {
|
|
477
|
+
return this.sessions.has(sessionId)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async getCommands(sessionId: string): Promise<{ name: string; description: string }[]> {
|
|
481
|
+
// Try cached commands from init message first
|
|
482
|
+
const cached = this.sessionCommands.get(sessionId)
|
|
483
|
+
if (cached && cached.length > 0) return cached
|
|
484
|
+
|
|
485
|
+
// Fallback to SDK API
|
|
486
|
+
const session = this.sessions.get(sessionId)
|
|
487
|
+
if (!session) return []
|
|
488
|
+
try {
|
|
489
|
+
const query = (session as unknown as { query: { supportedCommands(): Promise<{ name: string; description: string }[]> } }).query
|
|
490
|
+
if (!query?.supportedCommands) return []
|
|
491
|
+
const commands = await query.supportedCommands()
|
|
492
|
+
if (commands.length > 0) {
|
|
493
|
+
this.sessionCommands.set(sessionId, commands)
|
|
494
|
+
}
|
|
495
|
+
return commands
|
|
496
|
+
} catch {
|
|
497
|
+
return []
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async getSupportedModels(sessionId: string): Promise<{ value: string; displayName: string; description: string }[]> {
|
|
502
|
+
const session = this.sessions.get(sessionId)
|
|
503
|
+
if (!session) return []
|
|
504
|
+
try {
|
|
505
|
+
const query = (session as unknown as { query: { supportedModels(): Promise<{ value: string; displayName: string; description: string }[]> } }).query
|
|
506
|
+
if (!query?.supportedModels) return []
|
|
507
|
+
return await query.supportedModels()
|
|
508
|
+
} catch {
|
|
509
|
+
return []
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async setModel(sessionId: string, model: string): Promise<void> {
|
|
514
|
+
const session = this.sessions.get(sessionId)
|
|
515
|
+
if (!session) {
|
|
516
|
+
throw new Error(`Session ${sessionId} not found`)
|
|
517
|
+
}
|
|
518
|
+
// setModel lives on the internal Query object (session.query), not on SDKSession itself
|
|
519
|
+
const query = (session as unknown as { query: { setModel(model?: string): Promise<void> } }).query
|
|
520
|
+
if (!query?.setModel) {
|
|
521
|
+
throw new Error(`Session ${sessionId} does not support model switching`)
|
|
522
|
+
}
|
|
523
|
+
await query.setModel(model)
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
async setEffortLevel(sessionId: string, level: EffortLevel): Promise<void> {
|
|
527
|
+
const session = this.sessions.get(sessionId)
|
|
528
|
+
if (!session) {
|
|
529
|
+
throw new Error(`Session ${sessionId} not found`)
|
|
530
|
+
}
|
|
531
|
+
// applyFlagSettings lives on the internal Query object (session.query)
|
|
532
|
+
const query = (session as unknown as { query: { applyFlagSettings(settings: { effort?: EffortLevel }): Promise<void> } }).query
|
|
533
|
+
if (!query?.applyFlagSettings) {
|
|
534
|
+
throw new Error(`Session ${sessionId} does not support effort level`)
|
|
535
|
+
}
|
|
536
|
+
await query.applyFlagSettings({ effort: level })
|
|
537
|
+
this.broadcast(sessionId, (l) => l.onMessage(sessionId, {
|
|
538
|
+
type: 'effort_level_changed', sessionId, level,
|
|
539
|
+
} as unknown as SDKMessage))
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async sendMessage(sessionId: string, content: string): Promise<void> {
|
|
543
|
+
const session = this.sessions.get(sessionId)
|
|
544
|
+
if (!session) {
|
|
545
|
+
throw new Error(`Session ${sessionId} not found`)
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Broadcast user message to all listeners so other connections can see it.
|
|
549
|
+
// The sender deduplicates on the client side via sentMessagesRef.
|
|
550
|
+
this.broadcast(sessionId, (l) => l.onMessage(sessionId, {
|
|
551
|
+
type: 'user',
|
|
552
|
+
message: { role: 'user', content: [{ type: 'text', text: content }] },
|
|
553
|
+
} as unknown as SDKMessage))
|
|
554
|
+
|
|
555
|
+
// Send first, THEN start stream consumer.
|
|
556
|
+
// This ensures the SDK turn is queued before stream() is entered,
|
|
557
|
+
// avoiding the race where stream() completes immediately (no active turn)
|
|
558
|
+
// and the 50ms re-entry gap causes the turn response to be lost.
|
|
559
|
+
await session.send(content)
|
|
560
|
+
|
|
561
|
+
this.startStreamConsumer(sessionId, session)
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
resolvePermission(toolUseId: string, approved: boolean, reason?: string, alwaysAllow?: boolean): void {
|
|
565
|
+
const pending = this.pendingPermissions.get(toolUseId)
|
|
566
|
+
if (pending) {
|
|
567
|
+
const updatedPermissions = (approved && alwaysAllow) ? pending.suggestions : undefined
|
|
568
|
+
pending.resolve(approved, reason, updatedPermissions)
|
|
569
|
+
this.pendingPermissions.delete(toolUseId)
|
|
570
|
+
|
|
571
|
+
// Broadcast permission decision to all listeners
|
|
572
|
+
this.broadcast(pending.sessionId, (l) => l.onMessage(pending.sessionId, {
|
|
573
|
+
type: 'permission_decided', toolUseId, approved,
|
|
574
|
+
} as unknown as SDKMessage))
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
resolveElicitation(id: string, action: 'accept' | 'decline' | 'cancel', content?: Record<string, unknown>): void {
|
|
579
|
+
const pending = this.pendingElicitations.get(id)
|
|
580
|
+
if (!pending) return
|
|
581
|
+
this.pendingElicitations.delete(id)
|
|
582
|
+
pending.resolve({ action, content: content as ElicitationResult['content'] })
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
async listSessions(): Promise<SessionInfo[]> {
|
|
586
|
+
const sessions = await listSessions()
|
|
587
|
+
// Cache cwd for each session so resumeSession can use the correct project dir
|
|
588
|
+
for (const s of sessions) {
|
|
589
|
+
const cwd = (s as Record<string, unknown>).cwd as string | undefined
|
|
590
|
+
if (cwd) {
|
|
591
|
+
this.sessionCwds.set(s.sessionId, cwd)
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return sessions.map((s) => ({
|
|
595
|
+
sessionId: s.sessionId,
|
|
596
|
+
summary: s.summary || 'Untitled',
|
|
597
|
+
lastModified: s.lastModified,
|
|
598
|
+
status: this.runningSessionIds.has(s.sessionId) ? 'running' as const : 'idle' as const,
|
|
599
|
+
}))
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
async getHistory(sessionId: string): Promise<unknown[]> {
|
|
603
|
+
const cwd = this.sessionCwds.get(sessionId)
|
|
604
|
+
try {
|
|
605
|
+
const messages = await getSessionMessages(sessionId, { dir: cwd })
|
|
606
|
+
return messages
|
|
607
|
+
} catch (err) {
|
|
608
|
+
console.error('[SessionManager] Failed to load history for', sessionId, err)
|
|
609
|
+
return []
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async getSubagentMessages(sessionId: string, agentId: string): Promise<unknown[]> {
|
|
614
|
+
const cwd = this.sessionCwds.get(sessionId)
|
|
615
|
+
try {
|
|
616
|
+
return await sdkGetSubagentMessages(sessionId, agentId, { dir: cwd })
|
|
617
|
+
} catch (err) {
|
|
618
|
+
console.error('[SessionManager] Failed to load subagent messages for', sessionId, agentId, err)
|
|
619
|
+
return []
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
getCwd(sessionId?: string): string {
|
|
624
|
+
if (sessionId) {
|
|
625
|
+
const cwd = this.sessionCwds.get(sessionId)
|
|
626
|
+
if (cwd) return cwd
|
|
627
|
+
}
|
|
628
|
+
return process.env.CC_WEB_CONSOLE_CWD ?? process.env.HOME ?? '/'
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async resumeSession(
|
|
632
|
+
sessionId: string,
|
|
633
|
+
): Promise<void> {
|
|
634
|
+
// Check if any process (ours or external) is already using this session
|
|
635
|
+
if (this.sessions.has(sessionId)) {
|
|
636
|
+
throw new Error('Session is already running in this server')
|
|
637
|
+
}
|
|
638
|
+
if (isSessionLockedExternally(sessionId)) {
|
|
639
|
+
throw new Error('Session is currently in use by another client (e.g. CLI)')
|
|
640
|
+
}
|
|
641
|
+
this.closedSessionIds.delete(sessionId)
|
|
642
|
+
|
|
643
|
+
// Use the cached cwd from listSessions so claude spawns in the correct project
|
|
644
|
+
const cwd = this.sessionCwds.get(sessionId)
|
|
645
|
+
const resumeSessionIdRef = { current: sessionId }
|
|
646
|
+
const sessionOptions = {
|
|
647
|
+
permissionMode: 'default',
|
|
648
|
+
canUseTool: this.buildCanUseTool(resumeSessionIdRef),
|
|
649
|
+
onElicitation: this.buildOnElicitation(resumeSessionIdRef),
|
|
650
|
+
env: cleanEnv(cwd),
|
|
651
|
+
pathToClaudeCodeExecutable: CLAUDE_EXECUTABLE,
|
|
652
|
+
executableArgs: getPluginDirArgs(),
|
|
653
|
+
} as unknown as SDKSessionOptions
|
|
654
|
+
|
|
655
|
+
const originalCwd = process.cwd()
|
|
656
|
+
if (cwd) { try { process.chdir(cwd) } catch { /* ignore */ } }
|
|
657
|
+
const session = unstable_v2_resumeSession(sessionId, sessionOptions)
|
|
658
|
+
try { process.chdir(originalCwd) } catch { /* ignore */ }
|
|
659
|
+
this.sessions.set(sessionId, session)
|
|
660
|
+
this.runningSessionIds.add(sessionId)
|
|
661
|
+
|
|
662
|
+
// Broadcast session_resumed to all listeners so other connections update their UI
|
|
663
|
+
this.broadcast(sessionId, (l) => l.onMessage(sessionId, {
|
|
664
|
+
type: 'session_resumed', sessionId,
|
|
665
|
+
} as unknown as SDKMessage))
|
|
666
|
+
|
|
667
|
+
// Do NOT start stream consumer yet.
|
|
668
|
+
// Stream will be started by sendMessage() AFTER send() queues the turn,
|
|
669
|
+
// ensuring stream() has an active turn to consume.
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
closeSession(sessionId: string): void {
|
|
673
|
+
const session = this.sessions.get(sessionId)
|
|
674
|
+
if (session) {
|
|
675
|
+
this.closedSessionIds.add(sessionId)
|
|
676
|
+
session.close()
|
|
677
|
+
this.sessions.delete(sessionId)
|
|
678
|
+
this.runningSessionIds.delete(sessionId)
|
|
679
|
+
this.streamingSessionIds.delete(sessionId)
|
|
680
|
+
|
|
681
|
+
// Clean up idle timer
|
|
682
|
+
const timer = this.idleTimers.get(sessionId)
|
|
683
|
+
if (timer) {
|
|
684
|
+
clearTimeout(timer)
|
|
685
|
+
this.idleTimers.delete(sessionId)
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Broadcast onEnd but keep listeners — they represent "watching this session"
|
|
689
|
+
// and should persist across stop/resume cycles. They're only removed on WS disconnect.
|
|
690
|
+
this.broadcast(sessionId, (l) => l.onEnd(sessionId))
|
|
691
|
+
|
|
692
|
+
// Keep sessionCwds and sessionCommands — they're metadata needed for re-resume.
|
|
693
|
+
// Only clean up runtime state.
|
|
694
|
+
this.pendingRemaps.delete(sessionId)
|
|
695
|
+
// Deny pending permissions only for this session
|
|
696
|
+
for (const [id, entry] of this.pendingPermissions) {
|
|
697
|
+
if (entry.sessionId === sessionId) {
|
|
698
|
+
entry.resolve(false, 'Session closed')
|
|
699
|
+
this.pendingPermissions.delete(id)
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
// Cancel pending elicitations for this session
|
|
703
|
+
for (const [id, entry] of this.pendingElicitations) {
|
|
704
|
+
if (entry.sessionId === sessionId) {
|
|
705
|
+
entry.resolve({ action: 'cancel' })
|
|
706
|
+
this.pendingElicitations.delete(id)
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
async getSessionSettings(sessionId: string): Promise<Record<string, unknown>> {
|
|
713
|
+
const session = this.sessions.get(sessionId)
|
|
714
|
+
if (!session) return { permissionMode: 'default', mcpServers: [], account: {} }
|
|
715
|
+
|
|
716
|
+
// Access query the same way as setModel/setEffortLevel
|
|
717
|
+
const query = (session as unknown as { query: {
|
|
718
|
+
mcpServerStatus: () => Promise<unknown[]>
|
|
719
|
+
initializationResult: () => Promise<unknown>
|
|
720
|
+
} }).query
|
|
721
|
+
|
|
722
|
+
const [mcpServers, initResult] = await Promise.all([
|
|
723
|
+
query.mcpServerStatus().catch((err) => { console.error('[SessionManager] mcpServerStatus failed:', err); return [] }),
|
|
724
|
+
query.initializationResult().catch((err) => { console.error('[SessionManager] initializationResult failed:', err); return {} }),
|
|
725
|
+
])
|
|
726
|
+
|
|
727
|
+
return {
|
|
728
|
+
permissionMode: 'default',
|
|
729
|
+
mcpServers,
|
|
730
|
+
account: initResult, // SDK initializationResult: active account/auth info
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** Close all active sessions. Called on server shutdown. */
|
|
735
|
+
closeAll(): void {
|
|
736
|
+
for (const sessionId of [...this.sessions.keys()]) {
|
|
737
|
+
console.log('[SessionManager] Shutting down session', sessionId)
|
|
738
|
+
this.closeSession(sessionId)
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
async renameSession(sessionId: string, title: string): Promise<void> {
|
|
743
|
+
// Note: no active-session guard here — renameSession operates on saved session
|
|
744
|
+
// files, not running processes. this.sessions only tracks active sessions, so
|
|
745
|
+
// idle sessions would be incorrectly rejected. The SDK handles missing sessions.
|
|
746
|
+
const cwd = this.sessionCwds.get(sessionId)
|
|
747
|
+
await sdkRenameSession(sessionId, title, { dir: cwd })
|
|
748
|
+
this.broadcast(sessionId, (l) => l.onMessage(sessionId, {
|
|
749
|
+
type: 'session_renamed', sessionId, title,
|
|
750
|
+
} as unknown as SDKMessage))
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
async forkSession(sessionId: string, upToMessageId: string): Promise<string> {
|
|
754
|
+
if (!upToMessageId) {
|
|
755
|
+
throw new Error('upToMessageId is required')
|
|
756
|
+
}
|
|
757
|
+
const cwd = this.sessionCwds.get(sessionId)
|
|
758
|
+
const result = await sdkForkSession(sessionId, { upToMessageId, dir: cwd })
|
|
759
|
+
const newSessionId = result.sessionId
|
|
760
|
+
this.broadcast(sessionId, (l) => l.onMessage(sessionId, {
|
|
761
|
+
type: 'session_forked', sessionId, newSessionId,
|
|
762
|
+
} as unknown as SDKMessage))
|
|
763
|
+
return newSessionId
|
|
764
|
+
}
|
|
765
|
+
}
|