@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.
- package/README.md +29 -8
- package/package.json +6 -7
- package/src/app-runtime/backend-runtime-events.ts +9 -0
- package/src/app-runtime/side-effects.ts +8 -0
- package/src/config/loader.ts +2 -2
- package/src/config.ts +3 -3
- package/src/daemon/daemon.ts +262 -118
- package/src/daemon/runtime-paths.ts +34 -2
- package/src/index.tsx +11 -3
- package/src/ipc/manager-protocol.ts +347 -0
- package/src/ipc/protocol.ts +55 -3
- package/src/platform/daemon-control.ts +42 -11
- package/src/profile-paths.ts +27 -0
- package/src/pty/pty-manager.ts +24 -0
- package/src/restart-daemon.ts +10 -10
- package/src/session-backend/bootstrap.ts +197 -46
- package/src/session-backend/remote-session-backend.ts +126 -59
- package/src/state/session-catalog.ts +5 -4
- package/src/state/snippet-catalog.ts +4 -4
- package/src/terminal-manager/manager-client.ts +360 -0
- package/src/terminal-manager/terminal-manager.ts +272 -0
- package/src/update.ts +3 -3
|
@@ -3,32 +3,45 @@ import { connect } from 'node:net'
|
|
|
3
3
|
import type { SessionBackend } from './types'
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
getIpcDaemonSocketPath,
|
|
7
|
+
getSocketSecurityIssue,
|
|
8
8
|
removeDaemonSocketIfExists,
|
|
9
9
|
} from '../daemon/runtime-paths'
|
|
10
10
|
import { logDebug } from '../debug/input-log'
|
|
11
|
-
import {
|
|
12
|
-
|
|
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:
|
|
32
|
+
socketPath: getIpcDaemonSocketPath(),
|
|
20
33
|
})
|
|
21
|
-
const ok = await
|
|
34
|
+
const ok = await spawnDetachedIpcDaemon()
|
|
22
35
|
if (ok) {
|
|
23
|
-
logDebug('backend.spawnDaemon.ready', { socketPath:
|
|
36
|
+
logDebug('backend.spawnDaemon.ready', { socketPath: getIpcDaemonSocketPath() })
|
|
24
37
|
return
|
|
25
38
|
}
|
|
26
39
|
|
|
27
|
-
|
|
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 =
|
|
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
|
|
198
|
+
const pid = await findIpcDaemonPid()
|
|
59
199
|
if (pid !== null) {
|
|
60
|
-
logDebug('backend.restartDaemon.killing', { pid })
|
|
61
|
-
await
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
213
|
+
const socketPath = getIpcDaemonSocketPath()
|
|
214
|
+
const initialReachable = await canConnectToDaemon(socketPath)
|
|
215
|
+
logDebug('backend.create.start', { initialReachable, socketPath })
|
|
82
216
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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.
|
|
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
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
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 !==
|
|
226
|
-
this.
|
|
227
|
-
|
|
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:
|
|
317
|
+
activeTabId: result.activeTabId,
|
|
236
318
|
sessionId: options.sessionId,
|
|
237
|
-
tabs:
|
|
319
|
+
tabs: result.tabs.length,
|
|
238
320
|
})
|
|
239
321
|
|
|
240
|
-
return
|
|
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
|
-
|
|
433
|
+
this.shouldReconnect = false
|
|
434
|
+
this.reconnectPromise = null
|
|
435
|
+
if (!keepSessions && this.attached) {
|
|
369
436
|
this.disposeAll()
|
|
370
437
|
}
|
|
371
|
-
this.
|
|
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 {
|
|
2
|
+
import { join } from 'node:path'
|
|
3
3
|
|
|
4
4
|
import type { SessionRecord } from './types'
|
|
5
5
|
|
|
6
|
-
import {
|
|
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(
|
|
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(
|
|
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) {
|