@brimveyn/aimux 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +206 -0
  3. package/package.json +79 -0
  4. package/src/app-runtime/backend-attach-runtime.ts +135 -0
  5. package/src/app-runtime/backend-runtime-events.ts +78 -0
  6. package/src/app-runtime/click-selection-resolver.ts +130 -0
  7. package/src/app-runtime/pty-write.ts +32 -0
  8. package/src/app-runtime/render-invalidation.ts +20 -0
  9. package/src/app-runtime/selection-clipboard.ts +69 -0
  10. package/src/app-runtime/selection-scroll.ts +143 -0
  11. package/src/app-runtime/session-actions.ts +170 -0
  12. package/src/app-runtime/side-effects.ts +434 -0
  13. package/src/app-runtime/snippet-actions.ts +90 -0
  14. package/src/app-runtime/split-drag-controller.ts +15 -0
  15. package/src/app-runtime/tab-runtime-timeouts.ts +86 -0
  16. package/src/app-runtime/terminal-mouse-adapter.ts +31 -0
  17. package/src/app-runtime/use-backend-runtime.ts +99 -0
  18. package/src/app-runtime/use-directory-search.ts +52 -0
  19. package/src/app-runtime/use-mouse-handlers.ts +183 -0
  20. package/src/app-runtime/use-renderer-bindings.ts +163 -0
  21. package/src/app-runtime/use-terminal-resize.ts +141 -0
  22. package/src/app-runtime/use-workspace-autosave.ts +39 -0
  23. package/src/app.tsx +247 -0
  24. package/src/config/loader.ts +36 -0
  25. package/src/config.ts +146 -0
  26. package/src/daemon/daemon.ts +236 -0
  27. package/src/daemon/runtime-paths.ts +69 -0
  28. package/src/daemon/session-manager.ts +109 -0
  29. package/src/daemon/session-registry.ts +207 -0
  30. package/src/debug/input-log.ts +29 -0
  31. package/src/doctor.ts +106 -0
  32. package/src/git/git-poller.ts +49 -0
  33. package/src/git/git-status.ts +183 -0
  34. package/src/index.tsx +56 -0
  35. package/src/input/keymap/build-handlers.ts +34 -0
  36. package/src/input/keymap/key-chord.ts +201 -0
  37. package/src/input/keymap/keymap-mode-handler.ts +88 -0
  38. package/src/input/keymap/sequence-resolver.ts +117 -0
  39. package/src/input/keymap/trie.ts +103 -0
  40. package/src/input/modes/bridge.ts +58 -0
  41. package/src/input/modes/handlers/index.ts +18 -0
  42. package/src/input/modes/handlers/shared.ts +70 -0
  43. package/src/input/modes/registry.ts +34 -0
  44. package/src/input/modes/transitions.ts +37 -0
  45. package/src/input/modes/types.ts +63 -0
  46. package/src/input/mouse-forwarding.ts +98 -0
  47. package/src/input/multi-click-detector.ts +55 -0
  48. package/src/input/paste.ts +12 -0
  49. package/src/input/raw-input-handler.ts +191 -0
  50. package/src/input/terminal-text-extraction.ts +78 -0
  51. package/src/ipc/protocol.ts +341 -0
  52. package/src/platform/clipboard.ts +13 -0
  53. package/src/platform/daemon-control.ts +62 -0
  54. package/src/platform/id.ts +7 -0
  55. package/src/platform/project-search.ts +75 -0
  56. package/src/pty/command-registry.ts +69 -0
  57. package/src/pty/pty-manager.ts +268 -0
  58. package/src/pty/terminal-snapshot.ts +210 -0
  59. package/src/restart-daemon.ts +28 -0
  60. package/src/session-backend/bootstrap.ts +107 -0
  61. package/src/session-backend/local-session-backend.ts +164 -0
  62. package/src/session-backend/remote-session-backend.ts +373 -0
  63. package/src/session-backend/types.ts +47 -0
  64. package/src/state/app-store.ts +19 -0
  65. package/src/state/layout-resize.ts +56 -0
  66. package/src/state/layout-tree.ts +323 -0
  67. package/src/state/reducers/git-panel-state.ts +102 -0
  68. package/src/state/reducers/modal-state.ts +358 -0
  69. package/src/state/reducers/session-state.ts +86 -0
  70. package/src/state/reducers/tab-state.ts +531 -0
  71. package/src/state/reducers/ui-state.ts +29 -0
  72. package/src/state/selectors.ts +30 -0
  73. package/src/state/session-catalog.ts +96 -0
  74. package/src/state/session-persistence.ts +210 -0
  75. package/src/state/snippet-catalog.ts +90 -0
  76. package/src/state/store.ts +93 -0
  77. package/src/state/terminal-modes.ts +11 -0
  78. package/src/state/types.ts +367 -0
  79. package/src/state/validation.ts +154 -0
  80. package/src/state/workspace-save.ts +33 -0
  81. package/src/ui/components/create-session-modal.tsx +100 -0
  82. package/src/ui/components/git-panel.tsx +200 -0
  83. package/src/ui/components/help-modal.tsx +79 -0
  84. package/src/ui/components/input-field.tsx +18 -0
  85. package/src/ui/components/list-item.tsx +30 -0
  86. package/src/ui/components/modal-filter-bar.tsx +18 -0
  87. package/src/ui/components/modal-shell.tsx +47 -0
  88. package/src/ui/components/new-tab-modal.tsx +52 -0
  89. package/src/ui/components/pending-chord-indicator.tsx +41 -0
  90. package/src/ui/components/session-name-modal.tsx +15 -0
  91. package/src/ui/components/session-picker-modal.tsx +93 -0
  92. package/src/ui/components/sidebar-group-metadata.ts +44 -0
  93. package/src/ui/components/sidebar-scroll.ts +33 -0
  94. package/src/ui/components/sidebar.tsx +225 -0
  95. package/src/ui/components/snippet-editor-modal.tsx +39 -0
  96. package/src/ui/components/snippet-picker-modal.tsx +56 -0
  97. package/src/ui/components/split-layout.tsx +201 -0
  98. package/src/ui/components/status-bar.tsx +60 -0
  99. package/src/ui/components/surface.tsx +63 -0
  100. package/src/ui/components/tab-item.tsx +118 -0
  101. package/src/ui/components/terminal-pane.tsx +215 -0
  102. package/src/ui/components/theme-picker-modal.tsx +35 -0
  103. package/src/ui/components/use-sidebar-auto-scroll.ts +63 -0
  104. package/src/ui/components/use-sidebar-branch.ts +36 -0
  105. package/src/ui/directory-search.ts +1 -0
  106. package/src/ui/git-branch.ts +11 -0
  107. package/src/ui/path-format.ts +6 -0
  108. package/src/ui/root.tsx +261 -0
  109. package/src/ui/status-bar-model.ts +87 -0
  110. package/src/ui/theme.ts +9 -0
  111. package/src/ui/themes.ts +255 -0
  112. package/src/ui/ui-tokens.ts +11 -0
  113. package/src/update.ts +62 -0
