@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
@@ -0,0 +1,107 @@
1
+ import { connect } from 'node:net'
2
+
3
+ import type { SessionBackend } from './types'
4
+
5
+ import {
6
+ getDaemonSocketPath,
7
+ getDaemonSocketSecurityIssue,
8
+ removeDaemonSocketIfExists,
9
+ } from '../daemon/runtime-paths'
10
+ import { logDebug } from '../debug/input-log'
11
+ import { ProtocolMismatchError } from '../ipc/protocol'
12
+ import { findDaemonPid, killDaemon, spawnDetachedDaemon } from '../platform/daemon-control'
13
+ import { LocalSessionBackend } from './local-session-backend'
14
+ import { RemoteSessionBackend } from './remote-session-backend'
15
+
16
+ async function spawnDaemon(): Promise<void> {
17
+ logDebug('backend.spawnDaemon.start', {
18
+ execPath: process.execPath,
19
+ socketPath: getDaemonSocketPath(),
20
+ })
21
+ const ok = await spawnDetachedDaemon()
22
+ if (ok) {
23
+ logDebug('backend.spawnDaemon.ready', { socketPath: getDaemonSocketPath() })
24
+ return
25
+ }
26
+
27
+ logDebug('backend.spawnDaemon.timeout', { socketPath: getDaemonSocketPath() })
28
+ }
29
+
30
+ async function canConnectToDaemon(socketPath: string): Promise<boolean> {
31
+ const securityIssue = getDaemonSocketSecurityIssue(socketPath)
32
+ if (securityIssue) {
33
+ logDebug('backend.healthcheck.socketIssue', { issue: securityIssue, socketPath })
34
+ return false
35
+ }
36
+
37
+ return await new Promise<boolean>((resolve) => {
38
+ const socket = connect(socketPath)
39
+ const finish = (result: boolean) => {
40
+ socket.removeAllListeners()
41
+ socket.destroy()
42
+ resolve(result)
43
+ }
44
+
45
+ socket.once('connect', () => finish(true))
46
+ socket.once('error', (error: NodeJS.ErrnoException) => {
47
+ logDebug('backend.healthcheck.error', {
48
+ code: error.code ?? 'unknown',
49
+ error: error.message,
50
+ socketPath,
51
+ })
52
+ finish(false)
53
+ })
54
+ })
55
+ }
56
+
57
+ async function restartDaemon(socketPath: string): Promise<void> {
58
+ const pid = await findDaemonPid(socketPath)
59
+ if (pid !== null) {
60
+ logDebug('backend.restartDaemon.killing', { pid })
61
+ await killDaemon(pid)
62
+ }
63
+ removeDaemonSocketIfExists()
64
+ await spawnDaemon()
65
+ }
66
+
67
+ export async function createSessionBackend(): Promise<SessionBackend> {
68
+ try {
69
+ const socketPath = getDaemonSocketPath()
70
+ const initialReachable = await canConnectToDaemon(socketPath)
71
+ logDebug('backend.create.start', { initialReachable, socketPath })
72
+
73
+ if (!initialReachable) {
74
+ removeDaemonSocketIfExists()
75
+ await spawnDaemon()
76
+ }
77
+
78
+ const reachable = await canConnectToDaemon(socketPath)
79
+ if (!reachable) {
80
+ throw new Error(`Daemon unavailable at ${socketPath}`)
81
+ }
82
+
83
+ logDebug('backend.create.remote', { socketPath })
84
+ return new RemoteSessionBackend()
85
+ } catch (error) {
86
+ if (error instanceof ProtocolMismatchError) {
87
+ logDebug('backend.create.protocolMismatch', {
88
+ clientVersion: error.clientVersion,
89
+ daemonVersion: error.daemonVersion,
90
+ })
91
+ try {
92
+ await restartDaemon(getDaemonSocketPath())
93
+ logDebug('backend.create.remoteAfterRestart')
94
+ return new RemoteSessionBackend()
95
+ } catch (retryError) {
96
+ logDebug('backend.create.retryFailed', {
97
+ error: retryError instanceof Error ? retryError.message : String(retryError),
98
+ })
99
+ }
100
+ }
101
+
102
+ logDebug('backend.create.localFallback', {
103
+ error: error instanceof Error ? error.message : String(error),
104
+ })
105
+ return new LocalSessionBackend()
106
+ }
107
+ }
@@ -0,0 +1,164 @@
1
+ import { EventEmitter } from 'node:events'
2
+
3
+ import type { AssistantId, WorkspaceSnapshotV1 } from '../state/types'
4
+ import type { SessionBackend, SessionBackendEvents } from './types'
5
+
6
+ import { SessionManager } from '../daemon/session-manager'
7
+ import { logDebug } from '../debug/input-log'
8
+ import {
9
+ createTerminalBounds,
10
+ forEachSplitPaneRect,
11
+ getSnapshotTrees,
12
+ toTerminalContentSize,
13
+ } from '../state/layout-resize'
14
+
15
+ export class LocalSessionBackend
16
+ extends EventEmitter<SessionBackendEvents>
17
+ implements SessionBackend
18
+ {
19
+ private readonly sessionManager = new SessionManager()
20
+ private currentSessionId: string | null = null
21
+
22
+ constructor() {
23
+ super()
24
+ this.sessionManager.on('render', (sessionId, tabId, viewport, terminalModes) => {
25
+ if (sessionId === this.currentSessionId) {
26
+ this.emit('render', tabId, viewport, terminalModes)
27
+ }
28
+ })
29
+ this.sessionManager.on('exit', (sessionId, tabId, exitCode) => {
30
+ if (sessionId === this.currentSessionId) {
31
+ this.emit('exit', tabId, exitCode)
32
+ }
33
+ })
34
+ this.sessionManager.on('error', (sessionId, tabId, message) => {
35
+ if (sessionId === this.currentSessionId) {
36
+ this.emit('error', tabId, message)
37
+ }
38
+ })
39
+ }
40
+
41
+ async attach(options: {
42
+ sessionId: string
43
+ cols: number
44
+ rows: number
45
+ workspaceSnapshot?: WorkspaceSnapshotV1
46
+ }) {
47
+ logDebug('backend.local.attach', {
48
+ cols: options.cols,
49
+ rows: options.rows,
50
+ sessionId: options.sessionId,
51
+ snapshotTabs: options.workspaceSnapshot?.tabs.length ?? 0,
52
+ })
53
+ this.currentSessionId = options.sessionId
54
+ const trees = getSnapshotTrees(options.workspaceSnapshot)
55
+ const splitTrees = trees.filter((t) => t.type === 'split')
56
+ if (splitTrees.length > 0) {
57
+ const bounds = createTerminalBounds(options.cols, options.rows)
58
+ forEachSplitPaneRect(splitTrees, bounds, (tabId, rect) => {
59
+ const size = toTerminalContentSize(rect)
60
+ this.sessionManager.resizeTab(options.sessionId, tabId, size.cols, size.rows)
61
+ })
62
+ } else {
63
+ this.sessionManager.resize(options.sessionId, options.cols, options.rows)
64
+ }
65
+ return this.sessionManager.attachSession(options.sessionId, options.workspaceSnapshot)
66
+ }
67
+
68
+ createSession(options: {
69
+ tabId: string
70
+ assistant: AssistantId
71
+ title: string
72
+ command: string
73
+ args?: string[]
74
+ cols: number
75
+ rows: number
76
+ cwd?: string
77
+ }): void {
78
+ if (!this.currentSessionId) {
79
+ logDebug('backend.local.skipCreateWithoutSession', { tabId: options.tabId })
80
+ return
81
+ }
82
+ logDebug('backend.local.createSession', {
83
+ sessionId: this.currentSessionId,
84
+ tabId: options.tabId,
85
+ title: options.title,
86
+ })
87
+ this.sessionManager.createTab(this.currentSessionId, options)
88
+ }
89
+
90
+ write(tabId: string, input: string): void {
91
+ if (!this.currentSessionId) {
92
+ logDebug('backend.local.skipWriteWithoutSession', { inputLength: input.length, tabId })
93
+ return
94
+ }
95
+ logDebug('backend.local.write', {
96
+ inputLength: input.length,
97
+ sessionId: this.currentSessionId,
98
+ tabId,
99
+ })
100
+ this.sessionManager.write(this.currentSessionId, tabId, input)
101
+ }
102
+
103
+ scrollViewport(tabId: string, deltaLines: number): void {
104
+ if (!this.currentSessionId) {
105
+ return
106
+ }
107
+ this.sessionManager.scroll(this.currentSessionId, tabId, deltaLines)
108
+ }
109
+
110
+ scrollViewportToBottom(tabId: string): void {
111
+ if (!this.currentSessionId) {
112
+ return
113
+ }
114
+ this.sessionManager.scrollToBottom(this.currentSessionId, tabId)
115
+ }
116
+
117
+ setActiveTab(tabId: string | null): void {
118
+ if (!this.currentSessionId) {
119
+ return
120
+ }
121
+ logDebug('backend.local.setActiveTab', { sessionId: this.currentSessionId, tabId })
122
+ this.sessionManager.setActiveTab(this.currentSessionId, tabId)
123
+ }
124
+
125
+ resizeAll(cols: number, rows: number): void {
126
+ if (!this.currentSessionId) {
127
+ return
128
+ }
129
+ this.sessionManager.resize(this.currentSessionId, cols, rows)
130
+ }
131
+
132
+ resizeTab(tabId: string, cols: number, rows: number): void {
133
+ if (!this.currentSessionId) {
134
+ return
135
+ }
136
+ this.sessionManager.resizeTab(this.currentSessionId, tabId, cols, rows)
137
+ }
138
+
139
+ disposeSession(tabId: string): void {
140
+ if (!this.currentSessionId) {
141
+ return
142
+ }
143
+ logDebug('backend.local.disposeSession', { sessionId: this.currentSessionId, tabId })
144
+ this.sessionManager.closeTab(this.currentSessionId, tabId)
145
+ }
146
+
147
+ disposeAll(): void {
148
+ if (!this.currentSessionId) {
149
+ return
150
+ }
151
+ logDebug('backend.local.disposeAll', { sessionId: this.currentSessionId })
152
+ this.sessionManager.disposeSession(this.currentSessionId)
153
+ }
154
+
155
+ destroy(keepSessions = true): void {
156
+ logDebug('backend.local.destroy', { keepSessions, sessionId: this.currentSessionId })
157
+ if (!keepSessions) {
158
+ if (this.currentSessionId) {
159
+ this.sessionManager.disposeSession(this.currentSessionId)
160
+ }
161
+ }
162
+ this.currentSessionId = null
163
+ }
164
+ }
@@ -0,0 +1,373 @@
1
+ import { EventEmitter } from 'node:events'
2
+ import { connect, Socket } from 'node:net'
3
+
4
+ import type { AssistantId, WorkspaceSnapshotV1 } from '../state/types'
5
+ import type { SessionBackend, SessionBackendEvents } from './types'
6
+
7
+ import { getDaemonSocketPath } from '../daemon/runtime-paths'
8
+ import { logDebug } from '../debug/input-log'
9
+ import {
10
+ type AttachResult,
11
+ type ClientRequest,
12
+ encodeMessage,
13
+ IPC_PROTOCOL_VERSION,
14
+ MessageDecoder,
15
+ parseServerMessage,
16
+ ProtocolMismatchError,
17
+ type ServerEvent,
18
+ type ServerResponse,
19
+ } from '../ipc/protocol'
20
+
21
+ const IPC_REQUEST_TIMEOUT_MS = 10_000
22
+
23
+ export class RemoteSessionBackend
24
+ extends EventEmitter<SessionBackendEvents>
25
+ implements SessionBackend
26
+ {
27
+ private socket: Socket | null = null
28
+ private readonly pending = new Map<
29
+ string,
30
+ {
31
+ resolve: (message: ServerResponse) => void
32
+ reject: (error: Error) => void
33
+ timer: ReturnType<typeof setTimeout>
34
+ }
35
+ >()
36
+ private decoder = new MessageDecoder<ServerResponse | ServerEvent>(parseServerMessage)
37
+ private attached = false
38
+ private currentSessionId: string | null = null
39
+
40
+ private rejectPendingRequests(error: Error): void {
41
+ for (const [id, pending] of this.pending.entries()) {
42
+ clearTimeout(pending.timer)
43
+ this.pending.delete(id)
44
+ pending.reject(error)
45
+ }
46
+ }
47
+
48
+ private resetConnection(reason: string): void {
49
+ const socket = this.socket
50
+ this.socket = null
51
+ this.attached = false
52
+ this.currentSessionId = null
53
+ this.decoder.reset()
54
+ this.rejectPendingRequests(new Error(reason))
55
+
56
+ if (!socket) {
57
+ return
58
+ }
59
+
60
+ socket.removeAllListeners()
61
+ if (!socket.destroyed) {
62
+ socket.end()
63
+ socket.destroy()
64
+ }
65
+ }
66
+
67
+ private getConnectedSocket(): Socket {
68
+ if (!this.socket || this.socket.destroyed) {
69
+ throw new Error('Remote backend socket is unavailable')
70
+ }
71
+
72
+ return this.socket
73
+ }
74
+
75
+ private send(request: ClientRequest): Promise<ServerResponse> {
76
+ const socket = this.getConnectedSocket()
77
+ logDebug('backend.remote.send', { id: request.id, type: request.type })
78
+ return new Promise((resolve, reject) => {
79
+ const timer = setTimeout(() => {
80
+ this.pending.delete(request.id)
81
+ logDebug('backend.remote.timeout', { id: request.id, type: request.type })
82
+ reject(
83
+ new Error(`IPC request timed out after ${IPC_REQUEST_TIMEOUT_MS}ms: ${request.type}`)
84
+ )
85
+ }, IPC_REQUEST_TIMEOUT_MS)
86
+ this.pending.set(request.id, { reject, resolve, timer })
87
+ socket.write(encodeMessage(request), (error) => {
88
+ if (error) {
89
+ clearTimeout(timer)
90
+ this.pending.delete(request.id)
91
+ logDebug('backend.remote.sendError', {
92
+ error: error.message,
93
+ id: request.id,
94
+ type: request.type,
95
+ })
96
+ reject(error)
97
+ }
98
+ })
99
+ })
100
+ }
101
+
102
+ private async sendExpectOk(request: ClientRequest): Promise<void> {
103
+ const response = await this.send(request)
104
+ if (response.type === 'ok') {
105
+ return
106
+ }
107
+
108
+ throw new Error(
109
+ response.type === 'error'
110
+ ? response.payload.message
111
+ : `Unexpected response for ${request.type}: ${response.type}`
112
+ )
113
+ }
114
+
115
+ private reportCommandError(context: string, error: unknown, tabId?: string): void {
116
+ const message = error instanceof Error ? error.message : String(error)
117
+ logDebug('backend.remote.commandError', { context, error: message, tabId })
118
+ if (tabId) {
119
+ this.emit('error', tabId, message)
120
+ }
121
+ }
122
+
123
+ private handleServerEvent(message: ServerEvent): void {
124
+ logDebug('backend.remote.event', { type: message.type })
125
+ switch (message.type) {
126
+ case 'tabRender':
127
+ this.emit(
128
+ 'render',
129
+ message.payload.tabId,
130
+ message.payload.viewport,
131
+ message.payload.terminalModes
132
+ )
133
+ break
134
+ case 'tabExit':
135
+ this.emit('exit', message.payload.tabId, message.payload.exitCode)
136
+ break
137
+ case 'tabError':
138
+ this.emit('error', message.payload.tabId, message.payload.message)
139
+ break
140
+ }
141
+ }
142
+
143
+ async attach(options: {
144
+ sessionId: string
145
+ cols: number
146
+ rows: number
147
+ workspaceSnapshot?: WorkspaceSnapshotV1
148
+ }): Promise<AttachResult> {
149
+ const socketPath = getDaemonSocketPath()
150
+ logDebug('backend.remote.attach.start', {
151
+ cols: options.cols,
152
+ rows: options.rows,
153
+ sessionId: options.sessionId,
154
+ snapshotTabs: options.workspaceSnapshot?.tabs.length ?? 0,
155
+ socketPath,
156
+ })
157
+ this.resetConnection('Connection replaced during attach')
158
+
159
+ const socket = connect(socketPath)
160
+ this.socket = socket
161
+ this.attached = false
162
+ this.currentSessionId = options.sessionId
163
+
164
+ await new Promise<void>((resolve, reject) => {
165
+ socket.once('connect', resolve)
166
+ socket.once('error', reject)
167
+ })
168
+ logDebug('backend.remote.attach.connected', { socketPath })
169
+
170
+ socket.on('error', (error) => {
171
+ if (this.socket !== socket) {
172
+ return
173
+ }
174
+ logDebug('backend.remote.socketError', { error: error.message })
175
+ this.resetConnection(`Remote backend socket error: ${error.message}`)
176
+ })
177
+ socket.on('close', () => {
178
+ if (this.socket !== socket) {
179
+ return
180
+ }
181
+ logDebug('backend.remote.socketClose')
182
+ this.resetConnection('Remote backend socket closed')
183
+ })
184
+
185
+ socket.on('data', (chunk) => {
186
+ if (this.socket !== socket) {
187
+ return
188
+ }
189
+ logDebug('backend.remote.data', { byteLength: chunk.length })
190
+ try {
191
+ for (const message of this.decoder.push(chunk)) {
192
+ if ('id' in message) {
193
+ logDebug('backend.remote.response', { id: message.id, type: message.type })
194
+ const pending = this.pending.get(message.id)
195
+ if (pending) {
196
+ clearTimeout(pending.timer)
197
+ this.pending.delete(message.id)
198
+ pending.resolve(message)
199
+ }
200
+ } else {
201
+ this.handleServerEvent(message)
202
+ }
203
+ }
204
+ } catch (error) {
205
+ const message = error instanceof Error ? error.message : String(error)
206
+ logDebug('backend.remote.socketError', { error: message })
207
+ this.resetConnection(`Remote backend parse error: ${message}`)
208
+ }
209
+ })
210
+
211
+ const response = await this.send({
212
+ id: crypto.randomUUID(),
213
+ payload: { ...options, protocolVersion: IPC_PROTOCOL_VERSION },
214
+ type: 'attach',
215
+ })
216
+
217
+ if (response.type !== 'attachResult') {
218
+ logDebug('backend.remote.attach.unexpected', { type: response.type })
219
+ this.resetConnection(`Unexpected attach response: ${response.type}`)
220
+ throw new Error(
221
+ response.type === 'error' ? response.payload.message : 'Unexpected attach response'
222
+ )
223
+ }
224
+
225
+ if (response.payload.protocolVersion !== IPC_PROTOCOL_VERSION) {
226
+ this.resetConnection(
227
+ `Protocol mismatch: client v${IPC_PROTOCOL_VERSION}, daemon v${response.payload.protocolVersion}`
228
+ )
229
+ throw new ProtocolMismatchError(IPC_PROTOCOL_VERSION, response.payload.protocolVersion)
230
+ }
231
+
232
+ this.attached = true
233
+
234
+ logDebug('backend.remote.attach.success', {
235
+ activeTabId: response.payload.activeTabId,
236
+ sessionId: options.sessionId,
237
+ tabs: response.payload.tabs.length,
238
+ })
239
+
240
+ return response.payload
241
+ }
242
+
243
+ createSession(options: {
244
+ tabId: string
245
+ assistant: AssistantId
246
+ title: string
247
+ command: string
248
+ args?: string[]
249
+ cols: number
250
+ rows: number
251
+ cwd?: string
252
+ }): void {
253
+ if (!this.attached) {
254
+ logDebug('backend.remote.skipCreateBeforeAttach', { tabId: options.tabId })
255
+ return
256
+ }
257
+
258
+ logDebug('backend.remote.createSession', {
259
+ sessionId: this.currentSessionId,
260
+ tabId: options.tabId,
261
+ title: options.title,
262
+ })
263
+ void this.sendExpectOk({ id: crypto.randomUUID(), payload: options, type: 'createTab' }).catch(
264
+ (error) => this.reportCommandError('createTab', error, options.tabId)
265
+ )
266
+ }
267
+
268
+ write(tabId: string, input: string): void {
269
+ if (!this.attached) {
270
+ logDebug('backend.remote.skipWriteBeforeAttach', { inputLength: input.length, tabId })
271
+ return
272
+ }
273
+ logDebug('backend.remote.write', {
274
+ inputLength: input.length,
275
+ sessionId: this.currentSessionId,
276
+ tabId,
277
+ })
278
+ void this.sendExpectOk({
279
+ id: crypto.randomUUID(),
280
+ payload: { data: input, tabId },
281
+ type: 'write',
282
+ }).catch((error) => this.reportCommandError('write', error, tabId))
283
+ }
284
+
285
+ scrollViewport(tabId: string, deltaLines: number): void {
286
+ if (!this.attached) {
287
+ return
288
+ }
289
+ logDebug('backend.remote.scroll', { deltaLines, sessionId: this.currentSessionId, tabId })
290
+ void this.sendExpectOk({
291
+ id: crypto.randomUUID(),
292
+ payload: { deltaLines, tabId },
293
+ type: 'scroll',
294
+ }).catch((error) => this.reportCommandError('scroll', error, tabId))
295
+ }
296
+
297
+ scrollViewportToBottom(tabId: string): void {
298
+ if (!this.attached) {
299
+ return
300
+ }
301
+ logDebug('backend.remote.scrollToBottom', { sessionId: this.currentSessionId, tabId })
302
+ void this.sendExpectOk({
303
+ id: crypto.randomUUID(),
304
+ payload: { tabId },
305
+ type: 'scrollToBottom',
306
+ }).catch((error) => this.reportCommandError('scrollToBottom', error, tabId))
307
+ }
308
+
309
+ setActiveTab(tabId: string | null): void {
310
+ if (!this.attached) {
311
+ return
312
+ }
313
+ logDebug('backend.remote.setActiveTab', { sessionId: this.currentSessionId, tabId })
314
+ void this.sendExpectOk({
315
+ id: crypto.randomUUID(),
316
+ payload: { tabId },
317
+ type: 'setActiveTab',
318
+ }).catch((error) => this.reportCommandError('setActiveTab', error))
319
+ }
320
+
321
+ resizeAll(cols: number, rows: number): void {
322
+ if (!this.attached) {
323
+ logDebug('backend.remote.skipResizeBeforeAttach', { cols, rows })
324
+ return
325
+ }
326
+ logDebug('backend.remote.resize', { cols, rows, sessionId: this.currentSessionId })
327
+ void this.sendExpectOk({
328
+ id: crypto.randomUUID(),
329
+ payload: { cols, rows },
330
+ type: 'resizeClient',
331
+ }).catch((error) => this.reportCommandError('resizeClient', error))
332
+ }
333
+
334
+ resizeTab(tabId: string, cols: number, rows: number): void {
335
+ if (!this.attached) {
336
+ return
337
+ }
338
+ logDebug('backend.remote.resizeTab', { cols, rows, sessionId: this.currentSessionId, tabId })
339
+ void this.sendExpectOk({
340
+ id: crypto.randomUUID(),
341
+ payload: { cols, rows, tabId },
342
+ type: 'resizeTab',
343
+ }).catch((error) => this.reportCommandError('resizeTab', error, tabId))
344
+ }
345
+
346
+ disposeSession(tabId: string): void {
347
+ if (!this.attached) {
348
+ return
349
+ }
350
+ logDebug('backend.remote.disposeSession', { sessionId: this.currentSessionId, tabId })
351
+ void this.sendExpectOk({ id: crypto.randomUUID(), payload: { tabId }, type: 'closeTab' }).catch(
352
+ (error) => this.reportCommandError('closeTab', error, tabId)
353
+ )
354
+ }
355
+
356
+ disposeAll(): void {
357
+ if (!this.attached) {
358
+ return
359
+ }
360
+ logDebug('backend.remote.disposeAll', { sessionId: this.currentSessionId })
361
+ void this.sendExpectOk({ id: crypto.randomUUID(), payload: {}, type: 'disposeAll' }).catch(
362
+ (error) => this.reportCommandError('disposeAll', error)
363
+ )
364
+ }
365
+
366
+ async destroy(keepSessions = true): Promise<void> {
367
+ logDebug('backend.remote.destroy', { keepSessions })
368
+ if (!keepSessions) {
369
+ this.disposeAll()
370
+ }
371
+ this.resetConnection('Remote backend destroyed')
372
+ }
373
+ }
@@ -0,0 +1,47 @@
1
+ import type { EventEmitter } from 'node:events'
2
+
3
+ import type {
4
+ TabSession,
5
+ TerminalModeState,
6
+ TerminalSnapshot,
7
+ WorkspaceSnapshotV1,
8
+ } from '../state/types'
9
+
10
+ export type SessionBackendEvents = {
11
+ render: [tabId: string, viewport: TerminalSnapshot, terminalModes: TerminalModeState]
12
+ exit: [tabId: string, exitCode: number]
13
+ error: [tabId: string, message: string]
14
+ }
15
+
16
+ export interface BackendAttachResult {
17
+ tabs: TabSession[]
18
+ activeTabId: string | null
19
+ }
20
+
21
+ export interface SessionBackend extends EventEmitter<SessionBackendEvents> {
22
+ attach(options: {
23
+ sessionId: string
24
+ cols: number
25
+ rows: number
26
+ workspaceSnapshot?: WorkspaceSnapshotV1
27
+ }): Promise<BackendAttachResult | null>
28
+ createSession(options: {
29
+ tabId: string
30
+ assistant: TabSession['assistant']
31
+ title: string
32
+ command: string
33
+ args?: string[]
34
+ cols: number
35
+ rows: number
36
+ cwd?: string
37
+ }): void
38
+ write(tabId: string, input: string): void
39
+ scrollViewport(tabId: string, deltaLines: number): void
40
+ scrollViewportToBottom(tabId: string): void
41
+ setActiveTab(tabId: string | null): void
42
+ resizeAll(cols: number, rows: number): void
43
+ resizeTab(tabId: string, cols: number, rows: number): void
44
+ disposeSession(tabId: string): void
45
+ disposeAll(): void
46
+ destroy(keepSessions?: boolean): Promise<void> | void
47
+ }
@@ -0,0 +1,19 @@
1
+ import { useStore } from 'zustand'
2
+ import { createStore } from 'zustand/vanilla'
3
+
4
+ import type { AppAction, AppState } from './types'
5
+
6
+ import { appReducer, createInitialState } from './store'
7
+
8
+ export interface AppStore extends AppState {
9
+ dispatch: (action: AppAction) => void
10
+ }
11
+
12
+ export const appStore = createStore<AppStore>((set) => ({
13
+ ...createInitialState(),
14
+ dispatch: (action: AppAction) => set((state) => appReducer(state, action)),
15
+ }))
16
+
17
+ export function useAppStore<T>(selector: (state: AppStore) => T): T {
18
+ return useStore(appStore, selector)
19
+ }