@jacexh/claude-web-console 0.12.4 → 0.13.0

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 (67) hide show
  1. package/client/dist/assets/{_baseUniq-4U_gS4M1.js → _baseUniq-B6sYeW4g.js} +1 -1
  2. package/client/dist/assets/{arc-C59lV2XZ.js → arc-B3qp8GtI.js} +1 -1
  3. package/client/dist/assets/{architectureDiagram-Q4EWVU46-De8p0m6N.js → architectureDiagram-Q4EWVU46-DUaflb5F.js} +1 -1
  4. package/client/dist/assets/{blockDiagram-DXYQGD6D-BEzLwzF0.js → blockDiagram-DXYQGD6D-dJ5MP9NF.js} +1 -1
  5. package/client/dist/assets/{c4Diagram-AHTNJAMY-DGkH6t15.js → c4Diagram-AHTNJAMY-BUyLffeg.js} +1 -1
  6. package/client/dist/assets/channel-Hdhu6NSU.js +1 -0
  7. package/client/dist/assets/{chunk-4BX2VUAB-DCoVX0i8.js → chunk-4BX2VUAB-CL6RSkDB.js} +1 -1
  8. package/client/dist/assets/{chunk-4TB4RGXK-dGyuU23q.js → chunk-4TB4RGXK-Bpos2uLs.js} +1 -1
  9. package/client/dist/assets/{chunk-55IACEB6-DDiu2l5t.js → chunk-55IACEB6-DXBFgKxO.js} +1 -1
  10. package/client/dist/assets/{chunk-EDXVE4YY-B_5Djzmq.js → chunk-EDXVE4YY-2YRg8OOI.js} +1 -1
  11. package/client/dist/assets/{chunk-FMBD7UC4-O6auhXOZ.js → chunk-FMBD7UC4-BBAsqSsh.js} +1 -1
  12. package/client/dist/assets/{chunk-OYMX7WX6-C2l5EG0a.js → chunk-OYMX7WX6-kkD25xqj.js} +1 -1
  13. package/client/dist/assets/{chunk-QZHKN3VN-CGKUgT3w.js → chunk-QZHKN3VN-BFbEMvTe.js} +1 -1
  14. package/client/dist/assets/{chunk-YZCP3GAM-CSmbvz_K.js → chunk-YZCP3GAM-CCNsuiYn.js} +1 -1
  15. package/client/dist/assets/classDiagram-6PBFFD2Q-DDpCsFzN.js +1 -0
  16. package/client/dist/assets/classDiagram-v2-HSJHXN6E-DDpCsFzN.js +1 -0
  17. package/client/dist/assets/clone-CyGsvajQ.js +1 -0
  18. package/client/dist/assets/{cose-bilkent-S5V4N54A-DtjeJt3O.js → cose-bilkent-S5V4N54A-CFic2XgR.js} +1 -1
  19. package/client/dist/assets/{dagre-KV5264BT-CBGSZE2K.js → dagre-KV5264BT-DmhbJsrh.js} +1 -1
  20. package/client/dist/assets/{diagram-5BDNPKRD-DWej4_Dm.js → diagram-5BDNPKRD-EMWFsNjs.js} +1 -1
  21. package/client/dist/assets/{diagram-G4DWMVQ6-CsBZJklM.js → diagram-G4DWMVQ6-D4bNVW2H.js} +1 -1
  22. package/client/dist/assets/{diagram-MMDJMWI5-CAnJ5oHd.js → diagram-MMDJMWI5-C_YYb2VJ.js} +1 -1
  23. package/client/dist/assets/{diagram-TYMM5635-BNwTUJzz.js → diagram-TYMM5635-C-Uatjas.js} +1 -1
  24. package/client/dist/assets/{erDiagram-SMLLAGMA-CwJQuBj5.js → erDiagram-SMLLAGMA-Zk5yMi0J.js} +1 -1
  25. package/client/dist/assets/{flowDiagram-DWJPFMVM-COosf1Qi.js → flowDiagram-DWJPFMVM-DQFo3wKV.js} +1 -1
  26. package/client/dist/assets/{ganttDiagram-T4ZO3ILL-_aInr4aJ.js → ganttDiagram-T4ZO3ILL-BvIdlFEU.js} +1 -1
  27. package/client/dist/assets/{gitGraphDiagram-UUTBAWPF-DcXA_pZG.js → gitGraphDiagram-UUTBAWPF-IyBYKltU.js} +1 -1
  28. package/client/dist/assets/{graph-BLaCZz6d.js → graph-Cjv1Iudr.js} +1 -1
  29. package/client/dist/assets/index-3fTJUkcw.css +1 -0
  30. package/client/dist/assets/index-DyP4iMkT.js +359 -0
  31. package/client/dist/assets/{infoDiagram-42DDH7IO-CRQ7YC07.js → infoDiagram-42DDH7IO-BbbU3b3O.js} +1 -1
  32. package/client/dist/assets/{ishikawaDiagram-UXIWVN3A-DbI4M_iy.js → ishikawaDiagram-UXIWVN3A-BvTy237T.js} +1 -1
  33. package/client/dist/assets/{journeyDiagram-VCZTEJTY-DnivGAau.js → journeyDiagram-VCZTEJTY-CvqHRg-v.js} +1 -1
  34. package/client/dist/assets/{kanban-definition-6JOO6SKY-BZXMUpR7.js → kanban-definition-6JOO6SKY-_qWxod8i.js} +1 -1
  35. package/client/dist/assets/{layout-DaCJPJ4j.js → layout-QRga_jZh.js} +1 -1
  36. package/client/dist/assets/{linear-D3Tj8-2V.js → linear-dO_OA3Zq.js} +1 -1
  37. package/client/dist/assets/{mermaid.core-D970jp78.js → mermaid.core-DC6AubH-.js} +4 -4
  38. package/client/dist/assets/{min-7eDP7Usr.js → min-C-W75DLw.js} +1 -1
  39. package/client/dist/assets/{mindmap-definition-QFDTVHPH-DN_5Nzl7.js → mindmap-definition-QFDTVHPH-DuSy2wWg.js} +1 -1
  40. package/client/dist/assets/{pieDiagram-DEJITSTG-BqiFSuiC.js → pieDiagram-DEJITSTG-K-01TW82.js} +1 -1
  41. package/client/dist/assets/{quadrantDiagram-34T5L4WZ-1bj64KPR.js → quadrantDiagram-34T5L4WZ-CH5sCVdG.js} +1 -1
  42. package/client/dist/assets/{requirementDiagram-MS252O5E-d36Oj16U.js → requirementDiagram-MS252O5E-qNmh8nmD.js} +1 -1
  43. package/client/dist/assets/{sankeyDiagram-XADWPNL6-DWZ3s91n.js → sankeyDiagram-XADWPNL6-DDrfQolI.js} +1 -1
  44. package/client/dist/assets/{sequenceDiagram-FGHM5R23-C3l3gLwX.js → sequenceDiagram-FGHM5R23-BYXjyIvv.js} +1 -1
  45. package/client/dist/assets/{stateDiagram-FHFEXIEX-_Ee_DnHr.js → stateDiagram-FHFEXIEX-DKa3Ub_p.js} +1 -1
  46. package/client/dist/assets/stateDiagram-v2-QKLJ7IA2-DW-O7br7.js +1 -0
  47. package/client/dist/assets/{timeline-definition-GMOUNBTQ-BtuhmgP-.js → timeline-definition-GMOUNBTQ-BdeASN9k.js} +1 -1
  48. package/client/dist/assets/{vennDiagram-DHZGUBPP-B67BiofD.js → vennDiagram-DHZGUBPP-DjAjT_jR.js} +1 -1
  49. package/client/dist/assets/{wardley-RL74JXVD-Da_3UXKp.js → wardley-RL74JXVD-B0cvAuVw.js} +1 -1
  50. package/client/dist/assets/{wardleyDiagram-NUSXRM2D-CNL4fx4y.js → wardleyDiagram-NUSXRM2D-DSRvUIwc.js} +1 -1
  51. package/client/dist/assets/{xychartDiagram-5P7HB3ND-CXovLg1r.js → xychartDiagram-5P7HB3ND-Cs8afIsZ.js} +1 -1
  52. package/client/dist/index.html +2 -2
  53. package/package.json +1 -1
  54. package/server/src/__tests__/http-routes.test.ts +28 -14
  55. package/server/src/__tests__/turn-started-broadcast.test.ts +0 -20
  56. package/server/src/http-routes.ts +18 -6
  57. package/server/src/session-manager.ts +155 -147
  58. package/server/src/turn-lifecycle.ts +4 -1
  59. package/server/src/types.ts +14 -7
  60. package/server/src/ws-handler.ts +14 -11
  61. package/client/dist/assets/channel-DETbjbce.js +0 -1
  62. package/client/dist/assets/classDiagram-6PBFFD2Q-BcM7pkgM.js +0 -1
  63. package/client/dist/assets/classDiagram-v2-HSJHXN6E-BcM7pkgM.js +0 -1
  64. package/client/dist/assets/clone-CBKKAfIW.js +0 -1
  65. package/client/dist/assets/index-CQyb-QJw.js +0 -358
  66. package/client/dist/assets/index-MeijXGw4.css +0 -1
  67. package/client/dist/assets/stateDiagram-v2-QKLJ7IA2-BAk4qFFA.js +0 -1
