@brimveyn/aimux 1.2.5 → 1.3.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.
- package/README.md +29 -8
- package/package.json +5 -5
- package/src/app-runtime/backend-runtime-events.ts +16 -1
- package/src/app-runtime/pty-write.ts +7 -4
- package/src/app-runtime/side-effects.ts +62 -4
- package/src/app-runtime/snippet-actions.ts +3 -2
- package/src/app-runtime/use-backend-runtime.ts +7 -2
- package/src/app-runtime/use-renderer-bindings.ts +2 -2
- package/src/app-runtime/use-terminal-resize.ts +15 -6
- package/src/app.tsx +89 -30
- package/src/config/loader.ts +2 -2
- package/src/config.ts +14 -3
- package/src/daemon/daemon.ts +279 -118
- package/src/daemon/runtime-paths.ts +34 -2
- package/src/daemon/session-manager.ts +20 -5
- package/src/daemon/session-registry.ts +18 -11
- package/src/index.tsx +19 -4
- package/src/input/keymap/describe-bindings.ts +68 -0
- package/src/input/keymap/key-format.ts +67 -0
- package/src/input/modes/bridge.ts +1 -0
- package/src/input/modes/transitions.ts +2 -0
- package/src/input/modes/types.ts +2 -0
- package/src/ipc/manager-protocol.ts +396 -0
- package/src/ipc/protocol.ts +98 -5
- package/src/platform/daemon-control.ts +42 -11
- package/src/profile-paths.ts +27 -0
- package/src/pty/pty-manager.ts +53 -3
- package/src/restart-daemon.ts +10 -10
- package/src/session-backend/bootstrap.ts +197 -46
- package/src/session-backend/local-session-backend.ts +12 -5
- package/src/session-backend/remote-session-backend.ts +143 -63
- package/src/session-backend/types.ts +4 -2
- package/src/state/reducers/modal-state.ts +18 -1
- package/src/state/reducers/tab-state.ts +20 -2
- package/src/state/session-catalog.ts +5 -4
- package/src/state/session-persistence.ts +9 -2
- package/src/state/snippet-catalog.ts +4 -4
- package/src/state/types.ts +23 -0
- package/src/state/validation.ts +8 -0
- package/src/terminal-manager/manager-client.ts +384 -0
- package/src/terminal-manager/terminal-manager.ts +288 -0
- package/src/ui/components/create-session-modal.tsx +3 -5
- package/src/ui/components/git-commit-modal.tsx +3 -5
- package/src/ui/components/help-modal.tsx +49 -68
- package/src/ui/components/list-item.tsx +24 -5
- package/src/ui/components/new-tab-modal.tsx +8 -9
- package/src/ui/components/pending-chord-overlay.tsx +28 -0
- package/src/ui/components/session-name-modal.tsx +3 -5
- package/src/ui/components/session-picker-modal.tsx +3 -1
- package/src/ui/components/snippet-editor-modal.tsx +3 -1
- package/src/ui/components/snippet-picker-modal.tsx +3 -1
- package/src/ui/components/status-bar.tsx +6 -2
- package/src/ui/components/theme-picker-modal.tsx +3 -6
- package/src/ui/components/update-available-modal.tsx +42 -0
- package/src/ui/keymap-context.ts +39 -0
- package/src/ui/root.tsx +12 -0
- package/src/ui/status-bar-model.ts +67 -39
- package/src/update/version-check.ts +67 -0
- package/src/update.ts +3 -3
package/src/state/validation.ts
CHANGED
|
@@ -55,6 +55,13 @@ function isTerminalSnapshot(value: unknown): value is TerminalSnapshot {
|
|
|
55
55
|
)
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
function isScrollIntent(value: unknown): boolean {
|
|
59
|
+
if (!isObjectRecord(value)) return false
|
|
60
|
+
if (value.kind === 'bottom') return true
|
|
61
|
+
if (value.kind === 'anchor') return isFiniteNumber(value.absoluteLine)
|
|
62
|
+
return false
|
|
63
|
+
}
|
|
64
|
+
|
|
58
65
|
function isTerminalModeState(value: unknown): value is TerminalModeState {
|
|
59
66
|
return (
|
|
60
67
|
isObjectRecord(value) &&
|
|
@@ -125,6 +132,7 @@ export function isWorkspaceSnapshotV1(value: unknown): value is WorkspaceSnapsho
|
|
|
125
132
|
isString(tab.buffer) &&
|
|
126
133
|
isTerminalModeState(tab.terminalModes) &&
|
|
127
134
|
(tab.viewport === undefined || isTerminalSnapshot(tab.viewport)) &&
|
|
135
|
+
(tab.scrollIntent === undefined || isScrollIntent(tab.scrollIntent)) &&
|
|
128
136
|
(tab.errorMessage === undefined || isString(tab.errorMessage)) &&
|
|
129
137
|
(tab.exitCode === undefined || isFiniteNumber(tab.exitCode))
|
|
130
138
|
) &&
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events'
|
|
2
|
+
import { connect, Socket } from 'node:net'
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
ScrollIntent,
|
|
6
|
+
TerminalModeState,
|
|
7
|
+
TerminalSnapshot,
|
|
8
|
+
WorkspaceSnapshotV1,
|
|
9
|
+
} from '../state/types'
|
|
10
|
+
|
|
11
|
+
import { getTerminalManagerSocketPath } from '../daemon/runtime-paths'
|
|
12
|
+
import { logDebug } from '../debug/input-log'
|
|
13
|
+
import {
|
|
14
|
+
encodeManagerMessage,
|
|
15
|
+
MANAGER_PROTOCOL_MIN_VERSION,
|
|
16
|
+
MANAGER_PROTOCOL_VERSION,
|
|
17
|
+
type ManagerAttachResult,
|
|
18
|
+
type ManagerEvent,
|
|
19
|
+
type ManagerRequest,
|
|
20
|
+
type ManagerResponse,
|
|
21
|
+
MessageDecoder,
|
|
22
|
+
parseManagerMessage,
|
|
23
|
+
} from '../ipc/manager-protocol'
|
|
24
|
+
|
|
25
|
+
type ManagerClientEvents = {
|
|
26
|
+
render: [
|
|
27
|
+
sessionId: string,
|
|
28
|
+
tabId: string,
|
|
29
|
+
viewport: TerminalSnapshot,
|
|
30
|
+
terminalModes: TerminalModeState,
|
|
31
|
+
]
|
|
32
|
+
exit: [sessionId: string, tabId: string, exitCode: number]
|
|
33
|
+
error: [sessionId: string, tabId: string, message: string]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const REQUEST_TIMEOUT_MS = 10_000
|
|
37
|
+
|
|
38
|
+
export class TerminalManagerClient extends EventEmitter<ManagerClientEvents> {
|
|
39
|
+
private socket: Socket | null = null
|
|
40
|
+
private readonly pending = new Map<
|
|
41
|
+
string,
|
|
42
|
+
{
|
|
43
|
+
resolve: (message: ManagerResponse) => void
|
|
44
|
+
reject: (error: Error) => void
|
|
45
|
+
timer: ReturnType<typeof setTimeout>
|
|
46
|
+
}
|
|
47
|
+
>()
|
|
48
|
+
private readonly decoder = new MessageDecoder<ManagerResponse | ManagerEvent>(parseManagerMessage)
|
|
49
|
+
private selectedProtocolVersion: number | null = null
|
|
50
|
+
|
|
51
|
+
private rejectPendingRequests(error: Error): void {
|
|
52
|
+
for (const [id, pending] of this.pending.entries()) {
|
|
53
|
+
clearTimeout(pending.timer)
|
|
54
|
+
this.pending.delete(id)
|
|
55
|
+
pending.reject(error)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private resetConnection(reason: string): void {
|
|
60
|
+
logDebug('managerClient.resetConnection', { reason })
|
|
61
|
+
const socket = this.socket
|
|
62
|
+
this.socket = null
|
|
63
|
+
this.selectedProtocolVersion = null
|
|
64
|
+
this.decoder.reset()
|
|
65
|
+
this.rejectPendingRequests(new Error(reason))
|
|
66
|
+
|
|
67
|
+
if (!socket) {
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
socket.removeAllListeners()
|
|
72
|
+
if (!socket.destroyed) {
|
|
73
|
+
socket.end()
|
|
74
|
+
socket.destroy()
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private getConnectedSocket(): Socket {
|
|
79
|
+
if (!this.socket || this.socket.destroyed) {
|
|
80
|
+
throw new Error('Terminal manager socket is unavailable')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return this.socket
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private send(request: ManagerRequest): Promise<ManagerResponse> {
|
|
87
|
+
const socket = this.getConnectedSocket()
|
|
88
|
+
logDebug('managerClient.send', { id: request.id, type: request.type })
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
const timer = setTimeout(() => {
|
|
91
|
+
this.pending.delete(request.id)
|
|
92
|
+
logDebug('managerClient.timeout', { id: request.id, type: request.type })
|
|
93
|
+
reject(
|
|
94
|
+
new Error(`Manager request timed out after ${REQUEST_TIMEOUT_MS}ms: ${request.type}`)
|
|
95
|
+
)
|
|
96
|
+
}, REQUEST_TIMEOUT_MS)
|
|
97
|
+
this.pending.set(request.id, { reject, resolve, timer })
|
|
98
|
+
socket.write(encodeManagerMessage(request), (error) => {
|
|
99
|
+
if (error) {
|
|
100
|
+
clearTimeout(timer)
|
|
101
|
+
this.pending.delete(request.id)
|
|
102
|
+
logDebug('managerClient.sendError', {
|
|
103
|
+
error: error.message,
|
|
104
|
+
id: request.id,
|
|
105
|
+
type: request.type,
|
|
106
|
+
})
|
|
107
|
+
reject(error)
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private async sendExpectOk(request: ManagerRequest): Promise<void> {
|
|
114
|
+
const response = await this.send(request)
|
|
115
|
+
if (response.type === 'ok') {
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
throw new Error(
|
|
120
|
+
response.type === 'error'
|
|
121
|
+
? response.payload.message
|
|
122
|
+
: `Unexpected response for ${request.type}: ${response.type}`
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private handleManagerEvent(message: ManagerEvent): void {
|
|
127
|
+
logDebug('managerClient.event', {
|
|
128
|
+
sessionId: message.payload.sessionId,
|
|
129
|
+
tabId: message.payload.tabId,
|
|
130
|
+
type: message.type,
|
|
131
|
+
})
|
|
132
|
+
switch (message.type) {
|
|
133
|
+
case 'tabRender':
|
|
134
|
+
this.emit(
|
|
135
|
+
'render',
|
|
136
|
+
message.payload.sessionId,
|
|
137
|
+
message.payload.tabId,
|
|
138
|
+
message.payload.viewport,
|
|
139
|
+
message.payload.terminalModes
|
|
140
|
+
)
|
|
141
|
+
break
|
|
142
|
+
case 'tabExit':
|
|
143
|
+
this.emit(
|
|
144
|
+
'exit',
|
|
145
|
+
message.payload.sessionId,
|
|
146
|
+
message.payload.tabId,
|
|
147
|
+
message.payload.exitCode
|
|
148
|
+
)
|
|
149
|
+
break
|
|
150
|
+
case 'tabError':
|
|
151
|
+
this.emit(
|
|
152
|
+
'error',
|
|
153
|
+
message.payload.sessionId,
|
|
154
|
+
message.payload.tabId,
|
|
155
|
+
message.payload.message
|
|
156
|
+
)
|
|
157
|
+
break
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private async performHandshake(): Promise<void> {
|
|
162
|
+
logDebug('managerClient.handshake.start', {
|
|
163
|
+
maxVersion: MANAGER_PROTOCOL_VERSION,
|
|
164
|
+
minVersion: MANAGER_PROTOCOL_MIN_VERSION,
|
|
165
|
+
})
|
|
166
|
+
const response = await this.send({
|
|
167
|
+
id: crypto.randomUUID(),
|
|
168
|
+
payload: {
|
|
169
|
+
maxVersion: MANAGER_PROTOCOL_VERSION,
|
|
170
|
+
minVersion: MANAGER_PROTOCOL_MIN_VERSION,
|
|
171
|
+
},
|
|
172
|
+
type: 'hello',
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
if (response.type !== 'helloResult') {
|
|
176
|
+
throw new Error(
|
|
177
|
+
response.type === 'error' ? response.payload.message : 'Unexpected hello response'
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
this.selectedProtocolVersion = response.payload.selectedVersion
|
|
182
|
+
logDebug('managerClient.handshake.success', {
|
|
183
|
+
processVersion: response.payload.processVersion,
|
|
184
|
+
selectedVersion: this.selectedProtocolVersion,
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async connect(): Promise<void> {
|
|
189
|
+
if (this.socket && !this.socket.destroyed && this.selectedProtocolVersion !== null) {
|
|
190
|
+
logDebug('managerClient.connect.reuse', {
|
|
191
|
+
selectedProtocolVersion: this.selectedProtocolVersion,
|
|
192
|
+
})
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
this.resetConnection('Terminal manager connection replaced')
|
|
197
|
+
|
|
198
|
+
logDebug('managerClient.connect.start', { socketPath: getTerminalManagerSocketPath() })
|
|
199
|
+
const socket = connect(getTerminalManagerSocketPath())
|
|
200
|
+
this.socket = socket
|
|
201
|
+
|
|
202
|
+
await new Promise<void>((resolve, reject) => {
|
|
203
|
+
socket.once('connect', resolve)
|
|
204
|
+
socket.once('error', reject)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
logDebug('managerClient.connect.connected', { socketPath: getTerminalManagerSocketPath() })
|
|
208
|
+
|
|
209
|
+
socket.on('error', (error) => {
|
|
210
|
+
if (this.socket !== socket) {
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
logDebug('managerClient.socketError', { error: error.message })
|
|
214
|
+
this.resetConnection(`Terminal manager socket error: ${error.message}`)
|
|
215
|
+
})
|
|
216
|
+
socket.on('close', () => {
|
|
217
|
+
if (this.socket !== socket) {
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
logDebug('managerClient.socketClose')
|
|
221
|
+
this.resetConnection('Terminal manager socket closed')
|
|
222
|
+
})
|
|
223
|
+
socket.on('data', (chunk) => {
|
|
224
|
+
if (this.socket !== socket) {
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
for (const message of this.decoder.push(chunk)) {
|
|
230
|
+
if ('id' in message) {
|
|
231
|
+
logDebug('managerClient.response', { id: message.id, type: message.type })
|
|
232
|
+
const pending = this.pending.get(message.id)
|
|
233
|
+
if (pending) {
|
|
234
|
+
clearTimeout(pending.timer)
|
|
235
|
+
this.pending.delete(message.id)
|
|
236
|
+
pending.resolve(message)
|
|
237
|
+
}
|
|
238
|
+
} else {
|
|
239
|
+
this.handleManagerEvent(message)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
} catch (error) {
|
|
243
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
244
|
+
logDebug('managerClient.parseError', { error: message })
|
|
245
|
+
this.resetConnection(`Terminal manager parse error: ${message}`)
|
|
246
|
+
}
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
await this.performHandshake()
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async attachSession(options: {
|
|
253
|
+
sessionId: string
|
|
254
|
+
cols: number
|
|
255
|
+
rows: number
|
|
256
|
+
workspaceSnapshot?: WorkspaceSnapshotV1
|
|
257
|
+
}): Promise<ManagerAttachResult> {
|
|
258
|
+
logDebug('managerClient.attach.start', {
|
|
259
|
+
cols: options.cols,
|
|
260
|
+
rows: options.rows,
|
|
261
|
+
sessionId: options.sessionId,
|
|
262
|
+
snapshotTabs: options.workspaceSnapshot?.tabs.length ?? 0,
|
|
263
|
+
})
|
|
264
|
+
await this.connect()
|
|
265
|
+
const response = await this.send({
|
|
266
|
+
id: crypto.randomUUID(),
|
|
267
|
+
payload: {
|
|
268
|
+
...options,
|
|
269
|
+
protocolVersion: this.selectedProtocolVersion ?? MANAGER_PROTOCOL_VERSION,
|
|
270
|
+
},
|
|
271
|
+
type: 'attachSession',
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
if (response.type !== 'attachResult') {
|
|
275
|
+
throw new Error(
|
|
276
|
+
response.type === 'error' ? response.payload.message : 'Unexpected attach response'
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
logDebug('managerClient.attach.success', {
|
|
281
|
+
activeTabId: response.payload.activeTabId,
|
|
282
|
+
sessionId: options.sessionId,
|
|
283
|
+
tabs: response.payload.tabs.length,
|
|
284
|
+
})
|
|
285
|
+
return response.payload
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
createTab(options: Extract<ManagerRequest, { type: 'createTab' }>['payload']): Promise<void> {
|
|
289
|
+
logDebug('managerClient.createTab', {
|
|
290
|
+
command: options.command,
|
|
291
|
+
sessionId: options.sessionId,
|
|
292
|
+
tabId: options.tabId,
|
|
293
|
+
title: options.title,
|
|
294
|
+
})
|
|
295
|
+
return this.sendExpectOk({ id: crypto.randomUUID(), payload: options, type: 'createTab' })
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
write(sessionId: string, tabId: string, data: string): Promise<void> {
|
|
299
|
+
return this.sendExpectOk({
|
|
300
|
+
id: crypto.randomUUID(),
|
|
301
|
+
payload: { data, sessionId, tabId },
|
|
302
|
+
type: 'write',
|
|
303
|
+
})
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
resize(
|
|
307
|
+
sessionId: string,
|
|
308
|
+
cols: number,
|
|
309
|
+
rows: number,
|
|
310
|
+
intents?: Record<string, ScrollIntent>
|
|
311
|
+
): Promise<void> {
|
|
312
|
+
return this.sendExpectOk({
|
|
313
|
+
id: crypto.randomUUID(),
|
|
314
|
+
payload: { cols, intents, rows, sessionId },
|
|
315
|
+
type: 'resizeClient',
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
resizeTab(
|
|
320
|
+
sessionId: string,
|
|
321
|
+
tabId: string,
|
|
322
|
+
cols: number,
|
|
323
|
+
rows: number,
|
|
324
|
+
intent?: ScrollIntent
|
|
325
|
+
): Promise<void> {
|
|
326
|
+
return this.sendExpectOk({
|
|
327
|
+
id: crypto.randomUUID(),
|
|
328
|
+
payload: { cols, intent, rows, sessionId, tabId },
|
|
329
|
+
type: 'resizeTab',
|
|
330
|
+
})
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
scroll(sessionId: string, tabId: string, deltaLines: number): Promise<void> {
|
|
334
|
+
return this.sendExpectOk({
|
|
335
|
+
id: crypto.randomUUID(),
|
|
336
|
+
payload: { deltaLines, sessionId, tabId },
|
|
337
|
+
type: 'scroll',
|
|
338
|
+
})
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
scrollToBottom(sessionId: string, tabId: string): Promise<void> {
|
|
342
|
+
return this.sendExpectOk({
|
|
343
|
+
id: crypto.randomUUID(),
|
|
344
|
+
payload: { sessionId, tabId },
|
|
345
|
+
type: 'scrollToBottom',
|
|
346
|
+
})
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
reapplyScrollIntent(sessionId: string, tabId: string, intent: ScrollIntent): Promise<void> {
|
|
350
|
+
return this.sendExpectOk({
|
|
351
|
+
id: crypto.randomUUID(),
|
|
352
|
+
payload: { intent, sessionId, tabId },
|
|
353
|
+
type: 'reapplyScrollIntent',
|
|
354
|
+
})
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
setActiveTab(sessionId: string, tabId: string | null): Promise<void> {
|
|
358
|
+
return this.sendExpectOk({
|
|
359
|
+
id: crypto.randomUUID(),
|
|
360
|
+
payload: { sessionId, tabId },
|
|
361
|
+
type: 'setActiveTab',
|
|
362
|
+
})
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
closeTab(sessionId: string, tabId: string): Promise<void> {
|
|
366
|
+
return this.sendExpectOk({
|
|
367
|
+
id: crypto.randomUUID(),
|
|
368
|
+
payload: { sessionId, tabId },
|
|
369
|
+
type: 'closeTab',
|
|
370
|
+
})
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
disposeSession(sessionId: string): Promise<void> {
|
|
374
|
+
return this.sendExpectOk({
|
|
375
|
+
id: crypto.randomUUID(),
|
|
376
|
+
payload: { sessionId },
|
|
377
|
+
type: 'disposeSession',
|
|
378
|
+
})
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
destroy(): void {
|
|
382
|
+
this.resetConnection('Terminal manager client destroyed')
|
|
383
|
+
}
|
|
384
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { createServer, type Socket } from 'node:net'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
getTerminalManagerSocketPath,
|
|
5
|
+
removeTerminalManagerSocketIfExists,
|
|
6
|
+
tightenSocketPermissions,
|
|
7
|
+
} from '../daemon/runtime-paths'
|
|
8
|
+
import { SessionManager } from '../daemon/session-manager'
|
|
9
|
+
import { logDebug } from '../debug/input-log'
|
|
10
|
+
import {
|
|
11
|
+
createManagerHelloResult,
|
|
12
|
+
encodeManagerMessage,
|
|
13
|
+
type ManagerEvent,
|
|
14
|
+
type ManagerRequest,
|
|
15
|
+
type ManagerResponse,
|
|
16
|
+
MessageDecoder,
|
|
17
|
+
parseManagerRequest,
|
|
18
|
+
selectManagerProtocolVersion,
|
|
19
|
+
} from '../ipc/manager-protocol'
|
|
20
|
+
import { findSocketProcessPid } from '../platform/daemon-control'
|
|
21
|
+
|
|
22
|
+
function send(socket: Socket, message: ManagerResponse | ManagerEvent): void {
|
|
23
|
+
socket.write(encodeManagerMessage(message))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function sendOk(socket: Socket, id: string): void {
|
|
27
|
+
send(socket, { id, payload: {}, type: 'ok' })
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function requireNegotiatedVersion(socket: Socket, versions: Map<Socket, number>): number {
|
|
31
|
+
const version = versions.get(socket)
|
|
32
|
+
if (version === undefined) {
|
|
33
|
+
throw new Error('Protocol handshake required before using terminal manager')
|
|
34
|
+
}
|
|
35
|
+
return version
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function runTerminalManager(): Promise<void> {
|
|
39
|
+
const socketPath = getTerminalManagerSocketPath()
|
|
40
|
+
logDebug('terminalManager.start', { pid: process.pid, socketPath })
|
|
41
|
+
|
|
42
|
+
const existingPid = await findSocketProcessPid(socketPath)
|
|
43
|
+
if (existingPid !== null && existingPid !== process.pid) {
|
|
44
|
+
logDebug('terminalManager.alreadyRunning', { existingPid })
|
|
45
|
+
process.stderr.write(`aimux terminal manager already running (pid ${existingPid})\n`)
|
|
46
|
+
process.exit(1)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
removeTerminalManagerSocketIfExists()
|
|
50
|
+
|
|
51
|
+
const sessionManager = new SessionManager()
|
|
52
|
+
const sockets = new Set<Socket>()
|
|
53
|
+
const negotiatedVersions = new Map<Socket, number>()
|
|
54
|
+
|
|
55
|
+
sessionManager.on('render', (sessionId, tabId, viewport, terminalModes) => {
|
|
56
|
+
const event: ManagerEvent = {
|
|
57
|
+
payload: { sessionId, tabId, terminalModes, viewport },
|
|
58
|
+
type: 'tabRender',
|
|
59
|
+
}
|
|
60
|
+
for (const socket of sockets) {
|
|
61
|
+
send(socket, event)
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
sessionManager.on('exit', (sessionId, tabId, exitCode) => {
|
|
65
|
+
const event: ManagerEvent = { payload: { exitCode, sessionId, tabId }, type: 'tabExit' }
|
|
66
|
+
for (const socket of sockets) {
|
|
67
|
+
send(socket, event)
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
sessionManager.on('error', (sessionId, tabId, message) => {
|
|
71
|
+
const event: ManagerEvent = { payload: { message, sessionId, tabId }, type: 'tabError' }
|
|
72
|
+
for (const socket of sockets) {
|
|
73
|
+
send(socket, event)
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const server = createServer((socket) => {
|
|
78
|
+
logDebug('terminalManager.client.connected')
|
|
79
|
+
sockets.add(socket)
|
|
80
|
+
const decoder = new MessageDecoder<ManagerRequest>(parseManagerRequest)
|
|
81
|
+
|
|
82
|
+
socket.on('data', (chunk) => {
|
|
83
|
+
try {
|
|
84
|
+
for (const message of decoder.push(chunk)) {
|
|
85
|
+
try {
|
|
86
|
+
switch (message.type) {
|
|
87
|
+
case 'hello': {
|
|
88
|
+
logDebug('terminalManager.request.hello', {
|
|
89
|
+
maxVersion: message.payload.maxVersion,
|
|
90
|
+
minVersion: message.payload.minVersion,
|
|
91
|
+
})
|
|
92
|
+
const selectedVersion = selectManagerProtocolVersion(message.payload)
|
|
93
|
+
if (selectedVersion === null) {
|
|
94
|
+
send(socket, {
|
|
95
|
+
id: message.id,
|
|
96
|
+
payload: { message: 'No compatible terminal-manager protocol version' },
|
|
97
|
+
type: 'error',
|
|
98
|
+
})
|
|
99
|
+
break
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
negotiatedVersions.set(socket, selectedVersion)
|
|
103
|
+
logDebug('terminalManager.request.hello.success', { selectedVersion })
|
|
104
|
+
send(socket, {
|
|
105
|
+
id: message.id,
|
|
106
|
+
payload: createManagerHelloResult(selectedVersion),
|
|
107
|
+
type: 'helloResult',
|
|
108
|
+
})
|
|
109
|
+
break
|
|
110
|
+
}
|
|
111
|
+
case 'attachSession': {
|
|
112
|
+
logDebug('terminalManager.request.attach.start', {
|
|
113
|
+
cols: message.payload.cols,
|
|
114
|
+
rows: message.payload.rows,
|
|
115
|
+
sessionId: message.payload.sessionId,
|
|
116
|
+
snapshotTabs: message.payload.workspaceSnapshot?.tabs.length ?? 0,
|
|
117
|
+
})
|
|
118
|
+
const negotiatedVersion = requireNegotiatedVersion(socket, negotiatedVersions)
|
|
119
|
+
if (message.payload.protocolVersion !== negotiatedVersion) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`Manager protocol mismatch: client v${message.payload.protocolVersion}, server v${negotiatedVersion}`
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
sessionManager.resize(
|
|
125
|
+
message.payload.sessionId,
|
|
126
|
+
message.payload.cols,
|
|
127
|
+
message.payload.rows
|
|
128
|
+
)
|
|
129
|
+
const attachResult = sessionManager.attachSession(
|
|
130
|
+
message.payload.sessionId,
|
|
131
|
+
message.payload.workspaceSnapshot
|
|
132
|
+
)
|
|
133
|
+
send(socket, {
|
|
134
|
+
id: message.id,
|
|
135
|
+
payload: { protocolVersion: negotiatedVersion, ...attachResult },
|
|
136
|
+
type: 'attachResult',
|
|
137
|
+
})
|
|
138
|
+
logDebug('terminalManager.request.attach.success', {
|
|
139
|
+
activeTabId: attachResult.activeTabId,
|
|
140
|
+
sessionId: message.payload.sessionId,
|
|
141
|
+
tabs: attachResult.tabs.length,
|
|
142
|
+
})
|
|
143
|
+
break
|
|
144
|
+
}
|
|
145
|
+
case 'createTab':
|
|
146
|
+
requireNegotiatedVersion(socket, negotiatedVersions)
|
|
147
|
+
logDebug('terminalManager.request.createTab.start', {
|
|
148
|
+
command: message.payload.command,
|
|
149
|
+
sessionId: message.payload.sessionId,
|
|
150
|
+
tabId: message.payload.tabId,
|
|
151
|
+
title: message.payload.title,
|
|
152
|
+
})
|
|
153
|
+
sessionManager.createTab(message.payload.sessionId, message.payload)
|
|
154
|
+
sendOk(socket, message.id)
|
|
155
|
+
logDebug('terminalManager.request.createTab.success', {
|
|
156
|
+
sessionId: message.payload.sessionId,
|
|
157
|
+
tabId: message.payload.tabId,
|
|
158
|
+
})
|
|
159
|
+
break
|
|
160
|
+
case 'write':
|
|
161
|
+
requireNegotiatedVersion(socket, negotiatedVersions)
|
|
162
|
+
sessionManager.write(
|
|
163
|
+
message.payload.sessionId,
|
|
164
|
+
message.payload.tabId,
|
|
165
|
+
message.payload.data
|
|
166
|
+
)
|
|
167
|
+
sendOk(socket, message.id)
|
|
168
|
+
break
|
|
169
|
+
case 'resizeClient': {
|
|
170
|
+
requireNegotiatedVersion(socket, negotiatedVersions)
|
|
171
|
+
const intentsRecord = message.payload.intents
|
|
172
|
+
const intentsMap = intentsRecord
|
|
173
|
+
? new Map(Object.entries(intentsRecord))
|
|
174
|
+
: undefined
|
|
175
|
+
sessionManager.resize(
|
|
176
|
+
message.payload.sessionId,
|
|
177
|
+
message.payload.cols,
|
|
178
|
+
message.payload.rows,
|
|
179
|
+
intentsMap
|
|
180
|
+
)
|
|
181
|
+
sendOk(socket, message.id)
|
|
182
|
+
break
|
|
183
|
+
}
|
|
184
|
+
case 'resizeTab':
|
|
185
|
+
requireNegotiatedVersion(socket, negotiatedVersions)
|
|
186
|
+
sessionManager.resizeTab(
|
|
187
|
+
message.payload.sessionId,
|
|
188
|
+
message.payload.tabId,
|
|
189
|
+
message.payload.cols,
|
|
190
|
+
message.payload.rows,
|
|
191
|
+
message.payload.intent
|
|
192
|
+
)
|
|
193
|
+
sendOk(socket, message.id)
|
|
194
|
+
break
|
|
195
|
+
case 'scroll':
|
|
196
|
+
requireNegotiatedVersion(socket, negotiatedVersions)
|
|
197
|
+
sessionManager.scroll(
|
|
198
|
+
message.payload.sessionId,
|
|
199
|
+
message.payload.tabId,
|
|
200
|
+
message.payload.deltaLines
|
|
201
|
+
)
|
|
202
|
+
sendOk(socket, message.id)
|
|
203
|
+
break
|
|
204
|
+
case 'scrollToBottom':
|
|
205
|
+
requireNegotiatedVersion(socket, negotiatedVersions)
|
|
206
|
+
sessionManager.scrollToBottom(message.payload.sessionId, message.payload.tabId)
|
|
207
|
+
sendOk(socket, message.id)
|
|
208
|
+
break
|
|
209
|
+
case 'reapplyScrollIntent':
|
|
210
|
+
requireNegotiatedVersion(socket, negotiatedVersions)
|
|
211
|
+
sessionManager.reapplyScrollIntent(
|
|
212
|
+
message.payload.sessionId,
|
|
213
|
+
message.payload.tabId,
|
|
214
|
+
message.payload.intent
|
|
215
|
+
)
|
|
216
|
+
sendOk(socket, message.id)
|
|
217
|
+
break
|
|
218
|
+
case 'setActiveTab':
|
|
219
|
+
requireNegotiatedVersion(socket, negotiatedVersions)
|
|
220
|
+
sessionManager.setActiveTab(message.payload.sessionId, message.payload.tabId)
|
|
221
|
+
sendOk(socket, message.id)
|
|
222
|
+
break
|
|
223
|
+
case 'closeTab':
|
|
224
|
+
requireNegotiatedVersion(socket, negotiatedVersions)
|
|
225
|
+
sessionManager.closeTab(message.payload.sessionId, message.payload.tabId)
|
|
226
|
+
sendOk(socket, message.id)
|
|
227
|
+
break
|
|
228
|
+
case 'disposeSession':
|
|
229
|
+
requireNegotiatedVersion(socket, negotiatedVersions)
|
|
230
|
+
sessionManager.disposeSession(message.payload.sessionId)
|
|
231
|
+
sendOk(socket, message.id)
|
|
232
|
+
break
|
|
233
|
+
case 'ping':
|
|
234
|
+
sendOk(socket, message.id)
|
|
235
|
+
break
|
|
236
|
+
}
|
|
237
|
+
} catch (error) {
|
|
238
|
+
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
239
|
+
logDebug('terminalManager.request.error', {
|
|
240
|
+
error: errorMessage,
|
|
241
|
+
requestId: message.id,
|
|
242
|
+
type: message.type,
|
|
243
|
+
})
|
|
244
|
+
send(socket, { id: message.id, payload: { message: errorMessage }, type: 'error' })
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
} catch (error) {
|
|
248
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
249
|
+
logDebug('terminalManager.decoder.error', { error: message })
|
|
250
|
+
decoder.reset()
|
|
251
|
+
send(socket, { id: crypto.randomUUID(), payload: { message }, type: 'error' })
|
|
252
|
+
}
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
socket.on('close', () => {
|
|
256
|
+
logDebug('terminalManager.client.close')
|
|
257
|
+
sockets.delete(socket)
|
|
258
|
+
negotiatedVersions.delete(socket)
|
|
259
|
+
})
|
|
260
|
+
socket.on('error', () => {
|
|
261
|
+
logDebug('terminalManager.client.error')
|
|
262
|
+
sockets.delete(socket)
|
|
263
|
+
negotiatedVersions.delete(socket)
|
|
264
|
+
})
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
await new Promise<void>((resolve, reject) => {
|
|
268
|
+
server.once('error', reject)
|
|
269
|
+
server.listen(socketPath, () => resolve())
|
|
270
|
+
})
|
|
271
|
+
tightenSocketPermissions(socketPath)
|
|
272
|
+
|
|
273
|
+
const gracefulShutdown = (signal: string) => {
|
|
274
|
+
logDebug(`terminalManager.${signal}`)
|
|
275
|
+
sessionManager.disposeAll()
|
|
276
|
+
server.close()
|
|
277
|
+
process.exit(0)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
process.on('SIGTERM', () => gracefulShutdown('sigterm'))
|
|
281
|
+
process.on('SIGINT', () => gracefulShutdown('sigint'))
|
|
282
|
+
process.on('uncaughtException', () => gracefulShutdown('uncaughtException'))
|
|
283
|
+
process.on('unhandledRejection', () => gracefulShutdown('unhandledRejection'))
|
|
284
|
+
|
|
285
|
+
await new Promise<void>(() => {
|
|
286
|
+
// Keep the terminal-manager process alive until it is terminated.
|
|
287
|
+
})
|
|
288
|
+
}
|