@brimveyn/aimux 1.16.3 → 1.18.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -2
- package/src/app-runtime/backend-runtime-events.ts +179 -0
- package/src/app-runtime/side-effects.ts +3 -1
- package/src/app.tsx +26 -0
- package/src/cli/chord.ts +77 -0
- package/src/cli/client/bootstrap.ts +76 -0
- package/src/cli/client/daemon-client.ts +228 -0
- package/src/cli/client/workspace-resolver.ts +57 -0
- package/src/cli/commands/tab/close.ts +33 -0
- package/src/cli/commands/tab/create.ts +109 -0
- package/src/cli/commands/tab/focus.ts +33 -0
- package/src/cli/commands/tab/list.ts +52 -0
- package/src/cli/commands/tab/send.ts +83 -0
- package/src/cli/commands/tab/snapshot.ts +127 -0
- package/src/cli/commands/tab/tail.ts +171 -0
- package/src/cli/commands/tab/wait.ts +86 -0
- package/src/cli/commands/workspace/close.ts +32 -0
- package/src/cli/commands/workspace/create.ts +92 -0
- package/src/cli/commands/workspace/list.ts +25 -0
- package/src/cli/commands/workspace/show.ts +32 -0
- package/src/cli/commands/workspace/switch.ts +75 -0
- package/src/cli/commands/worktree/create.ts +113 -0
- package/src/cli/commands/worktree/list.ts +44 -0
- package/src/cli/commands/worktree/remove.ts +59 -0
- package/src/cli/context.ts +16 -0
- package/src/cli/flags.ts +101 -0
- package/src/cli/index.ts +258 -0
- package/src/cli/output.ts +30 -0
- package/src/cli/registry.ts +54 -0
- package/src/cli/snapshot-render.ts +54 -0
- package/src/daemon/catalog-writer.ts +123 -0
- package/src/daemon/daemon.ts +566 -11
- package/src/daemon/reexec-client.ts +147 -0
- package/src/daemon/runtime-paths.ts +161 -1
- package/src/daemon/session-registry.ts +17 -0
- package/src/index.tsx +80 -2
- package/src/input/modes/bridge.ts +6 -0
- package/src/input/modes/transitions.ts +2 -0
- package/src/input/modes/types.ts +1 -0
- package/src/ipc/README.md +112 -0
- package/src/ipc/manager-protocol.ts +54 -4
- package/src/ipc/protocol.ts +466 -25
- package/src/platform/daemon-control.ts +14 -0
- package/src/restart-daemon.ts +36 -4
- package/src/session-backend/bootstrap.ts +110 -0
- package/src/session-backend/local-session-backend.ts +6 -0
- package/src/session-backend/remote-session-backend.ts +63 -0
- package/src/session-backend/types.ts +34 -0
- package/src/state/reducers/modal-state.ts +66 -1
- package/src/state/types.ts +40 -0
- package/src/state/validation.ts +1 -1
- package/src/terminal-manager/manager-client.ts +24 -7
- package/src/ui/components/flash/flash-label-badge.tsx +38 -0
- package/src/ui/components/layout/sidebar/tab-item.tsx +2 -0
- package/src/ui/components/layout/sidebar/workspace-list.tsx +4 -0
- package/src/ui/components/layout/sidebar/worktree-row.tsx +2 -0
- package/src/ui/components/layout/top-tab-bar.tsx +2 -0
- package/src/ui/flash/assign-labels.ts +126 -0
- package/src/ui/flash/build-labels.ts +78 -0
- package/src/ui/hooks/use-flash-label.ts +40 -0
- package/src/ui/root.tsx +3 -0
package/src/ipc/protocol.ts
CHANGED
|
@@ -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 =
|
|
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
|
+
}
|
package/src/restart-daemon.ts
CHANGED
|
@@ -1,13 +1,45 @@
|
|
|
1
|
+
import { negotiateDaemonReexec, waitForSocketRemoval } from './daemon/reexec-client'
|
|
1
2
|
import { getDaemonSocketPath, removeDaemonSocketIfExists } from './daemon/runtime-paths'
|
|
2
|
-
import {
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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')
|