@brimveyn/aimux 1.2.0 → 1.3.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.
@@ -3,32 +3,45 @@ import { connect } from 'node:net'
3
3
  import type { SessionBackend } from './types'
4
4
 
5
5
  import {
6
- getDaemonSocketPath,
7
- getDaemonSocketSecurityIssue,
6
+ getIpcDaemonSocketPath,
7
+ getSocketSecurityIssue,
8
8
  removeDaemonSocketIfExists,
9
9
  } from '../daemon/runtime-paths'
10
10
  import { logDebug } from '../debug/input-log'
11
- import { ProtocolMismatchError } from '../ipc/protocol'
12
- import { findDaemonPid, killDaemon, spawnDetachedDaemon } from '../platform/daemon-control'
11
+ import {
12
+ encodeMessage,
13
+ IPC_PROTOCOL_MIN_VERSION,
14
+ IPC_PROTOCOL_VERSION,
15
+ MessageDecoder,
16
+ parseServerMessage,
17
+ } from '../ipc/protocol'
18
+ import { findIpcDaemonPid, killProcess, spawnDetachedIpcDaemon } from '../platform/daemon-control'
13
19
  import { LocalSessionBackend } from './local-session-backend'
14
20
  import { RemoteSessionBackend } from './remote-session-backend'
15
21
 
22
+ interface DaemonHandshakeProbeResult {
23
+ compatible: boolean
24
+ error?: string
25
+ processVersion?: string
26
+ selectedVersion?: number
27
+ }
28
+
16
29
  async function spawnDaemon(): Promise<void> {
17
30
  logDebug('backend.spawnDaemon.start', {
18
31
  execPath: process.execPath,
19
- socketPath: getDaemonSocketPath(),
32
+ socketPath: getIpcDaemonSocketPath(),
20
33
  })
21
- const ok = await spawnDetachedDaemon()
34
+ const ok = await spawnDetachedIpcDaemon()
22
35
  if (ok) {
23
- logDebug('backend.spawnDaemon.ready', { socketPath: getDaemonSocketPath() })
36
+ logDebug('backend.spawnDaemon.ready', { socketPath: getIpcDaemonSocketPath() })
24
37
  return
25
38
  }
26
39
 
27
- logDebug('backend.spawnDaemon.timeout', { socketPath: getDaemonSocketPath() })
40
+ throw new Error(`IPC daemon unavailable at ${getIpcDaemonSocketPath()}`)
28
41
  }
29
42
 
30
43
  async function canConnectToDaemon(socketPath: string): Promise<boolean> {
31
- const securityIssue = getDaemonSocketSecurityIssue(socketPath)
44
+ const securityIssue = getSocketSecurityIssue(socketPath)
32
45
  if (securityIssue) {
33
46
  logDebug('backend.healthcheck.socketIssue', { issue: securityIssue, socketPath })
34
47
  return false
@@ -54,54 +67,192 @@ async function canConnectToDaemon(socketPath: string): Promise<boolean> {
54
67
  })
55
68
  }
56
69
 
