@brimveyn/aimux 1.16.2 → 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
@@ -1,7 +1,7 @@
1
- import { existsSync, unlinkSync, writeFileSync } from 'node:fs'
1
+ import { existsSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
2
2
  import { connect, createServer, type Socket } from 'node:net'
3
3
 
4
- import type { AssistantId, TerminalSnapshot } from '../state/types'
4
+ import type { AssistantId, TabSession, TabStatus, TerminalSnapshot } from '../state/types'
5
5
 
6
6
  import { logDebug } from '../debug/input-log'
7
7
  import { type ClaudeHookServer, startClaudeHookServer } from '../integrations/claude-hook-server'
@@ -9,6 +9,7 @@ import {
9
9
  type ClientRequest,
10
10
  encodeMessage,
11
11
  getProcessVersion,
12
+ IPC_PROTOCOL_CAPABILITIES,
12
13
  IPC_PROTOCOL_MIN_VERSION,
13
14
  IPC_PROTOCOL_VERSION,
14
15
  MessageDecoder,
@@ -16,18 +17,35 @@ import {
16
17
  parseClientRequest,
17
18
  type ServerEvent,
18
19
  type ServerResponse,
20
+ type TabSessionSummary,
19
21
  } from '../ipc/protocol'
20
22
  import { findSocketProcessPid, spawnDetachedTerminalManager } from '../platform/daemon-control'
21
23
  import { type LoopTabView, runStatusDetectionLoop } from '../pty/assistant-status-detection-loop'
24
+ import { createDefaultTerminalModes } from '../state/terminal-modes'
22
25
  import { TerminalManagerClient } from '../terminal-manager/manager-client'
23
26
  import {
27
+ addWorktreeToCatalog,
28
+ assertSessionInCatalog,
29
+ bumpLastOpenedInCatalog,
30
+ createWorkspaceInCatalog,
31
+ deleteFromCatalog,
32
+ removeWorktreeFromCatalog,
33
+ } from './catalog-writer'
34
+ import {
35
+ consumeDaemonHandoff,
24
36
  getClaudeHookUrlFilePath,
37
+ getDaemonOldSocketPath,
25
38
  getIpcDaemonSocketPath,
26
39
  getSocketSecurityIssue,
27
40
  getTerminalManagerSocketPath,
41
+ removeDaemonSidecars,
42
+ removeDaemonSidecarsForReexec,
28
43
  removeDaemonSocketIfExists,
29
44
  removeTerminalManagerSocketIfExists,
30
45
  tightenSocketPermissions,
46
+ writeDaemonHandoff,
47
+ writeDaemonPidFile,
48
+ writeDaemonVersionFile,
31
49
  } from './runtime-paths'
32
50
 
33
51
  export interface DaemonTabEntry {
@@ -42,6 +60,15 @@ export interface DaemonTabEntry {
42
60
  * `attachSession` call was still in flight.
43
61
  */
44
62
  viewportSeq: number
63
+ /**
64
+ * Slim TabSession metadata cached so the `listTabs` request type can answer
65
+ * without round-tripping the TM. Populated on attach (full data from
66
+ * `attachResult.tabs`) and on createTab (title taken from the request,
67
+ * status defaulted to 'starting' until a render or status update lands).
68
+ */
69
+ title?: string
70
+ status?: TabStatus
71
+ worktreeId?: string
45
72
  }
46
73
 
47
74
  /**
@@ -62,7 +89,8 @@ export function mergeTabRegistryEntry(
62
89
  assistant: AssistantId,
63
90
  command: string,
64
91
  initialViewport: TerminalSnapshot | undefined,
65
- allocateSeq: () => number
92
+ allocateSeq: () => number,
93
+ metadata?: { title?: string; status?: TabStatus; worktreeId?: string }
66
94
  ): DaemonTabEntry {
67
95
  const existing = registry.get(tabId)
68
96
  const viewport = existing?.viewport ?? initialViewport
@@ -70,8 +98,11 @@ export function mergeTabRegistryEntry(
70
98
  assistant,
71
99
  command,
72
100
  sessionId,
101
+ status: metadata?.status ?? existing?.status,
102
+ title: metadata?.title ?? existing?.title,
73
103
  viewport,
74
104
  viewportSeq: existing?.viewportSeq ?? (viewport ? allocateSeq() : 0),
105
+ worktreeId: metadata?.worktreeId ?? existing?.worktreeId,
75
106
  }
76
107
  registry.set(tabId, entry)
77
108
  return entry
@@ -150,7 +181,17 @@ async function ensureTerminalManagerReady(manager: TerminalManagerClient): Promi
150
181
 
151
182
  export async function runDaemon(): Promise<void> {
152
183
  const socketPath = getIpcDaemonSocketPath()
153
- logDebug('daemon.start', { pid: process.pid, socketPath })
184
+ // A handoff file means we were spawned to take over from a predecessor
185
+ // daemon that already drained and renamed its socket away. Consume the
186
+ // file so a later fresh boot won't see it. The TM is still running and
187
+ // we'll connect to it normally (no spawn needed).
188
+ const handoff = consumeDaemonHandoff()
189
+ logDebug('daemon.start', {
190
+ handoffFromPid: handoff?.fromPid ?? null,
191
+ handoffFromVersion: handoff?.fromProcessVersion ?? null,
192
+ pid: process.pid,
193
+ socketPath,
194
+ })
154
195
 
155
196
  const existingPid = await findSocketProcessPid(socketPath)
156
197
  if (existingPid !== null && existingPid !== process.pid) {
@@ -167,10 +208,23 @@ export async function runDaemon(): Promise<void> {
167
208
  const sockets = new Set<Socket>()
168
209
  const attachedSessions = new Map<Socket, string>()
169
210
  const negotiatedVersions = new Map<Socket, number>()
211
+ // Sockets that attached with `thin: true` (headless CLIs). Used to
212
+ // distinguish "a UI is attached" from "only CLIs are talking to me" when
213
+ // deciding whether workspace/worktree mutations go via broadcast (UI does
214
+ // the write) or via catalog-writer (daemon does the write).
215
+ const thinAttachers = new Set<Socket>()
170
216
 
171
217
  // Per-tab registry so the status-detection loop can poll every terminal
172
218
  // continuously, not just the one the UI is currently attached to.
173
219
  const tabRegistry = new Map<string, DaemonTabEntry>()
220
+ // Active tab id per session, populated from attachResult and updated by
221
+ // setActiveTab. Surfaced by `listTabs` so a headless CLI doesn't need to
222
+ // attach just to know which tab the UI is focused on.
223
+ const sessionActiveTabIds = new Map<string, string | null>()
224
+ // Last (cols, rows) the session was attached with. `createTab` with
225
+ // cols/rows = 0 falls back to this when the `createTabSizeFallback`
226
+ // capability is in play.
227
+ const sessionDimensions = new Map<string, { cols: number; rows: number }>()
174
228
  let nextViewportSeq = 1
175
229
 
176
230
  const allocateSeq = (): number => nextViewportSeq++
@@ -179,7 +233,8 @@ export async function runDaemon(): Promise<void> {
179
233
  tabId: string,
180
234
  assistant: AssistantId,
181
235
  command: string,
182
- initialViewport?: TerminalSnapshot
236
+ initialViewport?: TerminalSnapshot,
237
+ metadata?: { title?: string; status?: TabStatus; worktreeId?: string }
183
238
  ): void => {
184
239
  const before = tabRegistry.get(tabId)
185
240
  const entry = mergeTabRegistryEntry(
@@ -189,7 +244,8 @@ export async function runDaemon(): Promise<void> {
189
244
  assistant,
190
245
  command,
191
246
  initialViewport,
192
- allocateSeq
247
+ allocateSeq,
248
+ metadata
193
249
  )
194
250
  logDebug('daemon.rememberTab', {
195
251
  hadExistingEntry: before !== undefined,
@@ -217,6 +273,49 @@ export async function runDaemon(): Promise<void> {
217
273
  }
218
274
  }
219
275
 
276
+ // Fan a server event only to sockets that negotiated a version supporting
277
+ // it. Older peers whose `parseServerMessage` doesn't recognise the type
278
+ // would throw on receipt and drop the connection — MIN_VERSION stays at 10
279
+ // for compat, so we can't rely on every attached socket being v11.
280
+ const broadcastForSessionVersioned = (
281
+ sessionId: string,
282
+ minVersion: number,
283
+ event: ServerEvent
284
+ ): void => {
285
+ for (const socket of sockets) {
286
+ if (attachedSessions.get(socket) !== sessionId) continue
287
+ const version = negotiatedVersions.get(socket)
288
+ if (version === undefined || version < minVersion) continue
289
+ send(socket, event)
290
+ }
291
+ }
292
+
293
+ // Workspace-scope broadcast: reaches every socket that negotiated at least
294
+ // `minVersion` regardless of which session it's attached to. Workspace
295
+ // lifecycle events (create/switch/close) target a session that the UI may
296
+ // not currently be attached to, so `broadcastForSessionVersioned` won't do.
297
+ const broadcastAllVersioned = (minVersion: number, event: ServerEvent): void => {
298
+ for (const socket of sockets) {
299
+ const version = negotiatedVersions.get(socket)
300
+ if (version === undefined || version < minVersion) continue
301
+ send(socket, event)
302
+ }
303
+ }
304
+
305
+ // Count UI attachers so workspace/worktree handlers can decide whether to
306
+ // relay via broadcast (UI attached → its reducer owns the write) or mutate
307
+ // the catalog directly. A "UI attacher" here is any non-thin attach — thin
308
+ // attachers are CLIs which don't run reducers.
309
+ const countUiAttachers = (): number => {
310
+ let count = 0
311
+ for (const socket of sockets) {
312
+ if (attachedSessions.get(socket) === undefined) continue
313
+ if (thinAttachers.has(socket)) continue
314
+ count++
315
+ }
316
+ return count
317
+ }
318
+
220
319
  manager.on('render', (sessionId, tabId, viewport, terminalModes) => {
221
320
  const existing = tabRegistry.get(tabId)
222
321
  let newSeq: number | null = null
@@ -240,6 +339,9 @@ export async function runDaemon(): Promise<void> {
240
339
  manager.on('exit', (sessionId, tabId, exitCode) => {
241
340
  logDebug('daemon.manager.exit', { exitCode, sessionId, tabId })
242
341
  tabRegistry.delete(tabId)
342
+ if (sessionActiveTabIds.get(sessionId) === tabId) {
343
+ sessionActiveTabIds.set(sessionId, null)
344
+ }
243
345
  const event: ServerEvent = { payload: { exitCode, tabId }, type: 'tabExit' }
244
346
  broadcastForSession(sessionId, event)
245
347
  })
@@ -389,9 +491,12 @@ export async function runDaemon(): Promise<void> {
389
491
  }
390
492
  negotiatedVersions.set(socket, selectedVersion)
391
493
  logDebug('daemon.request.hello.success', { selectedVersion })
494
+ const managerSelectedVersion = manager.getSelectedProtocolVersion()
392
495
  send(socket, {
393
496
  id: message.id,
394
497
  payload: {
498
+ capabilities: [...IPC_PROTOCOL_CAPABILITIES],
499
+ ...(managerSelectedVersion !== null && { managerSelectedVersion }),
395
500
  maxVersion: IPC_PROTOCOL_VERSION,
396
501
  minVersion: IPC_PROTOCOL_MIN_VERSION,
397
502
  processVersion: getProcessVersion(),
@@ -407,6 +512,7 @@ export async function runDaemon(): Promise<void> {
407
512
  rows: message.payload.rows,
408
513
  sessionId: message.payload.sessionId,
409
514
  snapshotTabs: message.payload.workspaceSnapshot?.tabs.length ?? 0,
515
+ thin: message.payload.thin === true,
410
516
  })
411
517
  const negotiatedVersion = requireNegotiatedVersion(socket, negotiatedVersions)
412
518
  if (message.payload.protocolVersion !== negotiatedVersion) {
@@ -416,19 +522,67 @@ export async function runDaemon(): Promise<void> {
416
522
  }
417
523
 
418
524
  attachedSessions.set(socket, message.payload.sessionId)
525
+ if (message.payload.thin === true) {
526
+ thinAttachers.add(socket)
527
+ } else {
528
+ thinAttachers.delete(socket)
529
+ }
530
+ // A thin attacher (headless CLI) does not own a viewport, so
531
+ // it must not resize PTYs on a session a UI is driving. TM's
532
+ // attachSession always calls `sessionManager.resize` on the
533
+ // incoming dimensions, so we substitute prior dims (or a
534
+ // safe default when none exist) before calling it. That
535
+ // makes the resize a no-op against the current PTY size
536
+ // instead of clobbering it. We also seed sessionDimensions
537
+ // on first-ever thin attach so `createTab`'s 0×0 fallback
538
+ // works for headless bootstrap flows (no UI has ever
539
+ // attached to this session).
540
+ let attachCols = message.payload.cols
541
+ let attachRows = message.payload.rows
542
+ const isThin = message.payload.thin === true
543
+ if (isThin) {
544
+ const prior = sessionDimensions.get(message.payload.sessionId)
545
+ if (prior) {
546
+ attachCols = prior.cols
547
+ attachRows = prior.rows
548
+ } else {
549
+ // No UI has established dimensions yet. Seed a safe
550
+ // default so PTYs don't spawn at 0×0 and so the
551
+ // createTab fallback has something to work with. Any
552
+ // real UI attach afterwards overwrites this.
553
+ attachCols = 80
554
+ attachRows = 24
555
+ sessionDimensions.set(message.payload.sessionId, {
556
+ cols: attachCols,
557
+ rows: attachRows,
558
+ })
559
+ logDebug('daemon.attach.thin.seedDefaultDimensions', {
560
+ cols: attachCols,
561
+ rows: attachRows,
562
+ sessionId: message.payload.sessionId,
563
+ })
564
+ }
565
+ } else {
566
+ sessionDimensions.set(message.payload.sessionId, {
567
+ cols: message.payload.cols,
568
+ rows: message.payload.rows,
569
+ })
570
+ }
419
571
  const attachResult = await manager.attachSession({
420
- cols: message.payload.cols,
421
- rows: message.payload.rows,
572
+ cols: attachCols,
573
+ rows: attachRows,
422
574
  sessionId: message.payload.sessionId,
423
575
  workspaceSnapshot: message.payload.workspaceSnapshot,
424
576
  })
577
+ sessionActiveTabIds.set(message.payload.sessionId, attachResult.activeTabId)
425
578
  for (const tab of attachResult.tabs) {
426
579
  rememberTab(
427
580
  message.payload.sessionId,
428
581
  tab.id,
429
582
  tab.assistant,
430
583
  tab.command,
431
- tab.viewport
584
+ tab.viewport,
585
+ { status: tab.status, title: tab.title, worktreeId: tab.worktreeId }
432
586
  )
433
587
  }
434
588
  // Classify synchronously BEFORE sending attachResult so
@@ -478,17 +632,49 @@ export async function runDaemon(): Promise<void> {
478
632
  case 'createTab': {
479
633
  const sessionId = requireSession(socket, attachedSessions)
480
634
  requireNegotiatedVersion(socket, negotiatedVersions)
635
+ // Capability `createTabSizeFallback`: cols/rows = 0 means
636
+ // "use the session's last attached dimensions". Headless
637
+ // CLIs don't have a viewport of their own, so this lets
638
+ // them spawn a PTY that lines up with the UI on the same
639
+ // session. If we have no remembered size yet, the TM
640
+ // would receive 0 and spawn a zero-sized PTY — surface a
641
+ // clear error instead.
642
+ let cols = message.payload.cols
643
+ let rows = message.payload.rows
644
+ if (cols === 0 || rows === 0) {
645
+ const prior = sessionDimensions.get(sessionId)
646
+ if (!prior) {
647
+ throw new Error(
648
+ 'createTab requested size fallback (cols=0 or rows=0) but no UI has attached to this session yet'
649
+ )
650
+ }
651
+ if (cols === 0) cols = prior.cols
652
+ if (rows === 0) rows = prior.rows
653
+ }
481
654
  logDebug('daemon.request.createTab.start', {
655
+ cols,
482
656
  command: message.payload.command,
657
+ rows,
483
658
  sessionId,
659
+ sizeFallback: message.payload.cols === 0 || message.payload.rows === 0,
484
660
  tabId: message.payload.tabId,
485
661
  title: message.payload.title,
486
662
  })
663
+ const fullCommand = [
664
+ message.payload.command,
665
+ ...(message.payload.args ?? []),
666
+ ].join(' ')
487
667
  rememberTab(
488
668
  sessionId,
489
669
  message.payload.tabId,
490
670
  message.payload.assistant,
491
- [message.payload.command, ...(message.payload.args ?? [])].join(' ')
671
+ fullCommand,
672
+ undefined,
673
+ {
674
+ status: 'starting',
675
+ title: message.payload.title,
676
+ worktreeId: message.payload.worktreeId,
677
+ }
492
678
  )
493
679
  // Inject the hook bridge env so Claude Code's hooks can
494
680
  // call back into our status loop. Safe to add for every
@@ -498,8 +684,27 @@ export async function runDaemon(): Promise<void> {
498
684
  // even on PTYs that outlive this daemon process.
499
685
  const env: Record<string, string> = { AIMUX_PANE_ID: message.payload.tabId }
500
686
  if (hookServer) env.AIMUX_HOOK_URL_FILE = hookUrlFilePath
501
- await manager.createTab({ ...message.payload, env, sessionId })
687
+ await manager.createTab({ ...message.payload, cols, env, rows, sessionId })
502
688
  sendOk(socket, message.id)
689
+ // Fan a `tabAdded` event only to peers that negotiated at
690
+ // least v11 — older parsers throw on unknown message types
691
+ // and would drop the connection. MIN_VERSION stays at 10
692
+ // for backward compat, so we must gate this at send time.
693
+ const synthesizedTab: TabSession = {
694
+ activity: 'idle',
695
+ assistant: message.payload.assistant,
696
+ buffer: '',
697
+ command: fullCommand,
698
+ id: message.payload.tabId,
699
+ status: 'starting',
700
+ terminalModes: createDefaultTerminalModes(),
701
+ title: message.payload.title,
702
+ worktreeId: message.payload.worktreeId,
703
+ }
704
+ broadcastForSessionVersioned(sessionId, 11, {
705
+ payload: { sessionId, tab: synthesizedTab },
706
+ type: 'tabAdded',
707
+ })
503
708
  logDebug('daemon.request.createTab.success', {
504
709
  sessionId,
505
710
  tabId: message.payload.tabId,
@@ -516,6 +721,10 @@ export async function runDaemon(): Promise<void> {
516
721
  case 'resizeClient': {
517
722
  const sessionId = requireSession(socket, attachedSessions)
518
723
  requireNegotiatedVersion(socket, negotiatedVersions)
724
+ sessionDimensions.set(sessionId, {
725
+ cols: message.payload.cols,
726
+ rows: message.payload.rows,
727
+ })
519
728
  await manager.resize(sessionId, message.payload.cols, message.payload.rows)
520
729
  sendOk(socket, message.id)
521
730
  break
@@ -549,7 +758,12 @@ export async function runDaemon(): Promise<void> {
549
758
  case 'setActiveTab': {
550
759
  const sessionId = requireSession(socket, attachedSessions)
551
760
  requireNegotiatedVersion(socket, negotiatedVersions)
761
+ // Update the cache AFTER the TM confirms the change — if
762
+ // manager.setActiveTab throws (tab doesn't exist, TM
763
+ // rejects), the cache would otherwise retain the bogus tabId
764
+ // and a subsequent listTabs would return a stale activeTabId.
552
765
  await manager.setActiveTab(sessionId, message.payload.tabId)
766
+ sessionActiveTabIds.set(sessionId, message.payload.tabId)
553
767
  sendOk(socket, message.id)
554
768
  break
555
769
  }
@@ -557,10 +771,40 @@ export async function runDaemon(): Promise<void> {
557
771
  const sessionId = requireSession(socket, attachedSessions)
558
772
  requireNegotiatedVersion(socket, negotiatedVersions)
559
773
  tabRegistry.delete(message.payload.tabId)
774
+ if (sessionActiveTabIds.get(sessionId) === message.payload.tabId) {
775
+ sessionActiveTabIds.set(sessionId, null)
776
+ }
560
777
  await manager.closeTab(sessionId, message.payload.tabId)
561
778
  sendOk(socket, message.id)
562
779
  break
563
780
  }
781
+ case 'listTabs': {
782
+ requireNegotiatedVersion(socket, negotiatedVersions)
783
+ const sessionId = message.payload.sessionId
784
+ const tabs: TabSessionSummary[] = []
785
+ for (const [tabId, entry] of tabRegistry) {
786
+ if (entry.sessionId !== sessionId) continue
787
+ tabs.push({
788
+ activity: statusLoop.getTabStatus(tabId) ?? undefined,
789
+ assistant: entry.assistant,
790
+ command: entry.command,
791
+ id: tabId,
792
+ status: entry.status ?? 'running',
793
+ title: entry.title ?? '',
794
+ worktreeId: entry.worktreeId,
795
+ })
796
+ }
797
+ send(socket, {
798
+ id: message.id,
799
+ payload: {
800
+ activeTabId: sessionActiveTabIds.get(sessionId) ?? null,
801
+ tabs,
802
+ },
803
+ type: 'listTabsResult',
804
+ })
805
+ logDebug('daemon.request.listTabs', { sessionId, tabs: tabs.length })
806
+ break
807
+ }
564
808
  case 'disposeAll': {
565
809
  const sessionId = attachedSessions.get(socket)
566
810
  requireNegotiatedVersion(socket, negotiatedVersions)
@@ -568,6 +812,8 @@ export async function runDaemon(): Promise<void> {
568
812
  for (const [tabId, entry] of tabRegistry) {
569
813
  if (entry.sessionId === sessionId) tabRegistry.delete(tabId)
570
814
  }
815
+ sessionActiveTabIds.delete(sessionId)
816
+ sessionDimensions.delete(sessionId)
571
817
  await manager.disposeSession(sessionId)
572
818
  }
573
819
  sendOk(socket, message.id)
@@ -576,6 +822,146 @@ export async function runDaemon(): Promise<void> {
576
822
  case 'ping':
577
823
  sendOk(socket, message.id)
578
824
  break
825
+ case 'prepareReexec': {
826
+ // No version check needed: the requester observed the
827
+ // `hotReexec` capability before sending this. The handler
828
+ // itself enforces draining=false to keep concurrent
829
+ // requests from corrupting the handoff.
830
+ await handleReexecRequest(socket, message.id, message.payload.reason)
831
+ break
832
+ }
833
+ case 'createWorkspace': {
834
+ requireNegotiatedVersion(socket, negotiatedVersions)
835
+ const { name, projectPath, switch: doSwitch } = message.payload
836
+ if (countUiAttachers() > 0) {
837
+ // Relay to the UI so it can preserve the live snapshot
838
+ // of the currently-open workspace before appending the
839
+ // new one. Fire-and-forget from the daemon's side.
840
+ broadcastAllVersioned(12, {
841
+ payload: { name, projectPath, switch: doSwitch },
842
+ type: 'workspaceCreateRequested',
843
+ })
844
+ logDebug('daemon.request.createWorkspace.relay', { name, projectPath })
845
+ } else {
846
+ const created = createWorkspaceInCatalog(name, projectPath)
847
+ if (doSwitch === true) {
848
+ bumpLastOpenedInCatalog(created.id)
849
+ // Mirror the UI-attached path: an ack event so a
850
+ // `--wait` CLI can exit even in the headless flow.
851
+ broadcastAllVersioned(12, {
852
+ payload: { sessionId: created.id },
853
+ type: 'workspaceSwitched',
854
+ })
855
+ }
856
+ logDebug('daemon.request.createWorkspace.direct', {
857
+ name,
858
+ sessionId: created.id,
859
+ })
860
+ }
861
+ sendOk(socket, message.id)
862
+ break
863
+ }
864
+ case 'switchWorkspace': {
865
+ requireNegotiatedVersion(socket, negotiatedVersions)
866
+ const { targetSessionId } = message.payload
867
+ // Fail fast when the target is unknown — otherwise a UI-
868
+ // attached `--wait` CLI would sit until timeout while the
869
+ // UI silently drops the broadcast.
870
+ assertSessionInCatalog(targetSessionId)
871
+ if (countUiAttachers() > 0) {
872
+ broadcastAllVersioned(12, {
873
+ payload: { targetSessionId },
874
+ type: 'workspaceSwitchRequested',
875
+ })
876
+ logDebug('daemon.request.switchWorkspace.relay', { targetSessionId })
877
+ } else {
878
+ bumpLastOpenedInCatalog(targetSessionId)
879
+ // No UI to run the switch handler, so the switch is
880
+ // effectively complete once the catalog reflects it.
881
+ // Emit the switched event so a --wait CLI can exit.
882
+ broadcastAllVersioned(12, {
883
+ payload: { sessionId: targetSessionId },
884
+ type: 'workspaceSwitched',
885
+ })
886
+ logDebug('daemon.request.switchWorkspace.direct', { targetSessionId })
887
+ }
888
+ sendOk(socket, message.id)
889
+ break
890
+ }
891
+ case 'closeWorkspace': {
892
+ requireNegotiatedVersion(socket, negotiatedVersions)
893
+ const { targetSessionId } = message.payload
894
+ assertSessionInCatalog(targetSessionId)
895
+ if (countUiAttachers() > 0) {
896
+ broadcastAllVersioned(12, {
897
+ payload: { targetSessionId },
898
+ type: 'workspaceCloseRequested',
899
+ })
900
+ logDebug('daemon.request.closeWorkspace.relay', { targetSessionId })
901
+ } else {
902
+ deleteFromCatalog(targetSessionId)
903
+ logDebug('daemon.request.closeWorkspace.direct', { targetSessionId })
904
+ }
905
+ sendOk(socket, message.id)
906
+ break
907
+ }
908
+ case 'announceWorkspaceSwitched': {
909
+ requireNegotiatedVersion(socket, negotiatedVersions)
910
+ // UI-emitted acknowledgement. Relay so any --wait CLI can
911
+ // exit. We don't validate that the announcement matches
912
+ // an outstanding request — a UI can announce a switch that
913
+ // happened via any means (menu, keybind, CLI).
914
+ broadcastAllVersioned(12, {
915
+ payload: { sessionId: message.payload.sessionId },
916
+ type: 'workspaceSwitched',
917
+ })
918
+ sendOk(socket, message.id)
919
+ break
920
+ }
921
+ case 'addWorktreeRecord': {
922
+ requireNegotiatedVersion(socket, negotiatedVersions)
923
+ const { sessionId: targetSessionId, worktree } = message.payload
924
+ if (countUiAttachers() > 0) {
925
+ broadcastAllVersioned(12, {
926
+ payload: { sessionId: targetSessionId, worktree },
927
+ type: 'worktreeAdded',
928
+ })
929
+ logDebug('daemon.request.addWorktreeRecord.relay', {
930
+ sessionId: targetSessionId,
931
+ worktreeId: worktree.id,
932
+ })
933
+ } else {
934
+ addWorktreeToCatalog(targetSessionId, worktree)
935
+ logDebug('daemon.request.addWorktreeRecord.direct', {
936
+ sessionId: targetSessionId,
937
+ worktreeId: worktree.id,
938
+ })
939
+ }
940
+ sendOk(socket, message.id)
941
+ break
942
+ }
943
+ case 'removeWorktreeRecord': {
944
+ requireNegotiatedVersion(socket, negotiatedVersions)
945
+ const { sessionId: targetSessionId, worktreeId } = message.payload
946
+ if (countUiAttachers() > 0) {
947
+ broadcastAllVersioned(12, {
948
+ payload: { sessionId: targetSessionId, worktreeId },
949
+ type: 'worktreeRemoved',
950
+ })
951
+ logDebug('daemon.request.removeWorktreeRecord.relay', {
952
+ sessionId: targetSessionId,
953
+ worktreeId,
954
+ })
955
+ } else {
956
+ removeWorktreeFromCatalog(targetSessionId, worktreeId)
957
+ logDebug('daemon.request.removeWorktreeRecord.direct', {
958
+ sessionId: targetSessionId,
959
+ worktreeId,
960
+ })
961
+ }
962
+ sendOk(socket, message.id)
963
+ break
964
+ }
579
965
  }
580
966
  } catch (error) {
581
967
  const errorMessage = error instanceof Error ? error.message : String(error)
@@ -601,6 +987,7 @@ export async function runDaemon(): Promise<void> {
601
987
  sockets.delete(socket)
602
988
  attachedSessions.delete(socket)
603
989
  negotiatedVersions.delete(socket)
990
+ thinAttachers.delete(socket)
604
991
  if (sockets.size === 0) updateTmBroadcastForClientCount(0)
605
992
  })
606
993
  socket.on('error', () => {
@@ -608,6 +995,7 @@ export async function runDaemon(): Promise<void> {
608
995
  sockets.delete(socket)
609
996
  attachedSessions.delete(socket)
610
997
  negotiatedVersions.delete(socket)
998
+ thinAttachers.delete(socket)
611
999
  if (sockets.size === 0) updateTmBroadcastForClientCount(0)
612
1000
  })
613
1001
  })
@@ -618,6 +1006,137 @@ export async function runDaemon(): Promise<void> {
618
1006
  })
619
1007
  tightenSocketPermissions(socketPath)
620
1008
 
1009
+ // Sidecar files let bystander processes detect "is the daemon running and
1010
+ // what version is it?" without a handshake. Required by the hot-reexec
1011
+ // path in bootstrap.ts (Ring 3).
1012
+ try {
1013
+ writeDaemonPidFile(process.pid)
1014
+ writeDaemonVersionFile(getProcessVersion())
1015
+ } catch (error) {
1016
+ logDebug('daemon.sidecars.writeFailed', {
1017
+ error: error instanceof Error ? error.message : String(error),
1018
+ })
1019
+ }
1020
+
1021
+ let draining = false
1022
+ // The renamed socket path is captured during drain so the shutdown handler
1023
+ // can unlink the dirent (the listening fd still pins the inode until close;
1024
+ // the dirent at daemon.old.sock would otherwise linger after exit).
1025
+ let renamedSocketPath: string | null = null
1026
+
1027
+ type DrainOutcome =
1028
+ | { ok: true; handoffPath: string; renamedSocketPath: string }
1029
+ | { ok: false; message: string }
1030
+
1031
+ /**
1032
+ * Hot-reexec drain. Called by either an IPC `prepareReexec` request or
1033
+ * by `kill -USR2 <pid>`. We:
1034
+ * 1. Write the handoff sidecar so the successor can tell it was spawned
1035
+ * as a reexec target (not a fresh boot).
1036
+ * 2. Rename the listening socket out of the canonical path. The OS keeps
1037
+ * the original inode pinned by our listening fd, so existing client
1038
+ * sockets stay live; the canonical path is freed for the successor
1039
+ * to bind. A new dirent appears at daemon.old.sock.
1040
+ * 3. Stop accepting new client connections (`server.close()` only
1041
+ * refuses new conns; existing sockets keep ferrying until they idle).
1042
+ * 4. Schedule shutdown on a short grace so any final replies flush and
1043
+ * clients observe socket close (their reconnect logic interprets it
1044
+ * as "daemon went away — try again," landing on the successor).
1045
+ *
1046
+ * Returns the outcome so the caller can send an ack (or error) over
1047
+ * whatever channel is appropriate — an IPC socket or nothing at all.
1048
+ */
1049
+ const drainAndHandoff = async (reason: string | undefined): Promise<DrainOutcome> => {
1050
+ if (draining) {
1051
+ return { message: 'Daemon is already draining for reexec', ok: false }
1052
+ }
1053
+ draining = true
1054
+ logDebug('daemon.reexec.start', { pid: process.pid, reason: reason ?? null })
1055
+
1056
+ const oldSocketPath = getDaemonOldSocketPath()
1057
+ // Clear any stale dirent from a previous botched reexec.
1058
+ if (existsSync(oldSocketPath)) {
1059
+ try {
1060
+ unlinkSync(oldSocketPath)
1061
+ } catch {
1062
+ // best-effort
1063
+ }
1064
+ }
1065
+
1066
+ let handoffPath: string
1067
+ try {
1068
+ handoffPath = writeDaemonHandoff({
1069
+ fromPid: process.pid,
1070
+ fromProcessVersion: getProcessVersion(),
1071
+ renamedSocketPath: oldSocketPath,
1072
+ version: 1,
1073
+ writtenAt: Date.now(),
1074
+ })
1075
+ } catch (error) {
1076
+ draining = false
1077
+ const message = error instanceof Error ? error.message : String(error)
1078
+ logDebug('daemon.reexec.handoffWriteFailed', { error: message })
1079
+ return { message: `Handoff file write failed: ${message}`, ok: false }
1080
+ }
1081
+
1082
+ try {
1083
+ renameSync(socketPath, oldSocketPath)
1084
+ renamedSocketPath = oldSocketPath
1085
+ } catch (error) {
1086
+ draining = false
1087
+ // Clean up the handoff file we just wrote — the successor must not see
1088
+ // a stale handoff if reexec didn't actually start.
1089
+ try {
1090
+ unlinkSync(handoffPath)
1091
+ } catch {
1092
+ // best-effort
1093
+ }
1094
+ const message = error instanceof Error ? error.message : String(error)
1095
+ logDebug('daemon.reexec.renameFailed', { error: message })
1096
+ return { message: `Socket rename failed: ${message}`, ok: false }
1097
+ }
1098
+
1099
+ // Refuse new connections from now on; the successor will bind the
1100
+ // canonical path.
1101
+ server.close()
1102
+ logDebug('daemon.reexec.drained', { handoffPath, renamedSocketPath: oldSocketPath })
1103
+
1104
+ // Give any in-flight replies a beat to flush, then bow out. Use the
1105
+ // same shutdown path as a SIGTERM so hookServer/manager are torn down
1106
+ // cleanly. `setTimeout` keeps the event loop alive long enough.
1107
+ setTimeout(() => gracefulShutdown('reexec'), 250)
1108
+
1109
+ return { handoffPath, ok: true, renamedSocketPath: oldSocketPath }
1110
+ }
1111
+
1112
+ const handleReexecRequest = async (
1113
+ requester: Socket,
1114
+ requestId: string,
1115
+ reason: string | undefined
1116
+ ): Promise<void> => {
1117
+ const outcome = await drainAndHandoff(reason)
1118
+ if (!outcome.ok) {
1119
+ send(requester, {
1120
+ id: requestId,
1121
+ payload: { message: outcome.message },
1122
+ type: 'error',
1123
+ })
1124
+ return
1125
+ }
1126
+ send(requester, {
1127
+ id: requestId,
1128
+ payload: {
1129
+ handoffPath: outcome.handoffPath,
1130
+ renamedSocketPath: outcome.renamedSocketPath,
1131
+ },
1132
+ type: 'reexecAck',
1133
+ })
1134
+ logDebug('daemon.reexec.ack', {
1135
+ handoffPath: outcome.handoffPath,
1136
+ renamedSocketPath: outcome.renamedSocketPath,
1137
+ })
1138
+ }
1139
+
621
1140
  const gracefulShutdown = (signal: string) => {
622
1141
  logDebug(`daemon.${signal}`)
623
1142
  statusLoop.stop()
@@ -633,11 +1152,47 @@ export async function runDaemon(): Promise<void> {
633
1152
  }
634
1153
  manager.destroy()
635
1154
  server.close()
1155
+ // Unlink the renamed-away socket dirent if we drained. Without this it
1156
+ // lingers at daemon.old.sock as a dead AF_UNIX inode the next reexec
1157
+ // would have to clean up itself.
1158
+ if (renamedSocketPath !== null && existsSync(renamedSocketPath)) {
1159
+ try {
1160
+ unlinkSync(renamedSocketPath)
1161
+ } catch (error) {
1162
+ logDebug('daemon.reexec.oldSocketUnlinkFailed', {
1163
+ error: error instanceof Error ? error.message : String(error),
1164
+ })
1165
+ }
1166
+ }
1167
+ // Sidecars: any bystander (harness, CLI) that reads daemon.pid /
1168
+ // daemon.version between our exit and the successor's writeDaemonPidFile
1169
+ // would see the predecessor's now-dead PID. Strip those on every
1170
+ // shutdown; reexec additionally preserves daemon.handoff.json so the
1171
+ // successor can log the takeover, while a regular shutdown strips the
1172
+ // lot.
1173
+ if (signal === 'reexec') {
1174
+ removeDaemonSidecarsForReexec()
1175
+ } else {
1176
+ removeDaemonSidecars()
1177
+ }
636
1178
  process.exit(0)
637
1179
  }
638
1180
 
639
1181
  process.on('SIGTERM', () => gracefulShutdown('sigterm'))
640
1182
  process.on('SIGINT', () => gracefulShutdown('sigint'))
1183
+ process.on('SIGUSR2', () => {
1184
+ // Out-of-band drain trigger. Useful for `kill -USR2 <pid>` debugging or
1185
+ // when a caller wants to nudge the daemon without negotiating a hello
1186
+ // first. No IPC partner is waiting for a reply — we call the drain
1187
+ // directly instead of forging a fake Socket.
1188
+ logDebug('daemon.signal.sigusr2')
1189
+ void (async () => {
1190
+ const outcome = await drainAndHandoff('sigusr2')
1191
+ if (!outcome.ok) {
1192
+ logDebug('daemon.signal.sigusr2.drainFailed', { message: outcome.message })
1193
+ }
1194
+ })()
1195
+ })
641
1196
 
642
1197
  process.on('uncaughtException', (error) => {
643
1198
  logDebug('daemon.uncaughtException', { error: error.message, stack: error.stack })