@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.
Files changed (61) hide show
  1. package/package.json +4 -2
  2. package/src/app-runtime/backend-runtime-events.ts +179 -0
  3. package/src/app-runtime/side-effects.ts +3 -1
  4. package/src/app.tsx +26 -0
  5. package/src/cli/chord.ts +77 -0
  6. package/src/cli/client/bootstrap.ts +76 -0
  7. package/src/cli/client/daemon-client.ts +228 -0
  8. package/src/cli/client/workspace-resolver.ts +57 -0
  9. package/src/cli/commands/tab/close.ts +33 -0
  10. package/src/cli/commands/tab/create.ts +109 -0
  11. package/src/cli/commands/tab/focus.ts +33 -0
  12. package/src/cli/commands/tab/list.ts +52 -0
  13. package/src/cli/commands/tab/send.ts +83 -0
  14. package/src/cli/commands/tab/snapshot.ts +127 -0
  15. package/src/cli/commands/tab/tail.ts +171 -0
  16. package/src/cli/commands/tab/wait.ts +86 -0
  17. package/src/cli/commands/workspace/close.ts +32 -0
  18. package/src/cli/commands/workspace/create.ts +92 -0
  19. package/src/cli/commands/workspace/list.ts +25 -0
  20. package/src/cli/commands/workspace/show.ts +32 -0
  21. package/src/cli/commands/workspace/switch.ts +75 -0
  22. package/src/cli/commands/worktree/create.ts +113 -0
  23. package/src/cli/commands/worktree/list.ts +44 -0
  24. package/src/cli/commands/worktree/remove.ts +59 -0
  25. package/src/cli/context.ts +16 -0
  26. package/src/cli/flags.ts +101 -0
  27. package/src/cli/index.ts +113 -0
  28. package/src/cli/output.ts +30 -0
  29. package/src/cli/registry.ts +54 -0
  30. package/src/cli/snapshot-render.ts +54 -0
  31. package/src/daemon/catalog-writer.ts +123 -0
  32. package/src/daemon/daemon.ts +566 -11
  33. package/src/daemon/reexec-client.ts +147 -0
  34. package/src/daemon/runtime-paths.ts +161 -1
  35. package/src/daemon/session-registry.ts +17 -0
  36. package/src/index.tsx +9 -0
  37. package/src/input/modes/bridge.ts +6 -0
  38. package/src/input/modes/transitions.ts +2 -0
  39. package/src/input/modes/types.ts +1 -0
  40. package/src/ipc/README.md +112 -0
  41. package/src/ipc/manager-protocol.ts +54 -4
  42. package/src/ipc/protocol.ts +466 -25
  43. package/src/platform/daemon-control.ts +14 -0
  44. package/src/restart-daemon.ts +36 -4
  45. package/src/session-backend/bootstrap.ts +110 -0
  46. package/src/session-backend/local-session-backend.ts +6 -0
  47. package/src/session-backend/remote-session-backend.ts +63 -0
  48. package/src/session-backend/types.ts +34 -0
  49. package/src/state/reducers/modal-state.ts +66 -1
  50. package/src/state/types.ts +40 -0
  51. package/src/state/validation.ts +1 -1
  52. package/src/terminal-manager/manager-client.ts +24 -7
  53. package/src/ui/components/flash/flash-label-badge.tsx +38 -0
  54. package/src/ui/components/layout/sidebar/tab-item.tsx +2 -0
  55. package/src/ui/components/layout/sidebar/workspace-list.tsx +4 -0
  56. package/src/ui/components/layout/sidebar/worktree-row.tsx +2 -0
  57. package/src/ui/components/layout/top-tab-bar.tsx +2 -0
  58. package/src/ui/flash/assign-labels.ts +126 -0
  59. package/src/ui/flash/build-labels.ts +78 -0
  60. package/src/ui/hooks/use-flash-label.ts +40 -0
  61. 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.16.3",
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.6.12",
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
@@ -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
+ }