70
+ export async function probeDaemonProtocolCompatibility(
71
+ socketPath: string
72
+ ): Promise<DaemonHandshakeProbeResult> {
73
+ const securityIssue = getSocketSecurityIssue(socketPath)
74
+ if (securityIssue) {
75
+ return { compatible: false, error: securityIssue }
76
+ }
77
+
78
+ return await new Promise<DaemonHandshakeProbeResult>((resolve) => {
79
+ const socket = connect(socketPath)
80
+ const decoder = new MessageDecoder(parseServerMessage)
81
+ const helloRequestId = crypto.randomUUID()
82
+ const attachRequestId = crypto.randomUUID()
83
+ const disposeRequestId = crypto.randomUUID()
84
+ const probeSessionId = `probe-${crypto.randomUUID()}`
85
+ let daemonProcessVersion: string | undefined
86
+ let settled = false
87
+ const timer = setTimeout(() => {
88
+ finish({ compatible: false, error: 'handshake timed out' })
89
+ }, 2_000)
90
+
91
+ const finish = (result: DaemonHandshakeProbeResult) => {
92
+ if (settled) {
93
+ return
94
+ }
95
+ settled = true
96
+ clearTimeout(timer)
97
+ socket.removeAllListeners()
98
+ socket.destroy()
99
+ resolve(result)
100
+ }
101
+
102
+ socket.once('connect', () => {
103
+ socket.write(
104
+ encodeMessage({
105
+ id: helloRequestId,
106
+ payload: {
107
+ maxVersion: IPC_PROTOCOL_VERSION,
108
+ minVersion: IPC_PROTOCOL_MIN_VERSION,
109
+ },
110
+ type: 'hello',
111
+ })
112
+ )
113
+ })
114
+ socket.once('error', (error: NodeJS.ErrnoException) => {
115
+ finish({ compatible: false, error: error.message })
116
+ })
117
+ socket.on('data', (chunk) => {
118
+ try {
119
+ for (const message of decoder.push(chunk)) {
120
+ if (!('id' in message)) {
121
+ continue
122
+ }
123
+
124
+ if (message.id === helloRequestId) {
125
+ if (message.type !== 'helloResult') {
126
+ finish({
127
+ compatible: false,
128
+ error:
129
+ message.type === 'error' ? message.payload.message : `unexpected ${message.type}`,
130
+ })
131
+ return
132
+ }
133
+
134
+ daemonProcessVersion = message.payload.processVersion
135
+
136
+ socket.write(
137
+ encodeMessage({
138
+ id: attachRequestId,
139
+ payload: {
140
+ cols: 80,
141
+ protocolVersion: message.payload.selectedVersion,
142
+ rows: 24,
143
+ sessionId: probeSessionId,
144
+ },
145
+ type: 'attach',
146
+ })
147
+ )
148
+ return
149
+ }
150
+
151
+ if (message.id === attachRequestId) {
152
+ if (message.type !== 'attachResult') {
153
+ finish({
154
+ compatible: false,
155
+ error:
156
+ message.type === 'error' ? message.payload.message : `unexpected ${message.type}`,
157
+ })
158
+ return
159
+ }
160
+
161
+ const compatible =
162
+ message.payload.protocolVersion >= IPC_PROTOCOL_MIN_VERSION &&
163
+ message.payload.protocolVersion <= IPC_PROTOCOL_VERSION
164
+
165
+ socket.write(
166
+ encodeMessage({
167
+ id: disposeRequestId,
168
+ payload: {},
169
+ type: 'disposeAll',
170
+ })
171
+ )
172
+ finish({
173
+ compatible,
174
+ error: compatible
175
+ ? undefined
176
+ : `attach returned protocol v${message.payload.protocolVersion}`,
177
+ processVersion: daemonProcessVersion,
178
+ selectedVersion: message.payload.protocolVersion,
179
+ })
180
+ return
181
+ }
182
+
183
+ if (message.id === disposeRequestId) {
184
+ continue
185
+ }
186
+ }
187
+ } catch (error) {
188
+ finish({
189
+ compatible: false,
190
+ error: error instanceof Error ? error.message : String(error),
191
+ })
192
+ }
193
+ })
194
+ })
195
+ }
196
+
57
197
  async function restartDaemon(socketPath: string): Promise<void> {
58
- const pid = await findDaemonPid(socketPath)
198
+ const pid = await findIpcDaemonPid()
59
199
  if (pid !== null) {
60
- logDebug('backend.restartDaemon.killing', { pid })
61
- await killDaemon(pid)
200
+ logDebug('backend.restartDaemon.killing', { pid, socketPath })
201
+ await killProcess(pid)
62
202
  }
63
203
  removeDaemonSocketIfExists()
64
204
  await spawnDaemon()
65
205
  }
66
206
 
