@brimveyn/aimux 1.6.2 → 1.7.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 (61) hide show
  1. package/README.md +3 -0
  2. package/package.json +2 -2
  3. package/src/app-runtime/backend-attach-runtime.ts +6 -0
  4. package/src/app-runtime/backend-runtime-events.ts +21 -21
  5. package/src/app-runtime/side-effects.ts +24 -12
  6. package/src/app-runtime/use-backend-runtime.ts +2 -20
  7. package/src/app-runtime/use-directory-search.ts +6 -1
  8. package/src/app.tsx +2 -1
  9. package/src/config.ts +9 -0
  10. package/src/daemon/daemon.ts +197 -15
  11. package/src/daemon/session-manager.ts +9 -0
  12. package/src/daemon/session-registry.ts +5 -5
  13. package/src/index.tsx +10 -2
  14. package/src/input/keymap/help-entries.ts +5 -5
  15. package/src/input/modes/bridge.ts +5 -8
  16. package/src/input/modes/transitions.ts +15 -23
  17. package/src/input/modes/types.ts +2 -5
  18. package/src/ipc/protocol.ts +49 -5
  19. package/src/platform/project-search.ts +45 -12
  20. package/src/pty/assistant-status-detection-loop.ts +192 -0
  21. package/src/pty/assistant-status-detector.ts +226 -0
  22. package/src/pty/pty-manager.ts +3 -37
  23. package/src/session-backend/bootstrap.ts +4 -1
  24. package/src/session-backend/local-session-backend.ts +43 -56
  25. package/src/session-backend/remote-session-backend.ts +15 -0
  26. package/src/session-backend/types.ts +10 -1
  27. package/src/state/reducers/modal-state.ts +91 -134
  28. package/src/state/reducers/session-state.ts +13 -6
  29. package/src/state/reducers/tab-state.ts +0 -10
  30. package/src/state/selectors.ts +12 -0
  31. package/src/state/session-persistence.ts +20 -15
  32. package/src/state/store.ts +11 -3
  33. package/src/state/types.ts +29 -13
  34. package/src/ui/breaking-update-screen.tsx +31 -0
  35. package/src/ui/components/bare-input.tsx +44 -0
  36. package/src/ui/components/create-session-modal.tsx +16 -8
  37. package/src/ui/components/diff-renderer/fold-strip.tsx +5 -13
  38. package/src/ui/components/diff-renderer/split-view.tsx +11 -17
  39. package/src/ui/components/diff-renderer/stacked-view.tsx +7 -14
  40. package/src/ui/components/git-panel.tsx +10 -3
  41. package/src/ui/components/git-view.tsx +4 -8
  42. package/src/ui/components/help-modal.tsx +45 -160
  43. package/src/ui/components/input-field.tsx +6 -2
  44. package/src/ui/components/list-item.tsx +36 -28
  45. package/src/ui/components/modal-shell.tsx +51 -13
  46. package/src/ui/components/new-tab-modal.tsx +78 -45
  47. package/src/ui/components/picker.tsx +179 -0
  48. package/src/ui/components/session-bar.tsx +47 -21
  49. package/src/ui/components/session-picker-modal.tsx +58 -56
  50. package/src/ui/components/sidebar.tsx +49 -22
  51. package/src/ui/components/snippet-picker-modal.tsx +43 -34
  52. package/src/ui/components/status-bar.tsx +3 -2
  53. package/src/ui/components/surface.tsx +11 -8
  54. package/src/ui/components/tab-item.tsx +38 -22
  55. package/src/ui/components/terminal-pane.tsx +8 -8
  56. package/src/ui/components/theme-picker-modal.tsx +51 -91
  57. package/src/ui/root.tsx +14 -23
  58. package/src/ui/status-bar-model.ts +5 -5
  59. package/src/ui/theme-store.ts +26 -2
  60. package/src/ui/theme.ts +9 -1
  61. package/src/ui/components/modal-filter-bar.tsx +0 -19
package/README.md CHANGED
@@ -202,6 +202,9 @@ not collide with a globally installed `aimux` instance.
202
202
  and focusing on the first change rather than the top of the file.
