@brimveyn/aimux 1.14.3 → 1.14.5
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 +2 -2
- package/src/app-runtime/side-effects.ts +7 -1
- package/src/app-runtime/use-terminal-resize.ts +32 -8
- package/src/app.tsx +16 -1
- package/src/daemon/daemon.ts +55 -1
- package/src/daemon/runtime-paths.ts +9 -0
- package/src/daemon/session-registry.ts +2 -0
- package/src/index.tsx +14 -0
- package/src/integrations/claude-hook-server.ts +99 -0
- package/src/integrations/claude-hooks-install.ts +199 -0
- package/src/ipc/manager-protocol.ts +33 -2
- package/src/pty/assistant-status-arbiter.ts +94 -0
- package/src/pty/assistant-status-detection-loop.ts +20 -1
- package/src/pty/pty-manager.ts +4 -0
- package/src/pty/terminal-snapshot.ts +43 -66
- package/src/session-backend/local-session-backend.ts +58 -6
- package/src/session-backend/remote-session-backend.ts +10 -3
- package/src/session-backend/types.ts +16 -2
- package/src/state/reducers/session-state.ts +40 -8
- package/src/state/reducers/tab-state.ts +18 -3
- package/src/state/session-worktrees.ts +0 -10
- package/src/state/types.ts +8 -0
- package/src/ui/components/git/pane/git-pane-header.tsx +1 -1
- package/src/ui/components/layout/sidebar/workspace-list.tsx +13 -14
- package/src/ui/components/layout/sidebar/worktree-row.tsx +30 -8
- package/src/ui/components/layout/terminal-pane.tsx +56 -4
- package/src/ui/components/layout/top-tab-bar.tsx +3 -1
- package/src/ui/host-palette.ts +75 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brimveyn/aimux",
|
|
3
|
-
"version": "1.14.
|
|
3
|
+
"version": "1.14.5",
|
|
4
4
|
"description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode side-by-side with tabbed navigation, split panes, and persistent sessions.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"bump": "bun run scripts/bump.ts"
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
-
"@brimveyn/aimux-config": "0.6.
|
|
63
|
+
"@brimveyn/aimux-config": "0.6.6",
|
|
64
64
|
"@opentui/core": "^0.1.90",
|
|
65
65
|
"@opentui/react": "^0.1.90",
|
|
66
66
|
"@resvg/resvg-wasm": "^2.6.2",
|
|
@@ -1059,7 +1059,13 @@ async function openEditorInline(
|
|
|
1059
1059
|
}
|
|
1060
1060
|
|
|
1061
1061
|
function handleSwitchSessionByIndex(ctx: SideEffectContext, index: number): void {
|
|
1062
|
-
const { backend, dispatch
|
|
1062
|
+
const { backend, dispatch } = ctx
|
|
1063
|
+
// Read fresh state. ctx.state is the snapshot from the previous render and
|
|
1064
|
+
// lags behind dispatches that happened in the same JS turn (a worktree-row
|
|
1065
|
+
// click first dispatches set-active-worktree then fires this side effect —
|
|
1066
|
+
// we need to see that just-applied activeWorktreeId so the new session
|
|
1067
|
+
// lands on the right worktree, not its last-saved one).
|
|
1068
|
+
const state = ctx.getState()
|
|
1063
1069
|
const ordered = [...state.sessions].sort(
|
|
1064
1070
|
(a, b) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER)
|
|
1065
1071
|
)
|
|
@@ -44,21 +44,26 @@ function resizeSplitTabs(
|
|
|
44
44
|
tabIds: string[],
|
|
45
45
|
cols: number,
|
|
46
46
|
rows: number,
|
|
47
|
-
options?: { sync?: boolean }
|
|
47
|
+
options?: { sync?: boolean },
|
|
48
|
+
skipTabId?: string | null
|
|
48
49
|
): void {
|
|
49
50
|
const bounds = getTerminalBounds(cols, rows)
|
|
50
51
|
const resizedTabIds = new Set<string>()
|
|
51
52
|
|
|
52
53
|
forEachSplitPaneRect(Object.values(layoutTrees), bounds, (tabId, rect) => {
|
|
54
|
+
if (skipTabId != null && tabId === skipTabId) {
|
|
55
|
+
resizedTabIds.add(tabId)
|
|
56
|
+
return
|
|
57
|
+
}
|
|
53
58
|
const size = toTerminalContentSize(rect)
|
|
54
59
|
backend.resizeTab(tabId, size.cols, size.rows, options)
|
|
55
60
|
resizedTabIds.add(tabId)
|
|
56
61
|
})
|
|
57
62
|
|
|
58
63
|
for (const id of tabIds) {
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
|
|
64
|
+
if (resizedTabIds.has(id)) continue
|
|
65
|
+
if (skipTabId != null && id === skipTabId) continue
|
|
66
|
+
backend.resizeTab(id, cols, rows, options)
|
|
62
67
|
}
|
|
63
68
|
}
|
|
64
69
|
|
|
@@ -81,6 +86,12 @@ interface RunResizeCascadeArgs {
|
|
|
81
86
|
layoutTrees: AppState['layoutTrees']
|
|
82
87
|
stableTabIds: string[]
|
|
83
88
|
sync: boolean
|
|
89
|
+
/** Tab whose backend resize is owned exclusively by `usePaneSizeReport`. The
|
|
90
|
+
* cascade still dispatches the global terminal-size update and resizes every
|
|
91
|
+
* other tab, but the active pane is left to be sized by its measurement —
|
|
92
|
+
* preventing the open-loop chrome estimate from competing with the closed
|
|
93
|
+
* measurement loop and tearing the rendered viewport. */
|
|
94
|
+
skipTabId: string | null
|
|
84
95
|
}
|
|
85
96
|
|
|
86
97
|
function runResizeCascade({
|
|
@@ -91,6 +102,7 @@ function runResizeCascade({
|
|
|
91
102
|
resizingRef,
|
|
92
103
|
resizingTimerRef,
|
|
93
104
|
rows,
|
|
105
|
+
skipTabId,
|
|
94
106
|
stableTabIds,
|
|
95
107
|
sync,
|
|
96
108
|
}: RunResizeCascadeArgs): void {
|
|
@@ -104,9 +116,14 @@ function runResizeCascade({
|
|
|
104
116
|
clearTimeout(resizingTimerRef.current)
|
|
105
117
|
}
|
|
106
118
|
if (hasSplits) {
|
|
107
|
-
resizeSplitTabs(backend, layoutTrees, stableTabIds, cols, rows, options)
|
|
108
|
-
} else {
|
|
119
|
+
resizeSplitTabs(backend, layoutTrees, stableTabIds, cols, rows, options, skipTabId)
|
|
120
|
+
} else if (skipTabId == null) {
|
|
109
121
|
backend.resizeAll(cols, rows, options)
|
|
122
|
+
} else {
|
|
123
|
+
for (const id of stableTabIds) {
|
|
124
|
+
if (id === skipTabId) continue
|
|
125
|
+
backend.resizeTab(id, cols, rows, options)
|
|
126
|
+
}
|
|
110
127
|
}
|
|
111
128
|
resizingTimerRef.current = setTimeout(() => {
|
|
112
129
|
resizingRef.current = false
|
|
@@ -162,11 +179,16 @@ export function useTerminalResize({
|
|
|
162
179
|
contentOriginRef.current = { cols, rows, x: rect.x, y: rect.y }
|
|
163
180
|
}
|
|
164
181
|
const prev = measuredRef.current.get(tabId)
|
|
165
|
-
if (prev && prev.cols === cols && prev.rows === rows) {
|
|
182
|
+
if (prev !== undefined && prev.cols === cols && prev.rows === rows) {
|
|
166
183
|
return
|
|
167
184
|
}
|
|
185
|
+
// First measurement for this tab: even if it happens to match the
|
|
186
|
+
// open-loop bootstrap size, we still call resizeTab so the backend
|
|
187
|
+
// gate (snapshot suppression until measurement confirms the pane
|
|
188
|
+
// size) is lifted. confirmedFromMeasurement marks this call as the
|
|
189
|
+
// authoritative size for the rendered viewport.
|
|
168
190
|
measuredRef.current.set(tabId, { cols, rows })
|
|
169
|
-
backend.resizeTab(tabId, cols, rows)
|
|
191
|
+
backend.resizeTab(tabId, cols, rows, { confirmedFromMeasurement: true })
|
|
170
192
|
},
|
|
171
193
|
[backend, contentOriginRef]
|
|
172
194
|
)
|
|
@@ -222,6 +244,7 @@ export function useTerminalResize({
|
|
|
222
244
|
resizingRef,
|
|
223
245
|
resizingTimerRef,
|
|
224
246
|
rows: terminalSize.rows,
|
|
247
|
+
skipTabId: activeTabIdRef.current ?? null,
|
|
225
248
|
stableTabIds,
|
|
226
249
|
sync: true,
|
|
227
250
|
})
|
|
@@ -249,6 +272,7 @@ export function useTerminalResize({
|
|
|
249
272
|
resizingRef,
|
|
250
273
|
resizingTimerRef,
|
|
251
274
|
rows: terminalSize.rows,
|
|
275
|
+
skipTabId: activeTabIdRef.current ?? null,
|
|
252
276
|
stableTabIds,
|
|
253
277
|
sync: false,
|
|
254
278
|
})
|
package/src/app.tsx
CHANGED
|
@@ -27,6 +27,7 @@ import { setActiveKeymap } from './input/keymap/keymap-ref'
|
|
|
27
27
|
import { deriveModeId } from './input/modes/bridge'
|
|
28
28
|
import { registerAllModes } from './input/modes/handlers'
|
|
29
29
|
import { getHandler, transitionTo } from './input/modes/registry'
|
|
30
|
+
import { ensureClaudeSettingsHooks } from './integrations/claude-hooks-install'
|
|
30
31
|
import { highlightSnapshot, warmClaudeSyntaxOverlay } from './integrations/claude-syntax-overlay'
|
|
31
32
|
import { ensureClaudeSettingsThemePref, syncClaudeTheme } from './integrations/claude-theme-sync'
|
|
32
33
|
import { getProfileConfigDir, getProfileName } from './profile-paths'
|
|
@@ -191,6 +192,16 @@ export function App({
|
|
|
191
192
|
})
|
|
192
193
|
}, [resolvedConfig.theme?.beta?.harmonizeClaudeTheme])
|
|
193
194
|
|
|
195
|
+
useEffect(() => {
|
|
196
|
+
// Opt-in via `integrations.claudeHooks` in aimux.config.ts. When enabled,
|
|
197
|
+
// idempotently patches ~/.claude/settings.json so Claude Code's hooks
|
|
198
|
+
// call back into the daemon for per-tab activity detection. Silent on
|
|
199
|
+
// failure; the visual PTY detector is the fallback either way.
|
|
200
|
+
if (resolvedConfig.integrations.claudeHooks) {
|
|
201
|
+
ensureClaudeSettingsHooks()
|
|
202
|
+
}
|
|
203
|
+
}, [resolvedConfig.integrations.claudeHooks])
|
|
204
|
+
|
|
194
205
|
useEffect(() => {
|
|
195
206
|
const aiUsage = resolvedConfig.statusBar?.aiUsage
|
|
196
207
|
if (!(aiUsage?.enabled === true)) {
|
|
@@ -398,7 +409,11 @@ export function App({
|
|
|
398
409
|
if (!(state.currentSessionId != null && state.currentSessionId !== '')) return
|
|
399
410
|
return getSessionProjectPath(state.sessions.find((s) => s.id === state.currentSessionId))
|
|
400
411
|
},
|
|
401
|
-
|
|
412
|
+
// Read straight from the store, not stateRef. stateRef is only refreshed
|
|
413
|
+
// on render, so within a single JS turn (a click handler that dispatches
|
|
414
|
+
// then fires a side effect) it lags one step behind. appStore.getState
|
|
415
|
+
// reflects every dispatch synchronously.
|
|
416
|
+
getState: () => appStore.getState(),
|
|
402
417
|
renderer,
|
|
403
418
|
setThemeId,
|
|
404
419
|
startStartupGrace,
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { existsSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
1
2
|
import { connect, createServer, type Socket } from 'node:net'
|
|
2
3
|
|
|
3
4
|
import type { AssistantId, TerminalSnapshot } from '../state/types'
|
|
4
5
|
|
|
5
6
|
import { logDebug } from '../debug/input-log'
|
|
7
|
+
import { type ClaudeHookServer, startClaudeHookServer } from '../integrations/claude-hook-server'
|
|
6
8
|
import {
|
|
7
9
|
type ClientRequest,
|
|
8
10
|
encodeMessage,
|
|
@@ -19,6 +21,7 @@ import { findSocketProcessPid, spawnDetachedTerminalManager } from '../platform/
|
|
|
19
21
|
import { type LoopTabView, runStatusDetectionLoop } from '../pty/assistant-status-detection-loop'
|
|
20
22
|
import { TerminalManagerClient } from '../terminal-manager/manager-client'
|
|
21
23
|
import {
|
|
24
|
+
getClaudeHookUrlFilePath,
|
|
22
25
|
getIpcDaemonSocketPath,
|
|
23
26
|
getSocketSecurityIssue,
|
|
24
27
|
getTerminalManagerSocketPath,
|
|
@@ -282,6 +285,39 @@ export async function runDaemon(): Promise<void> {
|
|
|
282
285
|
},
|
|
283
286
|
})
|
|
284
287
|
|
|
288
|
+
// Local HTTP server that receives Claude Code hook callbacks for every PTY
|
|
289
|
+
// spawned by this daemon. We publish its URL to a stable file path that the
|
|
290
|
+
// shipped shell bridge reads on every invocation, so PTYs spawned by a
|
|
291
|
+
// *previous* daemon transparently follow URL changes after a restart.
|
|
292
|
+
// Failure to start is non-fatal — detection falls back to the visual PTY scanner.
|
|
293
|
+
let hookServer: ClaudeHookServer | null = null
|
|
294
|
+
const hookUrlFilePath = getClaudeHookUrlFilePath()
|
|
295
|
+
try {
|
|
296
|
+
hookServer = startClaudeHookServer({
|
|
297
|
+
onEvent: (event) => {
|
|
298
|
+
statusLoop.recordHookEvent({
|
|
299
|
+
hookEventName: event.hookEventName,
|
|
300
|
+
paneId: event.paneId,
|
|
301
|
+
payload: event.payload,
|
|
302
|
+
receivedAt: event.receivedAt,
|
|
303
|
+
})
|
|
304
|
+
},
|
|
305
|
+
})
|
|
306
|
+
try {
|
|
307
|
+
writeFileSync(hookUrlFilePath, hookServer.url, { mode: 0o600 })
|
|
308
|
+
logDebug('daemon.hookServer.started', { path: hookUrlFilePath, url: hookServer.url })
|
|
309
|
+
} catch (error) {
|
|
310
|
+
logDebug('daemon.hookServer.urlFileWriteFailed', {
|
|
311
|
+
error: error instanceof Error ? error.message : String(error),
|
|
312
|
+
path: hookUrlFilePath,
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
} catch (error) {
|
|
316
|
+
logDebug('daemon.hookServer.startFailed', {
|
|
317
|
+
error: error instanceof Error ? error.message : String(error),
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
|
|
285
321
|
/**
|
|
286
322
|
* Tell the TM whether to bother snapshotting + broadcasting. Toggled on
|
|
287
323
|
* 0↔1 transitions of the client socket count: when no UI is watching, the
|
|
@@ -454,7 +490,15 @@ export async function runDaemon(): Promise<void> {
|
|
|
454
490
|
message.payload.assistant,
|
|
455
491
|
[message.payload.command, ...(message.payload.args ?? [])].join(' ')
|
|
456
492
|
)
|
|
457
|
-
|
|
493
|
+
// Inject the hook bridge env so Claude Code's hooks can
|
|
494
|
+
// call back into our status loop. Safe to add for every
|
|
495
|
+
// assistant: non-Claude binaries simply ignore the vars.
|
|
496
|
+
// We pass the URL *file path*, not the URL itself, so the
|
|
497
|
+
// bridge can pick up a fresh URL after a daemon restart
|
|
498
|
+
// even on PTYs that outlive this daemon process.
|
|
499
|
+
const env: Record<string, string> = { AIMUX_PANE_ID: message.payload.tabId }
|
|
500
|
+
if (hookServer) env.AIMUX_HOOK_URL_FILE = hookUrlFilePath
|
|
501
|
+
await manager.createTab({ ...message.payload, env, sessionId })
|
|
458
502
|
sendOk(socket, message.id)
|
|
459
503
|
logDebug('daemon.request.createTab.success', {
|
|
460
504
|
sessionId,
|
|
@@ -577,6 +621,16 @@ export async function runDaemon(): Promise<void> {
|
|
|
577
621
|
const gracefulShutdown = (signal: string) => {
|
|
578
622
|
logDebug(`daemon.${signal}`)
|
|
579
623
|
statusLoop.stop()
|
|
624
|
+
if (hookServer) {
|
|
625
|
+
void hookServer.stop()
|
|
626
|
+
try {
|
|
627
|
+
if (existsSync(hookUrlFilePath)) unlinkSync(hookUrlFilePath)
|
|
628
|
+
} catch (error) {
|
|
629
|
+
logDebug('daemon.hookServer.urlFileCleanupFailed', {
|
|
630
|
+
error: error instanceof Error ? error.message : String(error),
|
|
631
|
+
})
|
|
632
|
+
}
|
|
633
|
+
}
|
|
580
634
|
manager.destroy()
|
|
581
635
|
server.close()
|
|
582
636
|
process.exit(0)
|
|
@@ -38,6 +38,15 @@ export function getTerminalManagerSocketPath(): string {
|
|
|
38
38
|
return join(ensureRuntimeDir(), 'terminal-manager.sock')
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* File the daemon writes its current Claude hook server URL into. Stable path
|
|
43
|
+
* per profile, so PTYs spawned by a previous daemon can still resolve the
|
|
44
|
+
* URL of a freshly-restarted daemon by reading this file on every invocation.
|
|
45
|
+
*/
|
|
46
|
+
export function getClaudeHookUrlFilePath(): string {
|
|
47
|
+
return join(ensureRuntimeDir(), 'claude-hook.url')
|
|
48
|
+
}
|
|
49
|
+
|
|
41
50
|
export function ensureParentDir(filePath: string): void {
|
|
42
51
|
mkdirSync(dirname(filePath), { recursive: true })
|
|
43
52
|
}
|
|
@@ -129,6 +129,8 @@ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
|
|
|
129
129
|
cols: number
|
|
130
130
|
rows: number
|
|
131
131
|
cwd?: string
|
|
132
|
+
/** Extra env injected into the spawned shell. Passed through to the PTY. */
|
|
133
|
+
env?: Record<string, string>
|
|
132
134
|
}): void {
|
|
133
135
|
logDebug('daemon.registry.createSession', {
|
|
134
136
|
args: options.args ?? [],
|
package/src/index.tsx
CHANGED
|
@@ -13,6 +13,7 @@ import { runRestartTerminalManager } from './restart-terminal-manager'
|
|
|
13
13
|
import { createSessionBackend } from './session-backend/bootstrap'
|
|
14
14
|
import { runTerminalManager } from './terminal-manager/terminal-manager'
|
|
15
15
|
import { BreakingUpdateScreen } from './ui/breaking-update-screen'
|
|
16
|
+
import { setHostPalette } from './ui/host-palette'
|
|
16
17
|
import { runUpdate } from './update'
|
|
17
18
|
|
|
18
19
|
const command = process.argv[2]
|
|
@@ -65,6 +66,19 @@ const renderer = await createCliRenderer({
|
|
|
65
66
|
useMouse: true,
|
|
66
67
|
})
|
|
67
68
|
|
|
69
|
+
// Query the host terminal's actual ANSI palette (OSC 4) so PTY cells that
|
|
70
|
+
// emit indexed colors render with the user's configured terminal theme
|
|
71
|
+
// instead of hardcoded xterm defaults. Best-effort: terminals that don't
|
|
72
|
+
// respond keep the fallback xterm palette.
|
|
73
|
+
try {
|
|
74
|
+
const { palette } = await renderer.getPalette({ size: 256, timeout: 200 })
|
|
75
|
+
setHostPalette(palette)
|
|
76
|
+
} catch (error) {
|
|
77
|
+
logDebug('index.paletteDetectFailed', {
|
|
78
|
+
message: error instanceof Error ? error.message : String(error),
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
68
82
|
const root = createRoot(renderer)
|
|
69
83
|
|
|
70
84
|
const resolvedConfig = await loadUserConfig()
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Local HTTP server that receives Claude Code hook events from the shipped
|
|
2
|
+
// shell bridge (assets/claude-hooks/aimux-agent-state.sh). Bound to 127.0.0.1
|
|
3
|
+
// on an OS-assigned port; the URL is injected into each PTY via
|
|
4
|
+
// `AIMUX_HOOK_URL` so the bridge knows where to POST.
|
|
5
|
+
//
|
|
6
|
+
// The body is the Claude hook payload (see https://code.claude.com/docs/en/hooks.md)
|
|
7
|
+
// augmented with `aimuxPaneId` by the shell bridge. We forward minimal info
|
|
8
|
+
// to the daemon's status loop, which performs the actual hook → activity
|
|
9
|
+
// mapping in assistant-status-arbiter.
|
|
10
|
+
|
|
11
|
+
import { logDebug } from '../debug/input-log'
|
|
12
|
+
|
|
13
|
+
export interface ClaudeHookEvent {
|
|
14
|
+
paneId: string
|
|
15
|
+
hookEventName: string
|
|
16
|
+
/** Raw payload from Claude, keyed by hook event. */
|
|
17
|
+
payload: Record<string, unknown>
|
|
18
|
+
receivedAt: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ClaudeHookServer {
|
|
22
|
+
url: string
|
|
23
|
+
stop: () => Promise<void>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function pickString(value: unknown): string {
|
|
27
|
+
return typeof value === 'string' ? value : ''
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Start the hook ingestion server. Throws if Bun.serve is unavailable (e.g.
|
|
32
|
+
* running under plain Node). The daemon catches that and logs — detection
|
|
33
|
+
* falls back to the visual PTY scanner.
|
|
34
|
+
*/
|
|
35
|
+
export function startClaudeHookServer(options: {
|
|
36
|
+
onEvent: (event: ClaudeHookEvent) => void
|
|
37
|
+
}): ClaudeHookServer {
|
|
38
|
+
if (typeof Bun === 'undefined' || typeof Bun.serve !== 'function') {
|
|
39
|
+
throw new Error('claude-hook-server requires Bun.serve')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const handle = Bun.serve({
|
|
43
|
+
fetch: async (req) => {
|
|
44
|
+
const url = new URL(req.url)
|
|
45
|
+
if (req.method !== 'POST' || url.pathname !== '/hook/claude') {
|
|
46
|
+
return new Response('Not Found', { status: 404 })
|
|
47
|
+
}
|
|
48
|
+
let body: unknown
|
|
49
|
+
try {
|
|
50
|
+
body = await req.json()
|
|
51
|
+
} catch {
|
|
52
|
+
return new Response('Bad JSON', { status: 400 })
|
|
53
|
+
}
|
|
54
|
+
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
|
55
|
+
return new Response('Body must be an object', { status: 400 })
|
|
56
|
+
}
|
|
57
|
+
const record = body as Record<string, unknown>
|
|
58
|
+
const paneId = pickString(record.aimuxPaneId)
|
|
59
|
+
const hookEventName = pickString(record.hook_event_name)
|
|
60
|
+
if (paneId === '' || hookEventName === '') {
|
|
61
|
+
return new Response('Missing aimuxPaneId or hook_event_name', { status: 400 })
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
options.onEvent({
|
|
65
|
+
hookEventName,
|
|
66
|
+
paneId,
|
|
67
|
+
payload: record,
|
|
68
|
+
receivedAt: Date.now(),
|
|
69
|
+
})
|
|
70
|
+
} catch (error) {
|
|
71
|
+
logDebug('claudeHookServer.dispatchError', {
|
|
72
|
+
error: error instanceof Error ? error.message : String(error),
|
|
73
|
+
hookEventName,
|
|
74
|
+
paneId,
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
return new Response('ok', { status: 200 })
|
|
78
|
+
},
|
|
79
|
+
hostname: '127.0.0.1',
|
|
80
|
+
port: 0,
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const port = handle.port
|
|
84
|
+
const url = `http://127.0.0.1:${port}/hook/claude`
|
|
85
|
+
logDebug('claudeHookServer.listening', { url })
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
stop: async () => {
|
|
89
|
+
try {
|
|
90
|
+
await handle.stop(true)
|
|
91
|
+
} catch (error) {
|
|
92
|
+
logDebug('claudeHookServer.stopError', {
|
|
93
|
+
error: error instanceof Error ? error.message : String(error),
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
url,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Patch `~/.claude/settings.json` so Claude Code invokes aimux's hook bridge
|
|
2
|
+
// for the lifecycle events that drive per-tab activity detection. Idempotent:
|
|
3
|
+
// existing aimux entries (marked via `__aimux: true`) are replaced; unrelated
|
|
4
|
+
// hooks the user has configured are preserved.
|
|
5
|
+
//
|
|
6
|
+
// Hooks reference: https://code.claude.com/docs/en/hooks.md
|
|
7
|
+
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
9
|
+
import { homedir } from 'node:os'
|
|
10
|
+
import { dirname, join, resolve } from 'node:path'
|
|
11
|
+
import { fileURLToPath } from 'node:url'
|
|
12
|
+
|
|
13
|
+
import { logDebug } from '../debug/input-log'
|
|
14
|
+
|
|
15
|
+
const HOOK_EVENTS = [
|
|
16
|
+
'UserPromptSubmit',
|
|
17
|
+
'PreToolUse',
|
|
18
|
+
'PostToolUse',
|
|
19
|
+
'Stop',
|
|
20
|
+
'SubagentStop',
|
|
21
|
+
'Notification',
|
|
22
|
+
] as const
|
|
23
|
+
|
|
24
|
+
type HookEvent = (typeof HOOK_EVENTS)[number]
|
|
25
|
+
|
|
26
|
+
interface HookCommandEntry {
|
|
27
|
+
type: 'command'
|
|
28
|
+
command: string
|
|
29
|
+
__aimux?: true
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface HookGroupEntry {
|
|
33
|
+
matcher?: string
|
|
34
|
+
hooks: HookCommandEntry[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function claudeDir(): string {
|
|
38
|
+
return join(homedir(), '.claude')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function settingsFilePath(): string {
|
|
42
|
+
return join(claudeDir(), 'settings.json')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function writeAtomic(target: string, contents: string): void {
|
|
46
|
+
const tmp = `${target}.aimux.tmp`
|
|
47
|
+
writeFileSync(tmp, contents, 'utf8')
|
|
48
|
+
try {
|
|
49
|
+
renameSync(tmp, target)
|
|
50
|
+
} catch (error) {
|
|
51
|
+
try {
|
|
52
|
+
unlinkSync(tmp)
|
|
53
|
+
} catch {
|
|
54
|
+
/* ignore */
|
|
55
|
+
}
|
|
56
|
+
throw error
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function logSyncWarn(reason: string, details?: Record<string, unknown>): void {
|
|
61
|
+
logDebug('claude-hooks-install:warn', { reason, ...details })
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Absolute path to the shipped hook script. Resolved from this module's URL so
|
|
66
|
+
* it works from the install directory regardless of how aimux was launched.
|
|
67
|
+
* Walks up from `src/integrations` / built equivalent until it finds the
|
|
68
|
+
* `assets/claude-hooks` directory.
|
|
69
|
+
*/
|
|
70
|
+
export function resolveHookScriptPath(): string | null {
|
|
71
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
72
|
+
const candidates = [
|
|
73
|
+
resolve(here, '..', '..', 'assets', 'claude-hooks', 'aimux-agent-state.sh'),
|
|
74
|
+
resolve(here, '..', '..', '..', 'assets', 'claude-hooks', 'aimux-agent-state.sh'),
|
|
75
|
+
resolve(here, '..', 'assets', 'claude-hooks', 'aimux-agent-state.sh'),
|
|
76
|
+
]
|
|
77
|
+
for (const candidate of candidates) {
|
|
78
|
+
if (existsSync(candidate)) return candidate
|
|
79
|
+
}
|
|
80
|
+
logSyncWarn('hook-script-not-found', { candidates })
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isHookCommandEntry(value: unknown): value is HookCommandEntry {
|
|
85
|
+
return (
|
|
86
|
+
typeof value === 'object' &&
|
|
87
|
+
value !== null &&
|
|
88
|
+
(value as { type?: unknown }).type === 'command' &&
|
|
89
|
+
typeof (value as { command?: unknown }).command === 'string'
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isHookGroupEntry(value: unknown): value is HookGroupEntry {
|
|
94
|
+
if (typeof value !== 'object' || value === null) return false
|
|
95
|
+
const hooks = (value as { hooks?: unknown }).hooks
|
|
96
|
+
return Array.isArray(hooks)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isAimuxEntry(entry: HookGroupEntry): boolean {
|
|
100
|
+
return entry.hooks.some((hook) => {
|
|
101
|
+
if (!isHookCommandEntry(hook)) return false
|
|
102
|
+
if (hook.__aimux === true) return true
|
|
103
|
+
return hook.command.includes('aimux-agent-state')
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function buildAimuxEntry(event: HookEvent, scriptPath: string): HookGroupEntry {
|
|
108
|
+
// PreToolUse / PostToolUse expect a tool matcher. `*` matches every tool.
|
|
109
|
+
// The other events ignore `matcher` entirely.
|
|
110
|
+
const needsMatcher = event === 'PreToolUse' || event === 'PostToolUse'
|
|
111
|
+
return {
|
|
112
|
+
...(needsMatcher ? { matcher: '*' } : {}),
|
|
113
|
+
hooks: [{ __aimux: true, command: scriptPath, type: 'command' }],
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Idempotently install aimux hook entries into `~/.claude/settings.json`.
|
|
119
|
+
* Returns true when settings were written (or already correct), false on a
|
|
120
|
+
* hard failure that should be logged. Errors never throw — a failed install
|
|
121
|
+
* just means activity detection falls back to the visual detector.
|
|
122
|
+
*/
|
|
123
|
+
export function ensureClaudeSettingsHooks(): boolean {
|
|
124
|
+
const scriptPath = resolveHookScriptPath()
|
|
125
|
+
if (scriptPath === null || scriptPath === '') return false
|
|
126
|
+
|
|
127
|
+
const target = settingsFilePath()
|
|
128
|
+
|
|
129
|
+
let parsed: Record<string, unknown> = {}
|
|
130
|
+
if (existsSync(target)) {
|
|
131
|
+
let raw: string
|
|
132
|
+
try {
|
|
133
|
+
raw = readFileSync(target, 'utf8')
|
|
134
|
+
} catch (error) {
|
|
135
|
+
logSyncWarn('settings-read-failed', { err: String(error), path: target })
|
|
136
|
+
return false
|
|
137
|
+
}
|
|
138
|
+
if (raw.trim().length > 0) {
|
|
139
|
+
try {
|
|
140
|
+
const json = JSON.parse(raw) as unknown
|
|
141
|
+
if (typeof json !== 'object' || json === null || Array.isArray(json)) {
|
|
142
|
+
logSyncWarn('settings-not-object', { path: target })
|
|
143
|
+
return false
|
|
144
|
+
}
|
|
145
|
+
parsed = json as Record<string, unknown>
|
|
146
|
+
} catch (error) {
|
|
147
|
+
logSyncWarn('settings-parse-failed', { err: String(error), path: target })
|
|
148
|
+
return false
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const existingHooks = (
|
|
154
|
+
typeof parsed.hooks === 'object' && parsed.hooks !== null && !Array.isArray(parsed.hooks)
|
|
155
|
+
? parsed.hooks
|
|
156
|
+
: {}
|
|
157
|
+
) as Record<string, unknown>
|
|
158
|
+
|
|
159
|
+
const nextHooks: Record<string, HookGroupEntry[]> = {}
|
|
160
|
+
let changed = false
|
|
161
|
+
|
|
162
|
+
for (const event of HOOK_EVENTS) {
|
|
163
|
+
const raw = existingHooks[event]
|
|
164
|
+
const arr: HookGroupEntry[] = Array.isArray(raw) ? raw.filter(isHookGroupEntry) : []
|
|
165
|
+
const filtered = arr.filter((entry) => !isAimuxEntry(entry))
|
|
166
|
+
const desired = buildAimuxEntry(event, scriptPath)
|
|
167
|
+
const next = [...filtered, desired]
|
|
168
|
+
nextHooks[event] = next
|
|
169
|
+
if (!arraysShallowEqual(arr, next)) changed = true
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Preserve any non-aimux hook events the user already had.
|
|
173
|
+
for (const [event, value] of Object.entries(existingHooks)) {
|
|
174
|
+
if (HOOK_EVENTS.includes(event as HookEvent)) continue
|
|
175
|
+
if (Array.isArray(value)) nextHooks[event] = value as HookGroupEntry[]
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (!changed && parsed.hooks !== undefined) return true
|
|
179
|
+
|
|
180
|
+
parsed.hooks = nextHooks
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
mkdirSync(claudeDir(), { recursive: true })
|
|
184
|
+
writeAtomic(target, `${JSON.stringify(parsed, null, 2)}\n`)
|
|
185
|
+
logDebug('claude-hooks-install:wrote', { events: HOOK_EVENTS, path: target, scriptPath })
|
|
186
|
+
return true
|
|
187
|
+
} catch (error) {
|
|
188
|
+
logSyncWarn('settings-write-failed', { err: String(error), path: target })
|
|
189
|
+
return false
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function arraysShallowEqual<T>(a: T[], b: T[]): boolean {
|
|
194
|
+
if (a.length !== b.length) return false
|
|
195
|
+
for (let i = 0; i < a.length; i++) {
|
|
196
|
+
if (JSON.stringify(a[i]) !== JSON.stringify(b[i])) return false
|
|
197
|
+
}
|
|
198
|
+
return true
|
|
199
|
+
}
|