@brimveyn/aimux 1.14.2 → 1.14.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.14.2",
3
+ "version": "1.14.4",
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.5",
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",
@@ -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 (!resizedTabIds.has(id)) {
60
- backend.resizeTab(id, cols, rows, options)
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)) {
@@ -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
- await manager.createTab({ ...message.payload, sessionId })
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
+ }
@@ -17,8 +17,23 @@ import {
17
17
  // `intent`/`intents` from resize messages and the `reapplyScrollIntent`
18
18
  // message. Min is raised in lockstep so a pre-v5 peer (which could still send
19
19
  // the dropped message) can't negotiate a now-incompatible version.
20
- export const MANAGER_PROTOCOL_MIN_VERSION = 5
21
- export const MANAGER_PROTOCOL_VERSION = 5
20
+ //
21
+ // v6: `createTab` carries an optional `env` payload that the TM merges into
22
+ // the spawned PTY. Daemon uses it to inject `AIMUX_HOOK_URL_FILE` /
23
+ // `AIMUX_PANE_ID` for the Claude Code hook bridge. A pre-v6 TM silently
24
+ // drops the field, so Min is raised in lockstep to force a fresh TM that
25
+ // will actually forward env to the spawn.
26
+ //
27
+ // v7: `TerminalSpan` carries optional `fgPalette`/`bgPalette` (ANSI 0-255
28
+ // indices) instead of pre-converting palette cells to hex. The client
29
+ // resolves them against the host terminal's actual palette (queried via
30
+ // OSC 4 at startup) so user themes (Ghostty, iTerm2, …) show through
31
+ // instead of hardcoded xterm defaults. Cross-version mixing breaks colors
32
+ // either way (pre-v7 TM → new client never sees the indices; new TM →
33
+ // pre-v7 client ignores them and falls back to the theme default), so Min
34
+ // is raised in lockstep to force matching binaries.
35
+ export const MANAGER_PROTOCOL_MIN_VERSION = 7
36
+ export const MANAGER_PROTOCOL_VERSION = 7
22
37
  /**
23
38
  * Minimum version required to send `setBroadcastEnabled`. Older TMs (v3) will
24
39
  * not understand the message; the daemon must check the negotiated version
@@ -68,6 +83,8 @@ export type ManagerRequest =
68
83
  cols: number
69
84
  rows: number
70
85
  cwd?: string
86
+ /** Extra env vars merged into the spawned PTY's environment. */
87
+ env?: Record<string, string>
71
88
  }
72
89
  }
73
90
  | { id: string; type: 'write'; payload: { sessionId: string; tabId: string; data: string } }
@@ -147,12 +164,22 @@ function isStringArray(value: unknown): value is string[] {
147
164
  return Array.isArray(value) && value.every(isString)
148
165
  }
149
166
 
167
+ function isStringRecord(value: unknown): value is Record<string, string> {
168
+ if (!isObjectRecord(value)) return false
169
+ for (const v of Object.values(value)) {
170
+ if (!isString(v)) return false
171
+ }
172
+ return true
173
+ }
174
+
150
175
  function isTerminalSpan(value: unknown): boolean {
151
176
  return (
152
177
  isObjectRecord(value) &&
153
178
  isString(value.text) &&
154
179
  (value.fg === undefined || isString(value.fg)) &&
155
180
  (value.bg === undefined || isString(value.bg)) &&
181
+ (value.fgPalette === undefined || isFiniteNumber(value.fgPalette)) &&
182
+ (value.bgPalette === undefined || isFiniteNumber(value.bgPalette)) &&
156
183
  (value.bold === undefined || typeof value.bold === 'boolean') &&
157
184
  (value.italic === undefined || typeof value.italic === 'boolean') &&
158
185
  (value.underline === undefined || typeof value.underline === 'boolean') &&
@@ -277,6 +304,10 @@ export function parseManagerRequest(value: unknown): ManagerRequest {
277
304
  value.payload.cwd === undefined || isString(value.payload.cwd),
278
305
  'createTab.cwd must be a string'
279
306
  )
307
+ assert(
308
+ value.payload.env === undefined || isStringRecord(value.payload.env),
309
+ 'createTab.env must be a string-keyed string record'
310
+ )
280
311
  return value as ManagerRequest
281
312
  case 'write':
282
313
  assert(isString(value.payload.sessionId), 'write.sessionId must be a string')