@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
@@ -1,21 +1,130 @@
1
1
  import type {
2
+ AssistantId,
2
3
  SessionStatus,
3
4
  TabActivity,
4
5
  TabSession,
6
+ TabStatus,
5
7
  TerminalModeState,
6
8
  TerminalSnapshot,
7
9
  WorkspaceSnapshotV1,
10
+ WorktreeRecord,
8
11
  } from '../state/types'
9
12
 
10
- import { isWorkspaceSnapshotV1 } from '../state/validation'
13
+ import { isWorkspaceSnapshotV1, isWorktreeRecord } from '../state/validation'
11
14
 
12
15
  // v9: scroll position is owned entirely by the backend emulator. Dropped the
13
16
  // per-tab scroll `intent`/`intents` from resize messages and removed the
14
17
  // `reapplyScrollIntent` message — the frontend no longer derives or sends it.
15
18
  // (v8 was the unfilled-viewport status-detector change; this is a further
16
19
  // breaking wire change, so the version steps again.)
20
+ //
21
+ // v11: additive — exposes `listTabs` (read-only enumeration without `attach`),
22
+ // `attach.thin` (skip server-side resize so a headless CLI can attach
23
+ // alongside a UI without resizing PTYs), and `createTab` cols/rows=0 fallback
24
+ // to the session's last attached size. All three are gated behind
25
+ // capabilities; MIN stays at 10 so a v10 UI keeps talking to a v11 daemon
26
+ // (and vice-versa).
27
+ //
28
+ // v12: additive — workspace lifecycle requests (`createWorkspace`,
29
+ // `switchWorkspace`, `closeWorkspace`, `announceWorkspaceSwitched`) and
30
+ // worktree record requests (`addWorktreeRecord`, `removeWorktreeRecord`),
31
+ // plus matching broadcast events. All capability-gated; MIN stays at 10.
17
32
  export const IPC_PROTOCOL_MIN_VERSION = 10