67
207
  export async function createSessionBackend(): Promise<SessionBackend> {
68
- try {
69
- const socketPath = getDaemonSocketPath()
70
- const initialReachable = await canConnectToDaemon(socketPath)
71
- logDebug('backend.create.start', { initialReachable, socketPath })
72
-
73
- if (!initialReachable) {
74
- removeDaemonSocketIfExists()
75
- await spawnDaemon()
76
- }
208
+ if (process.env.AIMUX_LOCAL_BACKEND === '1') {
209
+ logDebug('backend.create.localExplicit')
210
+ return new LocalSessionBackend()
211
+ }
77
212
 
78
- const reachable = await canConnectToDaemon(socketPath)
79
- if (!reachable) {
80
- throw new Error(`Daemon unavailable at ${socketPath}`)
81
- }
213
+ const socketPath = getIpcDaemonSocketPath()
214
+ const initialReachable = await canConnectToDaemon(socketPath)
215
+ logDebug('backend.create.start', { initialReachable, socketPath })
82
216
 
83
- logDebug('backend.create.remote', { socketPath })
84
- return new RemoteSessionBackend()
85
- } catch (error) {
86
- if (error instanceof ProtocolMismatchError) {
87
- logDebug('backend.create.protocolMismatch', {
88
- clientVersion: error.clientVersion,
89
- daemonVersion: error.daemonVersion,
90
- })
91
- try {
92
- await restartDaemon(getDaemonSocketPath())
93
- logDebug('backend.create.remoteAfterRestart')
94
- return new RemoteSessionBackend()
95
- } catch (retryError) {
96
- logDebug('backend.create.retryFailed', {
97
- error: retryError instanceof Error ? retryError.message : String(retryError),
98
- })
99
- }
100
- }
217
+ if (!initialReachable) {
218
+ removeDaemonSocketIfExists()
219
+ await spawnDaemon()
220
+ }
221
+
222
+ const reachable = await canConnectToDaemon(socketPath)
223
+ if (!reachable) {
224
+ throw new Error(`IPC daemon unavailable at ${socketPath}`)
225
+ }
101
226
 
102
- logDebug('backend.create.localFallback', {
103
- error: error instanceof Error ? error.message : String(error),
227
+ const handshake = await probeDaemonProtocolCompatibility(socketPath)
228
+ logDebug('backend.create.handshake', {
229
+ compatible: handshake.compatible,
230
+ error: handshake.error ?? null,
231
+ processVersion: handshake.processVersion ?? null,
232
+ selectedVersion: handshake.selectedVersion ?? null,
233
+ socketPath,
234
+ })
235
+ if (!handshake.compatible) {
236
+ logDebug('backend.create.restartForHandshake', {
237
+ error: handshake.error ?? 'incompatible daemon handshake',
238
+ socketPath,
104
239
  })
105
- return new LocalSessionBackend()
240
+ await restartDaemon(socketPath)
241
+ const retriedHandshake = await probeDaemonProtocolCompatibility(socketPath)
242
+ logDebug('backend.create.handshakeAfterRestart', {
243
+ compatible: retriedHandshake.compatible,
244
+ error: retriedHandshake.error ?? null,
245
+ processVersion: retriedHandshake.processVersion ?? null,
246
+ selectedVersion: retriedHandshake.selectedVersion ?? null,
247
+ socketPath,
248
+ })
249
+ if (!retriedHandshake.compatible) {
250
+ throw new Error(
251
+ `IPC daemon handshake failed after restart: ${retriedHandshake.error ?? 'incompatible protocol'}`
252
+ )
253
+ }
106
254
  }
255
+
256
+ logDebug('backend.create.remote', { socketPath })
257
+ return new RemoteSessionBackend()
107
258
  }
@@ -4,12 +4,13 @@ import { connect, Socket } from 'node:net'
4
4
  import type { AssistantId, WorkspaceSnapshotV1 } from '../state/types'
5
5
  import type { SessionBackend, SessionBackendEvents } from './types'
6
6
 
7
- import { getDaemonSocketPath } from '../daemon/runtime-paths'
7
+ import { getIpcDaemonSocketPath } from '../daemon/runtime-paths'
8
8
  import { logDebug } from '../debug/input-log'
9
9
  import {
10
10
  type AttachResult,
11
11
  type ClientRequest,
12
12
  encodeMessage,
13
+ IPC_PROTOCOL_MIN_VERSION,
13
14
  IPC_PROTOCOL_VERSION,
14
15
  MessageDecoder,
15
16
  parseServerMessage,
@@ -19,6 +20,7 @@ import {
19
20
  } from '../ipc/protocol'
20
21
 
21
22
  const IPC_REQUEST_TIMEOUT_MS = 10_000
23
+ const RECONNECT_DELAY_MS = 250
22
24
 
23
25
  export class RemoteSessionBackend
24
26
  extends EventEmitter<SessionBackendEvents>
@@ -36,6 +38,15 @@ export class RemoteSessionBackend
36
38
  private decoder = new MessageDecoder<ServerResponse | ServerEvent>(parseServerMessage)
37
39
  private attached = false
38
40
  private currentSessionId: string | null = null
41
+ private attachOptions: {
42
+ sessionId: string
43
+ cols: number
44
+ rows: number
45
+ workspaceSnapshot?: WorkspaceSnapshotV1
46
+ } | null = null
47
+ private selectedProtocolVersion: number | null = null
48
+ private reconnectPromise: Promise<void> | null = null
49
+ private shouldReconnect = false
39
50
 
40
51
  private rejectPendingRequests(error: Error): void {
41
52
  for (const [id, pending] of this.pending.entries()) {
@@ -45,14 +56,19 @@ export class RemoteSessionBackend
45
56
  }
46
57
  }
47
58
 
48
- private resetConnection(reason: string): void {
59
+ private closeSocket(reason: string, preserveSession = false): void {
49
60
  const socket = this.socket
50
61
  this.socket = null
51
62
  this.attached = false
52
- this.currentSessionId = null
63
+ this.selectedProtocolVersion = null
53
64
  this.decoder.reset()
54
65
  this.rejectPendingRequests(new Error(reason))
55
66
 
67
+ if (!preserveSession) {
68
+ this.currentSessionId = null
69
+ this.attachOptions = null
70
+ }
71
+
56
72
  if (!socket) {
57
73
  return
58
74
  }
@@ -64,6 +80,14 @@ export class RemoteSessionBackend
64
80
  }
65
81
  }
66
82
 
83
+ private handleConnectionLoss(reason: string): void {
84
+ logDebug('backend.remote.connectionLoss', { reason, sessionId: this.currentSessionId })
85
+ this.closeSocket(reason, true)
86
+ if (this.shouldReconnect && this.attachOptions) {
87
+ void this.scheduleReconnect()
88
+ }
89
+ }
90
+
67
91
  private getConnectedSocket(): Socket {
68
92
  if (!this.socket || this.socket.destroyed) {
69
93
  throw new Error('Remote backend socket is unavailable')
@@ -140,57 +164,37 @@ export class RemoteSessionBackend
140
164
  }
141
165
  }
142
166
 
143
- async attach(options: {
144
- sessionId: string
145
- cols: number
146
- rows: number
147
- workspaceSnapshot?: WorkspaceSnapshotV1
148
- }): Promise<AttachResult> {
149
- const socketPath = getDaemonSocketPath()
150
- logDebug('backend.remote.attach.start', {
151
- cols: options.cols,
152
- rows: options.rows,
153
- sessionId: options.sessionId,
154
- snapshotTabs: options.workspaceSnapshot?.tabs.length ?? 0,
155
- socketPath,
156
- })
157
- this.resetConnection('Connection replaced during attach')
167
+ private async connectAndHandshake(): Promise<void> {
168
+ const socketPath = getIpcDaemonSocketPath()
169
+ this.closeSocket('Connection replaced during attach', true)
158
170
 
159
171
  const socket = connect(socketPath)
160
172
  this.socket = socket
161
- this.attached = false
162
- this.currentSessionId = options.sessionId
163
173
 
164
174
  await new Promise<void>((resolve, reject) => {
165
175
  socket.once('connect', resolve)
166
176
  socket.once('error', reject)
167
177
  })
168
- logDebug('backend.remote.attach.connected', { socketPath })
169
178
 
170
179
  socket.on('error', (error) => {
171
180
  if (this.socket !== socket) {
172
181
  return
173
182
  }
174
- logDebug('backend.remote.socketError', { error: error.message })
175
- this.resetConnection(`Remote backend socket error: ${error.message}`)
183
+ this.handleConnectionLoss(`Remote backend socket error: ${error.message}`)
176
184
  })
177
185
  socket.on('close', () => {
178
186
  if (this.socket !== socket) {
179
187
  return
180
188
  }
181
- logDebug('backend.remote.socketClose')
182
- this.resetConnection('Remote backend socket closed')
189
+ this.handleConnectionLoss('Remote backend socket closed')
183
190
  })
184
-
185
191
  socket.on('data', (chunk) => {
186
192
  if (this.socket !== socket) {
187
193
  return
188
194
  }
189
- logDebug('backend.remote.data', { byteLength: chunk.length })
190
195
  try {
191
196
  for (const message of this.decoder.push(chunk)) {
192
197
  if ('id' in message) {
193
- logDebug('backend.remote.response', { id: message.id, type: message.type })
194
198
  const pending = this.pending.get(message.id)
195
199
  if (pending) {
196
200
  clearTimeout(pending.timer)
@@ -203,41 +207,119 @@ export class RemoteSessionBackend
203
207
  }
204
208
  } catch (error) {
205
209
  const message = error instanceof Error ? error.message : String(error)
206
- logDebug('backend.remote.socketError', { error: message })
207
- this.resetConnection(`Remote backend parse error: ${message}`)
210
+ this.handleConnectionLoss(`Remote backend parse error: ${message}`)
208
211
  }
209
212
  })
210
213
 
211
214
  const response = await this.send({
212
215
  id: crypto.randomUUID(),
213
- payload: { ...options, protocolVersion: IPC_PROTOCOL_VERSION },
216
+ payload: { maxVersion: IPC_PROTOCOL_VERSION, minVersion: IPC_PROTOCOL_MIN_VERSION },
217
+ type: 'hello',
218
+ })
219
+
220
+ if (response.type !== 'helloResult') {
221
+ this.closeSocket('Unexpected hello response', true)
222
+ throw new Error(
223
+ response.type === 'error' ? response.payload.message : 'Unexpected hello response'
224
+ )
225
+ }
226
+
227
+ this.selectedProtocolVersion = response.payload.selectedVersion
228
+ }
229
+
230
+ private async performAttach(options: {
231
+ sessionId: string
232
+ cols: number
233
+ rows: number
234
+ workspaceSnapshot?: WorkspaceSnapshotV1
235
+ }): Promise<AttachResult> {
236
+ await this.connectAndHandshake()
237
+
238
+ const response = await this.send({
239
+ id: crypto.randomUUID(),
240
+ payload: {
241
+ ...options,
242
+ protocolVersion: this.selectedProtocolVersion ?? IPC_PROTOCOL_VERSION,
243
+ },
214
244
  type: 'attach',
215
245
  })
216
246
 
217
247
  if (response.type !== 'attachResult') {
218
- logDebug('backend.remote.attach.unexpected', { type: response.type })
219
- this.resetConnection(`Unexpected attach response: ${response.type}`)
248
+ this.closeSocket(`Unexpected attach response: ${response.type}`, true)
220
249
  throw new Error(
221
250
  response.type === 'error' ? response.payload.message : 'Unexpected attach response'
222
251
  )
223
252
  }
224
253
 
225
- if (response.payload.protocolVersion !== IPC_PROTOCOL_VERSION) {
226
- this.resetConnection(
227
- `Protocol mismatch: client v${IPC_PROTOCOL_VERSION}, daemon v${response.payload.protocolVersion}`
254
+ if (response.payload.protocolVersion !== this.selectedProtocolVersion) {
255
+ this.closeSocket('Attach protocol mismatch', true)
256
+ throw new ProtocolMismatchError(
257
+ this.selectedProtocolVersion ?? IPC_PROTOCOL_VERSION,
258
+ response.payload.protocolVersion
228
259
  )
229
- throw new ProtocolMismatchError(IPC_PROTOCOL_VERSION, response.payload.protocolVersion)
230
260
  }
231
261
 
232
262
  this.attached = true
263
+ return response.payload
264
+ }
265
+
266
+ private async scheduleReconnect(): Promise<void> {
267
+ if (this.reconnectPromise) {
268
+ return this.reconnectPromise
269
+ }
233
270
 
271
+ const reconnectOptions = this.attachOptions
272
+ this.reconnectPromise = (async () => {
273
+ while (this.shouldReconnect && reconnectOptions && this.attachOptions === reconnectOptions) {
274
+ try {
275
+ await this.performAttach(reconnectOptions)
276
+ logDebug('backend.remote.reconnect.success', {
277
+ sessionId: reconnectOptions.sessionId,
278
+ })
279
+ return
280
+ } catch (error) {
281
+ logDebug('backend.remote.reconnect.retry', {
282
+ error: error instanceof Error ? error.message : String(error),
283
+ sessionId: reconnectOptions.sessionId,
284
+ })
285
+ await Bun.sleep(RECONNECT_DELAY_MS)
286
+ }
287
+ }
288
+ })()
289
+
290
+ try {
291
+ await this.reconnectPromise
292
+ } finally {
293
+ this.reconnectPromise = null
294
+ }
295
+ }
296
+
297
+ async attach(options: {
298
+ sessionId: string
299
+ cols: number
300
+ rows: number
301
+ workspaceSnapshot?: WorkspaceSnapshotV1
302
+ }): Promise<AttachResult> {
303
+ this.shouldReconnect = true
304
+ this.currentSessionId = options.sessionId
305
+ this.attachOptions = options
306
+
307
+ logDebug('backend.remote.attach.start', {
308
+ cols: options.cols,
309
+ rows: options.rows,
310
+ sessionId: options.sessionId,
311
+ snapshotTabs: options.workspaceSnapshot?.tabs.length ?? 0,
312
+ socketPath: getIpcDaemonSocketPath(),
313
+ })
314
+
315
+ const result = await this.performAttach(options)
234
316
  logDebug('backend.remote.attach.success', {
235
- activeTabId: response.payload.activeTabId,
317
+ activeTabId: result.activeTabId,
236
318
  sessionId: options.sessionId,
237
- tabs: response.payload.tabs.length,
319
+ tabs: result.tabs.length,
238
320
  })
239
321
 
240
- return response.payload
322
+ return result
241
323
  }
242
324
 
243
325
  createSession(options: {
@@ -255,11 +337,6 @@ export class RemoteSessionBackend
255
337
  return
256
338
  }
257
339
 
258
- logDebug('backend.remote.createSession', {
259
- sessionId: this.currentSessionId,
260
- tabId: options.tabId,
261
- title: options.title,
262
- })
263
340
  void this.sendExpectOk({ id: crypto.randomUUID(), payload: options, type: 'createTab' }).catch(
264
341
  (error) => this.reportCommandError('createTab', error, options.tabId)
265
342
  )
@@ -270,11 +347,6 @@ export class RemoteSessionBackend
270
347
  logDebug('backend.remote.skipWriteBeforeAttach', { inputLength: input.length, tabId })
271
348
  return
272
349
  }
273
- logDebug('backend.remote.write', {
274
- inputLength: input.length,
275
- sessionId: this.currentSessionId,
276
- tabId,
277
- })
278
350
  void this.sendExpectOk({
279
351
  id: crypto.randomUUID(),
280
352
  payload: { data: input, tabId },
@@ -286,7 +358,6 @@ export class RemoteSessionBackend
286
358
  if (!this.attached) {
287
359
  return
288
360
  }
289
- logDebug('backend.remote.scroll', { deltaLines, sessionId: this.currentSessionId, tabId })
290
361
  void this.sendExpectOk({
291
362
  id: crypto.randomUUID(),
292
363
  payload: { deltaLines, tabId },
@@ -298,7 +369,6 @@ export class RemoteSessionBackend
298
369
  if (!this.attached) {
299
370
  return
300
371
  }
301
- logDebug('backend.remote.scrollToBottom', { sessionId: this.currentSessionId, tabId })
302
372
  void this.sendExpectOk({
303
373
  id: crypto.randomUUID(),
304
374
  payload: { tabId },
@@ -310,7 +380,6 @@ export class RemoteSessionBackend
310
380
  if (!this.attached) {
311
381
  return
312
382
  }
313
- logDebug('backend.remote.setActiveTab', { sessionId: this.currentSessionId, tabId })
314
383
  void this.sendExpectOk({
315
384
  id: crypto.randomUUID(),
316
385
  payload: { tabId },
@@ -323,7 +392,6 @@ export class RemoteSessionBackend
323
392
  logDebug('backend.remote.skipResizeBeforeAttach', { cols, rows })
324
393
  return
325
394
  }
326
- logDebug('backend.remote.resize', { cols, rows, sessionId: this.currentSessionId })
327
395
  void this.sendExpectOk({
328
396
  id: crypto.randomUUID(),
329
397
  payload: { cols, rows },
@@ -335,7 +403,6 @@ export class RemoteSessionBackend
335
403
  if (!this.attached) {
336
404
  return
337
405
  }
338
- logDebug('backend.remote.resizeTab', { cols, rows, sessionId: this.currentSessionId, tabId })
339
406
  void this.sendExpectOk({
340
407
  id: crypto.randomUUID(),
341
408
  payload: { cols, rows, tabId },
@@ -347,7 +414,6 @@ export class RemoteSessionBackend
347
414
  if (!this.attached) {
348
415
  return
349
416
  }
350
- logDebug('backend.remote.disposeSession', { sessionId: this.currentSessionId, tabId })
351
417
  void this.sendExpectOk({ id: crypto.randomUUID(), payload: { tabId }, type: 'closeTab' }).catch(
352
418
  (error) => this.reportCommandError('closeTab', error, tabId)
353
419
  )
@@ -357,7 +423,6 @@ export class RemoteSessionBackend
357
423
  if (!this.attached) {
358
424
  return
359
425
  }
360
- logDebug('backend.remote.disposeAll', { sessionId: this.currentSessionId })
361
426
  void this.sendExpectOk({ id: crypto.randomUUID(), payload: {}, type: 'disposeAll' }).catch(
362
427
  (error) => this.reportCommandError('disposeAll', error)
363
428
  )
@@ -365,9 +430,11 @@ export class RemoteSessionBackend
365
430
 
366
431
  async destroy(keepSessions = true): Promise<void> {
367
432
  logDebug('backend.remote.destroy', { keepSessions })
368
- if (!keepSessions) {
433
+ this.shouldReconnect = false
434
+ this.reconnectPromise = null
435
+ if (!keepSessions && this.attached) {
369
436
  this.disposeAll()
370
437
  }
371
- this.resetConnection('Remote backend destroyed')
438
+ this.closeSocket('Remote backend destroyed')
372
439
  }
373
440
  }
@@ -1,10 +1,11 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
- import { dirname, join } from 'node:path'
2
+ import { join } from 'node:path'
3
3
 
4
4
  import type { SessionRecord } from './types'
5
5
 
6
- import { CONFIG_PATH, loadConfig, saveConfig } from '../config'
6
+ import { loadConfig, saveConfig } from '../config'
7
7
  import { logDebug } from '../debug/input-log'
8
+ import { getProfileConfigDir } from '../profile-paths'
8
9
  import { isSessionRecord } from './validation'
9
10
 
10
11
  interface SessionCatalogFile {
@@ -12,7 +13,7 @@ interface SessionCatalogFile {
12
13
  sessions: SessionRecord[]
13
14
  }
14
15
 
15
- const SESSIONS_PATH = join(dirname(CONFIG_PATH), 'aimux-sessions.json')
16
+ const SESSIONS_PATH = join(getProfileConfigDir(), 'aimux-sessions.json')
16
17
 
17
18
  function readCatalogFile(): { file: SessionCatalogFile | null; issue?: string } {
18
19
  try {
@@ -79,7 +80,7 @@ export function loadSessionCatalog(): SessionRecord[] {
79
80
 
80
81
  export function saveSessionCatalog(sessions: SessionRecord[]): void {
81
82
  try {
82
- mkdirSync(dirname(SESSIONS_PATH), { recursive: true })
83
+ mkdirSync(getProfileConfigDir(), { recursive: true })
83
84
  writeFileSync(SESSIONS_PATH, `${JSON.stringify({ sessions, version: 1 }, null, 2)}\n`)
84
85
  logDebug('sessions.catalog.save', { sessionCount: sessions.length })
85
86
  } catch (error) {