@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.
Files changed (65) hide show
  1. package/README.md +102 -0
  2. package/bin/cli.js +64 -0
  3. package/claude-web-console.service +26 -0
  4. package/client/dist/assets/_baseUniq-Cnoz3HXL.js +1 -0
  5. package/client/dist/assets/arc-Z_2trbN8.js +1 -0
  6. package/client/dist/assets/architectureDiagram-Q4EWVU46-BkJc1Y5A.js +36 -0
  7. package/client/dist/assets/blockDiagram-DXYQGD6D-DajeZur_.js +132 -0
  8. package/client/dist/assets/c4Diagram-AHTNJAMY-DPrSiSIe.js +10 -0
  9. package/client/dist/assets/channel-xmJgF8AT.js +1 -0
  10. package/client/dist/assets/chunk-4BX2VUAB-Ap8VOuEx.js +1 -0
  11. package/client/dist/assets/chunk-4TB4RGXK-7WUmxI0T.js +206 -0
  12. package/client/dist/assets/chunk-55IACEB6-gyKf_szG.js +1 -0
  13. package/client/dist/assets/chunk-EDXVE4YY-Dqsdf8RE.js +1 -0
  14. package/client/dist/assets/chunk-FMBD7UC4-CFwejjYg.js +15 -0
  15. package/client/dist/assets/chunk-OYMX7WX6-DjhMJFGF.js +231 -0
  16. package/client/dist/assets/chunk-QZHKN3VN-BZl3lFpB.js +1 -0
  17. package/client/dist/assets/chunk-YZCP3GAM-Bgp9VGoJ.js +1 -0
  18. package/client/dist/assets/classDiagram-6PBFFD2Q-CUsL9OeO.js +1 -0
  19. package/client/dist/assets/classDiagram-v2-HSJHXN6E-CUsL9OeO.js +1 -0
  20. package/client/dist/assets/clone-YPoNuUWg.js +1 -0
  21. package/client/dist/assets/cose-bilkent-S5V4N54A-BhMgNxzB.js +1 -0
  22. package/client/dist/assets/cytoscape.esm-BQaXIfA_.js +331 -0
  23. package/client/dist/assets/dagre-KV5264BT-Im4xvruI.js +4 -0
  24. package/client/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  25. package/client/dist/assets/diagram-5BDNPKRD-C_LUH1t_.js +10 -0
  26. package/client/dist/assets/diagram-G4DWMVQ6-ovGPIIZa.js +24 -0
  27. package/client/dist/assets/diagram-MMDJMWI5-Cc_S6QYf.js +43 -0
  28. package/client/dist/assets/diagram-TYMM5635-DNtJ4zRJ.js +24 -0
  29. package/client/dist/assets/erDiagram-SMLLAGMA-mjVqYF51.js +85 -0
  30. package/client/dist/assets/flowDiagram-DWJPFMVM-gfeU3vbm.js +162 -0
  31. package/client/dist/assets/ganttDiagram-T4ZO3ILL-DHzKAwPu.js +292 -0
  32. package/client/dist/assets/gitGraphDiagram-UUTBAWPF-NmVr9v2T.js +106 -0
  33. package/client/dist/assets/graph-DWZBkQul.js +1 -0
  34. package/client/dist/assets/index-Bs3edPh7.js +329 -0
  35. package/client/dist/assets/index-BvGI0U64.css +1 -0
  36. package/client/dist/assets/infoDiagram-42DDH7IO-uZQs2FWg.js +2 -0
  37. package/client/dist/assets/init-Gi6I4Gst.js +1 -0
  38. package/client/dist/assets/ishikawaDiagram-UXIWVN3A-CVqWYfyh.js +70 -0
  39. package/client/dist/assets/journeyDiagram-VCZTEJTY-BM-S2Uek.js +139 -0
  40. package/client/dist/assets/kanban-definition-6JOO6SKY-DtfrMWsW.js +89 -0
  41. package/client/dist/assets/katex-DkKDou_j.js +257 -0
  42. package/client/dist/assets/layout-dcq7fgvs.js +1 -0
  43. package/client/dist/assets/linear-B-FGkSzh.js +1 -0
  44. package/client/dist/assets/mermaid.core-DDFJ1A5Z.js +309 -0
  45. package/client/dist/assets/min-DD4RQeGA.js +1 -0
  46. package/client/dist/assets/mindmap-definition-QFDTVHPH-B1E5NbCo.js +96 -0
  47. package/client/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  48. package/client/dist/assets/pieDiagram-DEJITSTG-Ciel1XRV.js +30 -0
  49. package/client/dist/assets/quadrantDiagram-34T5L4WZ-D1jxP0fH.js +7 -0
  50. package/client/dist/assets/requirementDiagram-MS252O5E-CTXKAocB.js +84 -0
  51. package/client/dist/assets/sankeyDiagram-XADWPNL6-Dj-i0BnN.js +10 -0
  52. package/client/dist/assets/sequenceDiagram-FGHM5R23-kCI4qbes.js +157 -0
  53. package/client/dist/assets/stateDiagram-FHFEXIEX-CiE-lplM.js +1 -0
  54. package/client/dist/assets/stateDiagram-v2-QKLJ7IA2-ZPI5JmoG.js +1 -0
  55. package/client/dist/assets/timeline-definition-GMOUNBTQ-MoAQpvF_.js +120 -0
  56. package/client/dist/assets/vennDiagram-DHZGUBPP-BqTjHn6B.js +34 -0
  57. package/client/dist/assets/wardley-RL74JXVD-C3OshQuH.js +162 -0
  58. package/client/dist/assets/wardleyDiagram-NUSXRM2D-Dl-s9oxJ.js +20 -0
  59. package/client/dist/assets/xychartDiagram-5P7HB3ND-BqyQEiVY.js +7 -0
  60. package/client/dist/index.html +16 -0
  61. package/package.json +57 -0
  62. package/server/src/index.ts +52 -0
  63. package/server/src/session-manager.ts +765 -0
  64. package/server/src/types.ts +295 -0
  65. 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
+ }