@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
@@ -0,0 +1,396 @@
1
+ import type {
2
+ ScrollIntent,
3
+ TabSession,
4
+ TerminalModeState,
5
+ TerminalSnapshot,
6
+ WorkspaceSnapshotV1,
7
+ } from '../state/types'
8
+
9
+ import { isWorkspaceSnapshotV1 } from '../state/validation'
10
+ import {
11
+ getProcessVersion,
12
+ IpcProtocolError,
13
+ MessageDecoder,
14
+ negotiateProtocolVersion,
15
+ } from './protocol'
16
+
17
+ export const MANAGER_PROTOCOL_MIN_VERSION = 1
18
+ export const MANAGER_PROTOCOL_VERSION = 1
19
+
20
+ export interface ManagerHelloRequest {
21
+ minVersion: number
22
+ maxVersion: number
23
+ }
24
+
25
+ export interface ManagerHelloResult {
26
+ minVersion: number
27
+ maxVersion: number
28
+ processVersion: string
29
+ selectedVersion: number
30
+ }
31
+
32
+ export interface ManagerAttachRequest {
33
+ protocolVersion: number
34
+ sessionId: string
35
+ cols: number
36
+ rows: number
37
+ workspaceSnapshot?: WorkspaceSnapshotV1
38
+ }
39
+
40
+ export interface ManagerAttachResult {
41
+ protocolVersion: number
42
+ tabs: TabSession[]
43
+ activeTabId: string | null
44
+ }
45
+
46
+ export type ManagerRequest =
47
+ | { id: string; type: 'hello'; payload: ManagerHelloRequest }
48
+ | { id: string; type: 'attachSession'; payload: ManagerAttachRequest }
49
+ | {
50
+ id: string
51
+ type: 'createTab'
52
+ payload: {
53
+ sessionId: string
54
+ tabId: string
55
+ assistant: TabSession['assistant']
56
+ title: string
57
+ command: string
58
+ args?: string[]
59
+ cols: number
60
+ rows: number
61
+ cwd?: string
62
+ }
63
+ }
64
+ | { id: string; type: 'write'; payload: { sessionId: string; tabId: string; data: string } }
65
+ | {
66
+ id: string
67
+ type: 'resizeClient'
68
+ payload: {
69
+ sessionId: string
70
+ cols: number
71
+ rows: number
72
+ intents?: Record<string, ScrollIntent>
73
+ }
74
+ }
75
+ | {
76
+ id: string
77
+ type: 'resizeTab'
78
+ payload: {
79
+ sessionId: string
80
+ tabId: string
81
+ cols: number
82
+ rows: number
83
+ intent?: ScrollIntent
84
+ }
85
+ }
86
+ | {
87
+ id: string
88
+ type: 'scrollToBottom'
89
+ payload: { sessionId: string; tabId: string }
90
+ }
91
+ | {
92
+ id: string
93
+ type: 'scroll'
94
+ payload: { sessionId: string; tabId: string; deltaLines: number }
95
+ }
96
+ | {
97
+ id: string
98
+ type: 'reapplyScrollIntent'
99
+ payload: { sessionId: string; tabId: string; intent: ScrollIntent }
100
+ }
101
+ | { id: string; type: 'setActiveTab'; payload: { sessionId: string; tabId: string | null } }
102
+ | { id: string; type: 'closeTab'; payload: { sessionId: string; tabId: string } }
103
+ | { id: string; type: 'disposeSession'; payload: { sessionId: string } }
104
+ | { id: string; type: 'ping'; payload: Record<string, never> }
105
+
106
+ export type ManagerResponse =
107
+ | { id: string; type: 'helloResult'; payload: ManagerHelloResult }
108
+ | { id: string; type: 'ok'; payload: Record<string, never> }
109
+ | { id: string; type: 'attachResult'; payload: ManagerAttachResult }
110
+ | { id: string; type: 'error'; payload: { message: string } }
111
+
112
+ export type ManagerEvent =
113
+ | {
114
+ type: 'tabRender'
115
+ payload: {
116
+ sessionId: string
117
+ tabId: string
118
+ viewport: TerminalSnapshot
119
+ terminalModes: TerminalModeState
120
+ }
121
+ }
122
+ | { type: 'tabExit'; payload: { sessionId: string; tabId: string; exitCode: number } }
123
+ | { type: 'tabError'; payload: { sessionId: string; tabId: string; message: string } }
124
+
125
+ export type ManagerMessage = ManagerRequest | ManagerResponse | ManagerEvent
126
+
127
+ function isObjectRecord(value: unknown): value is Record<string, unknown> {
128
+ return typeof value === 'object' && value !== null
129
+ }
130
+
131
+ function isString(value: unknown): value is string {
132
+ return typeof value === 'string'
133
+ }
134
+
135
+ function isNullableString(value: unknown): value is string | null {
136
+ return value === null || isString(value)
137
+ }
138
+
139
+ function isFiniteNumber(value: unknown): value is number {
140
+ return typeof value === 'number' && Number.isFinite(value)
141
+ }
142
+
143
+ function isStringArray(value: unknown): value is string[] {
144
+ return Array.isArray(value) && value.every(isString)
145
+ }
146
+
147
+ function isTerminalSpan(value: unknown): boolean {
148
+ return (
149
+ isObjectRecord(value) &&
150
+ isString(value.text) &&
151
+ (value.fg === undefined || isString(value.fg)) &&
152
+ (value.bg === undefined || isString(value.bg)) &&
153
+ (value.bold === undefined || typeof value.bold === 'boolean') &&
154
+ (value.italic === undefined || typeof value.italic === 'boolean') &&
155
+ (value.underline === undefined || typeof value.underline === 'boolean') &&
156
+ (value.cursor === undefined || typeof value.cursor === 'boolean')
157
+ )
158
+ }
159
+
160
+ function isTerminalSnapshot(value: unknown): value is TerminalSnapshot {
161
+ return (
162
+ isObjectRecord(value) &&
163
+ Array.isArray(value.lines) &&
164
+ value.lines.every(
165
+ (line) =>
166
+ isObjectRecord(line) && Array.isArray(line.spans) && line.spans.every(isTerminalSpan)
167
+ ) &&
168
+ isFiniteNumber(value.viewportY) &&
169
+ isFiniteNumber(value.baseY) &&
170
+ typeof value.cursorVisible === 'boolean'
171
+ )
172
+ }
173
+
174
+ function isScrollIntent(value: unknown): value is ScrollIntent {
175
+ if (!isObjectRecord(value)) return false
176
+ if (value.kind === 'bottom') return true
177
+ if (value.kind === 'anchor') return isFiniteNumber(value.absoluteLine)
178
+ return false
179
+ }
180
+
181
+ function isScrollIntentRecord(value: unknown): value is Record<string, ScrollIntent> {
182
+ if (!isObjectRecord(value)) return false
183
+ return Object.values(value).every(isScrollIntent)
184
+ }
185
+
186
+ function isTerminalModeState(value: unknown): value is TerminalModeState {
187
+ return (
188
+ isObjectRecord(value) &&
189
+ (value.mouseTrackingMode === 'none' ||
190
+ value.mouseTrackingMode === 'x10' ||
191
+ value.mouseTrackingMode === 'vt200' ||
192
+ value.mouseTrackingMode === 'drag' ||
193
+ value.mouseTrackingMode === 'any') &&
194
+ typeof value.sendFocusMode === 'boolean' &&
195
+ typeof value.alternateScrollMode === 'boolean' &&
196
+ typeof value.isAlternateBuffer === 'boolean' &&
197
+ typeof value.bracketedPasteMode === 'boolean'
198
+ )
199
+ }
200
+
201
+ function isAttachResult(value: unknown): value is ManagerAttachResult {
202
+ return (
203
+ isObjectRecord(value) &&
204
+ isFiniteNumber(value.protocolVersion) &&
205
+ Array.isArray(value.tabs) &&
206
+ value.tabs.every((tab) => isObjectRecord(tab) && isString(tab.id)) &&
207
+ isNullableString(value.activeTabId)
208
+ )
209
+ }
210
+
211
+ function isHelloResult(value: unknown): value is ManagerHelloResult {
212
+ return (
213
+ isObjectRecord(value) &&
214
+ isFiniteNumber(value.minVersion) &&
215
+ isFiniteNumber(value.maxVersion) &&
216
+ isFiniteNumber(value.selectedVersion) &&
217
+ isString(value.processVersion)
218
+ )
219
+ }
220
+
221
+ function assert(condition: boolean, message: string): asserts condition {
222
+ if (!condition) {
223
+ throw new IpcProtocolError(message)
224
+ }
225
+ }
226
+
227
+ export function selectManagerProtocolVersion(payload: ManagerHelloRequest): number | null {
228
+ return negotiateProtocolVersion(
229
+ payload.minVersion,
230
+ payload.maxVersion,
231
+ MANAGER_PROTOCOL_MIN_VERSION,
232
+ MANAGER_PROTOCOL_VERSION
233
+ )
234
+ }
235
+
236
+ export function createManagerHelloResult(selectedVersion: number): ManagerHelloResult {
237
+ return {
238
+ maxVersion: MANAGER_PROTOCOL_VERSION,
239
+ minVersion: MANAGER_PROTOCOL_MIN_VERSION,
240
+ processVersion: getProcessVersion(),
241
+ selectedVersion,
242
+ }
243
+ }
244
+
245
+ export function parseManagerRequest(value: unknown): ManagerRequest {
246
+ assert(isObjectRecord(value), 'IPC message must be an object')
247
+ assert(isString(value.id), 'IPC request id must be a string')
248
+ assert(isString(value.type), 'IPC request type must be a string')
249
+ assert(isObjectRecord(value.payload), 'IPC request payload must be an object')
250
+
251
+ switch (value.type) {
252
+ case 'hello':
253
+ assert(isFiniteNumber(value.payload.minVersion), 'hello.minVersion must be a number')
254
+ assert(isFiniteNumber(value.payload.maxVersion), 'hello.maxVersion must be a number')
255
+ return value as ManagerRequest
256
+ case 'attachSession':
257
+ assert(
258
+ isFiniteNumber(value.payload.protocolVersion),
259
+ 'attachSession.protocolVersion must be a number'
260
+ )
261
+ assert(isString(value.payload.sessionId), 'attachSession.sessionId must be a string')
262
+ assert(isFiniteNumber(value.payload.cols), 'attachSession.cols must be a number')
263
+ assert(isFiniteNumber(value.payload.rows), 'attachSession.rows must be a number')
264
+ assert(
265
+ value.payload.workspaceSnapshot === undefined ||
266
+ isWorkspaceSnapshotV1(value.payload.workspaceSnapshot),
267
+ 'attachSession.workspaceSnapshot must be a valid workspace snapshot'
268
+ )
269
+ return value as ManagerRequest
270
+ case 'createTab':
271
+ assert(isString(value.payload.sessionId), 'createTab.sessionId must be a string')
272
+ assert(isString(value.payload.tabId), 'createTab.tabId must be a string')
273
+ assert(
274
+ isString(value.payload.assistant) && value.payload.assistant.length > 0,
275
+ 'createTab.assistant must be a non-empty string'
276
+ )
277
+ assert(isString(value.payload.title), 'createTab.title must be a string')
278
+ assert(isString(value.payload.command), 'createTab.command must be a string')
279
+ assert(
280
+ value.payload.args === undefined || isStringArray(value.payload.args),
281
+ 'createTab.args must be a string array'
282
+ )
283
+ assert(isFiniteNumber(value.payload.cols), 'createTab.cols must be a number')
284
+ assert(isFiniteNumber(value.payload.rows), 'createTab.rows must be a number')
285
+ assert(
286
+ value.payload.cwd === undefined || isString(value.payload.cwd),
287
+ 'createTab.cwd must be a string'
288
+ )
289
+ return value as ManagerRequest
290
+ case 'write':
291
+ assert(isString(value.payload.sessionId), 'write.sessionId must be a string')
292
+ assert(isString(value.payload.tabId), 'write.tabId must be a string')
293
+ assert(isString(value.payload.data), 'write.data must be a string')
294
+ return value as ManagerRequest
295
+ case 'resizeClient':
296
+ assert(isString(value.payload.sessionId), 'resizeClient.sessionId must be a string')
297
+ assert(isFiniteNumber(value.payload.cols), 'resizeClient.cols must be a number')
298
+ assert(isFiniteNumber(value.payload.rows), 'resizeClient.rows must be a number')
299
+ assert(
300
+ value.payload.intents === undefined || isScrollIntentRecord(value.payload.intents),
301
+ 'resizeClient.intents must be a scroll-intent record'
302
+ )
303
+ return value as ManagerRequest
304
+ case 'resizeTab':
305
+ assert(isString(value.payload.sessionId), 'resizeTab.sessionId must be a string')
306
+ assert(isString(value.payload.tabId), 'resizeTab.tabId must be a string')
307
+ assert(isFiniteNumber(value.payload.cols), 'resizeTab.cols must be a number')
308
+ assert(isFiniteNumber(value.payload.rows), 'resizeTab.rows must be a number')
309
+ assert(
310
+ value.payload.intent === undefined || isScrollIntent(value.payload.intent),
311
+ 'resizeTab.intent must be a scroll intent'
312
+ )
313
+ return value as ManagerRequest
314
+ case 'scroll':
315
+ assert(isString(value.payload.sessionId), 'scroll.sessionId must be a string')
316
+ assert(isString(value.payload.tabId), 'scroll.tabId must be a string')
317
+ assert(isFiniteNumber(value.payload.deltaLines), 'scroll.deltaLines must be a number')
318
+ return value as ManagerRequest
319
+ case 'scrollToBottom':
320
+ assert(isString(value.payload.sessionId), 'scrollToBottom.sessionId must be a string')
321
+ assert(isString(value.payload.tabId), 'scrollToBottom.tabId must be a string')
322
+ return value as ManagerRequest
323
+ case 'reapplyScrollIntent':
324
+ assert(isString(value.payload.sessionId), 'reapplyScrollIntent.sessionId must be a string')
325
+ assert(isString(value.payload.tabId), 'reapplyScrollIntent.tabId must be a string')
326
+ assert(
327
+ isScrollIntent(value.payload.intent),
328
+ 'reapplyScrollIntent.intent must be a scroll intent'
329
+ )
330
+ return value as ManagerRequest
331
+ case 'setActiveTab':
332
+ assert(isString(value.payload.sessionId), 'setActiveTab.sessionId must be a string')
333
+ assert(isNullableString(value.payload.tabId), 'setActiveTab.tabId must be a string or null')
334
+ return value as ManagerRequest
335
+ case 'closeTab':
336
+ assert(isString(value.payload.sessionId), 'closeTab.sessionId must be a string')
337
+ assert(isString(value.payload.tabId), 'closeTab.tabId must be a string')
338
+ return value as ManagerRequest
339
+ case 'disposeSession':
340
+ assert(isString(value.payload.sessionId), 'disposeSession.sessionId must be a string')
341
+ return value as ManagerRequest
342
+ case 'ping':
343
+ return value as ManagerRequest
344
+ default:
345
+ throw new IpcProtocolError(`Unknown IPC request type: ${String(value.type)}`)
346
+ }
347
+ }
348
+
349
+ export function parseManagerMessage(value: unknown): ManagerResponse | ManagerEvent {
350
+ assert(isObjectRecord(value), 'IPC message must be an object')
351
+ assert(isString(value.type), 'IPC response type must be a string')
352
+ assert(isObjectRecord(value.payload), 'IPC response payload must be an object')
353
+
354
+ switch (value.type) {
355
+ case 'helloResult':
356
+ assert(isString(value.id), 'helloResult.id must be a string')
357
+ assert(isHelloResult(value.payload), 'helloResult.payload is invalid')
358
+ return value as ManagerResponse
359
+ case 'ok':
360
+ assert(isString(value.id), 'ok.id must be a string')
361
+ return value as ManagerResponse
362
+ case 'attachResult':
363
+ assert(isString(value.id), 'attachResult.id must be a string')
364
+ assert(isAttachResult(value.payload), 'attachResult.payload is invalid')
365
+ return value as ManagerResponse
366
+ case 'error':
367
+ assert(isString(value.id), 'error.id must be a string')
368
+ assert(isString(value.payload.message), 'error.message must be a string')
369
+ return value as ManagerResponse
370
+ case 'tabRender':
371
+ assert(isString(value.payload.sessionId), 'tabRender.sessionId must be a string')
372
+ assert(isString(value.payload.tabId), 'tabRender.tabId must be a string')
373
+ assert(isTerminalSnapshot(value.payload.viewport), 'tabRender.viewport is invalid')
374
+ assert(isTerminalModeState(value.payload.terminalModes), 'tabRender.terminalModes is invalid')
375
+ return value as ManagerEvent
376
+ case 'tabExit':
377
+ assert(isString(value.payload.sessionId), 'tabExit.sessionId must be a string')
378
+ assert(isString(value.payload.tabId), 'tabExit.tabId must be a string')
379
+ assert(isFiniteNumber(value.payload.exitCode), 'tabExit.exitCode must be a number')
380
+ return value as ManagerEvent
381
+ case 'tabError':
382
+ assert(isString(value.payload.sessionId), 'tabError.sessionId must be a string')
383
+ assert(isString(value.payload.tabId), 'tabError.tabId must be a string')
384
+ assert(isString(value.payload.message), 'tabError.message must be a string')
385
+ return value as ManagerEvent
386
+ default:
387
+ throw new IpcProtocolError(`Unknown IPC response type: ${String(value.type)}`)
388
+ }
389
+ }
390
+
391
+ export function encodeManagerMessage(message: ManagerMessage): Buffer {
392
+ const payload = JSON.stringify(message)
393
+ return Buffer.from(`${Buffer.byteLength(payload, 'utf8')}\n${payload}`, 'utf8')
394
+ }
395
+
396
+ export { MessageDecoder }
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ ScrollIntent,
2
3
  TabSession,
