@brimveyn/aimux 1.23.6 → 1.23.7

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.23.6",
3
+ "version": "1.23.7",
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",
@@ -10,6 +10,7 @@ import {
10
10
  getAssistantOption,
11
11
  isCommandAvailable,
12
12
  parseCommand,
13
+ stripInjectedSessionArgs,
13
14
  } from '../pty/command-registry'
14
15
  import { createTerminalBounds } from '../state/layout-resize'
15
16
  import {
@@ -116,6 +117,11 @@ export function startTabSession(
116
117
  ctx.startStartupGrace(tab.id, STARTUP_GRACE_MS)
117
118
 
118
119
  const { args, executable } = parseCommand(tab.command)
120
+ // `tab.command` is not always the string this client wrote: a hydrate from the
121
+ // daemon (or a snapshot taken after one) hands back the whole argv of the last
122
+ // spawn, session flags included. Strip ours back out before adding this
123
+ // spawn's, or claude exits on the duplicate and takes the tab with it.
124
+ const baseArgs = stripInjectedSessionArgs(tab.assistant, ctx.state.customCommands, args)
119
125
  // Ahead of `extraArgs` because that is where an initial prompt goes, and the
120
126
  // prompt is positional — a flag after it would be read as part of it.
121
127
  const sessionArgs =
@@ -134,7 +140,7 @@ export function startTabSession(
134
140
  }
135
141
 
136
142
  backend.createSession({
137
- args: [...args, ...sessionArgs, ...(extraArgs ?? [])],
143
+ args: [...baseArgs, ...sessionArgs, ...(extraArgs ?? [])],
138
144
  assistant: tab.assistant,
139
145
  autoRenameCandidate,
140
146
  cols,
@@ -251,6 +251,41 @@ export function buildAssistantSessionArgs(
251
251
  : option.session.buildSessionArgs(sessionId)
252
252
  }
253
253
 
254
+ const SESSION_FLAG = /^(?:-r|--resume|--session-id)$/
255
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
256
+
257
+ /**
258
+ * Drop the session flags an earlier spawn left inside `tab.command`.
259
+ *
260
+ * The daemon stores its tabs as `[command, ...args].join(' ')`, so a client that
261
+ * hydrated from it — or a snapshot written by one — carries this tab's own
262
+ * `--session-id <uuid>` in the string the next spawn parses. Re-spawning that
263
+ * verbatim is `Error: Session ID … is already in use`, and an exiting PTY takes
264
+ * the tab with it.
265
+ *
266
+ * Only a uuid-shaped value is ours, and only when the user's own custom command
267
+ * does not declare that flag — a `--resume` they wrote themselves is theirs to
268
+ * keep, and `buildAssistantSessionArgs` already stands aside for it.
269
+ */
270
+ export function stripInjectedSessionArgs(
271
+ assistant: AssistantId,
272
+ customCommands: Record<string, string>,
273
+ args: string[]
274
+ ): string[] {
275
+ const custom = customCommands[assistant] ?? ''
276
+ if (/(^|\s)(-r|--resume|--session-id|--continue|-c)(\s|$)/.test(custom)) return args
277
+ const kept: string[] = []
278
+ for (let i = 0; i < args.length; i++) {
279
+ const arg = args[i] as string
280
+ if (SESSION_FLAG.test(arg) && UUID.test(args[i + 1] ?? '')) {
281
+ i++
282
+ continue
283
+ }
284
+ kept.push(arg)
285
+ }
286
+ return kept
287
+ }
288
+
254
289
  export function isCommandAvailable(command: string): boolean {
255
290
  return Bun.which(command) !== null
256
291
  }
@@ -106,6 +106,21 @@ function pruneOrphanedTabs(
106
106
  )
107
107
  }
108
108
 
109
+ /**
110
+ * Recover a session id a snapshot lost. Until the client stopped adopting the
111
+ * daemon's tabs wholesale, `sessionId` was wiped on every attach while the
112
+ * daemon's argv echo left the uuid sitting in `command` — so the conversation
113
+ * these tabs own is still recoverable from the very string that broke them.
114
+ *
115
+ * ponytail: a migration, not a mechanism. Delete it once no snapshot in the
116
+ * wild predates the fix.
117
+ */
118
+ function recoverSessionId(command: string): string | undefined {
119
+ return /(?:^|\s)(?:-r|--resume|--session-id)\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:\s|$)/i.exec(
120
+ command
121
+ )?.[1]
122
+ }
123
+
109
124
  export function restoreTabsFromProject(
110
125
  snapshot: ProjectSnapshotV1 | undefined,
111
126
  options: RestoreOptions = {}
@@ -130,7 +145,7 @@ export function restoreTabsFromProject(
130
145
  errorMessage: tab.errorMessage,
131
146
  exitCode: tab.exitCode,
132
147
  id: tab.id,
133
- sessionId: tab.sessionId,
148
+ sessionId: tab.sessionId ?? recoverSessionId(tab.command),
134
149
  status: forceDisconnected ? getDisconnectedStatus(tab.status) : tab.status,
135
150
  terminalModes: tab.terminalModes,
136
151
  title: tab.title,
@@ -357,7 +357,18 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
357
357
  state.currentProjectId != null && state.currentProjectId !== ''
358
358
  ? state.projects.find((s) => s.id === state.currentProjectId)
359
359
  : undefined
360
- const visibleForWorkspace = filterTabsForActiveWorkspace(action.tabs, currentProject)
360
+ // The daemon rebuilds `command` as `[command, ...args].join(' ')` and has
361
+ // no `sessionId` on the wire at all. Adopting its tabs wholesale bakes this
362
+ // spawn's `--session-id <uuid>` into the string the *next* spawn parses —
363
+ // claude exits on "Session ID … is already in use" and takes the tab with
364
+ // it — and drops the id the resume depends on. Both fields belong to the
365
+ // client, so keep what it already holds for the tabs it knows.
366
+ const ownedById = new Map(state.tabs.map((entry) => [entry.id, entry]))
367
+ const hydratedTabs = action.tabs.map((entry) => {
368
+ const owned = ownedById.get(entry.id)
369
+ return owned ? { ...entry, command: owned.command, sessionId: owned.sessionId } : entry
370
+ })
371
+ const visibleForWorkspace = filterTabsForActiveWorkspace(hydratedTabs, currentProject)
361
372
  const visibleIds = new Set(visibleForWorkspace.map((t) => t.id))
362
373
  const hydratedActiveTabId =
363
374
  action.activeTabId != null &&
@@ -365,7 +376,7 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
365
376
  visibleIds.has(action.activeTabId)
366
377
  ? action.activeTabId
367
378
  : (visibleForWorkspace[0]?.id ?? null)
368
- const tabIds = new Set(action.tabs.map((t) => t.id))
379
+ const tabIds = new Set(hydratedTabs.map((t) => t.id))
369
380
 
370
381
  // Restore from new multi-tree format or migrate from legacy single tree
371
382
  const hydratedTrees: Record<string, LayoutNode> = {}
@@ -402,7 +413,7 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
402
413
  focusMode: 'navigation',
403
414
  layoutTrees: hydratedTrees,
404
415
  tabGroupMap: hydratedGroupMap,
405
- tabs: normalizeGroupedTabOrder(action.tabs, hydratedTrees, hydratedGroupMap),
416
+ tabs: normalizeGroupedTabOrder(hydratedTabs, hydratedTrees, hydratedGroupMap),
406
417
  },
407
418
  hydratedActiveTabId,
408
419
  { onlyIfMissing: true }
@@ -291,9 +291,14 @@ export const PrChecksPanel = memo(function PrChecksPanel({
291
291
  No checks
292
292
  </text>
293
293
  ) : (
294
- checks.map((check) => (
294
+ // GitHub can list the same workflow/name twice (a job that ran on
295
+ // both `push` and `pull_request`), so the pair is not a unique key
296
+ // and the duplicate crashes the reconciler. The rows hold no state
297
+ // and the list is the API's own order, so the index is the key.
298
+ checks.map((check, i) => (
295
299
  <CheckRow
296
- key={`${check.workflow}/${check.name}`}
300
+ // oxlint-disable-next-line no-array-index-key
301
+ key={`${i}:${check.workflow}/${check.name}`}
297
302
  bg={bg}
298
303
  check={check}
299
304
  showWorkflow={innerWidth >= WORKFLOW_MIN_WIDTH}