18
- export const IPC_PROTOCOL_VERSION = 10
33
+ export const IPC_PROTOCOL_VERSION = 12
34
+
35
+ /**
36
+ * Capability advertised by a daemon that knows how to drain + handoff its
37
+ * socket to a freshly-spawned successor binary instead of dying outright.
38
+ * Clients only attempt `prepareReexec` against a daemon that advertises
39
+ * this — older daemons would respond with an unknown-request error.
40
+ */
41
+ export const IPC_CAPABILITY_HOT_REEXEC = 'hotReexec'
42
+
43
+ /**
44
+ * Capability gating the `listTabs` request. CLI clients check for it before
45
+ * issuing the request; pre-v11 daemons would respond with `unknown request`.
46
+ */
47
+ export const IPC_CAPABILITY_LIST_TABS = 'listTabs'
48
+
49
+ /**
50
+ * Capability gating the `attach.thin` flag. When advertised, the daemon will
51
+ * skip the implicit `manager.resize` on attach so a headless CLI can attach
52
+ * to read state without clobbering the UI's dimensions on shared PTYs.
53
+ */
54
+ export const IPC_CAPABILITY_THIN_ATTACH = 'thinAttach'
55
+
56
+ /**
57
+ * Capability gating `createTab` with `cols: 0` or `rows: 0` meaning "use the
58
+ * session's last attached size". Older daemons would propagate 0 straight to
59
+ * the TM and spawn a zero-sized PTY, so the client only sends zeroes when
60
+ * this capability is advertised.
61
+ */
62
+ export const IPC_CAPABILITY_CREATE_TAB_SIZE_FALLBACK = 'createTabSizeFallback'
63
+
64
+ /**
65
+ * Capability gating the `tabAdded` server event. When advertised, the daemon
66
+ * fans a `tabAdded` event out to every socket attached to the session after
67
+ * a successful `createTab` — that's how a UI process learns about tabs
68
+ * created by a sibling CLI invocation. Pre-cap UIs simply never see the
69
+ * event and continue to discover tabs only via the next `attachResult`.
70
+ */
71
+ export const IPC_CAPABILITY_TAB_LIFECYCLE_EVENTS = 'tabLifecycleEvents'
72
+
73
+ /**
74
+ * Capability gating the optional `worktreeId` on `createTab` payloads. With
75
+ * it, the CLI can spawn a tab inside a specific worktree of the active
76
+ * session so the UI groups it correctly. Older daemons ignore the field
77
+ * (the parser accepts it but the TM has no knob for it pre-bump).
78
+ */
79
+ export const IPC_CAPABILITY_CREATE_TAB_WORKTREE_ID = 'createTabWorktreeId'
80
+
81
+ /**
82
+ * v12 — capability gating headless workspace lifecycle requests
83
+ * (`createWorkspace`, `switchWorkspace`, `closeWorkspace`,
84
+ * `announceWorkspaceSwitched`) and their `workspace*Requested` /
85
+ * `workspaceSwitched` broadcasts. When a UI is attached the daemon relays
86
+ * the request as an event so the UI's reducer stays authoritative for the
87
+ * live workspace snapshot; when no UI is attached the daemon mutates the
88
+ * catalog directly.
89
+ */
90
+ export const IPC_CAPABILITY_WORKSPACE_LIFECYCLE = 'workspaceLifecycle'
91
+
92
+ /**
93
+ * v12 — capability gating `addWorktreeRecord` / `removeWorktreeRecord` and
94
+ * their `worktreeAdded` / `worktreeRemoved` broadcasts. Same UI-vs-headless
95
+ * fanout as workspaceLifecycle.
96
+ */
97
+ export const IPC_CAPABILITY_WORKTREE_LIFECYCLE_EVENTS = 'worktreeLifecycleEvents'
98
+
99
+ /**
100
+ * v12 — capability marker for `aimux tab tail`. Functionally it's just a
101
+ * thin-attach + `tabRender` subscription, but declaring the capability lets
102
+ * older clients detect that the daemon is fresh enough to broadcast the
103
+ * events on which the streaming CLI depends.
104
+ */
105
+ export const IPC_CAPABILITY_TAB_TAIL = 'tabTail'
106
+
107
+ /**
108
+ * Capabilities advertised by *this* process in its `helloResult`. Additive
109
+ * features should be introduced as new capability strings here rather than
110
+ * via a MIN bump. See `src/ipc/README.md` for the discipline.
111
+ *
112
+ * Capabilities are namespaced informally per-protocol (camelCase in this
113
+ * file). Legacy peers that predate the field omit it entirely; the parser
114
+ * normalises a missing field to `[]`, so consumers can safely call
115
+ * `.includes(...)` on it.
116
+ */
117
+ export const IPC_PROTOCOL_CAPABILITIES: readonly string[] = [
118
+ IPC_CAPABILITY_HOT_REEXEC,
119
+ IPC_CAPABILITY_LIST_TABS,
120
+ IPC_CAPABILITY_THIN_ATTACH,
121
+ IPC_CAPABILITY_CREATE_TAB_SIZE_FALLBACK,
122
+ IPC_CAPABILITY_TAB_LIFECYCLE_EVENTS,
123
+ IPC_CAPABILITY_CREATE_TAB_WORKTREE_ID,
124
+ IPC_CAPABILITY_WORKSPACE_LIFECYCLE,
125
+ IPC_CAPABILITY_WORKTREE_LIFECYCLE_EVENTS,
126
+ IPC_CAPABILITY_TAB_TAIL,
127
+ ]
19
128
 
