@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.
Files changed (59) hide show
  1. package/README.md +29 -8
  2. package/package.json +5 -5
  3. package/src/app-runtime/backend-runtime-events.ts +16 -1
  4. package/src/app-runtime/pty-write.ts +7 -4
  5. package/src/app-runtime/side-effects.ts +62 -4
  6. package/src/app-runtime/snippet-actions.ts +3 -2
  7. package/src/app-runtime/use-backend-runtime.ts +7 -2
  8. package/src/app-runtime/use-renderer-bindings.ts +2 -2
  9. package/src/app-runtime/use-terminal-resize.ts +15 -6
  10. package/src/app.tsx +89 -30
  11. package/src/config/loader.ts +2 -2
  12. package/src/config.ts +14 -3
  13. package/src/daemon/daemon.ts +279 -118
  14. package/src/daemon/runtime-paths.ts +34 -2
  15. package/src/daemon/session-manager.ts +20 -5
  16. package/src/daemon/session-registry.ts +18 -11
  17. package/src/index.tsx +19 -4
  18. package/src/input/keymap/describe-bindings.ts +68 -0
  19. package/src/input/keymap/key-format.ts +67 -0
  20. package/src/input/modes/bridge.ts +1 -0
  21. package/src/input/modes/transitions.ts +2 -0
  22. package/src/input/modes/types.ts +2 -0
  23. package/src/ipc/manager-protocol.ts +396 -0
  24. package/src/ipc/protocol.ts +98 -5
  25. package/src/platform/daemon-control.ts +42 -11
  26. package/src/profile-paths.ts +27 -0
  27. package/src/pty/pty-manager.ts +53 -3
  28. package/src/restart-daemon.ts +10 -10
  29. package/src/session-backend/bootstrap.ts +197 -46
  30. package/src/session-backend/local-session-backend.ts +12 -5
  31. package/src/session-backend/remote-session-backend.ts +143 -63
  32. package/src/session-backend/types.ts +4 -2
  33. package/src/state/reducers/modal-state.ts +18 -1
  34. package/src/state/reducers/tab-state.ts +20 -2
  35. package/src/state/session-catalog.ts +5 -4
  36. package/src/state/session-persistence.ts +9 -2
  37. package/src/state/snippet-catalog.ts +4 -4
  38. package/src/state/types.ts +23 -0
  39. package/src/state/validation.ts +8 -0
  40. package/src/terminal-manager/manager-client.ts +384 -0
  41. package/src/terminal-manager/terminal-manager.ts +288 -0
  42. package/src/ui/components/create-session-modal.tsx +3 -5
  43. package/src/ui/components/git-commit-modal.tsx +3 -5
  44. package/src/ui/components/help-modal.tsx +49 -68
  45. package/src/ui/components/list-item.tsx +24 -5
  46. package/src/ui/components/new-tab-modal.tsx +8 -9
  47. package/src/ui/components/pending-chord-overlay.tsx +28 -0
  48. package/src/ui/components/session-name-modal.tsx +3 -5
  49. package/src/ui/components/session-picker-modal.tsx +3 -1
  50. package/src/ui/components/snippet-editor-modal.tsx +3 -1
  51. package/src/ui/components/snippet-picker-modal.tsx +3 -1
  52. package/src/ui/components/status-bar.tsx +6 -2
  53. package/src/ui/components/theme-picker-modal.tsx +3 -6
  54. package/src/ui/components/update-available-modal.tsx +42 -0
  55. package/src/ui/keymap-context.ts +39 -0
  56. package/src/ui/root.tsx +12 -0
  57. package/src/ui/status-bar-model.ts +67 -39
  58. package/src/update/version-check.ts +67 -0
  59. package/src/update.ts +3 -3
@@ -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
+ }