3
4
  TerminalModeState,
4
5
  TerminalSnapshot,
@@ -7,7 +8,20 @@ import type {
7
8
 
8
9
  import { isWorkspaceSnapshotV1 } from '../state/validation'
9
10
 
10
- export const IPC_PROTOCOL_VERSION = 1
11
+ export const IPC_PROTOCOL_MIN_VERSION = 2
12
+ export const IPC_PROTOCOL_VERSION = 2
13
+
14
+ export interface ProtocolHelloRequest {
15
+ minVersion: number
16
+ maxVersion: number
17
+ }
18
+
19
+ export interface ProtocolHelloResult {
20
+ minVersion: number
21
+ maxVersion: number
22
+ processVersion: string
23
+ selectedVersion: number
24
+ }
11
25
 
12
26
  export interface AttachRequest {
13
27
  protocolVersion: number
@@ -24,6 +38,7 @@ export interface AttachResult {
24
38
  }
25
39
 
26
40
  export type ClientRequest =
41
+ | { id: string; type: 'hello'; payload: ProtocolHelloRequest }
27
42
  | { id: string; type: 'attach'; payload: AttachRequest }
28
43
  | {
29
44
  id: string
@@ -40,16 +55,30 @@ export type ClientRequest =
40
55
  }
41
56
  }
42
57
  | { id: string; type: 'write'; payload: { tabId: string; data: string } }
43
- | { id: string; type: 'resizeClient'; payload: { cols: number; rows: number } }
44
- | { id: string; type: 'resizeTab'; payload: { tabId: string; cols: number; rows: number } }
58
+ | {
59
+ id: string
60
+ type: 'resizeClient'
61
+ payload: { cols: number; rows: number; intents?: Record<string, ScrollIntent> }
62
+ }
63
+ | {
64
+ id: string
65
+ type: 'resizeTab'
66
+ payload: { tabId: string; cols: number; rows: number; intent?: ScrollIntent }
67
+ }
45
68
  | { id: string; type: 'scrollToBottom'; payload: { tabId: string } }
46
69
  | { id: string; type: 'scroll'; payload: { tabId: string; deltaLines: number } }
70
+ | {
71
+ id: string
72
+ type: 'reapplyScrollIntent'
73
+ payload: { tabId: string; intent: ScrollIntent }
74
+ }
47
75
  | { id: string; type: 'setActiveTab'; payload: { tabId: string | null } }
48
76
  | { id: string; type: 'closeTab'; payload: { tabId: string } }
49
77
  | { id: string; type: 'disposeAll'; payload: Record<string, never> }
50
78
  | { id: string; type: 'ping'; payload: Record<string, never> }
51
79
 
52
80
  export type ServerResponse =
81
+ | { id: string; type: 'helloResult'; payload: ProtocolHelloResult }
53
82
  | { id: string; type: 'ok'; payload: Record<string, never> }
54
83
  | { id: string; type: 'attachResult'; payload: AttachResult }
55
84
  | { id: string; type: 'error'; payload: { message: string } }
@@ -128,6 +157,18 @@ function isTerminalSnapshot(value: unknown): value is TerminalSnapshot {
128
157
  )
129
158
  }
