@brimveyn/aimux 1.6.2 → 1.7.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 (62) 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/manager-protocol.ts +2 -2
  19. package/src/ipc/protocol.ts +49 -5
  20. package/src/platform/project-search.ts +45 -12
  21. package/src/pty/assistant-status-detection-loop.ts +192 -0
  22. package/src/pty/assistant-status-detector.ts +226 -0
  23. package/src/pty/pty-manager.ts +3 -37
  24. package/src/session-backend/bootstrap.ts +25 -2
  25. package/src/session-backend/local-session-backend.ts +43 -56
  26. package/src/session-backend/remote-session-backend.ts +15 -0
  27. package/src/session-backend/types.ts +10 -1
  28. package/src/state/reducers/modal-state.ts +91 -134
  29. package/src/state/reducers/session-state.ts +13 -6
  30. package/src/state/reducers/tab-state.ts +0 -10
  31. package/src/state/selectors.ts +12 -0
  32. package/src/state/session-persistence.ts +20 -15
  33. package/src/state/store.ts +11 -3
  34. package/src/state/types.ts +29 -13
  35. package/src/ui/breaking-update-screen.tsx +31 -0
  36. package/src/ui/components/bare-input.tsx +44 -0
  37. package/src/ui/components/create-session-modal.tsx +16 -8
  38. package/src/ui/components/diff-renderer/fold-strip.tsx +5 -13
  39. package/src/ui/components/diff-renderer/split-view.tsx +11 -17
  40. package/src/ui/components/diff-renderer/stacked-view.tsx +7 -14
  41. package/src/ui/components/git-panel.tsx +10 -3
  42. package/src/ui/components/git-view.tsx +4 -8
  43. package/src/ui/components/help-modal.tsx +45 -160
  44. package/src/ui/components/input-field.tsx +6 -2
  45. package/src/ui/components/list-item.tsx +36 -28
  46. package/src/ui/components/modal-shell.tsx +51 -13
  47. package/src/ui/components/new-tab-modal.tsx +78 -45
  48. package/src/ui/components/picker.tsx +179 -0
  49. package/src/ui/components/session-bar.tsx +47 -21
  50. package/src/ui/components/session-picker-modal.tsx +58 -56
  51. package/src/ui/components/sidebar.tsx +49 -22
  52. package/src/ui/components/snippet-picker-modal.tsx +43 -34
  53. package/src/ui/components/status-bar.tsx +3 -2
  54. package/src/ui/components/surface.tsx +11 -8
  55. package/src/ui/components/tab-item.tsx +38 -22
  56. package/src/ui/components/terminal-pane.tsx +8 -8
  57. package/src/ui/components/theme-picker-modal.tsx +51 -91
  58. package/src/ui/root.tsx +14 -23
  59. package/src/ui/status-bar-model.ts +5 -5
  60. package/src/ui/theme-store.ts +26 -2
  61. package/src/ui/theme.ts +9 -1
  62. package/src/ui/components/modal-filter-bar.tsx +0 -19
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Assistant status detection.
3
+ *
4
+ * Per-CLI content heuristics are adapted from herdr by @ogulcancelik
5
+ * (https://github.com/ogulcancelik/herdr, MIT). Rule tables trace back to
6
+ * `src/detect.rs` in that repo.
7
+ *
8
+ * The detector classifies a terminal session as `working`, `waiting-input`,
9
+ * or `idle`. Built-in CLIs (claude, codex, opencode) use per-CLI substring
10
+ * tables. Custom CLIs fall back to a generic heuristic that (a) recognises
11
+ * common shells as always-idle and (b) uses pane-tail change velocity plus
12
+ * generic y/n / confirm prompt patterns.
13
+ */
14
+ import type { AssistantId, TabActivity, TerminalSnapshot } from '../state/types'
15
+
16
+ import { getLineText } from '../input/terminal-text-extraction'
17
+
18
+ const TAIL_LINE_COUNT = 10
19
+ const ACTIVE_CHANGE_WINDOW_MS = 600
20
+
21
+ /** Spinner glyphs used by claude code's status lines. */
22
+ const CLAUDE_SPINNER_GLYPHS = '·✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❇❈❉❊❋✢✣✤✥✦✧✨⊛⊕⊙◉◎◍⁂⁕※⍟☼★☆'
23
+
24
+ const SHELL_COMMAND_PATTERN =
25
+ /(^|\/)(bash|zsh|fish|sh|dash|ash|ksh|tcsh|csh|nu|pwsh|powershell|elvish|xonsh)(\.exe)?$/i
26
+
27
+ interface DetectorEntry {
28
+ tail: string
29
+ changedAt: number
30
+ status: TabActivity
31
+ }
32
+
33
+ export interface DetectStatusInput {
34
+ tabId: string
35
+ assistant: AssistantId
36
+ /** The raw command string (first token matters for shell detection). */
37
+ command?: string
38
+ viewport: TerminalSnapshot | undefined
39
+ /** Override the clock for tests. Defaults to Date.now. */
40
+ now?: number
41
+ }
42
+
43
+ export class AssistantStatusDetector {
44
+ private readonly entries = new Map<string, DetectorEntry>()
45
+
46
+ classify(input: DetectStatusInput): TabActivity {
47
+ const { assistant, command, tabId, viewport } = input
48
+ const now = input.now ?? Date.now()
49
+
50
+ if (!viewport) return this.remember(tabId, '', now, 'idle')
51
+
52
+ const tail = extractTailText(viewport, TAIL_LINE_COUNT)
53
+ const prev = this.entries.get(tabId)
54
+ const changedAt = prev && prev.tail === tail ? prev.changedAt : now
55
+ const haystack = tail.toLowerCase()
56
+
57
+ if (assistant === 'terminal' || isShellCommand(command)) {
58
+ return this.remember(tabId, tail, changedAt, 'idle')
59
+ }
60
+
61
+ const perCli = classifyBuiltin(assistant, haystack, tail)
62
+ if (perCli) return this.remember(tabId, tail, changedAt, perCli)
63
+
64
+ const generic = classifyGeneric(haystack, changedAt, now)
65
+ return this.remember(tabId, tail, changedAt, generic)
66
+ }
67
+
68
+ forget(tabId: string): void {
69
+ this.entries.delete(tabId)
70
+ }
71
+
72
+ clear(): void {
73
+ this.entries.clear()
74
+ }
75
+
76
+ private remember(
77
+ tabId: string,
78
+ tail: string,
79
+ changedAt: number,
80
+ status: TabActivity
81
+ ): TabActivity {
82
+ this.entries.set(tabId, { changedAt, status, tail })
83
+ return status
84
+ }
85
+ }
86
+
87
+ function extractTailText(viewport: TerminalSnapshot, lineCount: number): string {
88
+ // Full-screen TUIs (claude, opencode) paint in the alternate buffer and
89
+ // often leave the last rows blank, putting their status bar higher up.
90
+ // Skip trailing blank rows before taking the last `lineCount`.
91
+ const lines = viewport.lines
92
+ let end = lines.length
93
+ while (end > 0) {
94
+ const line = lines[end - 1]
95
+ if (!line) {
96
+ end--
97
+ continue
98
+ }
99
+ const text = getLineText(line).trim()
100
+ if (text.length === 0) {
101
+ end--
102
+ continue
103
+ }
104
+ break
105
+ }
106
+ const start = Math.max(0, end - lineCount)
107
+ const parts: string[] = []
108
+ for (let i = start; i < end; i++) {
109
+ const line = lines[i]
110
+ if (!line) continue
111
+ parts.push(getLineText(line).replace(/\s+$/u, ''))
112
+ }
113
+ return parts.join('\n')
114
+ }
115
+
116
+ function classifyBuiltin(
117
+ assistant: AssistantId,
118
+ haystack: string,
119
+ rawTail: string
120
+ ): TabActivity | null {
121
+ switch (assistant) {
122
+ case 'claude':
123
+ return classifyClaude(haystack, rawTail)
124
+ case 'codex':
125
+ return classifyCodex(haystack)
126
+ case 'opencode':
127
+ return classifyOpencode(haystack)
128
+ default:
129
+ return null
130
+ }
131
+ }
132
+
133
+ function classifyClaude(haystack: string, rawTail: string): TabActivity {
134
+ if (
135
+ haystack.includes('do you want') ||
136
+ haystack.includes('would you like') ||
137
+ haystack.includes('tab to amend') ||
138
+ haystack.includes('enter to select') ||
139
+ (haystack.includes('esc to cancel') && haystack.includes('to navigate'))
140
+ ) {
141
+ return 'waiting-input'
142
+ }
143
+ if (
144
+ haystack.includes('esc/ctrl+c to interrupt') ||
145
+ haystack.includes('esc to interrupt') ||
146
+ haystack.includes('ctrl+c to interrupt') ||
147
+ haystack.includes('esc interrupt')
148
+ ) {
149
+ return 'working'
150
+ }
151
+ if (hasClaudeSpinner(rawTail)) return 'working'
152
+ return 'idle'
153
+ }
154
+
155
+ function hasClaudeSpinner(rawTail: string): boolean {
156
+ if (!rawTail.includes('…') && !rawTail.includes('...')) return false
157
+ for (const ch of CLAUDE_SPINNER_GLYPHS) {
158
+ if (rawTail.includes(ch)) return true
159
+ }
160
+ return false
161
+ }
162
+
163
+ function classifyCodex(haystack: string): TabActivity {
164
+ if (
165
+ haystack.includes('press enter to confirm') ||
166
+ haystack.includes('[y/n]') ||
167
+ haystack.includes('enter to submit answer')
168
+ ) {
169
+ return 'waiting-input'
170
+ }
171
+ if (haystack.includes('esc to interrupt') || haystack.includes('• working (')) {
172
+ return 'working'
173
+ }
174
+ return 'idle'
175
+ }
176
+
177
+ function classifyOpencode(haystack: string): TabActivity {
178
+ if (
179
+ haystack.includes('permission required') ||
180
+ haystack.includes('△ permission') ||
181
+ (haystack.includes('enter submit') && haystack.includes('esc dismiss'))
182
+ ) {
183
+ return 'waiting-input'
184
+ }
185
+ if (
186
+ haystack.includes('esc interrupt') ||
187
+ haystack.includes('esc to interrupt') ||
188
+ haystack.includes('esc again to interrupt')
189
+ ) {
190
+ return 'working'
191
+ }
192
+ return 'idle'
193
+ }
194
+
195
+ const GENERIC_WAITING_PATTERNS: string[] = [
196
+ '[y/n]',
197
+ '(y/n)',
198
+ 'yes/no',
199
+ 'y/n?',
200
+ 'confirm?',
201
+ 'continue?',
202
+ 'proceed?',
203
+ 'press enter to continue',
204
+ 'press any key',
205
+ 'allow?',
206
+ 'approve?',
207
+ 'do you want',
208
+ 'would you like',
209
+ 'permission required',
210
+ 'enter to select',
211
+ ]
212
+
213
+ function classifyGeneric(haystack: string, changedAt: number, now: number): TabActivity {
214
+ for (const pattern of GENERIC_WAITING_PATTERNS) {
215
+ if (haystack.includes(pattern)) return 'waiting-input'
216
+ }
217
+ if (now - changedAt < ACTIVE_CHANGE_WINDOW_MS) return 'working'
218
+ return 'idle'
219
+ }
220
+
221
+ export function isShellCommand(command: string | undefined): boolean {
222
+ if (!command) return false
223
+ const first = command.trim().split(/\s+/u)[0]
224
+ if (!first) return false
225
+ return SHELL_COMMAND_PATTERN.test(first)
226
+ }
@@ -87,13 +87,11 @@ function envInt(name: string, fallback: number): number {
87
87
  }
88
88
 
89
89
  const RENDER_COALESCE_MS = 16
90
- const DATA_DEBOUNCE_MS = envInt('AIMUX_RENDER_DEBOUNCE_MS', 32)
91
- const BURST_MAX_MS = envInt('AIMUX_RENDER_BURST_MS', 500)
90
+ const DATA_DEBOUNCE_MS = envInt('AIMUX_RENDER_DEBOUNCE_MS', 0)
92
91
 
93
92
  export class PtyManager extends EventEmitter<PtyManagerEvents> {
94
93
  private sessions = new Map<string, SessionHandle>()
95
94
  private pendingFlushes = new Map<string, ReturnType<typeof setTimeout>>()
96
- private pendingBurstCaps = new Map<string, ReturnType<typeof setTimeout>>()
97
95
 
98
96
  private clearTimers(tabId: string): void {
99
97
  const flush = this.pendingFlushes.get(tabId)
@@ -101,11 +99,6 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
101
99
  clearTimeout(flush)
102
100
  this.pendingFlushes.delete(tabId)
103
101
  }
104
- const burst = this.pendingBurstCaps.get(tabId)
105
- if (burst) {
106
- clearTimeout(burst)
107
- this.pendingBurstCaps.delete(tabId)
108
- }
109
102
  }
110
103
 
111
104
  private scheduleRender(session: SessionHandle): void {
@@ -117,20 +110,14 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
117
110
  if (this.sessions.get(session.tabId) !== session) {
118
111
  return
119
112
  }
120
- const burst = this.pendingBurstCaps.get(session.tabId)
121
- if (burst) {
122
- clearTimeout(burst)
123
- this.pendingBurstCaps.delete(session.tabId)
124
- }
125
113
  this.emitRenderIfChanged(session)
126
114
  }, RENDER_COALESCE_MS)