package/src/app.tsx ADDED
@@ -0,0 +1,247 @@
1
+ import { getDefaultKeymapConfig } from '@brimveyn/aimux-config'
2
+ import { useKeyboard, useRenderer, useTerminalDimensions } from '@opentui/react'
3
+ import { useCallback, useLayoutEffect, useMemo, useReducer, useRef, useState } from 'react'
4
+
5
+ import type { KeyChord } from './input/keymap/key-chord'
6
+ import type { TrieBinding } from './input/keymap/trie'
7
+ import type { KeyResult, ModeContext, ModeId } from './input/modes/types'
8
+ import type { SessionBackend } from './session-backend/types'
9
+ import type { ThemeId } from './ui/themes'
10
+
11
+ import { executeSideEffect, type SideEffectContext } from './app-runtime/side-effects'
12
+ import { useBackendRuntime } from './app-runtime/use-backend-runtime'
13
+ import { useDirectorySearch } from './app-runtime/use-directory-search'
14
+ import { useMouseHandlers } from './app-runtime/use-mouse-handlers'
15
+ import { useRendererBindings } from './app-runtime/use-renderer-bindings'
16
+ import { useTerminalResize } from './app-runtime/use-terminal-resize'
17
+ import { useWorkspaceAutosave } from './app-runtime/use-workspace-autosave'
18
+ import { loadConfig } from './config'
19
+ import { deriveModeId } from './input/modes/bridge'
20
+ import { registerAllModes } from './input/modes/handlers'
21
+ import { getHandler, transitionTo } from './input/modes/registry'
22
+ import { type TerminalContentOrigin } from './input/raw-input-handler'
23
+ import { appStore } from './state/app-store'
24
+ import { loadSessionCatalog } from './state/session-catalog'
25
+ import { loadSnippetCatalog } from './state/snippet-catalog'
26
+ import { appReducer, createInitialState } from './state/store'
27
+ import { RootView } from './ui/root'
28
+ import { applyTheme } from './ui/theme'
29
+
30
+ const keymapHandlers = registerAllModes(getDefaultKeymapConfig())
31
+
32
+ const WORKSPACE_SAVE_DEBOUNCE_MS = 250
33
+
34
+ export function App({ backend }: { backend: SessionBackend }) {
35
+ const renderer = useRenderer()
36
+ const dimensions = useTerminalDimensions()
37
+ const [themeId, setThemeId] = useState<ThemeId>(() => {
38
+ const config = loadConfig()
39
+ if (config.themeId) {
40
+ applyTheme(config.themeId)
41
+ }
42
+ return config.themeId ?? 'aimux'
43
+ })
44
+ const [state, dispatch] = useReducer(appReducer, undefined, () => {
45
+ const { customCommands, gitPanelRatio, gitPanelVisible } = loadConfig()
46
+ return createInitialState(customCommands, loadSessionCatalog(), loadSnippetCatalog(), true, {
47
+ gitPanelRatio,
48
+ gitPanelVisible,
49
+ })
50
+ })
51
+
52
+ useLayoutEffect(() => {
53
+ appStore.setState(state)
54
+ }, [state])
55
+
56
+ const resizingRef = useRef(false)
57
+ const layoutRef = useRef(state.layout)
58
+ layoutRef.current = state.layout
59
+ const activeTab = useMemo(
60
+ () => state.tabs.find((tab) => tab.id === state.activeTabId),
61
+ [state.activeTabId, state.tabs]
62
+ )
63
+ const currentSession = useMemo(
64
+ () => state.sessions.find((session) => session.id === state.currentSessionId),
65
+ [state.currentSessionId, state.sessions]
66
+ )
67
+ const activeMouseForwardingEnabled = activeTab?.terminalModes.mouseTrackingMode !== 'none'
68
+ const activeLocalScrollbackEnabled =
69
+ !!activeTab && !activeMouseForwardingEnabled && !activeTab.terminalModes.isAlternateBuffer
70
+
71
+ const focusModeRef = useRef(state.focusMode)
72
+ focusModeRef.current = state.focusMode
73
+
74
+ const activeTabIdRef = useRef(state.activeTabId)
75
+ activeTabIdRef.current = state.activeTabId
76
+ const activeTabRef = useRef(activeTab)
77
+ activeTabRef.current = activeTab
78
+
79
+ const stateRef = useRef(state)
80
+ stateRef.current = state
81
+
82
+ const contentOriginRef = useRef<TerminalContentOrigin>({ cols: 0, rows: 0, x: 0, y: 0 })
83
+ const currentSessionWorkspaceSnapshot = currentSession?.workspaceSnapshot
84
+
85
+ const { clearIdleTimer, clearStartupGrace, startStartupGrace } = useBackendRuntime({
86
+ activeTabId: state.activeTabId,
87
+ backend,
88
+ currentSessionId: state.currentSessionId,
89
+ currentSessionWorkspaceSnapshot,
90
+ dispatch,
91
+ layoutRef,
92
+ resizingRef,
93
+ })
94
+
95
+ useWorkspaceAutosave(state, WORKSPACE_SAVE_DEBOUNCE_MS)
96
+ useDirectorySearch(state.modal, dispatch)
97
+
98
+ const terminalSize = useTerminalResize({
99
+ backend,
100
+ contentOriginRef,
101
+ dimensions,
102
+ dispatch,
103
+ resizingRef,
104
+ state,
105
+ })
106
+
107
+ const {
108
+ handlePaneActivate,
109
+ handleSeparatorDrag,
110
+ handleSeparatorDragEnd,
111
+ handleSeparatorDragStart,
112
+ handleSplitResize,
113
+ handleTerminalClick,
114
+ handleTerminalMouseEvent,
115
+ handleTerminalScrollEvent,
116
+ } = useMouseHandlers({
117
+ activeLocalScrollbackEnabled,
118
+ activeMouseForwardingEnabled,
119
+ backend,
120
+ dispatch,
121
+ renderer,
122
+ state,
123
+ })
124
+
125
+ // Stable ref to processKeyResult — populated after it is defined below.
126
+ // Allows handleTerminalShortcut (a stable callback) to reach the latest closure.
127
+ const processKeyResultRef = useRef<(result: KeyResult, modeId: ModeId) => void>(() => {})
128
+
129
+ const handleTerminalShortcut = useCallback((chord: KeyChord): boolean => {
130
+ const terminalHandler = keymapHandlers.find((h) => h.id === 'terminal-input')
131
+ if (!terminalHandler) return false
132
+ const ctx: ModeContext = { state: stateRef.current }
133
+ const result = terminalHandler.handleChord(chord, ctx)
134
+ if (!result) return false
135
+ processKeyResultRef.current(result, 'terminal-input')
136
+ return true
137
+ }, [])
138
+
139
+ useRendererBindings({
140
+ activeTabId: state.activeTabId,
141
+ activeTabIdRef,
142
+ activeTabRef,
143
+ activeTabViewportY: activeTab?.viewport?.viewportY ?? null,
144
+ backend,
145
+ dispatch,
146
+ focusMode: state.focusMode,
147
+ focusModeRef,
148
+ handleTerminalShortcut,
149
+ renderer,
150
+ })
151
+
152
+ const sideEffectCtx: SideEffectContext = {
153
+ activeTab,
154
+ backend,
155
+ clearIdleTimer,
156
+ clearStartupGrace,
157
+ dispatch,
158
+ getCurrentSessionProjectPath: () => {
159
+ if (!state.currentSessionId) return undefined
160
+ return state.sessions.find((s) => s.id === state.currentSessionId)?.projectPath
161
+ },
162
+ renderer,
163
+ setThemeId,
164
+ startStartupGrace,
165
+ state,
166
+ themeId,
167
+ }
168
+
169
+ function processKeyResult(result: KeyResult, modeId: ModeId): void {
170
+ for (const action of result.actions) {
171
+ dispatch(action)
172
+ }
173
+
174
+ if (result.transition) {
175
+ const transResult = transitionTo(modeId, result.transition, { state })
176
+ for (const action of transResult.actions) {
177
+ dispatch(action)
178
+ }
179
+ for (const effect of transResult.effects) {
180
+ executeSideEffect(effect, sideEffectCtx)
181
+ }
182
+ }
183
+
184
+ for (const effect of result.effects) {
185
+ executeSideEffect(effect, sideEffectCtx)
186
+ }
187
+ }
188
+
189
+ // Keep the ref pointing at the latest closure so stable callbacks can invoke it
190
+ processKeyResultRef.current = processKeyResult
191
+
192
+ useLayoutEffect(() => {
193
+ for (const handler of keymapHandlers) {
194
+ handler.setTimeoutCallback((binding: TrieBinding) => {
195
+ const currentState = stateRef.current
196
+ const modeId = deriveModeId(currentState)
197
+ if (modeId !== handler.id) return // mode changed during timeout
198
+ const ctx: ModeContext = { state: currentState }
199
+ const result = typeof binding.result === 'function' ? binding.result(ctx) : binding.result
200
+ if (result) processKeyResultRef.current(result, modeId)
201
+ })
202
+
203
+ handler.setPendingChangeCallback((chords) => {
204
+ dispatch({ chords, type: 'set-pending-chords' })
205
+ })
206
+ }
207
+ }, []) // eslint-disable-line react-hooks/exhaustive-deps
208
+
209
+ useKeyboard((key) => {
210
+ // Global quit: Ctrl+C in any mode except terminal-input
211
+ if (key.ctrl && key.name === 'c' && state.focusMode !== 'terminal-input') {
212
+ key.preventDefault()
213
+ executeSideEffect({ state, type: 'quit' }, sideEffectCtx)
214
+ return
215
+ }
216
+
217
+ const modeId = deriveModeId(state)
218
+ const handler = getHandler(modeId)
219
+ if (!handler) return
220
+
221
+ const ctx: ModeContext = { state }
222
+ const result = handler.handleKey(key, ctx)
223
+ if (!result) return
224
+
225
+ key.preventDefault()
226
+ processKeyResult(result, modeId)
227
+ })
228
+
229
+ return (
230
+ <RootView
231
+ themeId={themeId}
232
+ contentOrigin={contentOriginRef.current}
233
+ mouseForwardingEnabled={activeMouseForwardingEnabled}
234
+ localScrollbackEnabled={activeLocalScrollbackEnabled}
235
+ onTerminalMouseEvent={handleTerminalMouseEvent}
236
+ onTerminalScrollEvent={handleTerminalScrollEvent}
237
+ onTerminalClick={handleTerminalClick}
238
+ onPaneActivate={handlePaneActivate}
239
+ onSplitResize={handleSplitResize}
240
+ onSeparatorDragStart={handleSeparatorDragStart}
241
+ onSeparatorDrag={handleSeparatorDrag}
242
+ onSeparatorDragEnd={handleSeparatorDragEnd}
243
+ terminalCols={terminalSize.cols}
244
+ terminalRows={terminalSize.rows}
245
+ />
246
+ )
247
+ }
@@ -0,0 +1,36 @@
1
+ import { type AimuxUserConfig, resolveConfig, type ResolvedConfig } from '@brimveyn/aimux-config'
2
+ import { homedir } from 'node:os'
3
+ import { join } from 'node:path'
4
+
5
+ import { logDebug } from '../debug/input-log'
6
+
7
+ const CONFIG_FILENAMES = ['aimux.config.ts', 'aimux.config.js']
8
+
9
+ /**
10
+ * Load the user's aimux.config.ts (or .js), merge with defaults, and return
11
+ * a resolved config. Falls back to pure defaults if no config file exists.
12
+ */
13
+ export async function loadUserConfig(): Promise<ResolvedConfig> {
14
+ const configDir = join(homedir(), '.config', 'aimux')
15
+
16
+ for (const filename of CONFIG_FILENAMES) {
17
+ const configPath = join(configDir, filename)
18
+
19
+ try {
20
+ const file = Bun.file(configPath)
21
+ if (!(await file.exists())) continue
22
+
23
+ const mod = await import(configPath)
24
+ const userConfig: AimuxUserConfig = mod.default ?? mod
25
+
26
+ logDebug('config.user.loaded', { path: configPath })
27
+ return resolveConfig(userConfig)
28
+ } catch (error) {
29
+ const message = error instanceof Error ? error.message : String(error)
30
+ logDebug('config.user.error', { error: message, path: configPath })
31
+ // Fall through to try next filename or use defaults
32
+ }
33
+ }
34
+
35
+ return resolveConfig({})
36
+ }
package/src/config.ts ADDED
@@ -0,0 +1,146 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+
4
+ import type { WorkspaceSnapshotV1 } from './state/types'
5
+
6
+ import { logDebug } from './debug/input-log'
7
+ import { isWorkspaceSnapshotV1 } from './state/validation'
8
+ import { THEME_IDS, type ThemeId } from './ui/themes'
9
+
10
+ export const CONFIG_PATH = join(process.env.HOME ?? '~', '.config', 'aimux.json')
11
+
12
+ export interface AimuxConfig {
13
+ version: 2
14
+ customCommands: Record<string, string>
15
+ themeId?: ThemeId
16
+ gitPanelVisible?: boolean
17
+ gitPanelRatio?: number
18
+ workspaceSnapshot?: WorkspaceSnapshotV1
19
+ }
20
+
21
+ const DEFAULT_CONFIG: AimuxConfig = {
22
+ customCommands: {},
23
+ version: 2,
24
+ }
25
+
26
+ export interface ConfigLoadResult {
27
+ config: AimuxConfig
28
+ source: 'defaults' | 'file'
29
+ issues: string[]
30
+ }
31
+
32
+ function isThemeId(value: unknown): value is ThemeId {
33
+ return typeof value === 'string' && THEME_IDS.includes(value as ThemeId)
34
+ }
35
+
36
+ function isCustomCommandsRecord(value: unknown): value is Record<string, string> {
37
+ if (typeof value !== 'object' || value === null) {
38
+ return false
39
+ }
40
+
41
+ return Object.entries(value).every(
42
+ ([key, entryValue]) =>
43
+ typeof key === 'string' && key.length > 0 && typeof entryValue === 'string'
44
+ )
45
+ }
46
+
47
+ export function loadConfigResult(): ConfigLoadResult {
48
+ try {
49
+ if (!existsSync(CONFIG_PATH)) {
50
+ return { config: DEFAULT_CONFIG, issues: [], source: 'defaults' }
51
+ }
52
+
53
+ const raw = readFileSync(CONFIG_PATH, 'utf8')
54
+ const parsed = JSON.parse(raw) as {
55
+ version?: number
56
+ customCommands?: unknown
57
+ themeId?: unknown
58
+ gitPanelVisible?: unknown
59
+ gitPanelRatio?: unknown
60
+ workspaceSnapshot?: unknown
61
+ }
62
+
63
+ const issues: string[] = []
64
+
65
+ if (parsed.version !== undefined && parsed.version !== 2) {
66
+ issues.push(`unsupported config version ${String(parsed.version)}`)
67
+ }
68
+
69
+ if (parsed.customCommands !== undefined && !isCustomCommandsRecord(parsed.customCommands)) {
70
+ issues.push('ignored invalid customCommands')
71
+ }
72
+
73
+ if (parsed.themeId !== undefined && !isThemeId(parsed.themeId)) {
74
+ issues.push('ignored invalid themeId')
75
+ }
76
+
77
+ const validGitPanelVisible =
78
+ typeof parsed.gitPanelVisible === 'boolean' ? parsed.gitPanelVisible : undefined
79
+ if (parsed.gitPanelVisible !== undefined && validGitPanelVisible === undefined) {
80
+ issues.push('ignored invalid gitPanelVisible')
81
+ }
82
+
83
+ const validGitPanelRatio =
84
+ typeof parsed.gitPanelRatio === 'number' &&
85
+ Number.isFinite(parsed.gitPanelRatio) &&
86
+ parsed.gitPanelRatio > 0 &&
87
+ parsed.gitPanelRatio < 1
88
+ ? parsed.gitPanelRatio
89
+ : undefined
90
+ if (parsed.gitPanelRatio !== undefined && validGitPanelRatio === undefined) {
91
+ issues.push('ignored invalid gitPanelRatio')
92
+ }
93
+
94
+ if (
95
+ parsed.workspaceSnapshot !== undefined &&
96
+ !isWorkspaceSnapshotV1(parsed.workspaceSnapshot)
97
+ ) {
98
+ issues.push('ignored invalid workspaceSnapshot')
99
+ }
100
+
101
+ if (issues.length > 0) {
102
+ logDebug('config.load.validationIssue', { issues, path: CONFIG_PATH })
103
+ }
104
+
105
+ return {
106
+ config: {
107
+ customCommands: isCustomCommandsRecord(parsed.customCommands) ? parsed.customCommands : {},
108
+ gitPanelRatio: validGitPanelRatio,
109
+ gitPanelVisible: validGitPanelVisible,
110
+ themeId: isThemeId(parsed.themeId) ? parsed.themeId : undefined,
111
+ version: 2,
112
+ workspaceSnapshot: isWorkspaceSnapshotV1(parsed.workspaceSnapshot)
113
+ ? parsed.workspaceSnapshot
114
+ : undefined,
115
+ },
116
+ issues,
117
+ source: 'file',
118
+ }
119
+ } catch (error) {
120
+ const message = error instanceof Error ? error.message : String(error)
121
+ logDebug('config.load.error', { error: message, path: CONFIG_PATH })
122
+ return {
123
+ config: DEFAULT_CONFIG,
124
+ issues: [`failed to load config: ${message}`],
125
+ source: 'defaults',
126
+ }
127
+ }
128
+ }
129
+
130
+ export function loadConfig(): AimuxConfig {
131
+ return loadConfigResult().config
132
+ }
133
+
134
+ export function saveConfig(config: AimuxConfig): boolean {
135
+ try {
136
+ mkdirSync(dirname(CONFIG_PATH), { recursive: true })
137
+ writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`)
138
+ return true
139
+ } catch (error) {
140
+ logDebug('config.save.error', {
141
+ error: error instanceof Error ? error.message : String(error),
142
+ path: CONFIG_PATH,
143
+ })
144
+ return false
145
+ }
146
+ }
@@ -0,0 +1,236 @@
1
+ import { createServer, type Socket } from 'node:net'
2
+
3
+ import { logDebug } from '../debug/input-log'
4
+ import {
5
+ type ClientRequest,
6
+ encodeMessage,
7
+ IPC_PROTOCOL_VERSION,
8
+ MessageDecoder,
9
+ parseClientRequest,
10
+ type ServerEvent,
11
+ type ServerResponse,
12
+ } from '../ipc/protocol'
13
+ import { findDaemonPid } from '../platform/daemon-control'
14
+ import {
15
+ getDaemonSocketPath,
16
+ removeDaemonSocketIfExists,
17
+ tightenDaemonSocketPermissions,
18
+ } from './runtime-paths'
19
+ import { SessionManager } from './session-manager'
20
+
21
+ function send(socket: Socket, message: ServerResponse | ServerEvent): void {
22
+ socket.write(encodeMessage(message))
23
+ }
24
+
25
+ function sendOk(socket: Socket, id: string): void {
26
+ send(socket, { id, payload: {}, type: 'ok' })
27
+ }
28
+
29
+ function requireSession(socket: Socket, attachedSessions: Map<Socket, string>): string {
30
+ const sessionId = attachedSessions.get(socket)
31
+ if (!sessionId) {
32
+ throw new Error('No session attached')
33
+ }
34
+ return sessionId
35
+ }
36
+
37
+ export async function runDaemon(): Promise<void> {
38
+ const socketPath = getDaemonSocketPath()
39
+ logDebug('daemon.start', { pid: process.pid, socketPath })
40
+
41
+ const existingPid = await findDaemonPid(socketPath)
42
+ if (existingPid !== null && existingPid !== process.pid) {
43
+ logDebug('daemon.alreadyRunning', { existingPid })
44
+ process.stderr.write(`aimux daemon already running (pid ${existingPid})\n`)
45
+ process.exit(1)
46
+ }
47
+
48
+ logDebug('daemon.removeStaleSocket', { socketPath })
49
+ removeDaemonSocketIfExists()
50
+
51
+ const sessionManager = new SessionManager()
52
+ const sockets = new Set<Socket>()
53
+ const attachedSessions = new Map<Socket, string>()
54
+
55
+ sessionManager.on('render', (sessionId, tabId, viewport, terminalModes) => {
56
+ const event: ServerEvent = { payload: { tabId, terminalModes, viewport }, type: 'tabRender' }
57
+ for (const socket of sockets) {
58
+ if (attachedSessions.get(socket) === sessionId) {
59
+ send(socket, event)
60
+ }
61
+ }
62
+ })
63
+ sessionManager.on('exit', (sessionId, tabId, exitCode) => {
64
+ const event: ServerEvent = { payload: { exitCode, tabId }, type: 'tabExit' }
65
+ for (const socket of sockets) {
66
+ if (attachedSessions.get(socket) === sessionId) {
67
+ send(socket, event)
68
+ }
69
+ }
70
+ })
71
+ sessionManager.on('error', (sessionId, tabId, message) => {
72
+ const event: ServerEvent = { payload: { message, tabId }, type: 'tabError' }
73
+ for (const socket of sockets) {
74
+ if (attachedSessions.get(socket) === sessionId) {
75
+ send(socket, event)
76
+ }
77
+ }
78
+ })
79
+
80
+ const server = createServer((socket) => {
81
+ logDebug('daemon.client.connected')
82
+ sockets.add(socket)
83
+ const decoder = new MessageDecoder<ClientRequest>(parseClientRequest)
84
+
85
+ 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)
166
+ }
167
+ sendOk(socket, message.id)
168
+ break
169
+ }
170
+ case 'ping':
171
+ sendOk(socket, message.id)
172
+ break
173
+ }
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
+ }
179
+ }
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
+ }
186
+ })
187
+
188
+ socket.on('close', () => {
189
+ logDebug('daemon.client.close')
190
+ sockets.delete(socket)
191
+ attachedSessions.delete(socket)
192
+ })
193
+ socket.on('error', () => {
194
+ logDebug('daemon.client.error')
195
+ sockets.delete(socket)
196
+ attachedSessions.delete(socket)
197
+ })
198
+ })
199
+
200
+ await new Promise<void>((resolve, reject) => {
201
+ server.once('error', reject)
202
+ server.listen(socketPath, () => resolve())
203
+ })
204
+ tightenDaemonSocketPermissions(socketPath)
205
+ logDebug('daemon.listening', { socketPath })
206
+
207
+ const gracefulShutdown = (signal: string) => {
208
+ logDebug(`daemon.${signal}`)
209
+ sessionManager.disposeAll()
210
+ server.close()
211
+ process.exit(0)
212
+ }
213
+
214
+ process.on('SIGTERM', () => gracefulShutdown('sigterm'))
215
+ process.on('SIGINT', () => gracefulShutdown('sigint'))
216
+
217
+ process.on('uncaughtException', (error) => {
218
+ logDebug('daemon.uncaughtException', { error: error.message, stack: error.stack })
219
+ sessionManager.disposeAll()
220
+ server.close()
221
+ process.exit(1)
222
+ })
223
+
224
+ process.on('unhandledRejection', (reason) => {
225
+ const message = reason instanceof Error ? reason.message : String(reason)
226
+ const stack = reason instanceof Error ? reason.stack : undefined
227
+ logDebug('daemon.unhandledRejection', { error: message, stack })
228
+ sessionManager.disposeAll()
229
+ server.close()
230
+ process.exit(1)
231
+ })
232
+
233
+ await new Promise<void>(() => {
234
+ // Keep the daemon process alive until it is terminated.
235
+ })
236
+ }