@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
package/src/config.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
- import { dirname, join } from 'node:path'
3
2
 
4
3
  import type { WorkspaceSnapshotV1 } from './state/types'
5
4
 
6
5
  import { logDebug } from './debug/input-log'
6
+ import { getProfileConfigDir } from './profile-paths'
7
7
  import { isWorkspaceSnapshotV1 } from './state/validation'
8
8
  import { THEME_IDS, type ThemeId } from './ui/themes'
9
9
 
10
- export const CONFIG_PATH = join(process.env.HOME ?? '~', '.config', 'aimux.json')
10
+ export const CONFIG_PATH = `${getProfileConfigDir()}/aimux.json`
11
11
 
12
12
  export interface AimuxConfig {
13
13
  version: 2
@@ -16,6 +16,7 @@ export interface AimuxConfig {
16
16
  gitPanelVisible?: boolean
17
17
  gitPanelRatio?: number
18
18
  workspaceSnapshot?: WorkspaceSnapshotV1
19
+ skippedUpdateVersion?: string
19
20
  }
20
21
 
21
22
  const DEFAULT_CONFIG: AimuxConfig = {
@@ -58,6 +59,7 @@ export function loadConfigResult(): ConfigLoadResult {
58
59
  gitPanelVisible?: unknown
59
60
  gitPanelRatio?: unknown
60
61
  workspaceSnapshot?: unknown
62
+ skippedUpdateVersion?: unknown
61
63
  }
62
64
 
63
65
  const issues: string[] = []
@@ -98,6 +100,14 @@ export function loadConfigResult(): ConfigLoadResult {
98
100
  issues.push('ignored invalid workspaceSnapshot')
99
101
  }
100
102
 
103
+ const validSkippedUpdateVersion =
104
+ typeof parsed.skippedUpdateVersion === 'string' && parsed.skippedUpdateVersion.length > 0
105
+ ? parsed.skippedUpdateVersion
106
+ : undefined
107
+ if (parsed.skippedUpdateVersion !== undefined && validSkippedUpdateVersion === undefined) {
108
+ issues.push('ignored invalid skippedUpdateVersion')
109
+ }
110
+
101
111
  if (issues.length > 0) {
102
112
  logDebug('config.load.validationIssue', { issues, path: CONFIG_PATH })
103
113
  }
@@ -107,6 +117,7 @@ export function loadConfigResult(): ConfigLoadResult {
107
117
  customCommands: isCustomCommandsRecord(parsed.customCommands) ? parsed.customCommands : {},
108
118
  gitPanelRatio: validGitPanelRatio,
109
119
  gitPanelVisible: validGitPanelVisible,
120
+ skippedUpdateVersion: validSkippedUpdateVersion,
110
121
  themeId: isThemeId(parsed.themeId) ? parsed.themeId : undefined,
111
122
  version: 2,
112
123
  workspaceSnapshot: isWorkspaceSnapshotV1(parsed.workspaceSnapshot)
@@ -133,7 +144,7 @@ export function loadConfig(): AimuxConfig {
133
144
 
134
145
  export function saveConfig(config: AimuxConfig): boolean {
135
146
  try {
136
- mkdirSync(dirname(CONFIG_PATH), { recursive: true })
147
+ mkdirSync(getProfileConfigDir(), { recursive: true })
137
148
  writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`)
138
149
  return true
139
150
  } catch (error) {
@@ -1,22 +1,28 @@
1
- import { createServer, type Socket } from 'node:net'
1
+ import { connect, createServer, type Socket } from 'node:net'
2
2
 
3
3
  import { logDebug } from '../debug/input-log'
4
4
  import {
5
5
  type ClientRequest,
6
6
  encodeMessage,
7
+ getProcessVersion,
8
+ IPC_PROTOCOL_MIN_VERSION,
7
9
  IPC_PROTOCOL_VERSION,
8
10
  MessageDecoder,
11
+ negotiateProtocolVersion,
9
12
  parseClientRequest,
10
13
  type ServerEvent,
11
14
  type ServerResponse,
12
15
  } from '../ipc/protocol'
13
- import { findDaemonPid } from '../platform/daemon-control'
16
+ import { findSocketProcessPid, spawnDetachedTerminalManager } from '../platform/daemon-control'
17
+ import { TerminalManagerClient } from '../terminal-manager/manager-client'
14
18
  import {
15
- getDaemonSocketPath,
19
+ getIpcDaemonSocketPath,
20
+ getSocketSecurityIssue,
21
+ getTerminalManagerSocketPath,
16
22
  removeDaemonSocketIfExists,
17
- tightenDaemonSocketPermissions,
23
+ removeTerminalManagerSocketIfExists,
24
+ tightenSocketPermissions,
18
25
  } from './runtime-paths'
19
- import { SessionManager } from './session-manager'
20
26
 
21
27
  function send(socket: Socket, message: ServerResponse | ServerEvent): void {
22
28
  socket.write(encodeMessage(message))
@@ -34,25 +40,88 @@ function requireSession(socket: Socket, attachedSessions: Map<Socket, string>):
34
40
  return sessionId
35
41
  }
36
42
 
43
+ function requireNegotiatedVersion(socket: Socket, versions: Map<Socket, number>): number {
44
+ const version = versions.get(socket)
45
+ if (version === undefined) {
46
+ throw new Error('Protocol handshake required before attach')
47
+ }
48
+ return version
49
+ }
50
+
51
+ async function canConnectToSocket(socketPath: string): Promise<boolean> {
52
+ const securityIssue = getSocketSecurityIssue(socketPath)
53
+ if (securityIssue) {
54
+ logDebug('daemon.socketUnhealthy', { issue: securityIssue, socketPath })
55
+ return false
56
+ }
57
+
58
+ return new Promise<boolean>((resolve) => {
59
+ const socket = connect(socketPath)
60
+ const finish = (result: boolean) => {
61
+ socket.removeAllListeners()
62
+ socket.destroy()
63
+ resolve(result)
64
+ }
65
+
66
+ socket.once('connect', () => finish(true))
67
+ socket.once('error', () => finish(false))
68
+ })
69
+ }
70
+
71
+ async function ensureTerminalManagerReady(manager: TerminalManagerClient): Promise<void> {
72
+ try {
73
+ logDebug('daemon.ensureTerminalManager.connectExisting.start', {
74
+ socketPath: getTerminalManagerSocketPath(),
75
+ })
76
+ await manager.connect()
77
+ logDebug('daemon.ensureTerminalManager.connectExisting.success')
78
+ return
79
+ } catch (error) {
80
+ logDebug('daemon.ensureTerminalManager.connectExisting.failed', {
81
+ error: error instanceof Error ? error.message : String(error),
82
+ })
83
+ const socketPath = getTerminalManagerSocketPath()
84
+ if (!(await canConnectToSocket(socketPath))) {
85
+ removeTerminalManagerSocketIfExists()
86
+ logDebug('daemon.ensureTerminalManager.spawn.start', { socketPath })
87
+ const ok = await spawnDetachedTerminalManager()
88
+ if (!ok) {
89
+ throw new Error('Failed to start terminal manager')
90
+ }
91
+ logDebug('daemon.ensureTerminalManager.spawn.success', { socketPath })
92
+ }
93
+ await manager.connect()
94
+ logDebug('daemon.ensureTerminalManager.connectAfterSpawn.success')
95
+ }
96
+ }
97
+
37
98
  export async function runDaemon(): Promise<void> {
38
- const socketPath = getDaemonSocketPath()
99
+ const socketPath = getIpcDaemonSocketPath()
39
100
  logDebug('daemon.start', { pid: process.pid, socketPath })
40
101
 
41
- const existingPid = await findDaemonPid(socketPath)
102
+ const existingPid = await findSocketProcessPid(socketPath)
42
103
  if (existingPid !== null && existingPid !== process.pid) {
43
104
  logDebug('daemon.alreadyRunning', { existingPid })
44
105
  process.stderr.write(`aimux daemon already running (pid ${existingPid})\n`)
45
106
  process.exit(1)
46
107
  }
47
108
 
48
- logDebug('daemon.removeStaleSocket', { socketPath })
49
109
  removeDaemonSocketIfExists()
50
110
 
51
- const sessionManager = new SessionManager()
111
+ const manager = new TerminalManagerClient()
112
+ await ensureTerminalManagerReady(manager)
113
+
52
114
  const sockets = new Set<Socket>()
53
115
  const attachedSessions = new Map<Socket, string>()
116
+ const negotiatedVersions = new Map<Socket, number>()
54
117
 
55
- sessionManager.on('render', (sessionId, tabId, viewport, terminalModes) => {
118
+ manager.on('render', (sessionId, tabId, viewport, terminalModes) => {
119
+ logDebug('daemon.manager.render', {
120
+ attachedSocketCount: sockets.size,
121
+ sessionId,
122
+ tabId,
123
+ viewportLines: viewport.lines.length,
124
+ })
56
125
  const event: ServerEvent = { payload: { tabId, terminalModes, viewport }, type: 'tabRender' }
57
126
  for (const socket of sockets) {
58
127
  if (attachedSessions.get(socket) === sessionId) {
@@ -60,7 +129,8 @@ export async function runDaemon(): Promise<void> {
60
129
  }
61
130
  }
62
131
  })
63
- sessionManager.on('exit', (sessionId, tabId, exitCode) => {
132
+ manager.on('exit', (sessionId, tabId, exitCode) => {
133
+ logDebug('daemon.manager.exit', { exitCode, sessionId, tabId })
64
134
  const event: ServerEvent = { payload: { exitCode, tabId }, type: 'tabExit' }
65
135
  for (const socket of sockets) {
66
136
  if (attachedSessions.get(socket) === sessionId) {
@@ -68,7 +138,8 @@ export async function runDaemon(): Promise<void> {
68
138
  }
69
139
  }
70
140
  })
71
- sessionManager.on('error', (sessionId, tabId, message) => {
141
+ manager.on('error', (sessionId, tabId, message) => {
142
+ logDebug('daemon.manager.error', { message, sessionId, tabId })
72
143
  const event: ServerEvent = { payload: { message, tabId }, type: 'tabError' }
73
144
  for (const socket of sockets) {
74
145
  if (attachedSessions.get(socket) === sessionId) {
@@ -83,117 +154,212 @@ export async function runDaemon(): Promise<void> {
83
154
  const decoder = new MessageDecoder<ClientRequest>(parseClientRequest)
84
155
 
85
156
  socket.on('data', (chunk) => {
86
- try {
87
- for (const message of decoder.push(chunk)) {
88
- try {
89
- logDebug('daemon.request', { id: message.id, type: message.type })
90
- switch (message.type) {
91
- case 'attach': {
92
- attachedSessions.set(socket, message.payload.sessionId)
93
- sessionManager.resize(
94
- message.payload.sessionId,
95
- message.payload.cols,
96
- message.payload.rows
97
- )
98
- const attachResult = sessionManager.attachSession(
99
- message.payload.sessionId,
100
- message.payload.workspaceSnapshot
101
- )
102
- send(socket, {
103
- id: message.id,
104
- payload: { protocolVersion: IPC_PROTOCOL_VERSION, ...attachResult },
105
- type: 'attachResult',
106
- })
107
- break
108
- }
109
- case 'createTab': {
110
- const sessionId = requireSession(socket, attachedSessions)
111
- sessionManager.createTab(sessionId, message.payload)
112
- sendOk(socket, message.id)
113
- break
114
- }
115
- case 'write': {
116
- const sessionId = requireSession(socket, attachedSessions)
117
- sessionManager.write(sessionId, message.payload.tabId, message.payload.data)
118
- sendOk(socket, message.id)
119
- break
120
- }
121
- case 'resizeClient': {
122
- const sessionId = requireSession(socket, attachedSessions)
123
- sessionManager.resize(sessionId, message.payload.cols, message.payload.rows)
124
- sendOk(socket, message.id)
125
- break
126
- }
127
- case 'resizeTab': {
128
- const sessionId = requireSession(socket, attachedSessions)
129
- sessionManager.resizeTab(
130
- sessionId,
131
- message.payload.tabId,
132
- message.payload.cols,
133
- message.payload.rows
134
- )
135
- sendOk(socket, message.id)
136
- break
137
- }
138
- case 'scroll': {
139
- const sessionId = requireSession(socket, attachedSessions)
140
- sessionManager.scroll(sessionId, message.payload.tabId, message.payload.deltaLines)
141
- sendOk(socket, message.id)
142
- break
143
- }
144
- case 'scrollToBottom': {
145
- const sessionId = requireSession(socket, attachedSessions)
146
- sessionManager.scrollToBottom(sessionId, message.payload.tabId)
147
- sendOk(socket, message.id)
148
- break
149
- }
150
- case 'setActiveTab': {
151
- const sessionId = requireSession(socket, attachedSessions)
152
- sessionManager.setActiveTab(sessionId, message.payload.tabId)
153
- sendOk(socket, message.id)
154
- break
155
- }
156
- case 'closeTab': {
157
- const sessionId = requireSession(socket, attachedSessions)
158
- sessionManager.closeTab(sessionId, message.payload.tabId)
159
- sendOk(socket, message.id)
160
- break
161
- }
162
- case 'disposeAll': {
163
- const sessionId = attachedSessions.get(socket)
164
- if (sessionId) {
165
- sessionManager.disposeSession(sessionId)
157
+ void (async () => {
158
+ try {
159
+ for (const message of decoder.push(chunk)) {
160
+ try {
161
+ switch (message.type) {
162
+ case 'hello': {
163
+ logDebug('daemon.request.hello', {
164
+ maxVersion: message.payload.maxVersion,
165
+ minVersion: message.payload.minVersion,
166
+ })
167
+ const selectedVersion = negotiateProtocolVersion(
168
+ message.payload.minVersion,
169
+ message.payload.maxVersion,
170
+ IPC_PROTOCOL_MIN_VERSION,
171
+ IPC_PROTOCOL_VERSION
172
+ )
173
+ if (selectedVersion === null) {
174
+ send(socket, {
175
+ id: message.id,
176
+ payload: { message: 'No compatible app protocol version' },
177
+ type: 'error',
178
+ })
179
+ break
180
+ }
181
+ negotiatedVersions.set(socket, selectedVersion)
182
+ logDebug('daemon.request.hello.success', { selectedVersion })
183
+ send(socket, {
184
+ id: message.id,
185
+ payload: {
186
+ maxVersion: IPC_PROTOCOL_VERSION,
187
+ minVersion: IPC_PROTOCOL_MIN_VERSION,
188
+ processVersion: getProcessVersion(),
189
+ selectedVersion,
190
+ },
191
+ type: 'helloResult',
192
+ })
193
+ break
194
+ }
195
+ case 'attach': {
196
+ logDebug('daemon.request.attach.start', {
197
+ cols: message.payload.cols,
198
+ rows: message.payload.rows,
199
+ sessionId: message.payload.sessionId,
200
+ snapshotTabs: message.payload.workspaceSnapshot?.tabs.length ?? 0,
201
+ })
202
+ const negotiatedVersion = requireNegotiatedVersion(socket, negotiatedVersions)
203
+ if (message.payload.protocolVersion !== negotiatedVersion) {
204
+ throw new Error(
205
+ `Protocol mismatch: client v${message.payload.protocolVersion}, daemon v${negotiatedVersion}`
206
+ )
207
+ }
208
+
209
+ attachedSessions.set(socket, message.payload.sessionId)
210
+ const attachResult = await manager.attachSession({
211
+ cols: message.payload.cols,
212
+ rows: message.payload.rows,
213
+ sessionId: message.payload.sessionId,
214
+ workspaceSnapshot: message.payload.workspaceSnapshot,
215
+ })
216
+ send(socket, {
217
+ id: message.id,
218
+ payload: {
219
+ activeTabId: attachResult.activeTabId,
220
+ protocolVersion: negotiatedVersion,
221
+ tabs: attachResult.tabs,
222
+ },
223
+ type: 'attachResult',
224
+ })
225
+ logDebug('daemon.request.attach.success', {
226
+ activeTabId: attachResult.activeTabId,
227
+ sessionId: message.payload.sessionId,
228
+ tabs: attachResult.tabs.length,
229
+ })
230
+ break
231
+ }
232
+ case 'createTab': {
233
+ const sessionId = requireSession(socket, attachedSessions)
234
+ requireNegotiatedVersion(socket, negotiatedVersions)
235
+ logDebug('daemon.request.createTab.start', {
236
+ command: message.payload.command,
237
+ sessionId,
238
+ tabId: message.payload.tabId,
239
+ title: message.payload.title,
240
+ })
241
+ await manager.createTab({ ...message.payload, sessionId })
242
+ sendOk(socket, message.id)
243
+ logDebug('daemon.request.createTab.success', {
244
+ sessionId,
245
+ tabId: message.payload.tabId,
246
+ })
247
+ break
248
+ }
249
+ case 'write': {
250
+ const sessionId = requireSession(socket, attachedSessions)
251
+ requireNegotiatedVersion(socket, negotiatedVersions)
252
+ await manager.write(sessionId, message.payload.tabId, message.payload.data)
253
+ sendOk(socket, message.id)
254
+ break
255
+ }
256
+ case 'resizeClient': {
257
+ const sessionId = requireSession(socket, attachedSessions)
258
+ requireNegotiatedVersion(socket, negotiatedVersions)
259
+ await manager.resize(
260
+ sessionId,
261
+ message.payload.cols,
262
+ message.payload.rows,
263
+ message.payload.intents
264
+ )
265
+ sendOk(socket, message.id)
266
+ break
267
+ }
268
+ case 'resizeTab': {
269
+ const sessionId = requireSession(socket, attachedSessions)
270
+ requireNegotiatedVersion(socket, negotiatedVersions)
271
+ await manager.resizeTab(
272
+ sessionId,
273
+ message.payload.tabId,
274
+ message.payload.cols,
275
+ message.payload.rows,
276
+ message.payload.intent
277
+ )
278
+ sendOk(socket, message.id)
279
+ break
166
280
  }
167
- sendOk(socket, message.id)
168
- break
281
+ case 'scroll': {
282
+ const sessionId = requireSession(socket, attachedSessions)
283
+ requireNegotiatedVersion(socket, negotiatedVersions)
284
+ await manager.scroll(sessionId, message.payload.tabId, message.payload.deltaLines)
285
+ sendOk(socket, message.id)
286
+ break
287
+ }
288
+ case 'scrollToBottom': {
289
+ const sessionId = requireSession(socket, attachedSessions)
290
+ requireNegotiatedVersion(socket, negotiatedVersions)
291
+ await manager.scrollToBottom(sessionId, message.payload.tabId)
292
+ sendOk(socket, message.id)
293
+ break
294
+ }
295
+ case 'reapplyScrollIntent': {
296
+ const sessionId = requireSession(socket, attachedSessions)
297
+ requireNegotiatedVersion(socket, negotiatedVersions)
298
+ await manager.reapplyScrollIntent(
299
+ sessionId,
300
+ message.payload.tabId,
301
+ message.payload.intent
302
+ )
303
+ sendOk(socket, message.id)
304
+ break
305
+ }
306
+ case 'setActiveTab': {
307
+ const sessionId = requireSession(socket, attachedSessions)
308
+ requireNegotiatedVersion(socket, negotiatedVersions)
309
+ await manager.setActiveTab(sessionId, message.payload.tabId)
310
+ sendOk(socket, message.id)
311
+ break
312
+ }
313
+ case 'closeTab': {
314
+ const sessionId = requireSession(socket, attachedSessions)
315
+ requireNegotiatedVersion(socket, negotiatedVersions)
316
+ await manager.closeTab(sessionId, message.payload.tabId)
317
+ sendOk(socket, message.id)
318
+ break
319
+ }
320
+ case 'disposeAll': {
321
+ const sessionId = attachedSessions.get(socket)
322
+ requireNegotiatedVersion(socket, negotiatedVersions)
323
+ if (sessionId) {
324
+ await manager.disposeSession(sessionId)
325
+ }
326
+ sendOk(socket, message.id)
327
+ break
328
+ }
329
+ case 'ping':
330
+ sendOk(socket, message.id)
331
+ break
169
332
  }
170
- case 'ping':
171
- sendOk(socket, message.id)
172
- break
333
+ } catch (error) {
334
+ const errorMessage = error instanceof Error ? error.message : String(error)
335
+ logDebug('daemon.request.error', {
336
+ error: errorMessage,
337
+ requestId: message.id,
338
+ type: message.type,
339
+ })
340
+ send(socket, { id: message.id, payload: { message: errorMessage }, type: 'error' })
173
341
  }
174
- } catch (error) {
175
- const errorMessage = error instanceof Error ? error.message : String(error)
176
- logDebug('daemon.request.error', { error: errorMessage, requestId: message.id })
177
- send(socket, { id: message.id, payload: { message: errorMessage }, type: 'error' })
178
342
  }
343
+ } catch (error) {
344
+ const message = error instanceof Error ? error.message : String(error)
345
+ logDebug('daemon.decoder.error', { error: message })
346
+ decoder.reset()
347
+ send(socket, { id: crypto.randomUUID(), payload: { message }, type: 'error' })
179
348
  }
180
- } catch (error) {
181
- const message = error instanceof Error ? error.message : String(error)
182
- logDebug('daemon.request.error', { error: message })
183
- decoder.reset()
184
- send(socket, { id: crypto.randomUUID(), payload: { message }, type: 'error' })
185
- }
349
+ })()
186
350
  })
187
351
 
188
352
  socket.on('close', () => {
189
- logDebug('daemon.client.close')
353
+ logDebug('daemon.client.close', { sessionId: attachedSessions.get(socket) ?? null })
190
354
  sockets.delete(socket)
191
355
  attachedSessions.delete(socket)
356
+ negotiatedVersions.delete(socket)
192
357
  })
193
358
  socket.on('error', () => {
194
- logDebug('daemon.client.error')
359
+ logDebug('daemon.client.error', { sessionId: attachedSessions.get(socket) ?? null })
195
360
  sockets.delete(socket)
196
361
  attachedSessions.delete(socket)
362
+ negotiatedVersions.delete(socket)
197
363
  })
198
364
  })
199
365
 
@@ -201,12 +367,11 @@ export async function runDaemon(): Promise<void> {
201
367
  server.once('error', reject)
202
368
  server.listen(socketPath, () => resolve())
203
369
  })
204
- tightenDaemonSocketPermissions(socketPath)
205
- logDebug('daemon.listening', { socketPath })
370
+ tightenSocketPermissions(socketPath)
206
371
 
207
372
  const gracefulShutdown = (signal: string) => {
208
373
  logDebug(`daemon.${signal}`)
209
- sessionManager.disposeAll()
374
+ manager.destroy()
210
375
  server.close()
211
376
  process.exit(0)
212
377
  }
@@ -216,18 +381,14 @@ export async function runDaemon(): Promise<void> {
216
381
 
217
382
  process.on('uncaughtException', (error) => {
218
383
  logDebug('daemon.uncaughtException', { error: error.message, stack: error.stack })
219
- sessionManager.disposeAll()
220
- server.close()
221
- process.exit(1)
384
+ gracefulShutdown('uncaughtException')
222
385
  })
223
386
 
224
387
  process.on('unhandledRejection', (reason) => {
225
388
  const message = reason instanceof Error ? reason.message : String(reason)
226
389
  const stack = reason instanceof Error ? reason.stack : undefined
227
390
  logDebug('daemon.unhandledRejection', { error: message, stack })
228
- sessionManager.disposeAll()
229
- server.close()
230
- process.exit(1)
391
+ gracefulShutdown('unhandledRejection')
231
392
  })
232
393
 
233
394
  await new Promise<void>(() => {
@@ -1,12 +1,18 @@
1
1
  import { chmodSync, constants, existsSync, lstatSync, mkdirSync, unlinkSync } from 'node:fs'
2
2
  import { dirname, join } from 'node:path'
3
3
 
4
+ import { getProfileName } from '../profile-paths'
5
+
6
+ export function getRuntimeProfile(): string {
7
+ return getProfileName()
8
+ }
9
+
4
10
  function getRuntimeBaseDir(): string {
5
11
  if (process.env.XDG_RUNTIME_DIR) {
6
- return join(process.env.XDG_RUNTIME_DIR, 'aimux')
12
+ return join(process.env.XDG_RUNTIME_DIR, `aimux-${getRuntimeProfile()}`)
7
13
  }
8
14
 
9
- return join(process.env.HOME ?? '.', '.local', 'state', 'aimux')
15
+ return join(process.env.HOME ?? '.', '.local', 'state', `aimux-${getRuntimeProfile()}`)
10
16
  }
11
17
 
12
18
  export function ensureRuntimeDir(): string {
@@ -24,6 +30,14 @@ export function getDaemonSocketPath(): string {
24
30
  return join(ensureRuntimeDir(), 'daemon.sock')
25
31
  }
26
32
 
33
+ export function getIpcDaemonSocketPath(): string {
34
+ return getDaemonSocketPath()
35
+ }
36
+
37
+ export function getTerminalManagerSocketPath(): string {
38
+ return join(ensureRuntimeDir(), 'terminal-manager.sock')
39
+ }
40
+
27
41
  export function ensureParentDir(filePath: string): void {
28
42
  mkdirSync(dirname(filePath), { recursive: true })
29
43
  }
@@ -36,6 +50,10 @@ export function tightenDaemonSocketPermissions(socketPath: string): void {
36
50
  }
37
51
  }
38
52
 
53
+ export function tightenSocketPermissions(socketPath: string): void {
54
+ tightenDaemonSocketPermissions(socketPath)
55
+ }
56
+
39
57
  export function getDaemonSocketSecurityIssue(socketPath: string): string | null {
40
58
  if (!existsSync(socketPath)) {
41
59
  return 'socket missing'
@@ -61,9 +79,23 @@ export function getDaemonSocketSecurityIssue(socketPath: string): string | null
61
79
  }
62
80
  }
63
81
 
82
+ export function getSocketSecurityIssue(socketPath: string): string | null {
83
+ return getDaemonSocketSecurityIssue(socketPath)
84
+ }
85
+
64
86
  export function removeDaemonSocketIfExists(): void {
65
87
  const socketPath = getDaemonSocketPath()
66
88
  if (existsSync(socketPath)) {
67
89
  unlinkSync(socketPath)
68
90
  }
69
91
  }
92
+
93
+ export function removeSocketIfExists(socketPath: string): void {
94
+ if (existsSync(socketPath)) {
95
+ unlinkSync(socketPath)
96
+ }
97
+ }
98
+
99
+ export function removeTerminalManagerSocketIfExists(): void {
100
+ removeSocketIfExists(getTerminalManagerSocketPath())
101
+ }
@@ -1,6 +1,11 @@
1
1
  import { EventEmitter } from 'node:events'
2
2
 
3
- import type { TerminalModeState, TerminalSnapshot, WorkspaceSnapshotV1 } from '../state/types'
3
+ import type {
4
+ ScrollIntent,
5
+ TerminalModeState,
6
+ TerminalSnapshot,
7
+ WorkspaceSnapshotV1,
8
+ } from '../state/types'
4
9
 
5
10
  import { logDebug } from '../debug/input-log'
6
11
  import { SessionRegistry } from './session-registry'
@@ -62,12 +67,18 @@ export class SessionManager extends EventEmitter<SessionManagerEvents> {
62
67
  this.getOrCreateRegistry(sessionId).write(tabId, data)
63
68
  }
64
69
 
65
- resize(sessionId: string, cols: number, rows: number): void {
66
- this.getOrCreateRegistry(sessionId).resizeAll(cols, rows)
70
+ resize(sessionId: string, cols: number, rows: number, intents?: Map<string, ScrollIntent>): void {
71
+ this.getOrCreateRegistry(sessionId).resizeAll(cols, rows, intents)
67
72
  }
68
73
 
69
- resizeTab(sessionId: string, tabId: string, cols: number, rows: number): void {
70
- this.getOrCreateRegistry(sessionId).resizeTab(tabId, cols, rows)
74
+ resizeTab(
75
+ sessionId: string,
76
+ tabId: string,
77
+ cols: number,
78
+ rows: number,
79
+ intent?: ScrollIntent
80
+ ): void {
81
+ this.getOrCreateRegistry(sessionId).resizeTab(tabId, cols, rows, intent)
71
82
  }
72
83
 
73
84
  scroll(sessionId: string, tabId: string, deltaLines: number): void {
@@ -78,6 +89,10 @@ export class SessionManager extends EventEmitter<SessionManagerEvents> {
78
89
  this.getOrCreateRegistry(sessionId).scrollViewportToBottom(tabId)
79
90
  }
80
91
 
92
+ reapplyScrollIntent(sessionId: string, tabId: string, intent: ScrollIntent): void {
93
+ this.getOrCreateRegistry(sessionId).reapplyScrollIntent(tabId, intent)
94
+ }
95
+
81
96
  setActiveTab(sessionId: string, tabId: string | null): void {
82
97
  this.getOrCreateRegistry(sessionId).setActiveTab(tabId)
83
98
  }