@brimveyn/aimux 1.22.4 → 1.22.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.22.4",
3
+ "version": "1.22.5",
4
4
  "description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode, Kimi side-by-side with tabbed navigation, split panes, and persistent sessions.",
5
5
  "keywords": [
6
6
  "ai",
@@ -206,7 +206,7 @@ export async function removeGitWorktree({
206
206
  force: boolean
207
207
  }): Promise<void> {
208
208
  if (!isInsideAimuxWorktreeRoot(targetPath)) {
209
- throw new Error(`refusing to delete worktree outside Aimux temp root: ${targetPath}`)
209
+ throw new Error(`refusing to delete worktree outside Aimux worktree root: ${targetPath}`)
210
210
  }
211
211
  const result = force
212
212
  ? await $`git -C ${repoPath} worktree remove --force ${targetPath}`.quiet().nothrow()
@@ -141,6 +141,7 @@ function findPlayer(platform: string): string | null {
141
141
  }
142
142
 
143
143
  let lastPlayedAt = 0
144
+ let playing: { kill: () => void } | null = null
144
145
 
145
146
  /**
146
147
  * Whether enough time has passed since the last sound. Separate from the play
@@ -151,16 +152,17 @@ export function shouldPlayNow(now: number, previous: number): boolean {
151
152
  }
152
153
 
153
154
  /**
154
- * Play a sound file. Returns whether a player was actually spawned, which is
155
- * what the settings screen's "Test sound" row reports.
155
+ * Play a sound file. Returns false only when nothing could play at all — a
156
+ * missing player or a failed spawn — which is what the settings screen's "Test
157
+ * sound" row reports. A throttled call is not a failure.
158
+ *
159
+ * The throttle covers every caller, the test row included: held down, its key
160
+ * repeats tens of times a second, and one live player per press is enough to
161
+ * take CoreAudio down with it.
156
162
  */
157
- export function playSoundFile(
158
- path: string,
159
- options?: { ignoreThrottle?: boolean; volume?: number }
160
- ): boolean {
163
+ export function playSoundFile(path: string, options?: { volume?: number }): boolean {
161
164
  const now = Date.now()
162
- const throttled = options?.ignoreThrottle !== true
163
- if (throttled && !shouldPlayNow(now, lastPlayedAt)) return false
165
+ if (!shouldPlayNow(now, lastPlayedAt)) return true
164
166
  const platform = process.platform
165
167
  const bin = findPlayer(platform)
166
168
  if (bin == null) {
@@ -169,10 +171,11 @@ export function playSoundFile(
169
171
  }
170
172
  const argv = soundPlayerArgv(platform, bin, path, options?.volume ?? DEFAULT_VOLUME)
171
173
  try {
172
- Bun.spawn(argv, { stderr: 'ignore', stdin: 'ignore', stdout: 'ignore' })
173
- // A test press does not start the window: it would swallow a real
174
- // notification arriving in the next few hundred milliseconds.
175
- if (throttled) lastPlayedAt = now
174
+ // Cut the previous one rather than layer on top of it. Players come and go
175
+ // on their own, so killing an already-exited one has to be harmless.
176
+ playing?.kill()
177
+ playing = Bun.spawn(argv, { stderr: 'ignore', stdin: 'ignore', stdout: 'ignore' })
178
+ lastPlayedAt = now
176
179
  return true
177
180
  } catch (error) {
178
181
  logDebug('platform.playSound.error', {
@@ -2,12 +2,23 @@ import { createHash } from 'node:crypto'
2
2
  import { lstat, mkdir, realpath, rmdir } from 'node:fs/promises'
3
3
  import { join, resolve } from 'node:path'
4
4
 
5
- const DEFAULT_WORKTREE_ROOT = '/tmp/aimux-wt'
5
+ // Worktrees hold uncommitted work, so they live in the XDG data dir, not /tmp:
6
+ // a reboot clears /tmp on macOS and on most Linux distros, and took the work
7
+ // with it. The old root stays recognized (never generated) so worktrees created
8
+ // before this change are still classified and deleted as aimux-managed.
9
+ const LEGACY_WORKTREE_ROOT = '/tmp/aimux-wt'
6
10
  const MAX_SLUG_LENGTH = 24
7
11
 
12
+ function defaultWorktreeRoot(): string {
13
+ const xdgData = process.env.XDG_DATA_HOME
14
+ const base =
15
+ xdgData != null && xdgData !== '' ? xdgData : join(process.env.HOME ?? '.', '.local', 'share')
16
+ return join(base, 'aimux', 'worktrees')
17
+ }
18
+
8
19
  export function getAimuxWorktreeRoot(): string {
9
20
  const root = process.env.AIMUX_WORKTREE_ROOT
10
- return root != null && root !== '' ? root : DEFAULT_WORKTREE_ROOT
21
+ return root != null && root !== '' ? root : defaultWorktreeRoot()
11
22
  }
12
23
 
13
24
  export function sanitizePathSegment(input: string, maxLength = MAX_SLUG_LENGTH): string {
@@ -42,9 +53,10 @@ export function makeWorktreePath({
42
53
 
43
54
  export function isInsideAimuxWorktreeRoot(path: string): boolean {
44
55
  const normalizeTmp = (value: string) => value.replace(/^\/private\/tmp(?=\/|$)/, '/tmp')
45
- const root = `${normalizeTmp(resolve(getAimuxWorktreeRoot()))}/`
46
56
  const target = `${normalizeTmp(resolve(path))}/`
47
- return target.startsWith(root)
57
+ return [getAimuxWorktreeRoot(), LEGACY_WORKTREE_ROOT].some((root) =>
58
+ target.startsWith(`${normalizeTmp(resolve(root))}/`)
59
+ )
48
60
  }
49
61
 
50
62
  export async function ensureAimuxWorktreeRoot(): Promise<string> {
@@ -80,13 +92,13 @@ export async function pruneEmptyWorktreeParent(worktreePath: string): Promise<vo
80
92
  export async function assertSafeAimuxWorktreePath(path: string): Promise<void> {
81
93
  const root = await ensureAimuxWorktreeRoot()
82
94
  if (!isInsideAimuxWorktreeRoot(path)) {
83
- throw new Error(`refusing worktree path outside Aimux temp root: ${path}`)
95
+ throw new Error(`refusing worktree path outside Aimux worktree root: ${path}`)
84
96
  }
85
97
  // Create the repo-scoped parent (<root>/r-<hash>) before resolving it: git
86
98
  // worktree add does not create intermediate dirs, and realpath() would throw
87
99
  // ENOENT on the first worktree for a repo. mkdir(recursive) leaves an
88
100
  // existing symlink in place, so the realpath check below still catches an
89
- // escape out of the temp root.
101
+ // escape out of the worktree root.
90
102
  const parent = resolve(path, '..')
91
103
  await mkdir(parent, { recursive: true })
92
104
  const realRoot = await realpath(root)
@@ -45,10 +45,10 @@ function selectedVolume(): number {
45
45
  * user has set the row to `off`, which is what makes it safe to call on every
46
46
  * status edge.
47
47
  */
48
- export function playNotificationSound(options?: { ignoreThrottle?: boolean }): boolean {
48
+ export function playNotificationSound(): boolean {
49
49
  const path = resolveSoundPath(selectedSoundId())
50
50
  if (path == null) return false
51
- return playSoundFile(path, { ...options, volume: selectedVolume() })
51
+ return playSoundFile(path, { volume: selectedVolume() })
52
52
  }
53
53
 
54
54
  export const NOTIFICATIONS_SECTION: SettingSection = {
@@ -89,7 +89,7 @@ export const NOTIFICATIONS_SECTION: SettingSection = {
89
89
  toast.info('Notification sound is off')
90
90
  return
91
91
  }
92
- if (!playNotificationSound({ ignoreThrottle: true })) {
92
+ if (!playNotificationSound()) {
93
93
  toast.error('Could not play that sound — no audio player found')
94
94
  }
95
95
  },