@brimveyn/aimux 1.16.3 → 1.18.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.
- package/package.json +4 -2
- package/src/app-runtime/backend-runtime-events.ts +179 -0
- package/src/app-runtime/side-effects.ts +3 -1
- package/src/app.tsx +26 -0
- package/src/cli/chord.ts +77 -0
- package/src/cli/client/bootstrap.ts +76 -0
- package/src/cli/client/daemon-client.ts +228 -0
- package/src/cli/client/workspace-resolver.ts +57 -0
- package/src/cli/commands/tab/close.ts +33 -0
- package/src/cli/commands/tab/create.ts +109 -0
- package/src/cli/commands/tab/focus.ts +33 -0
- package/src/cli/commands/tab/list.ts +52 -0
- package/src/cli/commands/tab/send.ts +83 -0
- package/src/cli/commands/tab/snapshot.ts +127 -0
- package/src/cli/commands/tab/tail.ts +171 -0
- package/src/cli/commands/tab/wait.ts +86 -0
- package/src/cli/commands/workspace/close.ts +32 -0
- package/src/cli/commands/workspace/create.ts +92 -0
- package/src/cli/commands/workspace/list.ts +25 -0
- package/src/cli/commands/workspace/show.ts +32 -0
- package/src/cli/commands/workspace/switch.ts +75 -0
- package/src/cli/commands/worktree/create.ts +113 -0
- package/src/cli/commands/worktree/list.ts +44 -0
- package/src/cli/commands/worktree/remove.ts +59 -0
- package/src/cli/context.ts +16 -0
- package/src/cli/flags.ts +101 -0
- package/src/cli/index.ts +113 -0
- package/src/cli/output.ts +30 -0
- package/src/cli/registry.ts +54 -0
- package/src/cli/snapshot-render.ts +54 -0
- package/src/daemon/catalog-writer.ts +123 -0
- package/src/daemon/daemon.ts +566 -11
- package/src/daemon/reexec-client.ts +147 -0
- package/src/daemon/runtime-paths.ts +161 -1
- package/src/daemon/session-registry.ts +17 -0
- package/src/index.tsx +9 -0
- package/src/input/modes/bridge.ts +6 -0
- package/src/input/modes/transitions.ts +2 -0
- package/src/input/modes/types.ts +1 -0
- package/src/ipc/README.md +112 -0
- package/src/ipc/manager-protocol.ts +54 -4
- package/src/ipc/protocol.ts +466 -25
- package/src/platform/daemon-control.ts +14 -0
- package/src/restart-daemon.ts +36 -4
- package/src/session-backend/bootstrap.ts +110 -0
- package/src/session-backend/local-session-backend.ts +6 -0
- package/src/session-backend/remote-session-backend.ts +63 -0
- package/src/session-backend/types.ts +34 -0
- package/src/state/reducers/modal-state.ts +66 -1
- package/src/state/types.ts +40 -0
- package/src/state/validation.ts +1 -1
- package/src/terminal-manager/manager-client.ts +24 -7
- package/src/ui/components/flash/flash-label-badge.tsx +38 -0
- package/src/ui/components/layout/sidebar/tab-item.tsx +2 -0
- package/src/ui/components/layout/sidebar/workspace-list.tsx +4 -0
- package/src/ui/components/layout/sidebar/worktree-row.tsx +2 -0
- package/src/ui/components/layout/top-tab-bar.tsx +2 -0
- package/src/ui/flash/assign-labels.ts +126 -0
- package/src/ui/flash/build-labels.ts +78 -0
- package/src/ui/hooks/use-flash-label.ts +40 -0
- package/src/ui/root.tsx +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brimveyn/aimux",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.18.1",
|
|
4
4
|
"description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode side-by-side with tabbed navigation, split panes, and persistent sessions.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -53,7 +53,9 @@
|
|
|
53
53
|
"demo:themes": "vhs assets/themes.tape",
|
|
54
54
|
"restart-daemon": "AIMUX_PROFILE=dev bun run src/index.tsx restart-daemon",
|
|
55
55
|
"restart-terminal-manager": "AIMUX_PROFILE=dev bun run src/index.tsx restart-terminal-manager",
|
|
56
|
+
"reexec-test": "AIMUX_PROFILE=reexec-test AIMUX_HOT_REEXEC=1 bun run scripts/manual-reexec-test.ts",
|
|
56
57
|
"lint": "oxlint --type-aware .",
|
|
58
|
+
"lint:protocol": "bun run scripts/check-protocol-discipline.ts",
|
|
57
59
|
"format": "oxfmt --write .",
|
|
58
60
|
"format:check": "oxfmt --check .",
|
|
59
61
|
"knip": "knip-bun",
|
|
@@ -62,7 +64,7 @@
|
|
|
62
64
|
"bump:terminal_manager": "bun run scripts/bump-protocol.ts terminal-manager"
|
|
63
65
|
},
|
|
64
66
|
"dependencies": {
|
|
65
|
-
"@brimveyn/aimux-config": "0.
|
|
67
|
+
"@brimveyn/aimux-config": "0.8.1",
|
|
66
68
|
"@opentui/core": "^0.1.90",
|
|
67
69
|
"@opentui/react": "^0.1.90",
|
|
68
70
|
"@resvg/resvg-wasm": "^2.6.2",
|
|
@@ -7,11 +7,19 @@ import type {
|
|
|
7
7
|
TabActivity,
|
|
8
8
|
TabSession,
|
|
9
9
|
TerminalModeState,
|
|
10
|
+
WorktreeRecord,
|
|
10
11
|
} from '../state/types'
|
|
11
12
|
import type { TabRuntimeTimeouts } from './tab-runtime-timeouts'
|
|
12
13
|
|
|
13
14
|
import { logInputDebug } from '../debug/input-log'
|
|
14
15
|
import { clearTabSyntaxState, highlightSnapshot } from '../integrations/claude-syntax-overlay'
|
|
16
|
+
import { appStore } from '../state/app-store'
|
|
17
|
+
import { loadSessionCatalog, saveSessionCatalog } from '../state/session-catalog'
|
|
18
|
+
import {
|
|
19
|
+
handleCreateSessionEffect,
|
|
20
|
+
handleDeleteSessionEffect,
|
|
21
|
+
handleSwitchSessionEffect,
|
|
22
|
+
} from './session-actions'
|
|
15
23
|
|
|
16
24
|
interface BindBackendRuntimeEventsOptions {
|
|
17
25
|
backend: SessionBackend
|
|
@@ -88,11 +96,175 @@ export function bindBackendRuntimeEvents({
|
|
|
88
96
|
dispatch({ activity, tabId, type: 'set-tab-activity' })
|
|
89
97
|
}
|
|
90
98
|
|
|
99
|
+
// Serialise workspace lifecycle handlers behind a small FIFO so back-to-back
|
|
100
|
+
// switch/create/close broadcasts from multiple CLIs don't race the
|
|
101
|
+
// in-flight `handleSwitchSessionEffect`. Every lifecycle handler awaits the
|
|
102
|
+
// previous chain link before running.
|
|
103
|
+
let lifecycleChain: Promise<void> = Promise.resolve()
|
|
104
|
+
const enqueueLifecycle = (fn: () => void | Promise<void>): void => {
|
|
105
|
+
const previous = lifecycleChain
|
|
106
|
+
lifecycleChain = (async () => {
|
|
107
|
+
await previous
|
|
108
|
+
try {
|
|
109
|
+
await fn()
|
|
110
|
+
} catch (error) {
|
|
111
|
+
logInputDebug('app.backend.event.lifecycle.error', {
|
|
112
|
+
error: error instanceof Error ? error.message : String(error),
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
})()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const handleWorkspaceCreateRequested = (
|
|
119
|
+
name: string,
|
|
120
|
+
projectPath: string | undefined,
|
|
121
|
+
doSwitch: boolean
|
|
122
|
+
) => {
|
|
123
|
+
enqueueLifecycle(() => {
|
|
124
|
+
logInputDebug('app.backend.event.workspaceCreateRequested', { doSwitch, name, projectPath })
|
|
125
|
+
const state = appStore.getState()
|
|
126
|
+
handleCreateSessionEffect(state, dispatch, name, projectPath)
|
|
127
|
+
if (doSwitch) {
|
|
128
|
+
// The create dispatched `set-sessions`+`load-session`; the new session
|
|
129
|
+
// is now in the catalog + current. Loading already ran, so the switch
|
|
130
|
+
// is effectively done — just tell the daemon so any --wait CLI exits.
|
|
131
|
+
const created = appStore
|
|
132
|
+
.getState()
|
|
133
|
+
.sessions.find((s) => s.name === name && s.projectPath === projectPath)
|
|
134
|
+
if (created) backend.announceWorkspaceSwitched(created.id)
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const handleWorkspaceSwitchRequested = (targetSessionId: string) => {
|
|
140
|
+
enqueueLifecycle(async () => {
|
|
141
|
+
logInputDebug('app.backend.event.workspaceSwitchRequested', { targetSessionId })
|
|
142
|
+
let state = appStore.getState()
|
|
143
|
+
let target = state.sessions.find((s) => s.id === targetSessionId)
|
|
144
|
+
if (!target) {
|
|
145
|
+
// In-memory list is stale (e.g. daemon just added a session via the
|
|
146
|
+
// headless path). Reload from disk and try once more before giving
|
|
147
|
+
// up — the daemon already validated the catalog, so this should hit.
|
|
148
|
+
const fresh = loadSessionCatalog()
|
|
149
|
+
target = fresh.find((s) => s.id === targetSessionId)
|
|
150
|
+
if (target) {
|
|
151
|
+
dispatch({ sessions: fresh, type: 'set-sessions' })
|
|
152
|
+
state = appStore.getState()
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (!target) {
|
|
156
|
+
// Announce anyway so any `--wait` CLI unblocks — better a spurious
|
|
157
|
+
// exit than a 30s hang while the caller retries.
|
|
158
|
+
logInputDebug('app.backend.event.workspaceSwitchRequested.notFound', { targetSessionId })
|
|
159
|
+
backend.announceWorkspaceSwitched(targetSessionId)
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
handleSwitchSessionEffect(state, backend, dispatch, target)
|
|
163
|
+
// Give the reducer a tick to settle before announcing — the switch
|
|
164
|
+
// fires `set-sessions`+`load-session` synchronously but the backend
|
|
165
|
+
// re-attach happens async.
|
|
166
|
+
await Promise.resolve()
|
|
167
|
+
backend.announceWorkspaceSwitched(targetSessionId)
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const handleWorkspaceCloseRequested = (targetSessionId: string) => {
|
|
172
|
+
enqueueLifecycle(() => {
|
|
173
|
+
logInputDebug('app.backend.event.workspaceCloseRequested', { targetSessionId })
|
|
174
|
+
const state = appStore.getState()
|
|
175
|
+
handleDeleteSessionEffect(state, backend, dispatch, targetSessionId)
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const handleWorkspaceSwitched = (sessionId: string) => {
|
|
180
|
+
// The daemon's own relay for --wait CLIs. UI side has no work to do —
|
|
181
|
+
// logging is enough.
|
|
182
|
+
logInputDebug('app.backend.event.workspaceSwitched', { sessionId })
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const handleWorktreeAdded = (sessionId: string, worktree: WorktreeRecord) => {
|
|
186
|
+
// Idempotent: skip if a duplicate id is already in the session (the CLI
|
|
187
|
+
// may have raced the UI's own worktree-add path).
|
|
188
|
+
const current = appStore.getState()
|
|
189
|
+
const session = current.sessions.find((s) => s.id === sessionId)
|
|
190
|
+
const already = session?.worktrees?.some((w) => w.id === worktree.id) === true
|
|
191
|
+
if (already) {
|
|
192
|
+
logInputDebug('app.backend.event.worktreeAdded.skipDuplicate', {
|
|
193
|
+
sessionId,
|
|
194
|
+
worktreeId: worktree.id,
|
|
195
|
+
})
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
logInputDebug('app.backend.event.worktreeAdded', {
|
|
199
|
+
sessionId,
|
|
200
|
+
worktreeId: worktree.id,
|
|
201
|
+
})
|
|
202
|
+
dispatch({ sessionId, type: 'add-worktree-record', worktree })
|
|
203
|
+
// Persist the catalog so the change survives restart even if no other
|
|
204
|
+
// side-effect saves shortly after.
|
|
205
|
+
saveSessionCatalog(appStore.getState().sessions)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const handleWorktreeRemoved = (sessionId: string, worktreeId: string) => {
|
|
209
|
+
const current = appStore.getState()
|
|
210
|
+
const session = current.sessions.find((s) => s.id === sessionId)
|
|
211
|
+
const present = session?.worktrees?.some((w) => w.id === worktreeId) === true
|
|
212
|
+
if (!present) {
|
|
213
|
+
logInputDebug('app.backend.event.worktreeRemoved.skipMissing', { sessionId, worktreeId })
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
logInputDebug('app.backend.event.worktreeRemoved', { sessionId, worktreeId })
|
|
217
|
+
// Build the updated sessions list and dispatch via `set-sessions` — this
|
|
218
|
+
// matches the pattern used by the existing worktree-delete side-effect
|
|
219
|
+
// and centralises the write in one action rather than adding a new one.
|
|
220
|
+
const sessions = current.sessions.map((s) => {
|
|
221
|
+
if (s.id !== sessionId) return s
|
|
222
|
+
const remaining = (s.worktrees ?? []).filter((w) => w.id !== worktreeId)
|
|
223
|
+
return {
|
|
224
|
+
...s,
|
|
225
|
+
activeWorktreeId: s.activeWorktreeId === worktreeId ? remaining[0]?.id : s.activeWorktreeId,
|
|
226
|
+
updatedAt: new Date().toISOString(),
|
|
227
|
+
worktrees: remaining,
|
|
228
|
+
}
|
|
229
|
+
})
|
|
230
|
+
saveSessionCatalog(sessions)
|
|
231
|
+
dispatch({ sessions, type: 'set-sessions' })
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const handleTabAdded = (sessionId: string, tab: TabSession) => {
|
|
235
|
+
// Idempotent: the daemon broadcasts `tabAdded` for every createTab,
|
|
236
|
+
// including the ones this UI process initiated (which already dispatched
|
|
237
|
+
// `add-tab` locally). Skip the duplicate so we don't get two entries for
|
|
238
|
+
// the same tab id.
|
|
239
|
+
const current = appStore.getState()
|
|
240
|
+
if (current.tabs.some((t) => t.id === tab.id)) {
|
|
241
|
+
logInputDebug('app.backend.event.tabAdded.skipDuplicate', { sessionId, tabId: tab.id })
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
if (current.currentSessionId !== sessionId) {
|
|
245
|
+
logInputDebug('app.backend.event.tabAdded.skipForeignSession', {
|
|
246
|
+
currentSessionId: current.currentSessionId,
|
|
247
|
+
sessionId,
|
|
248
|
+
tabId: tab.id,
|
|
249
|
+
})
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
logInputDebug('app.backend.event.tabAdded', { sessionId, tabId: tab.id })
|
|
253
|
+
dispatch({ tab, type: 'add-tab' })
|
|
254
|
+
}
|
|
255
|
+
|
|
91
256
|
backend.on('render', handleRender)
|
|
92
257
|
backend.on('exit', handleExit)
|
|
93
258
|
backend.on('error', handleError)
|
|
94
259
|
backend.on('sessionActivity', handleSessionActivity)
|
|
95
260
|
backend.on('tabActivity', handleTabActivity)
|
|
261
|
+
backend.on('tabAdded', handleTabAdded)
|
|
262
|
+
backend.on('workspaceCreateRequested', handleWorkspaceCreateRequested)
|
|
263
|
+
backend.on('workspaceSwitchRequested', handleWorkspaceSwitchRequested)
|
|
264
|
+
backend.on('workspaceCloseRequested', handleWorkspaceCloseRequested)
|
|
265
|
+
backend.on('workspaceSwitched', handleWorkspaceSwitched)
|
|
266
|
+
backend.on('worktreeAdded', handleWorktreeAdded)
|
|
267
|
+
backend.on('worktreeRemoved', handleWorktreeRemoved)
|
|
96
268
|
|
|
97
269
|
return () => {
|
|
98
270
|
timeouts.clearAllTimers()
|
|
@@ -101,6 +273,13 @@ export function bindBackendRuntimeEvents({
|
|
|
101
273
|
backend.off('error', handleError)
|
|
102
274
|
backend.off('sessionActivity', handleSessionActivity)
|
|
103
275
|
backend.off('tabActivity', handleTabActivity)
|
|
276
|
+
backend.off('tabAdded', handleTabAdded)
|
|
277
|
+
backend.off('workspaceCreateRequested', handleWorkspaceCreateRequested)
|
|
278
|
+
backend.off('workspaceSwitchRequested', handleWorkspaceSwitchRequested)
|
|
279
|
+
backend.off('workspaceCloseRequested', handleWorkspaceCloseRequested)
|
|
280
|
+
backend.off('workspaceSwitched', handleWorkspaceSwitched)
|
|
281
|
+
backend.off('worktreeAdded', handleWorktreeAdded)
|
|
282
|
+
backend.off('worktreeRemoved', handleWorktreeRemoved)
|
|
104
283
|
void backend.destroy(true)
|
|
105
284
|
}
|
|
106
285
|
}
|
|
@@ -326,7 +326,7 @@ export function startTabSession(
|
|
|
326
326
|
dispatch: (action: AppAction) => void,
|
|
327
327
|
clearStartupGrace: (tabId: string) => void,
|
|
328
328
|
startStartupGrace: (tabId: string) => void,
|
|
329
|
-
tab: Pick<TabSession, 'id' | 'assistant' | 'title' | 'command'>,
|
|
329
|
+
tab: Pick<TabSession, 'id' | 'assistant' | 'title' | 'command' | 'worktreeId'>,
|
|
330
330
|
cols: number,
|
|
331
331
|
rows: number,
|
|
332
332
|
cwd?: string
|
|
@@ -338,6 +338,7 @@ export function startTabSession(
|
|
|
338
338
|
rows,
|
|
339
339
|
tabId: tab.id,
|
|
340
340
|
title: tab.title,
|
|
341
|
+
worktreeId: tab.worktreeId ?? null,
|
|
341
342
|
})
|
|
342
343
|
startStartupGrace(tab.id)
|
|
343
344
|
|
|
@@ -362,6 +363,7 @@ export function startTabSession(
|
|
|
362
363
|
rows,
|
|
363
364
|
tabId: tab.id,
|
|
364
365
|
title: tab.title,
|
|
366
|
+
worktreeId: tab.worktreeId,
|
|
365
367
|
})
|
|
366
368
|
}
|
|
367
369
|
|
package/src/app.tsx
CHANGED
|
@@ -502,6 +502,32 @@ export function App({
|
|
|
502
502
|
}
|
|
503
503
|
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
504
504
|
|
|
505
|
+
const flashPendingJump = useAppStore((s) =>
|
|
506
|
+
s.modal.type === 'flash-jump' ? s.modal.pendingJump : null
|
|
507
|
+
)
|
|
508
|
+
useEffect(() => {
|
|
509
|
+
if (flashPendingJump === null) return
|
|
510
|
+
// Snapshot before clearing the modal: closing rewrites state.modal so we
|
|
511
|
+
// need the target in hand before dispatching.
|
|
512
|
+
const target = flashPendingJump
|
|
513
|
+
dispatch({ type: 'close-modal' })
|
|
514
|
+
if (target.kind === 'tab' && target.tabId != null && target.tabId !== '') {
|
|
515
|
+
dispatch({ tabId: target.tabId, type: 'set-active-tab' })
|
|
516
|
+
return
|
|
517
|
+
}
|
|
518
|
+
if (target.kind === 'workspace' || target.kind === 'worktree') {
|
|
519
|
+
executeSideEffect(
|
|
520
|
+
{
|
|
521
|
+
index: target.sessionIndex,
|
|
522
|
+
type: 'switch-session-by-index',
|
|
523
|
+
worktreeId: target.worktreeId,
|
|
524
|
+
},
|
|
525
|
+
sideEffectCtx
|
|
526
|
+
)
|
|
527
|
+
}
|
|
528
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
529
|
+
}, [flashPendingJump])
|
|
530
|
+
|
|
505
531
|
useKeyboard((key) => {
|
|
506
532
|
const currentState = stateRef.current
|
|
507
533
|
// Global quit: Ctrl+C in any mode except terminal-input
|
package/src/cli/chord.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { parseKeyNotation } from '../input/keymap/key-chord'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Map a vim-style key notation string ("<C-c>", "<Esc>", "ga", "<C-x><C-s>")
|
|
5
|
+
* into the raw bytes the receiving terminal expects. Reuses
|
|
6
|
+
* `parseKeyNotation` to turn the notation into canonical chord strings, then
|
|
7
|
+
* lowers each chord into a byte sequence.
|
|
8
|
+
*
|
|
9
|
+
* Recognised special keys:
|
|
10
|
+
* <CR>/<Tab>/<Esc>/<Space>/<BS>/<Up>/<Down>/<Left>/<Right>
|
|
11
|
+
* Recognised modifier: `<C-letter>` (Ctrl) — case insensitive `C-`.
|
|
12
|
+
*/
|
|
13
|
+
export function notationToBytes(notation: string): Buffer {
|
|
14
|
+
const chords = parseKeyNotation(notation)
|
|
15
|
+
const parts: Buffer[] = []
|
|
16
|
+
for (const chord of chords) {
|
|
17
|
+
parts.push(chordToBytes(chord))
|
|
18
|
+
}
|
|
19
|
+
return Buffer.concat(parts)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const ESC_BRACKET = Buffer.from([0x1b, 0x5b])
|
|
23
|
+
|
|
24
|
+
function chordToBytes(chord: string): Buffer {
|
|
25
|
+
if (chord.length === 1) {
|
|
26
|
+
return Buffer.from(chord, 'utf8')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Modifier+letter (Ctrl only — Alt/Meta lowering is more terminal-specific).
|
|
30
|
+
if (chord.startsWith('C-') && chord.length >= 3) {
|
|
31
|
+
const tail = chord.slice(2)
|
|
32
|
+
if (tail.length === 1) {
|
|
33
|
+
const ch = tail.toLowerCase()
|
|
34
|
+
const code = ch.charCodeAt(0)
|
|
35
|
+
if (code >= 0x61 && code <= 0x7a) {
|
|
36
|
+
return Buffer.from([code - 0x60])
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (tail === 'space') return Buffer.from([0x00])
|
|
40
|
+
if (tail === '?') return Buffer.from([0x7f])
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
switch (chord) {
|
|
44
|
+
case 'return':
|
|
45
|
+
return Buffer.from([0x0d])
|
|
46
|
+
case 'tab':
|
|
47
|
+
return Buffer.from([0x09])
|
|
48
|
+
case 'escape':
|
|
49
|
+
return Buffer.from([0x1b])
|
|
50
|
+
case 'space':
|
|
51
|
+
return Buffer.from([0x20])
|
|
52
|
+
case 'backspace':
|
|
53
|
+
return Buffer.from([0x7f])
|
|
54
|
+
case 'up':
|
|
55
|
+
return Buffer.concat([ESC_BRACKET, Buffer.from([0x41])])
|
|
56
|
+
case 'down':
|
|
57
|
+
return Buffer.concat([ESC_BRACKET, Buffer.from([0x42])])
|
|
58
|
+
case 'right':
|
|
59
|
+
return Buffer.concat([ESC_BRACKET, Buffer.from([0x43])])
|
|
60
|
+
case 'left':
|
|
61
|
+
return Buffer.concat([ESC_BRACKET, Buffer.from([0x44])])
|
|
62
|
+
default:
|
|
63
|
+
throw new Error(`unsupported chord: ${chord}`)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const BRACKETED_PASTE_START = '\x1b[200~'
|
|
68
|
+
const BRACKETED_PASTE_END = '\x1b[201~'
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Wrap a multi-line block in bracketed-paste markers so the receiver does not
|
|
72
|
+
* treat each `\n` as a submit keystroke. Single-line text is passed through.
|
|
73
|
+
*/
|
|
74
|
+
export function bracketedPaste(text: string): string {
|
|
75
|
+
if (!text.includes('\n')) return text
|
|
76
|
+
return `${BRACKETED_PASTE_START}${text}${BRACKETED_PASTE_END}`
|
|
77
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
import { getIpcDaemonSocketPath } from '../../daemon/runtime-paths'
|
|
4
|
+
import { spawnDetachedIpcDaemon } from '../../platform/daemon-control'
|
|
5
|
+
import { DaemonClient } from './daemon-client'
|
|
6
|
+
|
|
7
|
+
interface ConnectOptions {
|
|
8
|
+
/** When true (default), spawn the daemon if the socket is missing. */
|
|
9
|
+
autostart?: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const SPAWN_BACKOFF_MS = 300
|
|
13
|
+
const SPAWN_ATTEMPTS = 10
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Raised only from the bootstrap phase (connecting to / spawning the daemon).
|
|
17
|
+
* The top-level CLI runner catches this and maps to EXIT_DAEMON_UNREACHABLE,
|
|
18
|
+
* distinguishing genuine connection failures from downstream runtime errors
|
|
19
|
+
* whose messages might happen to mention "socket" or "daemon".
|
|
20
|
+
*/
|
|
21
|
+
export class DaemonUnreachableError extends Error {
|
|
22
|
+
constructor(message: string) {
|
|
23
|
+
super(message)
|
|
24
|
+
this.name = 'DaemonUnreachableError'
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Connect to the daemon, spawning it if necessary. Matches the UI's bootstrap
|
|
30
|
+
* but stripped of the breaking-update path — a CLI invocation is not the
|
|
31
|
+
* right place to surface "your binary is out of date" UX.
|
|
32
|
+
*
|
|
33
|
+
* Returns a connected, handshake-completed `DaemonClient`.
|
|
34
|
+
*/
|
|
35
|
+
export async function connectToDaemon(options: ConnectOptions = {}): Promise<DaemonClient> {
|
|
36
|
+
const autostart = options.autostart !== false
|
|
37
|
+
const socketPath = getIpcDaemonSocketPath()
|
|
38
|
+
|
|
39
|
+
if (existsSync(socketPath)) {
|
|
40
|
+
try {
|
|
41
|
+
return await DaemonClient.connect(socketPath)
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (!autostart) {
|
|
44
|
+
throw new DaemonUnreachableError(
|
|
45
|
+
`daemon socket unreachable: ${error instanceof Error ? error.message : String(error)}`
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
// Fall through to spawn — the socket exists but the daemon is wedged.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!autostart) {
|
|
53
|
+
throw new DaemonUnreachableError(`daemon socket missing: ${socketPath}`)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const spawned = await spawnDetachedIpcDaemon()
|
|
57
|
+
if (!spawned) {
|
|
58
|
+
throw new DaemonUnreachableError('failed to start aimux daemon')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let lastError: unknown
|
|
62
|
+
for (let attempt = 0; attempt < SPAWN_ATTEMPTS; attempt++) {
|
|
63
|
+
try {
|
|
64
|
+
return await DaemonClient.connect(socketPath)
|
|
65
|
+
} catch (error) {
|
|
66
|
+
lastError = error
|
|
67
|
+
await new Promise((resolve) => setTimeout(resolve, SPAWN_BACKOFF_MS))
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
throw new DaemonUnreachableError(
|
|
72
|
+
`daemon spawned but unreachable after ${SPAWN_ATTEMPTS} attempts: ${
|
|
73
|
+
lastError instanceof Error ? lastError.message : String(lastError)
|
|
74
|
+
}`
|
|
75
|
+
)
|
|
76
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { connect, type Socket } from 'node:net'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type AttachRequest,
|
|
5
|
+
type AttachResult,
|
|
6
|
+
type ClientRequest,
|
|
7
|
+
encodeMessage,
|
|
8
|
+
IPC_PROTOCOL_MIN_VERSION,
|
|
9
|
+
IPC_PROTOCOL_VERSION,
|
|
10
|
+
type ListTabsResult,
|
|
11
|
+
MessageDecoder,
|
|
12
|
+
parseServerMessage,
|
|
13
|
+
type ProtocolHelloResult,
|
|
14
|
+
type ServerEvent,
|
|
15
|
+
type ServerResponse,
|
|
16
|
+
} from '../../ipc/protocol'
|
|
17
|
+
|
|
18
|
+
interface PendingRequest {
|
|
19
|
+
resolve: (response: ServerResponse) => void
|
|
20
|
+
reject: (error: Error) => void
|
|
21
|
+
timer: ReturnType<typeof setTimeout>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type ServerEventType = ServerEvent['type']
|
|
25
|
+
|
|
26
|
+
type EventHandler<T extends ServerEventType> = (
|
|
27
|
+
payload: Extract<ServerEvent, { type: T }>['payload']
|
|
28
|
+
) => void
|
|
29
|
+
|
|
30
|
+
const REQUEST_TIMEOUT_MS = 10_000
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Thin daemon-side client used by the CLI. Mirrors the UI's `IpcClient` but
|
|
34
|
+
* trimmed down: no React store wiring, no presence tracking, no auto-reconnect
|
|
35
|
+
* loop — the CLI is one-shot. The connection lives only as long as the
|
|
36
|
+
* command runs.
|
|
37
|
+
*
|
|
38
|
+
* Capability checks are exposed so command implementations can gate on
|
|
39
|
+
* `listTabs` / `thinAttach` / `createTabSizeFallback` and fall back cleanly
|
|
40
|
+
* when the daemon predates v11.
|
|
41
|
+
*/
|
|
42
|
+
export class DaemonClient {
|
|
43
|
+
private constructor(
|
|
44
|
+
private readonly socket: Socket,
|
|
45
|
+
private readonly hello: ProtocolHelloResult,
|
|
46
|
+
private readonly pending: Map<string, PendingRequest>,
|
|
47
|
+
private readonly eventHandlers: Map<
|
|
48
|
+
ServerEventType,
|
|
49
|
+
Set<(payload: ServerEvent['payload']) => void>
|
|
50
|
+
>
|
|
51
|
+
) {}
|
|
52
|
+
|
|
53
|
+
static async connect(socketPath: string): Promise<DaemonClient> {
|
|
54
|
+
const socket = connect(socketPath)
|
|
55
|
+
await new Promise<void>((resolve, reject) => {
|
|
56
|
+
const onConnect = () => {
|
|
57
|
+
socket.off('error', onError)
|
|
58
|
+
resolve()
|
|
59
|
+
}
|
|
60
|
+
const onError = (error: Error) => {
|
|
61
|
+
socket.off('connect', onConnect)
|
|
62
|
+
reject(error)
|
|
63
|
+
}
|
|
64
|
+
socket.once('connect', onConnect)
|
|
65
|
+
socket.once('error', onError)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const pending = new Map<string, PendingRequest>()
|
|
69
|
+
const eventHandlers = new Map<ServerEventType, Set<(payload: ServerEvent['payload']) => void>>()
|
|
70
|
+
const decoder = new MessageDecoder<ServerResponse | ServerEvent>(parseServerMessage)
|
|
71
|
+
|
|
72
|
+
const failAllPending = (reason: string): void => {
|
|
73
|
+
for (const [id, entry] of pending) {
|
|
74
|
+
clearTimeout(entry.timer)
|
|
75
|
+
pending.delete(id)
|
|
76
|
+
entry.reject(new Error(reason))
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
socket.on('data', (chunk) => {
|
|
81
|
+
try {
|
|
82
|
+
for (const message of decoder.push(chunk)) {
|
|
83
|
+
if ('id' in message) {
|
|
84
|
+
const entry = pending.get(message.id)
|
|
85
|
+
if (entry) {
|
|
86
|
+
clearTimeout(entry.timer)
|
|
87
|
+
pending.delete(message.id)
|
|
88
|
+
entry.resolve(message)
|
|
89
|
+
}
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
const handlers = eventHandlers.get(message.type)
|
|
93
|
+
if (!handlers) continue
|
|
94
|
+
for (const handler of handlers) handler(message.payload)
|
|
95
|
+
}
|
|
96
|
+
} catch (error) {
|
|
97
|
+
failAllPending(
|
|
98
|
+
`daemon parse error: ${error instanceof Error ? error.message : String(error)}`
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
socket.on('close', () => failAllPending('daemon connection closed'))
|
|
103
|
+
socket.on('error', (error) => failAllPending(`daemon socket error: ${error.message}`))
|
|
104
|
+
|
|
105
|
+
// Handshake.
|
|
106
|
+
const id = crypto.randomUUID()
|
|
107
|
+
const helloResponse = await new Promise<ServerResponse>((resolve, reject) => {
|
|
108
|
+
const timer = setTimeout(() => {
|
|
109
|
+
pending.delete(id)
|
|
110
|
+
reject(new Error('hello timed out'))
|
|
111
|
+
}, REQUEST_TIMEOUT_MS)
|
|
112
|
+
pending.set(id, { reject, resolve, timer })
|
|
113
|
+
socket.write(
|
|
114
|
+
encodeMessage({
|
|
115
|
+
id,
|
|
116
|
+
payload: { maxVersion: IPC_PROTOCOL_VERSION, minVersion: IPC_PROTOCOL_MIN_VERSION },
|
|
117
|
+
type: 'hello',
|
|
118
|
+
})
|
|
119
|
+
)
|
|
120
|
+
})
|
|
121
|
+
if (helloResponse.type !== 'helloResult') {
|
|
122
|
+
const detail =
|
|
123
|
+
helloResponse.type === 'error' ? helloResponse.payload.message : helloResponse.type
|
|
124
|
+
throw new Error(`hello failed: ${detail}`)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return new DaemonClient(socket, helloResponse.payload, pending, eventHandlers)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
hasCapability(name: string): boolean {
|
|
131
|
+
return this.hello.capabilities.includes(name)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
getCapabilities(): readonly string[] {
|
|
135
|
+
return this.hello.capabilities
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
getSelectedVersion(): number {
|
|
139
|
+
return this.hello.selectedVersion
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private async send(request: ClientRequest): Promise<ServerResponse> {
|
|
143
|
+
return new Promise((resolve, reject) => {
|
|
144
|
+
const timer = setTimeout(() => {
|
|
145
|
+
this.pending.delete(request.id)
|
|
146
|
+
reject(new Error(`request timed out: ${request.type}`))
|
|
147
|
+
}, REQUEST_TIMEOUT_MS)
|
|
148
|
+
this.pending.set(request.id, { reject, resolve, timer })
|
|
149
|
+
this.socket.write(encodeMessage(request), (error) => {
|
|
150
|
+
if (error) {
|
|
151
|
+
clearTimeout(timer)
|
|
152
|
+
this.pending.delete(request.id)
|
|
153
|
+
reject(error)
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async request<T extends ClientRequest['type']>(
|
|
160
|
+
type: T,
|
|
161
|
+
payload: Extract<ClientRequest, { type: T }>['payload']
|
|
162
|
+
): Promise<ServerResponse> {
|
|
163
|
+
return this.send({ id: crypto.randomUUID(), payload, type } as ClientRequest)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async expectOk(type: ClientRequest['type'], payload: unknown): Promise<void> {
|
|
167
|
+
const response = await this.send({
|
|
168
|
+
id: crypto.randomUUID(),
|
|
169
|
+
payload,
|
|
170
|
+
type,
|
|
171
|
+
} as ClientRequest)
|
|
172
|
+
if (response.type === 'ok') return
|
|
173
|
+
throw new Error(
|
|
174
|
+
response.type === 'error' ? response.payload.message : `unexpected response: ${response.type}`
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async attach(request: Omit<AttachRequest, 'protocolVersion'>): Promise<AttachResult> {
|
|
179
|
+
const response = await this.send({
|
|
180
|
+
id: crypto.randomUUID(),
|
|
181
|
+
payload: { ...request, protocolVersion: this.hello.selectedVersion },
|
|
182
|
+
type: 'attach',
|
|
183
|
+
})
|
|
184
|
+
if (response.type !== 'attachResult') {
|
|
185
|
+
throw new Error(
|
|
186
|
+
response.type === 'error' ? response.payload.message : `attach failed: ${response.type}`
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
return response.payload
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async listTabs(sessionId: string): Promise<ListTabsResult> {
|
|
193
|
+
const response = await this.send({
|
|
194
|
+
id: crypto.randomUUID(),
|
|
195
|
+
payload: { sessionId },
|
|
196
|
+
type: 'listTabs',
|
|
197
|
+
})
|
|
198
|
+
if (response.type !== 'listTabsResult') {
|
|
199
|
+
throw new Error(
|
|
200
|
+
response.type === 'error' ? response.payload.message : `listTabs failed: ${response.type}`
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
return response.payload
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
on<T extends ServerEventType>(eventType: T, handler: EventHandler<T>): () => void {
|
|
207
|
+
let set = this.eventHandlers.get(eventType)
|
|
208
|
+
if (!set) {
|
|
209
|
+
set = new Set()
|
|
210
|
+
this.eventHandlers.set(eventType, set)
|
|
211
|
+
}
|
|
212
|
+
const cast = handler as (payload: ServerEvent['payload']) => void
|
|
213
|
+
set.add(cast)
|
|
214
|
+
return () => {
|
|
215
|
+
set?.delete(cast)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
close(): void {
|
|
220
|
+
for (const [id, entry] of this.pending) {
|
|
221
|
+
clearTimeout(entry.timer)
|
|
222
|
+
this.pending.delete(id)
|
|
223
|
+
entry.reject(new Error('client closed'))
|
|
224
|
+
}
|
|
225
|
+
this.socket.end()
|
|
226
|
+
this.socket.destroy()
|
|
227
|
+
}
|
|
228
|
+
}
|