127
115
  this.pendingFlushes.set(session.tabId, timer)
128
116
  }
129
117
 
130
118
  private scheduleDataRender(session: SessionHandle): void {
131
- const existingFlush = this.pendingFlushes.get(session.tabId)
132
- if (existingFlush) {
133
- clearTimeout(existingFlush)
119
+ if (this.pendingFlushes.has(session.tabId)) {
120
+ return
134
121
  }
135
122
  const flushTimer = setTimeout(() => {
136
123
  this.pendingFlushes.delete(session.tabId)
@@ -141,30 +128,9 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
141
128
  this.scheduleDataRender(session)
142
129
  return
143
130
  }
144
- const burst = this.pendingBurstCaps.get(session.tabId)
145
- if (burst) {
146
- clearTimeout(burst)
147
- this.pendingBurstCaps.delete(session.tabId)
148
- }
149
131
  this.emitRenderIfChanged(session)
150
132
  }, DATA_DEBOUNCE_MS)
151
133
  this.pendingFlushes.set(session.tabId, flushTimer)
152
-
153
- if (!this.pendingBurstCaps.has(session.tabId)) {
154
- const burstTimer = setTimeout(() => {
155
- this.pendingBurstCaps.delete(session.tabId)
156
- if (this.sessions.get(session.tabId) !== session) {
157
- return
158
- }
159
- const flush = this.pendingFlushes.get(session.tabId)
160
- if (flush) {
161
- clearTimeout(flush)
162
- this.pendingFlushes.delete(session.tabId)
163
- }
164
- this.emitRenderIfChanged(session)
165
- }, BURST_MAX_MS)
166
- this.pendingBurstCaps.set(session.tabId, burstTimer)
167
- }
168
134
  }