203
203
  - [shiki](https://shiki.style) — syntax highlighting for the git diff view
204
204
  and the source of the bundled theme catalog.
205
+ - [herdr](https://github.com/ogulcancelik/herdr) by @ogulcancelik — the
206
+ per-CLI assistant status heuristics (working / waiting-input / idle)
207
+ used in the session bar are adapted from herdr's `detect.rs` rule tables.
205
208
 
206
209
  ## License
207
210
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.6.2",
3
+ "version": "1.7.0",
4
4
  "description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode side-by-side with tabbed navigation, split panes, and persistent sessions.",
5
5
  "keywords": [
6
6
  "ai",
@@ -61,7 +61,7 @@
61
61
  "generate-themes": "bun run scripts/generate-themes.ts"
62
62
  },
63
63
  "dependencies": {
64
- "@brimveyn/aimux-config": "0.4.2",
64
+ "@brimveyn/aimux-config": "0.4.3",
65
65
  "@opentui/core": "^0.1.90",
66
66
  "@opentui/react": "^0.1.90",
67
67
  "@xterm/headless": "^6.0.0",
@@ -48,6 +48,12 @@ function hydrateAttachedSession(
48
48
  tabs: result.tabs,
49
49
  type: 'hydrate-workspace',
50
50
  })
51
+ // Dispatch the session-status snapshot *after* hydrate-workspace so
52
+ // sidebar chips reflect per-session state on attach without waiting
53
+ // for the next daemon-side status transition.
54
+ for (const entry of result.initialSessionStatuses) {
55
+ dispatch({ sessionId: entry.sessionId, status: entry.status, type: 'set-session-status' })
56
+ }
51
57
  resizeSnapshotPanes(workspaceSnapshot, layoutRef, backend)
52
58
  return
53
59
  }
@@ -1,25 +1,22 @@
1
1
  import type { MutableRefObject } from 'react'
2
2
 
3
3
  import type { SessionBackend } from '../session-backend/types'
4
- import type { AppAction, TabSession, TerminalModeState } from '../state/types'
4
+ import type {
5
+ AppAction,
6
+ SessionStatus,
7
+ TabActivity,
8
+ TabSession,
9
+ TerminalModeState,
10
+ } from '../state/types'
5
11
 
6
12
  import { logInputDebug } from '../debug/input-log'
7
13
  import { type TabRuntimeTimeouts } from './tab-runtime-timeouts'
8
14
 
9
- const IDLE_ACTIVITY_TIMEOUT_MS = 2_000
10
-
11
15
  interface BindBackendRuntimeEventsOptions {
12
16
  backend: SessionBackend
13
17
  dispatch: (action: AppAction) => void
14
18
  resizingRef: MutableRefObject<boolean>
15
- timeouts: Pick<
16
- TabRuntimeTimeouts,
17
- | 'clearIdleTimer'
18
- | 'clearStartupGrace'
19
- | 'isStartupGraceActive'
20
- | 'scheduleIdle'
21
- | 'clearAllTimers'
22
- >
19
+ timeouts: Pick<TabRuntimeTimeouts, 'clearIdleTimer' | 'clearStartupGrace' | 'clearAllTimers'>
23
20
  }
24
21
 
25
22
  function clearTabRuntimeState(
@@ -58,19 +55,14 @@ export function bindBackendRuntimeEvents({
58
55
  type: 'replace-tab-viewport',
59
56
  viewport,
60
57
  })
61
- if (timeouts.isStartupGraceActive(tabId) || resizingRef.current) {
62
- return
63
- }
64
-
65
- dispatch({ activity: 'busy', tabId, type: 'set-tab-activity' })
66
- timeouts.scheduleIdle(tabId, IDLE_ACTIVITY_TIMEOUT_MS)
58
+ // Per-tab activity is driven by the backend's status-detection loop via
59
+ // the `tabActivity` event — no client-side idle timer needed.
67
60
  }
68
61
 
69
62
  const handleExit = (tabId: string, exitCode: number) => {
70
63
  logInputDebug('app.backend.event.exit', { exitCode, tabId })
71
64
  clearTabRuntimeState(timeouts, tabId)
72
- dispatch({ exitCode, status: 'exited', tabId, type: 'set-tab-status' })
73
- dispatch({ activity: undefined, tabId, type: 'set-tab-activity' })
65
+ dispatch({ tabId, type: 'close-tab' })
74
66
  }
75
67
 
76
68
  const handleError = (tabId: string, message: string) => {
@@ -79,14 +71,21 @@ export function bindBackendRuntimeEvents({
79
71
  dispatch({ message, tabId, type: 'set-tab-error' })
80
72
  }
81
73
 
82
- const handleSessionActivity = (sessionId: string, busy: boolean) => {
83
- dispatch({ busy, sessionId, type: 'set-session-busy' })
74
+ const handleSessionActivity = (sessionId: string, status: SessionStatus) => {
75
+ logInputDebug('app.backend.event.sessionActivity', { sessionId, status })
76
+ dispatch({ sessionId, status, type: 'set-session-status' })
77
+ }
78
+
79
+ const handleTabActivity = (tabId: string, activity: TabActivity) => {
80
+ logInputDebug('app.backend.event.tabActivity', { activity, tabId })
81
+ dispatch({ activity, tabId, type: 'set-tab-activity' })
84
82
  }
85
83
 
86
84
  backend.on('render', handleRender)
87
85
  backend.on('exit', handleExit)
88
86
  backend.on('error', handleError)
89
87
  backend.on('sessionActivity', handleSessionActivity)
88
+ backend.on('tabActivity', handleTabActivity)
90
89
 
91
90
  return () => {
92
91
  timeouts.clearAllTimers()
@@ -94,6 +93,7 @@ export function bindBackendRuntimeEvents({
94
93
  backend.off('exit', handleExit)
95
94
  backend.off('error', handleError)
96
95
  backend.off('sessionActivity', handleSessionActivity)
96
+ backend.off('tabActivity', handleTabActivity)
97
97
  void backend.destroy(true)
98
98
  }
99
99
  }
@@ -24,7 +24,7 @@ import {
24
24
  type SplitDirection,
25
25
  splitNode,
26
26
  } from '../state/layout-tree'
27
- import { filterSessions, filterSnippets } from '../state/selectors'
27
+ import { filterAssistants, filterSessions, filterSnippets } from '../state/selectors'
28
28
  import { createDefaultTerminalModes } from '../state/terminal-modes'
29
29
  import {
30
30
  type AppAction,
@@ -36,7 +36,7 @@ import {
36
36
  import { saveCurrentWorkspace } from '../state/workspace-save'
37
37
  import { filterThemeIds } from '../ui/filter-themes'
38
38
  import { scrollGitDiff } from '../ui/git-view-controls'
39
- import { applyTheme } from '../ui/theme'
39
+ import { applyTheme, getTransparent, setTransparent } from '../ui/theme'
40
40
  import { type ThemeId } from '../ui/themes'
41
41
  import {
42
42
  handleCreateSessionEffect,
@@ -68,9 +68,10 @@ export interface SideEffectContext {
68
68
  }
69
69
 
70
70
  function getSelectedAssistantOption(state: AppState) {
71
- return (
72
- getAllAssistantOptions(state.customCommands)[state.modal.selectedIndex] ?? getAssistantOption(0)
73
- )
71
+ const all = getAllAssistantOptions(state.customCommands)
72
+ const filter = state.modal.type === 'new-tab' ? state.modal.editBuffer : null
73
+ const list = filterAssistants(all, filter)
74
+ return list[state.modal.selectedIndex] ?? list[0] ?? getAssistantOption(0)
74
75
  }
75
76
 
76
77
  function handleSessionSelection(ctx: SideEffectContext): void {
@@ -87,7 +88,7 @@ function handleSessionSelection(ctx: SideEffectContext): void {
87
88
  return
88
89
  }
89
90
 
90
- dispatch({ type: 'open-create-session-modal' })
91
+ dispatch({ returnToSessionPicker: true, type: 'open-create-session-modal' })
91
92
  }
92
93
 
93
94
  function handleSelectedSessionDelete(ctx: SideEffectContext): void {
@@ -143,24 +144,28 @@ function pasteSnippetToActiveGroup(ctx: SideEffectContext): void {
143
144
  }
144
145
  }
145
146
 
146
- function saveCustomCommandSelection(state: AppState): void {
147
- const option = getAllAssistantOptions(state.customCommands)[state.modal.selectedIndex]
148
- if (!option || state.modal.editBuffer === null) {
147
+ function saveCustomCommandSelection(ctx: SideEffectContext): void {
148
+ const { dispatch, state } = ctx
149
+ if (state.modal.type !== 'new-tab' || state.modal.editingCommand === null) {
149
150
  return
150
151
  }
152
+ const assistantId = state.modal.editingCommand
153
+ if (state.modal.editBuffer === null) return
151
154
 
152
155
  const trimmed = state.modal.editBuffer.trim()
153
156
  const newCustomCommands = { ...state.customCommands }
154
157
  if (trimmed) {
155
- newCustomCommands[option.id] = trimmed
158
+ newCustomCommands[assistantId] = trimmed
156
159
  } else {
157
- delete newCustomCommands[option.id]
160
+ delete newCustomCommands[assistantId]
158
161
  }
159
162
 
160
163
  saveConfig({
161
164
  ...loadConfig(),
162
165
  customCommands: newCustomCommands,
163
166
  })
167
+ dispatch({ customCommands: newCustomCommands, type: 'set-custom-commands' })
168
+ dispatch({ type: 'cancel-command-edit' })
164
169
  }
165
170
 
166
171
  function applyThemeEffect(
@@ -299,6 +304,7 @@ function launchAssistant(ctx: SideEffectContext, assistant: AssistantId): void {
299
304
  tabId: tab.id,
300
305
  })
301
306
  dispatch({ tab, type: 'add-tab' })
307
+ dispatch({ focusMode: 'terminal-input', type: 'set-focus-mode' })
302
308
  startTabSession(
303
309
  backend,
304
310
  dispatch,
@@ -435,7 +441,7 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
435
441
  return
436
442
  }
437
443
  case 'save-custom-command': {
438
- saveCustomCommandSelection(state)
444
+ saveCustomCommandSelection(ctx)
439
445
  return
440
446
  }
441
447
  case 'apply-theme': {
@@ -546,6 +552,12 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
546
552
  handleSwitchSessionByIndex(ctx, effect.index)
547
553
  return
548
554
  }
555
+ case 'toggle-transparent': {
556
+ const next = !getTransparent()
557
+ setTransparent(next)
558
+ saveConfig({ ...loadConfig(), themeTransparent: next })
559
+ return
560
+ }
549
561
  default:
550
562
  effect satisfies never
551
563
  }
@@ -36,14 +36,7 @@ export function useBackendRuntime({
36
36
  }: BackendRuntimeOptions): TabRuntimeControls {
37
37
  const attachRequestIdRef = useRef(0)
38
38
  const timeouts = useTabRuntimeTimeouts(dispatch)
39
- const {
40
- clearAllTimers,
41
- clearIdleTimer,
42
- clearStartupGrace,
43
- isStartupGraceActive,
44
- scheduleIdle,
45
- startStartupGrace,
46
- } = timeouts
39
+ const { clearAllTimers, clearIdleTimer, clearStartupGrace, startStartupGrace } = timeouts
47
40
 
48
41
  useEffect(() => {
49
42
  if (!currentSessionId) {
@@ -81,20 +74,9 @@ export function useBackendRuntime({
81
74
  clearAllTimers,
82
75
  clearIdleTimer,
83
76
  clearStartupGrace,
84
- isStartupGraceActive,
85
- scheduleIdle,
86
77
  },
87
78
  })
88
- }, [
89
- backend,
90
- clearAllTimers,
91
- clearIdleTimer,
92
- clearStartupGrace,
93
- dispatch,
94
- isStartupGraceActive,
95
- resizingRef,
96
- scheduleIdle,
97
- ])
79
+ }, [backend, clearAllTimers, clearIdleTimer, clearStartupGrace, dispatch, resizingRef])
98
80
 
99
81
  return {
100
82
  clearIdleTimer,
@@ -2,7 +2,7 @@ import { useEffect } from 'react'
2
2
 
3
3
  import type { AppAction, ModalState } from '../state/types'
4
4
 
5
- import { searchProjectDirectories } from '../platform/project-search'
5
+ import { searchProjectDirectories, warmDirectoryCache } from '../platform/project-search'
6
6
 
7
7
  const DEFAULT_DIRECTORY_SEARCH_DEBOUNCE_MS = 200
8
8
 
@@ -25,6 +25,11 @@ export function useDirectorySearch(
25
25
  ): void {
26
26
  const directoryQuery = getDirectoryQuery(modal)
27
27
 
28
+ useEffect(() => {
29
+ if (modal.type !== 'create-session') return
30
+ void warmDirectoryCache()
31
+ }, [modal.type])
32
+
28
33
  useEffect(() => {
29
34
  if (modal.type !== 'create-session') {
30
35
  return
package/src/app.tsx CHANGED
@@ -37,7 +37,7 @@ import { loadSnippetCatalog } from './state/snippet-catalog'
37
37
  import { appReducer, createInitialState } from './state/store'
38
38
  import { KeymapContext } from './ui/keymap-context'
39
39
  import { RootView } from './ui/root'
40
- import { applyTheme } from './ui/theme'
40
+ import { applyTheme, setTransparent } from './ui/theme'
41
41
  import { isKnownThemeId, registerUserThemes, type ThemeId } from './ui/themes'
42
42
  import {
43
43
  fetchLatestNpmVersion,
@@ -75,6 +75,7 @@ export function App({
75
75
  : undefined
76
76
  const initial = persisted ?? fromConfig ?? 'aimux'
77
77
  applyTheme(initial)
78
+ setTransparent(config.themeTransparent ?? false)
78
79
  return initial
79
80
  })
80
81
  const [state, dispatch] = useReducer(appReducer, undefined, () => {
package/src/config.ts CHANGED
@@ -37,6 +37,7 @@ export interface AimuxConfig {
37
37
  version: 2
38
38
  customCommands: Record<string, string>
39
39
  themeId?: ThemeId
40
+ themeTransparent?: boolean
40
41
  gitPane?: PersistedGitPane
41
42
  sessionBarVisible?: boolean
42
43
  sessionBarPosition?: SessionBarPosition
@@ -126,6 +127,7 @@ export function loadConfigResult(): ConfigLoadResult {
126
127
  version?: number
127
128
  customCommands?: unknown
128
129
  themeId?: unknown
130
+ themeTransparent?: unknown
129
131
  gitPane?: unknown
130
132
  gitPanelVisible?: unknown
131
133
  gitPanelRatio?: unknown
@@ -149,6 +151,12 @@ export function loadConfigResult(): ConfigLoadResult {
149
151
  issues.push('ignored invalid themeId')
150
152
  }
151
153
 
154
+ const validThemeTransparent =
155
+ typeof parsed.themeTransparent === 'boolean' ? parsed.themeTransparent : undefined
156
+ if (parsed.themeTransparent !== undefined && validThemeTransparent === undefined) {
157
+ issues.push('ignored invalid themeTransparent')
158
+ }
159
+
152
160
  let validGitPane = isPersistedGitPane(parsed.gitPane) ? parsed.gitPane : undefined
153
161
  if (parsed.gitPane !== undefined && validGitPane === undefined) {
154
162
  issues.push('ignored invalid gitPane')
@@ -218,6 +226,7 @@ export function loadConfigResult(): ConfigLoadResult {
218
226
  sessionBarVisible: validSessionBarVisible,
219
227
  skippedUpdateVersion: validSkippedUpdateVersion,
220
228
  themeId: migrateThemeId(parsed.themeId),
229
+ themeTransparent: validThemeTransparent,
221
230
  version: 2,
222
231
  workspaceSnapshot: isWorkspaceSnapshotV1(parsed.workspaceSnapshot)
223
232
  ? parsed.workspaceSnapshot
@@ -1,5 +1,7 @@
1
1
  import { connect, createServer, type Socket } from 'node:net'
2
2
 
3
+ import type { AssistantId, TerminalSnapshot } from '../state/types'
4
+
3
5
  import { logDebug } from '../debug/input-log'
4
6
  import {
5
7
  type ClientRequest,
@@ -14,6 +16,7 @@ import {
14
16
  type ServerResponse,
15
17
  } from '../ipc/protocol'
16
18
  import { findSocketProcessPid, spawnDetachedTerminalManager } from '../platform/daemon-control'
19
+ import { type LoopTabView, runStatusDetectionLoop } from '../pty/assistant-status-detection-loop'
17
20
  import { TerminalManagerClient } from '../terminal-manager/manager-client'
18
21
  import {
19
22
  getIpcDaemonSocketPath,
@@ -24,6 +27,53 @@ import {
24
27
  tightenSocketPermissions,
25
28
  } from './runtime-paths'
26
29
 
30
+ export interface DaemonTabEntry {
31
+ sessionId: string
32
+ assistant: AssistantId
33
+ command: string
34
+ viewport: TerminalSnapshot | undefined
35
+ /**
36
+ * Monotonic viewport sequence. Incremented on every render-event write so
37
+ * stale writers (e.g. `attach` returning a pre-await snapshot) can refuse
38
+ * to clobber a fresher viewport that arrived via `render` while the
39
+ * `attachSession` call was still in flight.
40
+ */
41
+ viewportSeq: number
42
+ }
43
+
44
+ /**
45
+ * Merges an attach-time tab view into the registry without clobbering a
46
+ * viewport that arrived via a render event during the `attachSession` await.
47
+ *
48
+ * Returns the updated entry (also written into `registry`). `seq` is a
49
+ * monotonic counter scoped to the registry; pass the same counter into every
50
+ * call so the new entry slot gets a stable ordinal when we first observe it.
51
+ *
52
+ * Exported for unit tests — the attach path in `runDaemon` uses the same
53
+ * helper through a closure that threads the counter.
54
+ */
55
+ export function mergeTabRegistryEntry(
56
+ registry: Map<string, DaemonTabEntry>,
57
+ sessionId: string,
58
+ tabId: string,
59
+ assistant: AssistantId,
60
+ command: string,
61
+ initialViewport: TerminalSnapshot | undefined,
62
+ allocateSeq: () => number
63
+ ): DaemonTabEntry {
64
+ const existing = registry.get(tabId)
65
+ const viewport = existing?.viewport ?? initialViewport
66
+ const entry: DaemonTabEntry = {
67
+ assistant,
68
+ command,
69
+ sessionId,
70
+ viewport,
71
+ viewportSeq: existing?.viewportSeq ?? (viewport ? allocateSeq() : 0),
72
+ }
73
+ registry.set(tabId, entry)
74
+ return entry
75
+ }
76
+
27
77
  function send(socket: Socket, message: ServerResponse | ServerEvent): void {
28
78
  socket.write(encodeMessage(message))
29
79
  }
@@ -115,37 +165,121 @@ export async function runDaemon(): Promise<void> {
115
165
  const attachedSessions = new Map<Socket, string>()
116
166
  const negotiatedVersions = new Map<Socket, number>()
117
167
 
118
- manager.on('render', (sessionId, tabId, viewport, terminalModes) => {
119
- logDebug('daemon.manager.render', {
120
- attachedSocketCount: sockets.size,
168
+ // Per-tab registry so the status-detection loop can poll every terminal
169
+ // continuously, not just the one the UI is currently attached to.
170
+ const tabRegistry = new Map<string, DaemonTabEntry>()
171
+ let nextViewportSeq = 1
172
+
173
+ const allocateSeq = (): number => nextViewportSeq++
174
+ const rememberTab = (
175
+ sessionId: string,
176
+ tabId: string,
177
+ assistant: AssistantId,
178
+ command: string,
179
+ initialViewport?: TerminalSnapshot
180
+ ): void => {
181
+ const before = tabRegistry.get(tabId)
182
+ const entry = mergeTabRegistryEntry(
183
+ tabRegistry,
184
+ sessionId,
185
+ tabId,
186
+ assistant,
187
+ command,
188
+ initialViewport,
189
+ allocateSeq
190
+ )
191
+ logDebug('daemon.rememberTab', {
192
+ hadExistingEntry: before !== undefined,
193
+ hadExistingViewport: before?.viewport !== undefined,
194
+ initialViewportProvided: initialViewport !== undefined,
195
+ preservedExistingViewport:
196
+ before?.viewport !== undefined && entry.viewport === before.viewport,
197
+ resultSeq: entry.viewportSeq,
121
198
  sessionId,
122
199
  tabId,
123
- viewportLines: viewport.lines.length,
124
200
  })
125
- const event: ServerEvent = { payload: { tabId, terminalModes, viewport }, type: 'tabRender' }
201
+ }
202
+
203
+ const broadcastAll = (event: ServerEvent): void => {
204
+ for (const socket of sockets) {
205
+ send(socket, event)
206
+ }
207
+ }
208
+
209
+ const broadcastForSession = (sessionId: string, event: ServerEvent): void => {
126
210
  for (const socket of sockets) {
127
211
  if (attachedSessions.get(socket) === sessionId) {
128
212
  send(socket, event)
129
213
  }
130
214
  }
215
+ }
216
+
217
+ manager.on('render', (sessionId, tabId, viewport, terminalModes) => {
218
+ const existing = tabRegistry.get(tabId)
219
+ let newSeq: number | null = null
220
+ if (existing) {
221
+ existing.viewport = viewport
222
+ existing.viewportSeq = nextViewportSeq++
223
+ existing.sessionId = sessionId
224
+ newSeq = existing.viewportSeq
225
+ }
226
+ logDebug('daemon.manager.render', {
227
+ attachedSocketCount: sockets.size,
228
+ hadRegistryEntry: existing !== undefined,
229
+ newSeq,
230
+ sessionId,
231
+ tabId,
232
+ viewportLines: viewport.lines.length,
233
+ })
234
+ const event: ServerEvent = { payload: { tabId, terminalModes, viewport }, type: 'tabRender' }
235
+ broadcastForSession(sessionId, event)
131
236
  })
132
237
  manager.on('exit', (sessionId, tabId, exitCode) => {
133
238
  logDebug('daemon.manager.exit', { exitCode, sessionId, tabId })
239
+ tabRegistry.delete(tabId)
134
240
  const event: ServerEvent = { payload: { exitCode, tabId }, type: 'tabExit' }
135
- for (const socket of sockets) {
136
- if (attachedSessions.get(socket) === sessionId) {
137
- send(socket, event)
138
- }
139
- }
241
+ broadcastForSession(sessionId, event)
140
242
  })
141
243
  manager.on('error', (sessionId, tabId, message) => {
142
244
  logDebug('daemon.manager.error', { message, sessionId, tabId })
143
245
  const event: ServerEvent = { payload: { message, tabId }, type: 'tabError' }
144
- for (const socket of sockets) {
145
- if (attachedSessions.get(socket) === sessionId) {
146
- send(socket, event)
147
- }
246
+ broadcastForSession(sessionId, event)
247
+ })
248
+
249
+ const listTabsForSession = (sessionId: string): LoopTabView[] => {
250
+ const result: LoopTabView[] = []
251
+ for (const [tabId, entry] of tabRegistry) {
252
+ if (entry.sessionId !== sessionId) continue
253
+ result.push({
254
+ assistant: entry.assistant,
255
+ command: entry.command,
256
+ id: tabId,
257
+ viewport: entry.viewport,
258
+ })
148
259
  }
260
+ return result
261
+ }
262
+
263
+ const statusLoop = runStatusDetectionLoop({
264
+ listSessions: () => {
265
+ const seen = new Set<string>()
266
+ for (const entry of tabRegistry.values()) seen.add(entry.sessionId)
267
+ return [...seen]
268
+ },
269
+ listTabs: listTabsForSession,
270
+ onSessionStatus: (sessionId, status) => {
271
+ logDebug('daemon.status.session', { sessionId, status })
272
+ broadcastAll({ payload: { sessionId, status }, type: 'sessionStatus' })
273
+ },
274
+ onTabStatus: (tabId, status, sessionId) => {
275
+ logDebug('daemon.status.tab', { sessionId, status, tabId })
276
+ // Broadcast to every client. Clients silently ignore events for tabIds
277
+ // they don't know about, so there's no UI impact — this costs one
278
+ // extra socket write per tab per change, which is trivial, and
279
+ // removes a race where in-flight tab events could be dropped when a
280
+ // client tears down its socket to switch sessions.
281
+ broadcastAll({ payload: { sessionId, status, tabId }, type: 'tabStatus' })
282
+ },
149
283
  })
150
284
 
151
285
  const server = createServer((socket) => {
@@ -213,12 +347,49 @@ export async function runDaemon(): Promise<void> {
213
347
  sessionId: message.payload.sessionId,
214
348
  workspaceSnapshot: message.payload.workspaceSnapshot,
215
349
  })
350
+ for (const tab of attachResult.tabs) {
351
+ rememberTab(
352
+ message.payload.sessionId,
353
+ tab.id,
354
+ tab.assistant,
355
+ tab.command,
356
+ tab.viewport
357
+ )
358
+ }
359
+ // Classify synchronously BEFORE sending attachResult so
360
+ // each tab's activity and the full session-status snapshot
361
+ // are available to embed in the reply. This makes the
362
+ // client apply them atomically with `hydrate-workspace`
363
+ // and closes the race where separate `tabStatus` events
364
+ // landed before the tab existed in client state.
365
+ const tabsForLoop = listTabsForSession(message.payload.sessionId)
366
+ logDebug('daemon.attach.preClassify', {
367
+ sessionId: message.payload.sessionId,
368
+ tabs: tabsForLoop.map((t) => ({
369
+ assistant: t.assistant,
370
+ hasViewport: t.viewport !== undefined,
371
+ id: t.id,
372
+ seq: tabRegistry.get(t.id)?.viewportSeq ?? null,
373
+ })),
374
+ })
375
+ statusLoop.classifyNow(message.payload.sessionId, tabsForLoop)
376
+ const tabsWithActivity = attachResult.tabs.map((tab) => ({
377
+ ...tab,
378
+ activity: statusLoop.getTabStatus(tab.id) ?? tab.activity,
379
+ }))
380
+ const initialSessionStatuses = statusLoop.snapshotSessions()
381
+ logDebug('daemon.attach.replay', {
382
+ sessionId: message.payload.sessionId,
383
+ sessions: initialSessionStatuses,
384
+ tabs: tabsWithActivity.map((t) => ({ activity: t.activity, id: t.id })),
385
+ })
216
386
  send(socket, {
217
387
  id: message.id,
218
388
  payload: {
219
389
  activeTabId: attachResult.activeTabId,
390
+ initialSessionStatuses,
220
391
  protocolVersion: negotiatedVersion,
221
- tabs: attachResult.tabs,
392
+ tabs: tabsWithActivity,
222
393
  },
223
394
  type: 'attachResult',
224
395
  })
@@ -238,6 +409,12 @@ export async function runDaemon(): Promise<void> {
238
409
  tabId: message.payload.tabId,
239
410
  title: message.payload.title,
240
411
  })
412
+ rememberTab(
413
+ sessionId,
414
+ message.payload.tabId,
415
+ message.payload.assistant,
416
+ [message.payload.command, ...(message.payload.args ?? [])].join(' ')
417
+ )
241
418
  await manager.createTab({ ...message.payload, sessionId })
242
419
  sendOk(socket, message.id)
243
420
  logDebug('daemon.request.createTab.success', {
@@ -313,6 +490,7 @@ export async function runDaemon(): Promise<void> {
313
490
  case 'closeTab': {
314
491
  const sessionId = requireSession(socket, attachedSessions)
315
492
  requireNegotiatedVersion(socket, negotiatedVersions)
493
+ tabRegistry.delete(message.payload.tabId)
316
494
  await manager.closeTab(sessionId, message.payload.tabId)
317
495
  sendOk(socket, message.id)
318
496
  break
@@ -321,6 +499,9 @@ export async function runDaemon(): Promise<void> {
321
499
  const sessionId = attachedSessions.get(socket)
322
500
  requireNegotiatedVersion(socket, negotiatedVersions)
323
501
  if (sessionId) {
502
+ for (const [tabId, entry] of tabRegistry) {
503
+ if (entry.sessionId === sessionId) tabRegistry.delete(tabId)
504
+ }
324
505
  await manager.disposeSession(sessionId)
325
506
  }
326
507
  sendOk(socket, message.id)
@@ -371,6 +552,7 @@ export async function runDaemon(): Promise<void> {
371
552
 
372
553
  const gracefulShutdown = (signal: string) => {
373
554
  logDebug(`daemon.${signal}`)
555
+ statusLoop.stop()
374
556
  manager.destroy()
375
557
  server.close()
376
558
  process.exit(0)
@@ -2,6 +2,7 @@ import { EventEmitter } from 'node:events'
2
2
 
3
3
  import type {
4
4
  ScrollIntent,
5
+ TabSession,
5
6
  TerminalModeState,
6
7
  TerminalSnapshot,
7
8
  WorkspaceSnapshotV1,
@@ -128,4 +129,12 @@ export class SessionManager extends EventEmitter<SessionManagerEvents> {
128
129
  }
129
130
  this.registries.clear()
130
131
  }
132
+
133
+ listSessionIds(): string[] {
134
+ return [...this.registries.keys()]
135
+ }
136
+
137
+ listTabs(sessionId: string): TabSession[] {
138
+ return this.registries.get(sessionId)?.listTabs() ?? []
139
+ }
131
140
  }
@@ -44,13 +44,13 @@ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
44
44
  this.emit('render', tabId, viewport, terminalModes)
45
45
  })
46
46
  this.ptyManager.on('exit', (tabId, exitCode) => {
47
- const tab = this.tabs.get(tabId)
48
- if (!tab) {
47
+ if (!this.tabs.has(tabId)) {
49
48
  return
50
49
  }
51
- tab.status = 'exited'
52
- tab.exitCode = exitCode
53
- tab.activity = undefined
50
+ this.tabs.delete(tabId)
51
+ if (this.activeTabId === tabId) {
52
+ this.activeTabId = this.listTabs()[0]?.id ?? null
53
+ }
54
54
  logDebug('daemon.registry.exit', { exitCode, tabId })
55
55
  this.emit('exit', tabId, exitCode)
56
56
  })