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