@@ -159,7 +159,6 @@ export class SessionManager {
159
159
  private streamingSessionIds = new Set<string>()
160
160
  private sessionListeners = new Map<string, Set<SessionListener>>()
161
161
  private idleTimers = new Map<string, ReturnType<typeof setTimeout>>()
162
- private pendingRemaps = new Map<string, { sessionIdRef: { current: string } }>()
163
162
  // Track cwd for each session so we can resume in the correct project
164
163
  private sessionCwds = new Map<string, string>()
165
164
  /** User-supplied creation options that must survive resume cycles */
@@ -350,18 +349,24 @@ export class SessionManager {
350
349
  }
351
350
 
352
351
  async createSession(
353
- options?: { model?: string; cwd?: string; permissionMode?: string; executableArgs?: string[]; env?: Record<string, string> },
352
+ options: { message: string; cwd?: string; model?: string; permissionMode?: string; effortLevel?: EffortLevel; executableArgs?: string[]; env?: Record<string, string> },
354
353
  ): Promise<string> {
355
- const cwd = options?.cwd ?? process.env.CC_WEB_CONSOLE_CWD ?? process.env.HOME ?? '/'
354
+ const cwd = options.cwd ?? process.env.CC_WEB_CONSOLE_CWD ?? process.env.HOME ?? '/'
356
355
  const sessionIdRef = { current: '' }
357
356
  const pluginArgs = getPluginDirArgs()
358
- const userArgs = options?.executableArgs ?? []
357
+ const userArgs = options.executableArgs ?? []
358
+ const opts: SessionCreationOptions = {
359
+ model: options.model,
360
+ permissionMode: options.permissionMode,
361
+ executableArgs: options.executableArgs,
362
+ env: options.env,
363
+ }
359
364
  const sessionOptions = {
360
- ...(options?.model ? { model: options.model } : {}),
361
- permissionMode: options?.permissionMode ?? 'default',
365
+ ...(options.model ? { model: options.model } : {}),
366
+ permissionMode: options.permissionMode ?? 'default',
362
367
  canUseTool: this.buildCanUseTool(sessionIdRef),
363
368
  onElicitation: this.buildOnElicitation(sessionIdRef),
364
- env: { ...cleanEnv(cwd), ...options?.env },
369
+ env: { ...cleanEnv(cwd), ...options.env },
365
370
  pathToClaudeCodeExecutable: CLAUDE_EXECUTABLE,
366
371
  executableArgs: [...pluginArgs, ...userArgs],
367
372
  } as SDKSessionOptions
@@ -371,28 +376,62 @@ export class SessionManager {
371
376
  const session = unstable_v2_createSession(sessionOptions)
372
377
  try { process.chdir(originalCwd) } catch { /* ignore */ }
373
378
 
374
- // New sessions need send() before sessionId is available.
375
- // Use a temporary ID, then remap when real sessionId arrives.
376
- const tempId = `pending-${Date.now()}`
377
- this.sessions.set(tempId, session)
378
- this.sessionStatus.set(tempId, 'idle')
379
- this.sessionCwds.set(tempId, cwd)
380
- this.sessionCreationOptions.set(tempId, {
381
- model: options?.model,
382
- permissionMode: options?.permissionMode,
383
- executableArgs: options?.executableArgs,
384
- env: options?.env,
385
- })
386
-
387
- // Store tempId in pendingRemaps for remap inside consumeStream
388
- this.pendingRemaps.set(tempId, { sessionIdRef })
389
-
390
- // For new sessions, start stream immediately SDK may emit init messages
391
- this.startStreamConsumer(tempId, session)
379
+ // Send first message SDK assigns sessionId asynchronously after send()
380
+ this.log.info({ cwd, model: options.model }, 'createSession: sending first message')
381
+ await session.send(options.message)
382
+
383
+ // SDK requires stream() to be consumed for the session to progress.
384
+ // Use a mutable ref so consumeStream picks up the real sessionId once resolved.
385
+ const streamIdRef = { current: `pending-${Date.now()}` }
386
+ this.log.info('createSession: starting stream + waiting for sessionId')
387
+ this.sessions.set(streamIdRef.current, session)
388
+ this.streamingSessionIds.add(streamIdRef.current)
389
+ this.consumeStream(streamIdRef, session)
390
+
391
+ const sessionId = await waitForSessionId(session, 30000)
392
+ this.log.info({ sessionId }, 'createSession: sessionId resolved')
393
+ sessionIdRef.current = sessionId
394
+
395
+ // Remap internal maps from tempKey real sessionId
396
+ const tempKey = streamIdRef.current === sessionId ? null : streamIdRef.current
397
+ streamIdRef.current = sessionId
398
+ if (tempKey && tempKey !== sessionId) {
399
+ const s = this.sessions.get(tempKey)
400
+ if (s) { this.sessions.delete(tempKey); this.sessions.set(sessionId, s) }
401
+ this.streamingSessionIds.delete(tempKey)
402
+ this.streamingSessionIds.add(sessionId)
403
+ const listeners = this.sessionListeners.get(tempKey)
404
+ if (listeners) { this.sessionListeners.delete(tempKey); this.sessionListeners.set(sessionId, listeners) }
405
+ const q = this.activeQueries.get(tempKey)
406
+ if (q) { this.activeQueries.delete(tempKey); this.activeQueries.set(sessionId, q) }
407
+ const prevStatus = this.sessionStatus.get(tempKey)
408
+ this.sessionStatus.delete(tempKey)
409
+ if (prevStatus) this.sessionStatus.set(sessionId, prevStatus)
410
+ }
411
+
412
+ // Register session state under real sessionId
413
+ this.sessionStatus.set(sessionId, 'running')
414
+ this.sessionCwds.set(sessionId, cwd)
415
+ this.sessionCreationOptions.set(sessionId, opts)
416
+ saveSessionOptions(sessionId, opts)
417
+
418
+ // Pre-populate model/effort so getSessionState() returns them immediately
419
+ // when the client sends switch_session (before consumeStream's init message)
420
+ if (options.model) {
421
+ this.sessionModels.set(sessionId, options.model)
422
+ }
423
+ if (options.effortLevel) {
424
+ this.sessionEffortLevels.set(sessionId, options.effortLevel)
425
+ }
426
+
427
+ // Apply effort level after session is running (SDK doesn't accept it in SDKSessionOptions)
428
+ if (options.effortLevel) {
429
+ this.setEffortLevel(sessionId, options.effortLevel).catch((err) => {
430
+ this.log.error({ err, sessionId }, 'Failed to set initial effort level')
431
+ })
432
+ }
392
433
 
393
- // SDK requires send() before sessionId is available.
394
- // Return tempId now; real sessionId arrives via session_id_resolved WS event after first send().
395
- return tempId
434
+ return sessionId
396
435
  }
397
436
 
398
437
  private fetchAndBroadcastModels(sessionId: string, session: SDKSession, currentModel?: string): void {
@@ -418,81 +457,68 @@ export class SessionManager {
418
457
  ): void {
419
458
  if (this.streamingSessionIds.has(sessionId)) return
420
459
  this.streamingSessionIds.add(sessionId)
421
- this.consumeStream(sessionId, session)
460
+ this.consumeStream({ current: sessionId }, session)
422
461
  }
423
462
 
424
463
  private async consumeStream(
425
- initialSessionId: string,
464
+ idRef: { current: string },
426
465
  session: SDKSession,
427
466
  ): Promise<void> {
428
- // Track the current sessionId — may change from tempId to real sessionId
429
- let currentSessionId = initialSessionId
467
+ // sessionId is read from ref — may change from tempKey to real ID during initial creation
468
+ const getId = () => idRef.current
430
469
  let modelsFetched = false
431
470
 
432
471
  try {
433
472
  // SDK's stream() ends after each turn (on "result" message).
434
473
  // Loop to re-enter stream() for subsequent turns.
435
474
  while (true) {
436
- this.log.info({ sessionId: currentSessionId }, 'consumeStream: entering stream()')
475
+ this.log.info({ sessionId: getId() }, 'consumeStream: entering stream()')
437
476
  const query = session.stream()
438
477
  const turnState: TurnState = { turnStarted: false }
439
- // Store query reference so control requests (setModel etc.) can use it
440
- try {
441
- const sid = session.sessionId
442
- this.activeQueries.set(sid, query)
443
- } catch { /* sessionId not yet available, will set after remap */ }
478
+ this.activeQueries.set(getId(), query)
444
479
 
445
480
  for await (const msg of query) {
481
+ const sid = getId()
446
482
  const msgAny = msg as Record<string, unknown>
447
483
  this.log.info({
448
484
  type: msgAny.type,
449
485
  subtype: msgAny.subtype,
450
486
  parentToolUseId: msgAny.parent_tool_use_id,
451
- sessionId: currentSessionId,
487
+ sessionId: sid,
452
488
  message: JSON.stringify(msg).slice(0, 50),
453
489
  }, 'Stream message received')
454
490
 
455
491
  // Fetch models once after stream is active (works for both new and resumed sessions)
456
492
  if (!modelsFetched) {
457
493
  modelsFetched = true
458
- try {
459
- const sid = session.sessionId
460
- // Update query ref now that sessionId is available
461
- this.activeQueries.set(sid, query)
462
- this.fetchAndBroadcastModels(sid, session, (msgAny.type === 'system' && msgAny.subtype === 'init') ? (msgAny.model as string) : undefined)
463
- } catch { /* session not yet initialized */ }
494
+ this.activeQueries.set(sid, query)
495
+ this.fetchAndBroadcastModels(sid, session, (msgAny.type === 'system' && msgAny.subtype === 'init') ? (msgAny.model as string) : undefined)
464
496
  }
465
497
 
466
498
  // Extract commands from init message, then enrich with full descriptions
467
499
  if (msgAny.type === 'system' && msgAny.subtype === 'init') {
468
- try {
469
- const sid = session.sessionId
470
- const slashCmds = (msgAny.slash_commands as string[]) ?? []
471
- const skills = (msgAny.skills as string[]) ?? []
472
- const allNames = new Set([...slashCmds, ...skills])
473
- // Set basic cache immediately so getCommands() doesn't return empty
474
- this.sessionCommands.set(sid, Array.from(allNames).map((name) => ({
475
- name,
476
- description: skills.includes(name) ? 'skill' : '',
477
- })))
478
- // Async: fetch full descriptions from supportedCommands()
479
- const q = (session as unknown as { query: { supportedCommands(): Promise<{ name: string; description: string }[]> } }).query
480
- q?.supportedCommands()?.then((cmds: { name: string; description: string }[]) => {
481
- if (cmds.length > 0) {
482
- // Merge: use supportedCommands descriptions, but keep init-only skills that supportedCommands missed
483
- const cmdMap = new Map(cmds.map((c) => [c.name, c]))
484
- const existing = this.sessionCommands.get(sid) ?? []
485
- for (const prev of existing) {
486
- if (!cmdMap.has(prev.name)) {
487
- cmdMap.set(prev.name, prev)
488
- }
500
+ const slashCmds = (msgAny.slash_commands as string[]) ?? []
501
+ const skills = (msgAny.skills as string[]) ?? []
502
+ const allNames = new Set([...slashCmds, ...skills])
503
+ this.sessionCommands.set(sid, Array.from(allNames).map((name) => ({
504
+ name,
505
+ description: skills.includes(name) ? 'skill' : '',
506
+ })))
507
+ const q = (session as unknown as { query: { supportedCommands(): Promise<{ name: string; description: string }[]> } }).query
508
+ q?.supportedCommands()?.then((cmds: { name: string; description: string }[]) => {
509
+ if (cmds.length > 0) {
510
+ const currentSid = getId()
511
+ const cmdMap = new Map(cmds.map((c) => [c.name, c]))
512
+ const existing = this.sessionCommands.get(currentSid) ?? []
513
+ for (const prev of existing) {
514
+ if (!cmdMap.has(prev.name)) {
515
+ cmdMap.set(prev.name, prev)
489
516
  }
490
- this.sessionCommands.set(sid, Array.from(cmdMap.values()))
491
- // Notify via broadcast so ws-handler can push updated list
492
- this.broadcast(sid, (l) => l.onMessage(sid, { type: 'commands_updated' } as unknown as SDKMessage))
493
517
  }
494
- }).catch(() => {})
495
- } catch { /* session not yet initialized */ }
518
+ this.sessionCommands.set(currentSid, Array.from(cmdMap.values()))
519
+ this.broadcast(currentSid, (l) => l.onMessage(currentSid, { type: 'commands_updated' } as unknown as SDKMessage))
520
+ }
521
+ }).catch(() => {})
496
522
  }
497
523
  if (msgAny.type === 'result') {
498
524
  if (msgAny.is_error) {
@@ -500,96 +526,40 @@ export class SessionManager {
500
526
  } else {
501
527
  this.log.info({ cost: (msgAny as Record<string, unknown>).total_cost_usd }, 'Turn complete')
502
528
  }
503
- this.sessionStatus.set(currentSessionId, 'idle')
504
- }
505
-
506
- let sessionId: string
507
- try {
508
- sessionId = session.sessionId
509
- } catch {
510
- continue
529
+ this.sessionStatus.set(sid, 'idle')
511
530
  }
512
531
 
513
- // Remap tempId → real sessionId (O(1) lookup)
514
- const remap = this.pendingRemaps.get(initialSessionId)
515
- if (remap && remap.sessionIdRef.current === '' && sessionId && !sessionId.startsWith('pending-')) {
516
- remap.sessionIdRef.current = sessionId
517
- const tempId = initialSessionId
518
- // Move session, cwd, listeners, streaming from tempId to real sessionId
519
- const s = this.sessions.get(tempId)
520
- if (s) { this.sessions.delete(tempId); this.sessions.set(sessionId, s) }
521
- const c = this.sessionCwds.get(tempId)
522
- if (c) { this.sessionCwds.delete(tempId); this.sessionCwds.set(sessionId, c) }
523
- const opts = this.sessionCreationOptions.get(tempId)
524
- if (opts) { this.sessionCreationOptions.delete(tempId); this.sessionCreationOptions.set(sessionId, opts); saveSessionOptions(sessionId, opts) }
525
- const listeners = this.sessionListeners.get(tempId)
526
- if (listeners) { this.sessionListeners.delete(tempId); this.sessionListeners.set(sessionId, listeners) }
527
- this.streamingSessionIds.delete(tempId)
528
- this.streamingSessionIds.add(sessionId)
529
- const prevStatus = this.sessionStatus.get(tempId)
530
- this.sessionStatus.delete(tempId)
531
- if (prevStatus) this.sessionStatus.set(sessionId, prevStatus)
532
- const q = this.activeQueries.get(tempId)
533
- if (q) { this.activeQueries.delete(tempId); this.activeQueries.set(sessionId, q) }
534
- this.pendingRemaps.delete(tempId)
535
- // Update our tracking variable
536
- currentSessionId = sessionId
537
- // Notify listeners of the remap
538
- this.broadcast(sessionId, (l) => l.onMessage(sessionId, {
539
- type: 'session_id_resolved', tempId, sessionId,
540
- } as unknown as SDKMessage))
541
- }
542
-
543
- // Set session status to running on first turn message (skip system/init)
544
532
  if (isTurnMessage(msgAny) && shouldBroadcastTurnStarted(turnState)) {
545
- this.sessionStatus.set(sessionId, 'running')
533
+ this.sessionStatus.set(sid, 'running')
546
534
  }
547
- this.broadcast(sessionId, (l) => l.onMessage(sessionId, msg))
535
+ this.broadcast(sid, (l) => l.onMessage(sid, msg))
548
536
  }
549
- this.log.info({ sessionId: currentSessionId }, 'consumeStream: stream() ended (turn complete)')
550
- // Check if session was closed while we were streaming
551
- let sessionId: string
552
- try { sessionId = session.sessionId } catch { break }
553
- if (this.closedSessionIds.has(sessionId)) break
554
- // Reset to idle if stream ended without a result (e.g. init-only stream)
555
- if (shouldResetToIdleOnStreamEnd(this.sessionStatus.get(sessionId))) {
556
- this.sessionStatus.set(sessionId, 'idle')
537
+ const sid = getId()
538
+ this.log.info({ sessionId: sid }, 'consumeStream: stream() ended (turn complete)')
539
+ if (this.closedSessionIds.has(sid)) break
540
+ // If stream ended without a result (e.g. control message like setModel response),
541
+ // reset status back to idle so the client isn't stuck in 'running'
542
+ if (shouldResetToIdleOnStreamEnd(this.sessionStatus.get(sid))) {
543
+ this.sessionStatus.set(sid, 'idle')
557
544
  }
558
545
  // Wait briefly before re-entering stream() for next turn
559
546
  await new Promise((r) => setTimeout(r, 50))
560
547
  }
561
548
  } catch (err) {
562
- // AbortError is expected when session is closed
563
549
  if (!(err instanceof Error && err.name === 'AbortError')) {
564
550
  this.log.error({ err }, 'Stream error')
565
551
  }
566
552
  } finally {
567
- try {
568
- const sessionId = session.sessionId
569
- this.sessionStatus.set(sessionId, 'stopped')
570
- this.streamingSessionIds.delete(sessionId)
571
- this.activeQueries.delete(sessionId)
572
- // Clean up stale session object if stream ended unexpectedly (not via explicit closeSession).
573
- // Without this, the session stays in this.sessions (appears "already running")
574
- // while sessionStatus shows it as idle making resume impossible.
575
- if (this.sessions.has(sessionId) && !this.closedSessionIds.has(sessionId)) {
576
- const s = this.sessions.get(sessionId)
577
- this.sessions.delete(sessionId)
578
- try { s?.close() } catch { /* already closed */ }
579
- }
580
- this.broadcast(sessionId, (l) => l.onEnd(sessionId))
581
- } catch {
582
- // Session never initialized — try with our tracked id
583
- this.sessionStatus.set(currentSessionId, 'stopped')
584
- this.streamingSessionIds.delete(currentSessionId)
585
- this.activeQueries.delete(currentSessionId)
586
- if (this.sessions.has(currentSessionId) && !this.closedSessionIds.has(currentSessionId)) {
587
- const s = this.sessions.get(currentSessionId)
588
- this.sessions.delete(currentSessionId)
589
- try { s?.close() } catch { /* already closed */ }
590
- }
591
- this.broadcast(currentSessionId, (l) => l.onEnd(currentSessionId))
553
+ const sid = getId()
554
+ this.sessionStatus.set(sid, 'stopped')
555
+ this.streamingSessionIds.delete(sid)
556
+ this.activeQueries.delete(sid)
557
+ if (this.sessions.has(sid) && !this.closedSessionIds.has(sid)) {
558
+ const s = this.sessions.get(sid)
559
+ this.sessions.delete(sid)
560
+ try { s?.close() } catch { /* already closed */ }
592
561
  }
562
+ this.broadcast(sid, (l) => l.onEnd(sid))
593
563
  }
594
564
  }
595
565
 
@@ -664,6 +634,45 @@ export class SessionManager {
664
634
  } as unknown as SDKMessage))
665
635
  }
666
636
 
637
+ async setPermissionMode(sessionId: string, mode: string): Promise<void> {
638
+ const session = this.sessions.get(sessionId)
639
+ if (!session) {
640
+ throw new Error(`Session ${sessionId} not found`)
641
+ }
642
+ const query = (session as unknown as { query: { setPermissionMode(mode: string): Promise<void> } }).query
643
+ if (!query?.setPermissionMode) {
644
+ throw new Error(`Session ${sessionId} does not support setPermissionMode`)
645
+ }
646
+ await query.setPermissionMode(mode)
647
+ // Persist so resume uses the updated permission mode
648
+ const opts = this.sessionCreationOptions.get(sessionId)
649
+ if (opts) {
650
+ opts.permissionMode = mode
651
+ saveSessionOptions(sessionId, opts)
652
+ }
653
+ this.broadcast(sessionId, (l) => l.onMessage(sessionId, {
654
+ type: 'permission_mode_changed', sessionId, mode,
655
+ } as unknown as SDKMessage))
656
+ }
657
+
658
+ async setEnv(sessionId: string, env: Record<string, string>): Promise<void> {
659
+ const session = this.sessions.get(sessionId)
660
+ if (!session) {
661
+ throw new Error(`Session ${sessionId} not found`)
662
+ }
663
+ const query = (session as unknown as { query: { applyFlagSettings(settings: { env?: Record<string, string> }): Promise<void> } }).query
664
+ if (!query?.applyFlagSettings) {
665
+ throw new Error(`Session ${sessionId} does not support applyFlagSettings`)
666
+ }
667
+ await query.applyFlagSettings({ env })
668
+ // Persist so resume uses the updated env
669
+ const opts = this.sessionCreationOptions.get(sessionId)
670
+ if (opts) {
671
+ opts.env = env
672
+ saveSessionOptions(sessionId, opts)
673
+ }
674
+ }
675
+
667
676
  getSessionState(sessionId: string): { model?: string; effortLevel?: EffortLevel; status: 'idle' | 'running' | 'stopped' } {
668
677
  return {
669
678
  model: this.sessionModels.get(sessionId),
@@ -875,7 +884,6 @@ export class SessionManager {
875
884
 
876
885
  // Keep sessionCwds and sessionCommands — they're metadata needed for re-resume.
877
886
  // Only clean up runtime state.
878
- this.pendingRemaps.delete(sessionId)
879
887
  // Deny pending permissions only for this session
880
888
  for (const [id, entry] of this.pendingPermissions) {
881
889
  if (entry.sessionId === sessionId) {
@@ -14,7 +14,10 @@ export function shouldResetToIdleOnStreamEnd(currentStatus: string): boolean {
14
14
  return currentStatus === 'running'
15
15
  }
16
16
 
17
- /** Returns true if this SDK message is part of an active turn (not session init). */
17
+ /**
18
+ * Returns true if this SDK message is part of an active turn (not session init
19
+ * or a control response like setModel/applyFlagSettings).
20
+ */
18
21
  export function isTurnMessage(msg: Record<string, unknown>): boolean {
19
22
  if (msg.type === 'system' && msg.subtype === 'init') return false
20
23
  return true
@@ -99,6 +99,18 @@ export interface ElicitationResponseMessage {
99
99
  content?: Record<string, unknown>
100
100
  }
101
101
 
102
+ export interface SetPermissionModeMessage {
103
+ type: 'set_permission_mode'
104
+ sessionId: string
105
+ mode: string
106
+ }
107
+
108
+ export interface SetEnvMessage {
109
+ type: 'set_env'
110
+ sessionId: string
111
+ env: Record<string, string>
112
+ }
113
+
102
114
  export interface GetSessionSettingsMessage {
103
115
  type: 'get_session_settings'
104
116
  sessionId: string
@@ -122,6 +134,8 @@ export type ClientMessage =
122
134
  | SetEffortLevelMessage
123
135
  | GetSubagentMessagesMessage
124
136
  | ElicitationResponseMessage
137
+ | SetPermissionModeMessage
138
+ | SetEnvMessage
125
139
  | GetSessionSettingsMessage
126
140
 
127
141
  // === Server → Client Messages ===
@@ -173,12 +187,6 @@ export interface SessionHistoryMessage {
173
187
  messages: unknown[]
174
188
  }
175
189
 
176
- export interface SessionIdResolvedMessage {
177
- type: 'session_id_resolved'
178
- tempId: string
179
- sessionId: string
180
- }
181
-
182
190
  export interface FileListMessage {
183
191
  type: 'file_list'
184
192
  files: FileEntry[]
@@ -293,7 +301,6 @@ export type ServerMessage =
293
301
  | ErrorMessage
294
302
  | SessionEndMessage
295
303
  | SessionHistoryMessage
296
- | SessionIdResolvedMessage
297
304
  | FileListMessage
298
305
  | DefaultCwdMessage
299
306
  | CommandListMessage
@@ -47,15 +47,6 @@ export function createWsHandler(sessionManager: SessionManager, log: FastifyBase
47
47
  return
48
48
  }
49
49
 
50
- if (msg.type === 'session_id_resolved') {
51
- send({
52
- type: 'session_id_resolved',
53
- tempId: msg.tempId as string,
54
- sessionId: msg.sessionId as string,
55
- })
56
- return
57
- }
58
-
59
50
  if (msg.type === 'session_resumed') {
60
51
  send({
61
52
  type: 'session_resumed',
@@ -185,6 +176,10 @@ export function createWsHandler(sessionManager: SessionManager, log: FastifyBase
185
176
  }
186
177
 
187
178
  case 'switch_session': {
179
+ // Subscribe FIRST so live stream messages are not lost while loading history.
180
+ // For new sessions, consumeStream is already running — registering early
181
+ // ensures we catch assistant response chunks during the getHistory() await.
182
+ sessionManager.subscribe(msg.sessionId, makeListener(msg.sessionId))
188
183
  // Load historical messages
189
184
  const history = await sessionManager.getHistory(msg.sessionId)
190
185
  send({ type: 'session_history', sessionId: msg.sessionId, messages: history })
@@ -193,8 +188,6 @@ export function createWsHandler(sessionManager: SessionManager, log: FastifyBase
193
188
  if (state.model || state.effortLevel) {
194
189
  send({ type: 'session_state', sessionId: msg.sessionId, ...state })
195
190
  }
196
- // Subscribe for live updates if session is running
197
- sessionManager.subscribe(msg.sessionId, makeListener(msg.sessionId))
198
191
  break
199
192
  }
200
193
 
@@ -292,6 +285,16 @@ export function createWsHandler(sessionManager: SessionManager, log: FastifyBase
292
285
  break
293
286
  }
294
287
 
288
+ case 'set_permission_mode': {
289
+ await sessionManager.setPermissionMode(msg.sessionId, msg.mode)
290
+ break
291
+ }
292
+
293
+ case 'set_env': {
294
+ await sessionManager.setEnv(msg.sessionId, msg.env)
295
+ break
296
+ }
297
+
295
298
  case 'get_subagent_messages': {
296
299
  const messages = await sessionManager.getSubagentMessages(msg.sessionId, msg.agentId)
297
300
  send({ type: 'subagent_messages', sessionId: msg.sessionId, agentId: msg.agentId, messages })
@@ -1 +0,0 @@
1
- import{aq as o,ar as n}from"./mermaid.core-D970jp78.js";const t=(r,a)=>o.lang.round(n.parse(r)[a]);export{t as c};
@@ -1 +0,0 @@
1
- import{s as a,c as s,a as e,C as t}from"./chunk-4TB4RGXK-dGyuU23q.js";import{_ as i}from"./mermaid.core-D970jp78.js";import"./chunk-FMBD7UC4-O6auhXOZ.js";import"./chunk-YZCP3GAM-CSmbvz_K.js";import"./chunk-55IACEB6-DDiu2l5t.js";import"./chunk-EDXVE4YY-B_5Djzmq.js";import"./index-CQyb-QJw.js";var n={parser:e,get db(){return new t},renderer:s,styles:a,init:i(r=>{r.class||(r.class={}),r.class.arrowMarkerAbsolute=r.arrowMarkerAbsolute},"init")};export{n as diagram};
@@ -1 +0,0 @@
1
- import{s as a,c as s,a as e,C as t}from"./chunk-4TB4RGXK-dGyuU23q.js";import{_ as i}from"./mermaid.core-D970jp78.js";import"./chunk-FMBD7UC4-O6auhXOZ.js";import"./chunk-YZCP3GAM-CSmbvz_K.js";import"./chunk-55IACEB6-DDiu2l5t.js";import"./chunk-EDXVE4YY-B_5Djzmq.js";import"./index-CQyb-QJw.js";var n={parser:e,get db(){return new t},renderer:s,styles:a,init:i(r=>{r.class||(r.class={}),r.class.arrowMarkerAbsolute=r.arrowMarkerAbsolute},"init")};export{n as diagram};
@@ -1 +0,0 @@
1
- import{b as r}from"./graph-BLaCZz6d.js";var e=4;function a(o){return r(o,e)}export{a as c};