20
129
  export interface ProtocolHelloRequest {
21
130
  minVersion: number
@@ -27,6 +136,20 @@ export interface ProtocolHelloResult {
27
136
  maxVersion: number
28
137
  processVersion: string
29
138
  selectedVersion: number
139
+ /**
140
+ * Feature flags. Always present on the typed shape; older peers that did
141
+ * not yet advertise capabilities are normalised to `[]` at parse time.
142
+ */
143
+ capabilities: string[]
144
+ /**
145
+ * Manager-protocol version currently negotiated between the daemon and
146
+ * its terminal-manager. `undefined` when the daemon has not yet connected
147
+ * (or when the peer predates the field). Bootstrap uses this to decide
148
+ * whether a hot-reexec attempt has any chance: if the caller's
149
+ * MANAGER_PROTOCOL_MIN_VERSION > this, the successor daemon would fail
150
+ * to speak to the existing TM and the reexec is doomed.
151
+ */
152
+ managerSelectedVersion?: number
30
153
  }
31
154
 
32
155
  export interface AttachRequest {
@@ -35,6 +158,37 @@ export interface AttachRequest {
35
158
  cols: number
36
159
  rows: number
37
160
  workspaceSnapshot?: WorkspaceSnapshotV1
161
+ /**
162
+ * v11 / capability `thinAttach`. When true, the daemon does not call
163
+ * `manager.resize` on the session — the headless attacher (CLI) does not
164
+ * own a viewport, so it must not clobber the dimensions a UI process is
165
+ * driving for the same session.
166
+ *
167
+ * Pre-v11 daemons ignore the field; the gate exists so the client knows
168
+ * to fall through to a full attach (with a sentinel size) when the daemon
169
+ * predates the flag.
170
+ */
171
+ thin?: boolean
172
+ }
173
+
174
+ /**
175
+ * Slim per-tab summary returned by `listTabs`. Subset of {@link TabSession}
176
+ * with the buffer/viewport/terminalModes intentionally omitted so the request
177
+ * stays cheap even on sessions with many tabs.
178
+ */
179
+ export interface TabSessionSummary {
180
+ id: string
181
+ assistant: AssistantId
182
+ title: string
183
+ status: TabStatus
184
+ activity?: TabActivity
185
+ command: string
186
+ worktreeId?: string
187
+ }
188
+
189
+ export interface ListTabsResult {
190
+ tabs: TabSessionSummary[]
191
+ activeTabId: string | null
38
192
  }
39
193
 
40
194
  export interface AttachResult {
@@ -65,6 +219,14 @@ export type ClientRequest =
65
219
  cols: number
66
220
  rows: number
67
221
  cwd?: string
222
+ /**
223
+ * v11 / capability `createTabWorktreeId`. Lets the CLI spawn a tab
224
+ * inside a specific worktree so the UI groups it correctly. The
225
+ * field is parsed unconditionally — older daemons accepted no such
226
+ * field, so this is a true additive on the wire — but the daemon
227
+ * only forwards it to the TM when its own capability is in play.
228
+ */
229
+ worktreeId?: string
68
230
  }
69
231
  }
70
232
  | { id: string; type: 'write'; payload: { tabId: string; data: string } }
@@ -84,12 +246,75 @@ export type ClientRequest =
84
246
  | { id: string; type: 'closeTab'; payload: { tabId: string } }
85
247
  | { id: string; type: 'disposeAll'; payload: Record<string, never> }
86
248
  | { id: string; type: 'ping'; payload: Record<string, never> }
249
+ // Capability-gated on `hotReexec`. The daemon drains, writes a handoff
250
+ // file, renames its socket out of the way, and exits cleanly so a freshly
251
+ // spawned successor binary can bind the canonical socket path while the
252
+ // terminal-manager (and every PTY) keeps running.
253
+ | { id: string; type: 'prepareReexec'; payload: { reason?: string } }
254
+ // Capability-gated on `listTabs`. Read-only enumeration of a session's
255
+ // tabs — does NOT attach. Lets a headless CLI inspect a session without
256
+ // implicit `manager.resize` or `setBroadcastEnabled` side effects.
257
+ | { id: string; type: 'listTabs'; payload: { sessionId: string } }
258
+ // v12 / capability `workspaceLifecycle`. Ask the daemon to create a new
259
+ // workspace in the catalog. When any UI socket is attached the daemon
260
+ // broadcasts `workspaceCreateRequested` so the UI's reducer performs the
261
+ // create (owning the live workspace snapshot); when no UI is attached the
262
+ // daemon writes the catalog directly.
263
+ | {
264
+ id: string
265
+ type: 'createWorkspace'
266
+ payload: { name: string; projectPath?: string; switch?: boolean }
267
+ }
268
+ // v12 / capability `workspaceLifecycle`. Ask the daemon to switch to
269
+ // another workspace. Broadcast → `workspaceSwitchRequested`; headless
270
+ // path bumps `lastOpenedAt` on the target so the next UI boot picks it.
271
+ | { id: string; type: 'switchWorkspace'; payload: { targetSessionId: string } }
272
+ // v12 / capability `workspaceLifecycle`. Ask the daemon to remove a
273
+ // workspace. Broadcast → `workspaceCloseRequested`; headless deletes the
274
+ // catalog entry directly.
275
+ | {
276
+ id: string
277
+ type: 'closeWorkspace'
278
+ payload: { targetSessionId: string }
279
+ }
280
+ // v12 / capability `workspaceLifecycle`. UI announces that its own
281
+ // `handleSwitchSessionEffect` has finished; the daemon relays as a
282
+ // `workspaceSwitched` broadcast so a `--wait`ing CLI can exit.
283
+ | { id: string; type: 'announceWorkspaceSwitched'; payload: { sessionId: string } }
284
+ // v12 / capability `worktreeLifecycleEvents`. Append a worktree record to
285
+ // a session in the catalog. UI-attached path relays as `worktreeAdded`;
286
+ // headless path writes the catalog directly.
287
+ | {
288
+ id: string
289
+ type: 'addWorktreeRecord'
290
+ payload: { sessionId: string; worktree: WorktreeRecord }
291
+ }
292
+ // v12 / capability `worktreeLifecycleEvents`. Remove a worktree record.
293
+ // UI-attached path relays as `worktreeRemoved`; headless path writes the
294
+ // catalog directly.
295
+ | {
296
+ id: string
297
+ type: 'removeWorktreeRecord'
298
+ payload: { sessionId: string; worktreeId: string }
299
+ }
87
300
 
88
301
  export type ServerResponse =
89
302
  | { id: string; type: 'helloResult'; payload: ProtocolHelloResult }
90
303
  | { id: string; type: 'ok'; payload: Record<string, never> }
91
304
  | { id: string; type: 'attachResult'; payload: AttachResult }
305
+ | { id: string; type: 'listTabsResult'; payload: ListTabsResult }
92
306
  | { id: string; type: 'error'; payload: { message: string } }
307
+ | {
308
+ id: string
309
+ type: 'reexecAck'
310
+ payload: {
311
+ /** Absolute path to the handoff file the successor should consume. */
312
+ handoffPath: string
313
+ /** Where the old daemon renamed its still-bound socket. Provided for
314
+ * diagnostics — the canonical socket path is now free. */
315
+ renamedSocketPath: string
316
+ }
317
+ }
93
318
 
94
319
  export type ServerEvent =
95
320
  | {
@@ -100,6 +325,57 @@ export type ServerEvent =
100
325
  | { type: 'tabError'; payload: { tabId: string; message: string } }
101
326
  | { type: 'tabStatus'; payload: { sessionId: string; tabId: string; status: TabActivity } }
102
327
  | { type: 'sessionStatus'; payload: { sessionId: string; status: SessionStatus } }
328
+ // Capability-gated on `tabLifecycleEvents`. Broadcast after a successful
329
+ // `createTab` so every UI/CLI client attached to the same session learns
330
+ // about the new tab without having to re-attach. The tab's `viewport` is
331
+ // intentionally omitted — the very next `tabRender` event carries it.
332
+ | {
333
+ type: 'tabAdded'
334
+ payload: { sessionId: string; tab: TabSession }
335
+ }
336
+ // v12 / capability `workspaceLifecycle`. Broadcast to every socket when a
337
+ // CLI issues `createWorkspace` while a UI is attached — the UI runs its
338
+ // create-session handler so the live workspace snapshot is preserved.
339
+ // Broadcast is NOT session-scoped; the UI may be attached to a different
340
+ // session than the one being created.
341
+ | {
342
+ type: 'workspaceCreateRequested'
343
+ payload: { name: string; projectPath?: string; switch?: boolean }
344
+ }
345
+ // v12 / capability `workspaceLifecycle`. Broadcast when a CLI issues
346
+ // `switchWorkspace`; UI runs `handleSwitchSessionEffect` and then emits
347
+ // `announceWorkspaceSwitched` when done.
348
+ | {
349
+ type: 'workspaceSwitchRequested'
350
+ payload: { targetSessionId: string }
351
+ }
352
+ // v12 / capability `workspaceLifecycle`. Broadcast when a CLI issues
353
+ // `closeWorkspace`; UI runs `handleDeleteSessionEffect`.
354
+ | {
355
+ type: 'workspaceCloseRequested'
356
+ payload: { targetSessionId: string }
357
+ }
358
+ // v12 / capability `workspaceLifecycle`. Daemon relay of the UI's
359
+ // `announceWorkspaceSwitched` so a `--wait`ing CLI can exit.
360
+ | {
361
+ type: 'workspaceSwitched'
362
+ payload: { sessionId: string }
363
+ }
364
+ // v12 / capability `worktreeLifecycleEvents`. Broadcast after a CLI
365
+ // `addWorktreeRecord` while a UI is attached; the UI reducer appends the
366
+ // record and persists the catalog.
367
+ | {
368
+ type: 'worktreeAdded'
369
+ payload: { sessionId: string; worktree: WorktreeRecord }
370
+ }
371
+ // v12 / capability `worktreeLifecycleEvents`. Broadcast after a CLI
372
+ // `removeWorktreeRecord` while a UI is attached; the UI reducer removes
373
+ // the record (and closes any tabs pinned to it via the existing
374
+ // worktree-removal side-effect).
375
+ | {
376
+ type: 'worktreeRemoved'
377
+ payload: { sessionId: string; worktreeId: string }
378
+ }
103
379
 
104
380
  export type IpcMessage = ClientRequest | ServerResponse | ServerEvent
105
381
 
@@ -208,7 +484,67 @@ function isProtocolHelloResult(value: unknown): value is ProtocolHelloResult {
208
484
  isFiniteNumber(value.minVersion) &&
209
485
  isFiniteNumber(value.maxVersion) &&
210
486
  isFiniteNumber(value.selectedVersion) &&
211
- isString(value.processVersion)
487
+ isString(value.processVersion) &&
488
+ // Wire-back-compat: peers that predate the capabilities field omit it
489
+ // entirely. parseServerMessage normalises that to `[]` before the cast
490
+ // so the typed shape stays non-optional.
491
+ (value.capabilities === undefined || isStringArray(value.capabilities)) &&
492
+ // Additive: peers that predate managerSelectedVersion omit it.
493
+ (value.managerSelectedVersion === undefined || isFiniteNumber(value.managerSelectedVersion))
494
+ )
495
+ }
496
+
497
+ function isTabSessionSummary(value: unknown): value is TabSessionSummary {
498
+ return (
499
+ isObjectRecord(value) &&
500
+ isString(value.id) &&
501
+ isString(value.assistant) &&
502
+ value.assistant.length > 0 &&
503
+ isString(value.title) &&
504
+ (value.status === 'starting' ||
505
+ value.status === 'running' ||
506
+ value.status === 'disconnected' ||
507
+ value.status === 'error') &&
508
+ (value.activity === undefined ||
509
+ value.activity === 'working' ||
510
+ value.activity === 'waiting-input' ||
511
+ value.activity === 'idle') &&
512
+ isString(value.command) &&
513
+ (value.worktreeId === undefined || isString(value.worktreeId))
514
+ )
515
+ }
516
+
517
+ function isListTabsResult(value: unknown): value is ListTabsResult {
518
+ return (
519
+ isObjectRecord(value) &&
520
+ Array.isArray(value.tabs) &&
521
+ value.tabs.every(isTabSessionSummary) &&
522
+ isNullableString(value.activeTabId)
523
+ )
524
+ }
525
+
526
+ function isTabSession(value: unknown): value is TabSession {
527
+ return (
528
+ isObjectRecord(value) &&
529
+ isString(value.id) &&
530
+ isString(value.assistant) &&
531
+ value.assistant.length > 0 &&
532
+ isString(value.title) &&
533
+ (value.status === 'starting' ||
534
+ value.status === 'running' ||
535
+ value.status === 'disconnected' ||
536
+ value.status === 'error') &&
537
+ (value.activity === undefined ||
538
+ value.activity === 'working' ||
539
+ value.activity === 'waiting-input' ||
540
+ value.activity === 'idle') &&
541
+ isString(value.buffer) &&
542
+ isTerminalModeState(value.terminalModes) &&
543
+ isString(value.command) &&
544
+ (value.viewport === undefined || isTerminalSnapshot(value.viewport)) &&
545
+ (value.errorMessage === undefined || isString(value.errorMessage)) &&
546
+ (value.exitCode === undefined || isFiniteNumber(value.exitCode)) &&
547
+ (value.worktreeId === undefined || isString(value.worktreeId))
212
548
  )
213
549
  }
214
550
 
@@ -217,28 +553,7 @@ function isAttachResult(value: unknown): value is AttachResult {
217
553
  isObjectRecord(value) &&
218
554
  isFiniteNumber(value.protocolVersion) &&
219
555
  Array.isArray(value.tabs) &&
220
- value.tabs.every(
221
- (tab) =>
222
- isObjectRecord(tab) &&
223
- isString(tab.id) &&
224
- isString(tab.assistant) &&
225
- tab.assistant.length > 0 &&
226
- isString(tab.title) &&
227
- (tab.status === 'starting' ||
228
- tab.status === 'running' ||
229
- tab.status === 'disconnected' ||
230
- tab.status === 'error') &&
231
- (tab.activity === undefined ||
232
- tab.activity === 'working' ||
233
- tab.activity === 'waiting-input' ||
234
- tab.activity === 'idle') &&
235
- isString(tab.buffer) &&
236
- isTerminalModeState(tab.terminalModes) &&
237
- isString(tab.command) &&
238
- (tab.viewport === undefined || isTerminalSnapshot(tab.viewport)) &&
239
- (tab.errorMessage === undefined || isString(tab.errorMessage)) &&
240
- (tab.exitCode === undefined || isFiniteNumber(tab.exitCode))
241
- ) &&
556
+ value.tabs.every(isTabSession) &&
242
557
  isNullableString(value.activeTabId) &&
243
558
  Array.isArray(value.initialSessionStatuses) &&
244
559
  value.initialSessionStatuses.every(
@@ -253,6 +568,12 @@ function assert(condition: boolean, message: string): asserts condition {
253
568
  }
254
569
  }
255
570
 
571
+ function normaliseCapabilities(payload: Record<string, unknown>): void {
572
+ if (!Array.isArray(payload.capabilities)) {
573
+ payload.capabilities = []
574
+ }
575
+ }
576
+
256
577
  export function negotiateProtocolVersion(
257
578
  clientMinVersion: number,
258
579
  clientMaxVersion: number,
@@ -296,6 +617,13 @@ export function parseClientRequest(value: unknown): ClientRequest {
296
617
  isWorkspaceSnapshotV1(value.payload.workspaceSnapshot),
297
618
  'attach.workspaceSnapshot must be a valid workspace snapshot'
298
619
  )
620
+ assert(
621
+ value.payload.thin === undefined || typeof value.payload.thin === 'boolean',
622
+ 'attach.thin must be a boolean when present'
623
+ )
624
+ return value as ClientRequest
625
+ case 'listTabs':
626
+ assert(isString(value.payload.sessionId), 'listTabs.sessionId must be a string')
299
627
  return value as ClientRequest
300
628
  case 'createTab':
301
629
  assert(isString(value.payload.tabId), 'createTab.tabId must be a string')
@@ -315,6 +643,10 @@ export function parseClientRequest(value: unknown): ClientRequest {
315
643
  value.payload.cwd === undefined || isString(value.payload.cwd),
316
644
  'createTab.cwd must be a string'
317
645
  )
646
+ assert(
647
+ value.payload.worktreeId === undefined || isString(value.payload.worktreeId),
648
+ 'createTab.worktreeId must be a string when present'
649
+ )
318
650
  return value as ClientRequest
319
651
  case 'write':
320
652
  assert(isString(value.payload.tabId), 'write.tabId must be a string')
@@ -345,6 +677,55 @@ export function parseClientRequest(value: unknown): ClientRequest {
345
677
  case 'disposeAll':
346
678
  case 'ping':
347
679
  return value as ClientRequest
680
+ case 'prepareReexec':
681
+ assert(
682
+ value.payload.reason === undefined || isString(value.payload.reason),
683
+ 'prepareReexec.reason must be a string when present'
684
+ )
685
+ return value as ClientRequest
686
+ case 'createWorkspace':
687
+ assert(
688
+ isString(value.payload.name) && value.payload.name.length > 0,
689
+ 'createWorkspace.name must be a non-empty string'
690
+ )
691
+ assert(
692
+ value.payload.projectPath === undefined || isString(value.payload.projectPath),
693
+ 'createWorkspace.projectPath must be a string when present'
694
+ )
695
+ assert(
696
+ value.payload.switch === undefined || typeof value.payload.switch === 'boolean',
697
+ 'createWorkspace.switch must be a boolean when present'
698
+ )
699
+ return value as ClientRequest
700
+ case 'switchWorkspace':
701
+ assert(
702
+ isString(value.payload.targetSessionId),
703
+ 'switchWorkspace.targetSessionId must be a string'
704
+ )
705
+ return value as ClientRequest
706
+ case 'closeWorkspace':
707
+ assert(
708
+ isString(value.payload.targetSessionId),
709
+ 'closeWorkspace.targetSessionId must be a string'
710
+ )
711
+ return value as ClientRequest
712
+ case 'announceWorkspaceSwitched':
713
+ assert(
714
+ isString(value.payload.sessionId),
715
+ 'announceWorkspaceSwitched.sessionId must be a string'
716
+ )
717
+ return value as ClientRequest
718
+ case 'addWorktreeRecord':
719
+ assert(isString(value.payload.sessionId), 'addWorktreeRecord.sessionId must be a string')
720
+ assert(
721
+ isWorktreeRecord(value.payload.worktree),
722
+ 'addWorktreeRecord.worktree must be a WorktreeRecord'
723
+ )
724
+ return value as ClientRequest
725
+ case 'removeWorktreeRecord':
726
+ assert(isString(value.payload.sessionId), 'removeWorktreeRecord.sessionId must be a string')
727
+ assert(isString(value.payload.worktreeId), 'removeWorktreeRecord.worktreeId must be a string')
728
+ return value as ClientRequest
348
729
  default:
349
730
  throw new IpcProtocolError(`Unknown IPC request type: ${String(value.type)}`)
350
731
  }
@@ -359,6 +740,10 @@ export function parseServerMessage(value: unknown): ServerResponse | ServerEvent
359
740
  case 'helloResult':
360
741
  assert(isString(value.id), 'helloResult.id must be a string')
361
742
  assert(isProtocolHelloResult(value.payload), 'helloResult.payload is invalid')
743
+ // Normalise wire-back-compat: older daemons omit `capabilities`. Force
744
+ // the field to `[]` so the typed shape ProtocolHelloResult.capabilities
745
+ // is always a real array — callers can `.includes(...)` without a guard.
746
+ normaliseCapabilities(value.payload)
362
747
  return value as ServerResponse
363
748
  case 'ok':
364
749
  assert(isString(value.id), 'ok.id must be a string')
@@ -367,10 +752,22 @@ export function parseServerMessage(value: unknown): ServerResponse | ServerEvent
367
752
  assert(isString(value.id), 'attachResult.id must be a string')
368
753
  assert(isAttachResult(value.payload), 'attachResult.payload is invalid')
369
754
  return value as ServerResponse
755
+ case 'listTabsResult':
756
+ assert(isString(value.id), 'listTabsResult.id must be a string')
757
+ assert(isListTabsResult(value.payload), 'listTabsResult.payload is invalid')
758
+ return value as ServerResponse
370
759
  case 'error':
371
760
  assert(isString(value.id), 'error.id must be a string')
372
761
  assert(isString(value.payload.message), 'error.message must be a string')
373
762
  return value as ServerResponse
763
+ case 'reexecAck':
764
+ assert(isString(value.id), 'reexecAck.id must be a string')
765
+ assert(isString(value.payload.handoffPath), 'reexecAck.handoffPath must be a string')
766
+ assert(
767
+ isString(value.payload.renamedSocketPath),
768
+ 'reexecAck.renamedSocketPath must be a string'
769
+ )
770
+ return value as ServerResponse
374
771
  case 'tabRender':
375
772
  assert(isString(value.payload.tabId), 'tabRender.tabId must be a string')
376
773
  assert(isTerminalSnapshot(value.payload.viewport), 'tabRender.viewport is invalid')
@@ -389,10 +786,54 @@ export function parseServerMessage(value: unknown): ServerResponse | ServerEvent
389
786
  assert(isString(value.payload.tabId), 'tabStatus.tabId must be a string')
390
787
  assert(isTabActivity(value.payload.status), 'tabStatus.status is invalid')
391
788
  return value as ServerEvent
789
+ case 'tabAdded':
790
+ assert(isString(value.payload.sessionId), 'tabAdded.sessionId must be a string')
791
+ assert(isTabSession(value.payload.tab), 'tabAdded.tab is invalid')
792
+ return value as ServerEvent
392
793
  case 'sessionStatus':
393
794
  assert(isString(value.payload.sessionId), 'sessionStatus.sessionId must be a string')
394
795
  assert(isSessionStatus(value.payload.status), 'sessionStatus.status is invalid')
395
796
  return value as ServerEvent
797
+ case 'workspaceCreateRequested':
798
+ assert(
799
+ isString(value.payload.name) && value.payload.name.length > 0,
800
+ 'workspaceCreateRequested.name must be a non-empty string'
801
+ )
802
+ assert(
803
+ value.payload.projectPath === undefined || isString(value.payload.projectPath),
804
+ 'workspaceCreateRequested.projectPath must be a string when present'
805
+ )
806
+ assert(
807
+ value.payload.switch === undefined || typeof value.payload.switch === 'boolean',
808
+ 'workspaceCreateRequested.switch must be a boolean when present'
809
+ )
810
+ return value as ServerEvent
811
+ case 'workspaceSwitchRequested':
812
+ assert(
813
+ isString(value.payload.targetSessionId),
814
+ 'workspaceSwitchRequested.targetSessionId must be a string'
815
+ )
816
+ return value as ServerEvent
817
+ case 'workspaceCloseRequested':
818
+ assert(
819
+ isString(value.payload.targetSessionId),
820
+ 'workspaceCloseRequested.targetSessionId must be a string'
821
+ )
822
+ return value as ServerEvent
823
+ case 'workspaceSwitched':
824
+ assert(isString(value.payload.sessionId), 'workspaceSwitched.sessionId must be a string')
825
+ return value as ServerEvent
826
+ case 'worktreeAdded':
827
+ assert(isString(value.payload.sessionId), 'worktreeAdded.sessionId must be a string')
828
+ assert(
829
+ isWorktreeRecord(value.payload.worktree),
830
+ 'worktreeAdded.worktree must be a WorktreeRecord'
831
+ )
832
+ return value as ServerEvent
833
+ case 'worktreeRemoved':
834
+ assert(isString(value.payload.sessionId), 'worktreeRemoved.sessionId must be a string')
835
+ assert(isString(value.payload.worktreeId), 'worktreeRemoved.worktreeId must be a string')
836
+ return value as ServerEvent
396
837
  default:
397
838
  throw new IpcProtocolError(`Unknown IPC response type: ${String(value.type)}`)
398
839
  }
@@ -83,3 +83,17 @@ export async function spawnDetachedIpcDaemon(): Promise<boolean> {
83
83
  export async function spawnDetachedTerminalManager(): Promise<boolean> {
84
84
  return spawnDetachedProcess('terminal-manager', getTerminalManagerSocketPath())
85
85
  }
86
+
87
+ /**
88
+ * Spawn the daemon as a hot-reexec successor. Identical to the fresh spawn
89
+ * at the OS level — the new daemon detects the handoff file written by the
90
+ * predecessor and adopts the reexec path itself
91
+ * (`consumeDaemonHandoff()` in src/daemon/daemon.ts). The wrapper exists so
92
+ * the call site reads intent ("we're swapping the daemon binary") rather
93
+ * than reuse of the fresh-boot spawner, and so a future SCM_RIGHTS variant
94
+ * (Ring 4) has a place to slot in.
95
+ */
96
+ export async function spawnDaemonReexec(): Promise<boolean> {
97
+ logDebug('platform.daemon.spawnReexec', { socketPath: getIpcDaemonSocketPath() })
98
+ return spawnDetachedDaemon()
99
+ }
@@ -1,13 +1,45 @@
1
+ import { negotiateDaemonReexec, waitForSocketRemoval } from './daemon/reexec-client'
1
2
  import { getDaemonSocketPath, removeDaemonSocketIfExists } from './daemon/runtime-paths'
2
- import { findIpcDaemonPid, killProcess, spawnDetachedIpcDaemon } from './platform/daemon-control'
3
+ import {
4
+ findIpcDaemonPid,
5
+ killProcess,
6
+ spawnDaemonReexec,
7
+ spawnDetachedIpcDaemon,
8
+ } from './platform/daemon-control'
3
9
 
4
10
  export async function runRestartDaemon(): Promise<number> {
5
11
  const socketPath = getDaemonSocketPath()
6
12
  const pid = await findIpcDaemonPid()
7
13
 
8
- if (pid !== null) {
9
- process.stdout.write(`Stopping IPC daemon (pid ${pid})...\n`)
10
- await killProcess(pid)
14
+ // Prefer hot-reexec when explicitly enabled — keeps the terminal-manager
15
+ // and every PTY alive. Fall back to brute restart on any failure so the
16
+ // command remains reliable.
17
+ const reexecEnabled = process.env.AIMUX_HOT_REEXEC === '1'
18
+ if (reexecEnabled && pid !== null) {
19
+ process.stdout.write(`Negotiating hot-reexec with daemon (pid ${pid})...\n`)
20
+ const negotiation = await negotiateDaemonReexec(socketPath, { reason: 'restart-daemon' })
21
+ if (negotiation.ok) {
22
+ await waitForSocketRemoval(socketPath, 2_000)
23
+ process.stdout.write('Spawning successor daemon...\n')
24
+ const ok = await spawnDaemonReexec()
25
+ if (ok) {
26
+ process.stdout.write(`Daemon hot-reexec complete on ${socketPath}. PTYs preserved.\n`)
27
+ return 0
28
+ }
29
+ process.stderr.write('Successor daemon failed to bind; falling back to full restart.\n')
30
+ } else {
31
+ process.stdout.write(`Hot-reexec not available (${negotiation.reason}); falling back.\n`)
32
+ }
33
+ }
34
+
35
+ // Re-resolve the daemon pid: the negotiation above may have already
36
+ // drained the predecessor, which exits ~250ms after ack. Using the pre-
37
+ // negotiation pid to killProcess risks signalling an unrelated process
38
+ // that recycled that PID on a busy host.
39
+ const livePid = await findIpcDaemonPid()
40
+ if (livePid !== null) {
41
+ process.stdout.write(`Stopping IPC daemon (pid ${livePid})...\n`)
42
+ await killProcess(livePid)
11
43
  process.stdout.write('IPC daemon stopped.\n')
12
44
  } else {
13
45
  process.stdout.write('No running IPC daemon found.\n')