130
159
 
160
+ function isScrollIntent(value: unknown): value is ScrollIntent {
161
+ if (!isObjectRecord(value)) return false
162
+ if (value.kind === 'bottom') return true
163
+ if (value.kind === 'anchor') return isFiniteNumber(value.absoluteLine)
164
+ return false
165
+ }
166
+
167
+ function isScrollIntentRecord(value: unknown): value is Record<string, ScrollIntent> {
168
+ if (!isObjectRecord(value)) return false
169
+ return Object.values(value).every(isScrollIntent)
170
+ }
171
+
131
172
  function isTerminalModeState(value: unknown): value is TerminalModeState {
132
173
  return (
133
174
  isObjectRecord(value) &&
@@ -143,6 +184,16 @@ function isTerminalModeState(value: unknown): value is TerminalModeState {
143
184
  )
144
185
  }
145
186
 
187
+ function isProtocolHelloResult(value: unknown): value is ProtocolHelloResult {
188
+ return (
189
+ isObjectRecord(value) &&
190
+ isFiniteNumber(value.minVersion) &&
191
+ isFiniteNumber(value.maxVersion) &&
192
+ isFiniteNumber(value.selectedVersion) &&
193
+ isString(value.processVersion)
194
+ )
195
+ }
196
+
146
197
  function isAttachResult(value: unknown): value is AttachResult {
147
198
  return (
148
199
  isObjectRecord(value) &&
@@ -178,6 +229,25 @@ function assert(condition: boolean, message: string): asserts condition {
178
229
  }
179
230
  }
180
231
 
232
+ export function negotiateProtocolVersion(
233
+ clientMinVersion: number,
234
+ clientMaxVersion: number,
235
+ serverMinVersion: number,
236
+ serverMaxVersion: number
237
+ ): number | null {
238
+ const minVersion = Math.max(clientMinVersion, serverMinVersion)
239
+ const maxVersion = Math.min(clientMaxVersion, serverMaxVersion)
240
+ if (minVersion > maxVersion) {
241
+ return null
242
+ }
243
+
244
+ return maxVersion
245
+ }
246
+
247
+ export function getProcessVersion(): string {
248
+ return Bun.version
249
+ }
250
+
181
251
  export function parseClientRequest(value: unknown): ClientRequest {
182
252
  assert(isObjectRecord(value), 'IPC message must be an object')
183
253
  assert(isString(value.id), 'IPC request id must be a string')
@@ -185,10 +255,14 @@ export function parseClientRequest(value: unknown): ClientRequest {
185
255
  assert(isObjectRecord(value.payload), 'IPC request payload must be an object')
186
256
 
187
257
  switch (value.type) {
258
+ case 'hello':
259
+ assert(isFiniteNumber(value.payload.minVersion), 'hello.minVersion must be a number')
260
+ assert(isFiniteNumber(value.payload.maxVersion), 'hello.maxVersion must be a number')
261
+ return value as ClientRequest
188
262
  case 'attach':
189
263
  assert(
190
- value.payload.protocolVersion === IPC_PROTOCOL_VERSION,
191
- `attach.protocolVersion must be ${IPC_PROTOCOL_VERSION}`
264
+ isFiniteNumber(value.payload.protocolVersion),
265
+ 'attach.protocolVersion must be a number'
192
266
  )
193
267
  assert(isString(value.payload.sessionId), 'attach.sessionId must be a string')
194
268
  assert(isFiniteNumber(value.payload.cols), 'attach.cols must be a number')
@@ -225,11 +299,19 @@ export function parseClientRequest(value: unknown): ClientRequest {
225
299
  case 'resizeClient':
226
300
  assert(isFiniteNumber(value.payload.cols), 'resizeClient.cols must be a number')
227
301
  assert(isFiniteNumber(value.payload.rows), 'resizeClient.rows must be a number')
302
+ assert(
303
+ value.payload.intents === undefined || isScrollIntentRecord(value.payload.intents),
304
+ 'resizeClient.intents must be a scroll-intent record'
305
+ )
228
306
  return value as ClientRequest
229
307
  case 'resizeTab':
230
308
  assert(isString(value.payload.tabId), 'resizeTab.tabId must be a string')
231
309
  assert(isFiniteNumber(value.payload.cols), 'resizeTab.cols must be a number')
232
310
  assert(isFiniteNumber(value.payload.rows), 'resizeTab.rows must be a number')
311
+ assert(
312
+ value.payload.intent === undefined || isScrollIntent(value.payload.intent),
313
+ 'resizeTab.intent must be a scroll intent'
314
+ )
233
315
  return value as ClientRequest
234
316
  case 'scroll':
235
317
  assert(isString(value.payload.tabId), 'scroll.tabId must be a string')
@@ -238,6 +320,13 @@ export function parseClientRequest(value: unknown): ClientRequest {
238
320
  case 'scrollToBottom':
239
321
  assert(isString(value.payload.tabId), 'scrollToBottom.tabId must be a string')
240
322
  return value as ClientRequest
323
+ case 'reapplyScrollIntent':
324
+ assert(isString(value.payload.tabId), 'reapplyScrollIntent.tabId must be a string')
325
+ assert(
326
+ isScrollIntent(value.payload.intent),
327
+ 'reapplyScrollIntent.intent must be a scroll intent'
328
+ )
329
+ return value as ClientRequest
241
330
  case 'setActiveTab':
242
331
  assert(isNullableString(value.payload.tabId), 'setActiveTab.tabId must be a string or null')
243
332
  return value as ClientRequest
@@ -258,6 +347,10 @@ export function parseServerMessage(value: unknown): ServerResponse | ServerEvent
258
347
  assert(isObjectRecord(value.payload), 'IPC response payload must be an object')
259
348
 
260
349
  switch (value.type) {
350
+ case 'helloResult':
351
+ assert(isString(value.id), 'helloResult.id must be a string')
352
+ assert(isProtocolHelloResult(value.payload), 'helloResult.payload is invalid')
353
+ return value as ServerResponse
261
354
  case 'ok':
262
355
  assert(isString(value.id), 'ok.id must be a string')
263
356
  return value as ServerResponse
@@ -1,12 +1,12 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { resolve } from 'node:path'
3
3
 
4
- import { getDaemonSocketPath } from '../daemon/runtime-paths'
4
+ import { getIpcDaemonSocketPath, getTerminalManagerSocketPath } from '../daemon/runtime-paths'
5
5
  import { logDebug } from '../debug/input-log'
6
6
 
7
7
  const ENTRY_POINT = resolve(import.meta.dir, '..', 'index.tsx')
8
8
 
9
- export async function findDaemonPid(socketPath: string): Promise<number | null> {
9
+ export async function findSocketProcessPid(socketPath: string): Promise<number | null> {
10
10
  try {
11
11
  const proc = Bun.spawn(['lsof', '-t', socketPath], { stderr: 'ignore', stdout: 'pipe' })
12
12
  const text = await new Response(proc.stdout).text()
@@ -21,7 +21,19 @@ export async function findDaemonPid(socketPath: string): Promise<number | null>
21
21
  }
22
22
  }
23
23
 
24
- export async function killDaemon(pid: number): Promise<void> {
24
+ export async function findDaemonPid(socketPath: string): Promise<number | null> {
25
+ return findSocketProcessPid(socketPath)
26
+ }
27
+
28
+ export async function findIpcDaemonPid(): Promise<number | null> {
29
+ return findSocketProcessPid(getIpcDaemonSocketPath())
30
+ }
31
+
32
+ export async function findTerminalManagerPid(): Promise<number | null> {
33
+ return findSocketProcessPid(getTerminalManagerSocketPath())
34
+ }
35
+
36
+ export async function killProcess(pid: number): Promise<void> {
25
37
  process.kill(pid, 'SIGTERM')
26
38
 
27
39
  const deadline = Date.now() + 3_000
@@ -41,16 +53,12 @@ export async function killDaemon(pid: number): Promise<void> {
41
53
  }
42
54
  }
43
55
 
44
- export async function spawnDetachedDaemon(): Promise<boolean> {
45
- Bun.spawn([process.execPath, 'run', ENTRY_POINT, 'daemon'], {
46
- detached: true,
47
- stderr: 'ignore',
48
- stdin: 'ignore',
49
- stdout: 'ignore',
50
- }).unref()
56
+ export async function killDaemon(pid: number): Promise<void> {
57
+ await killProcess(pid)
58
+ }
51
59
 
60
+ async function waitForSocket(socketPath: string): Promise<boolean> {
52
61
  const deadline = Date.now() + 2_000
53
- const socketPath = getDaemonSocketPath()
54
62
  while (Date.now() < deadline) {
55
63
  if (existsSync(socketPath)) {
56
64
  return true
@@ -60,3 +68,26 @@ export async function spawnDetachedDaemon(): Promise<boolean> {
60
68
 
61
69
  return false
62
70
  }
71
+
72
+ async function spawnDetachedProcess(command: 'daemon' | 'terminal-manager', socketPath: string) {
73
+ Bun.spawn([process.execPath, 'run', ENTRY_POINT, command], {
74
+ detached: true,
75
+ stderr: 'ignore',
76
+ stdin: 'ignore',
77
+ stdout: 'ignore',
78
+ }).unref()
79
+
80
+ return waitForSocket(socketPath)
81
+ }
82
+
83
+ export async function spawnDetachedDaemon(): Promise<boolean> {
84
+ return spawnDetachedProcess('daemon', getIpcDaemonSocketPath())
85
+ }
86
+
87
+ export async function spawnDetachedIpcDaemon(): Promise<boolean> {
88
+ return spawnDetachedDaemon()
89
+ }
90
+
91
+ export async function spawnDetachedTerminalManager(): Promise<boolean> {
92
+ return spawnDetachedProcess('terminal-manager', getTerminalManagerSocketPath())
93
+ }
@@ -0,0 +1,27 @@
1
+ import { join } from 'node:path'
2
+
3
+ export const DEFAULT_PROFILE = 'default'
4
+
5
+ function sanitizeProfile(profile: string): string {
6
+ const trimmed = profile.trim().toLowerCase()
7
+ const normalized = trimmed.replace(/[^a-z0-9._-]+/g, '-')
8
+ const collapsed = normalized.replace(/-+/g, '-').replace(/^-|-$/g, '')
9
+ return collapsed || DEFAULT_PROFILE
10
+ }
11
+
12
+ export function getProfileName(): string {
13
+ const configured = process.env.AIMUX_PROFILE ?? process.env.AIMUX_RUNTIME_PROFILE
14
+ if (!configured) {
15
+ return DEFAULT_PROFILE
16
+ }
17
+
18
+ return sanitizeProfile(configured)
19
+ }
20
+
21
+ export function getConfigProfilesRootDir(): string {
22
+ return join(process.env.HOME ?? '~', '.config', 'aimux')
23
+ }
24
+
25
+ export function getProfileConfigDir(): string {
26
+ return join(getConfigProfilesRootDir(), getProfileName())
27
+ }