169
135
 
170
136
  private flushRenderNow(session: SessionHandle): void {
@@ -6,6 +6,7 @@ import {
6
6
  getIpcDaemonSocketPath,
7
7
  getSocketSecurityIssue,
8
8
  removeDaemonSocketIfExists,
9
+ removeTerminalManagerSocketIfExists,
9
10
  } from '../daemon/runtime-paths'
10
11
  import { logDebug } from '../debug/input-log'
11
12
  import {
@@ -15,7 +16,12 @@ import {
15
16
  MessageDecoder,
16
17
  parseServerMessage,
17
18
  } from '../ipc/protocol'
18
- import { findIpcDaemonPid, killProcess, spawnDetachedIpcDaemon } from '../platform/daemon-control'
19
+ import {
20
+ findIpcDaemonPid,
21
+ findTerminalManagerPid,
22
+ killProcess,
23
+ spawnDetachedIpcDaemon,
24
+ } from '../platform/daemon-control'
19
25
  import { LocalSessionBackend } from './local-session-backend'
20
26
  import { RemoteSessionBackend } from './remote-session-backend'
21
27
 
@@ -204,7 +210,22 @@ async function restartDaemon(socketPath: string): Promise<void> {
204
210
  await spawnDaemon()
205
211
  }
206
212
 
207
- export async function createSessionBackend(): Promise<SessionBackend> {
213
+ // Kill the terminal-manager and clear its socket so the restarted daemon
214
+ // spawns a fresh one via ensureTerminalManagerReady. Without this, the new
215
+ // daemon reconnects to the still-running old terminal-manager and the
216
+ // breaking protocol mismatch persists.
217
+ async function stopTerminalManager(): Promise<void> {
218
+ const pid = await findTerminalManagerPid()
219
+ if (pid !== null) {
220
+ logDebug('backend.stopTerminalManager.killing', { pid })
221
+ await killProcess(pid)
222
+ }
223
+ removeTerminalManagerSocketIfExists()
224
+ }
225
+
226
+ export async function createSessionBackend(opts?: {
227
+ onBreakingUpdateRequired?: () => Promise<void>
228
+ }): Promise<SessionBackend> {
208
229
  if (process.env.AIMUX_LOCAL_BACKEND === '1') {
209
230
  logDebug('backend.create.localExplicit')
210
231
  return new LocalSessionBackend()
@@ -237,6 +258,8 @@ export async function createSessionBackend(): Promise<SessionBackend> {
237
258
  error: handshake.error ?? 'incompatible daemon handshake',
238
259
  socketPath,
239
260
  })
261
+ await opts?.onBreakingUpdateRequired?.()
262
+ await stopTerminalManager()
240
263
  await restartDaemon(socketPath)
241
264
  const retriedHandshake = await probeDaemonProtocolCompatibility(socketPath)
242
265
  logDebug('backend.create.handshakeAfterRestart', {
@@ -5,6 +5,7 @@ import type { SessionBackend, SessionBackendEvents } from './types'
5
5
 
6
6
  import { SessionManager } from '../daemon/session-manager'
7
7
  import { logDebug } from '../debug/input-log'
8
+ import { runStatusDetectionLoop } from '../pty/assistant-status-detection-loop'
8
9
  import {
9
10
  createTerminalBounds,
10
11
  forEachSplitPaneRect,
@@ -12,16 +13,13 @@ import {
12
13
  toTerminalContentSize,
13
14
  } from '../state/layout-resize'
14
15
 
15
- const SESSION_IDLE_TIMEOUT_MS = 2_000
16
-
17
16
  export class LocalSessionBackend
18
17
  extends EventEmitter<SessionBackendEvents>
19
18
  implements SessionBackend
20
19
  {
21
20
  private readonly sessionManager = new SessionManager()
22
21
  private currentSessionId: string | null = null
23
- private readonly sessionIdleTimers = new Map<string, ReturnType<typeof setTimeout>>()
24
- private readonly sessionBusy = new Map<string, boolean>()
22
+ private readonly statusLoop: ReturnType<typeof runStatusDetectionLoop>
25
23
 
26
24
  constructor() {
27
25
  super()
@@ -29,7 +27,6 @@ export class LocalSessionBackend
29
27
  if (sessionId === this.currentSessionId) {
30
28
  this.emit('render', tabId, viewport, terminalModes)
31
29
  }
32
- this.markSessionBusy(sessionId)
33
30
  })
34
31
  this.sessionManager.on('exit', (sessionId, tabId, exitCode) => {
35
32
  if (sessionId === this.currentSessionId) {
@@ -41,23 +38,18 @@ export class LocalSessionBackend
41
38
  this.emit('error', tabId, message)
42
39
  }
43
40
  })
44
- }
45
-
46
- private markSessionBusy(sessionId: string): void {
47
- if (this.sessionBusy.get(sessionId) !== true) {
48
- this.sessionBusy.set(sessionId, true)
49
- this.emit('sessionActivity', sessionId, true)
50
- }
51
- const existing = this.sessionIdleTimers.get(sessionId)
52
- if (existing) clearTimeout(existing)
53
- const timer = setTimeout(() => {
54
- this.sessionIdleTimers.delete(sessionId)
55
- if (this.sessionBusy.get(sessionId) === true) {
56
- this.sessionBusy.set(sessionId, false)
57
- this.emit('sessionActivity', sessionId, false)
58
- }
59
- }, SESSION_IDLE_TIMEOUT_MS)
60
- this.sessionIdleTimers.set(sessionId, timer)
41
+ this.statusLoop = runStatusDetectionLoop({
42
+ listSessions: () => this.sessionManager.listSessionIds(),
43
+ listTabs: (sessionId) => this.sessionManager.listTabs(sessionId),
44
+ onSessionStatus: (sessionId, status) => {
45
+ this.emit('sessionActivity', sessionId, status)
46
+ },
47
+ onTabStatus: (tabId, status, sessionId) => {
48
+ if (sessionId === this.currentSessionId) {
49
+ this.emit('tabActivity', tabId, status)
50
+ }
51
+ },
52
+ })
61
53
  }
62
54
 
63
55
  async attach(options: {
@@ -84,7 +76,24 @@ export class LocalSessionBackend
84
76
  } else {
85
77
  this.sessionManager.resize(options.sessionId, options.cols, options.rows)
86
78
  }
87
- return this.sessionManager.attachSession(options.sessionId, options.workspaceSnapshot)
79
+ const attachResult = this.sessionManager.attachSession(
80
+ options.sessionId,
81
+ options.workspaceSnapshot
82
+ )
83
+ // Run a synchronous classification pass so every tab's activity and the
84
+ // session-status snapshot are available to embed in the reply — mirrors
85
+ // the remote backend's behavior and keeps hydrate dispatches atomic on
86
+ // the client side.
87
+ this.statusLoop.classifyNow(options.sessionId, this.sessionManager.listTabs(options.sessionId))
88
+ const tabsWithActivity = attachResult.tabs.map((tab) => ({
89
+ ...tab,
90
+ activity: this.statusLoop.getTabStatus(tab.id) ?? tab.activity,
91
+ }))
92
+ return {
93
+ activeTabId: attachResult.activeTabId,
94
+ initialSessionStatuses: this.statusLoop.snapshotSessions(),
95
+ tabs: tabsWithActivity,
96
+ }
88
97
  }
89
98
 
90
99
  createSession(options: {
@@ -123,30 +132,22 @@ export class LocalSessionBackend
123
132
  }
124
133
 
125
134
  scrollViewport(tabId: string, deltaLines: number): void {
126
- if (!this.currentSessionId) {
127
- return
128
- }
135
+ if (!this.currentSessionId) return
129
136
  this.sessionManager.scroll(this.currentSessionId, tabId, deltaLines)
130
137
  }
131
138
 
132
139
  scrollViewportToBottom(tabId: string): void {
133
- if (!this.currentSessionId) {
134
- return
135
- }
140
+ if (!this.currentSessionId) return
136
141
  this.sessionManager.scrollToBottom(this.currentSessionId, tabId)
137
142
  }
138
143
 
139
144
  reapplyScrollIntent(tabId: string, intent: ScrollIntent): void {
140
- if (!this.currentSessionId) {
141
- return
142
- }
145
+ if (!this.currentSessionId) return
143
146
  this.sessionManager.reapplyScrollIntent(this.currentSessionId, tabId, intent)
144
147
  }
145
148
 
146
149
  setActiveTab(tabId: string | null): void {
147
- if (!this.currentSessionId) {
148
- return
149
- }
150
+ if (!this.currentSessionId) return
150
151
  logDebug('backend.local.setActiveTab', { sessionId: this.currentSessionId, tabId })
151
152
  this.sessionManager.setActiveTab(this.currentSessionId, tabId)
152
153
  }
@@ -157,9 +158,7 @@ export class LocalSessionBackend
157
158
  intents?: Map<string, ScrollIntent>,
158
159
  options?: { sync?: boolean }
159
160
  ): void {
160
- if (!this.currentSessionId) {
161
- return
162
- }
161
+ if (!this.currentSessionId) return
163
162
  this.sessionManager.resize(this.currentSessionId, cols, rows, intents, options)
164
163
  }
165
164
 
@@ -170,40 +169,28 @@ export class LocalSessionBackend
170
169
  intent?: ScrollIntent,
171
170
  options?: { sync?: boolean }
172
171
  ): void {
173
- if (!this.currentSessionId) {
174
- return
175
- }
172
+ if (!this.currentSessionId) return
176
173
  this.sessionManager.resizeTab(this.currentSessionId, tabId, cols, rows, intent, options)
177
174
  }
178
175
 
179
176
  disposeSession(tabId: string): void {
180
- if (!this.currentSessionId) {
181
- return
182
- }
177
+ if (!this.currentSessionId) return
183
178
  logDebug('backend.local.disposeSession', { sessionId: this.currentSessionId, tabId })
184
179
  this.sessionManager.closeTab(this.currentSessionId, tabId)
185
180
  }
186
181
 
187
182
  disposeAll(): void {
188
- if (!this.currentSessionId) {
189
- return
190
- }
183
+ if (!this.currentSessionId) return
191
184
  logDebug('backend.local.disposeAll', { sessionId: this.currentSessionId })
192
185
  this.sessionManager.disposeSession(this.currentSessionId)
193
186
  }
194
187
 
195
188
  destroy(keepSessions = true): void {
196
189
  logDebug('backend.local.destroy', { keepSessions, sessionId: this.currentSessionId })
197
- if (!keepSessions) {
198
- if (this.currentSessionId) {
199
- this.sessionManager.disposeSession(this.currentSessionId)
200
- }
201
- }
202
- for (const timer of this.sessionIdleTimers.values()) {
203
- clearTimeout(timer)
190
+ if (!keepSessions && this.currentSessionId) {
191
+ this.sessionManager.disposeSession(this.currentSessionId)
204
192
  }
205
- this.sessionIdleTimers.clear()
206
- this.sessionBusy.clear()
193
+ this.statusLoop.stop()
207
194
  this.currentSessionId = null
208
195
  }
209
196
  }
@@ -161,6 +161,21 @@ export class RemoteSessionBackend
161
161
  case 'tabError':
162
162
  this.emit('error', message.payload.tabId, message.payload.message)
163
163
  break
164
+ case 'tabStatus':
165
+ logDebug('backend.remote.tabStatus', {
166
+ sessionId: message.payload.sessionId,
167
+ status: message.payload.status,
168
+ tabId: message.payload.tabId,
169
+ })
170
+ this.emit('tabActivity', message.payload.tabId, message.payload.status)
171
+ break
172
+ case 'sessionStatus':
173
+ logDebug('backend.remote.sessionStatus', {
174
+ sessionId: message.payload.sessionId,
175
+ status: message.payload.status,
176
+ })
177
+ this.emit('sessionActivity', message.payload.sessionId, message.payload.status)
178
+ break
164
179
  }
165
180
  }
166
181
 
@@ -2,6 +2,8 @@ import type { EventEmitter } from 'node:events'
2
2
 
3
3
  import type {
4
4
  ScrollIntent,
5
+ SessionStatus,
6
+ TabActivity,
5
7
  TabSession,
6
8
  TerminalModeState,
7
9
  TerminalSnapshot,
@@ -12,12 +14,19 @@ export type SessionBackendEvents = {
12
14
  render: [tabId: string, viewport: TerminalSnapshot, terminalModes: TerminalModeState]
13
15
  exit: [tabId: string, exitCode: number]
14
16
  error: [tabId: string, message: string]
15
- sessionActivity: [sessionId: string, busy: boolean]
17
+ sessionActivity: [sessionId: string, status: SessionStatus]
18
+ tabActivity: [tabId: string, activity: TabActivity]
16
19
  }
17
20
 
18
21
  export interface BackendAttachResult {
19
22
  tabs: TabSession[]
20
23
  activeTabId: string | null
24
+ /**
25
+ * Per-session status snapshot taken at attach time and applied
26
+ * atomically with tab hydration to prevent an unknown-tab race on
27
+ * separate `sessionActivity` events.
28
+ */
29
+ initialSessionStatuses: Array<{ sessionId: string; status: SessionStatus }>
21
30
  }
22
31
 
23
32
  export interface SessionBackend extends EventEmitter<SessionBackendEvents> {