@brimveyn/aimux 1.23.5 → 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.5",
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",
@@ -66,7 +66,7 @@
66
66
  "bump:terminal_manager": "bun run scripts/bump-protocol.ts terminal-manager"
67
67
  },
68
68
  "dependencies": {
69
- "@brimveyn/aimux-config": "0.10.6",
69
+ "@brimveyn/aimux-config": "0.10.7",
70
70
  "@opentui/core": "^0.1.90",
71
71
  "@opentui/react": "^0.1.90",
72
72
  "@resvg/resvg-wasm": "^2.6.2",
@@ -65,6 +65,7 @@ import {
65
65
  startExistingTab,
66
66
  } from './tab-actions'
67
67
  import {
68
+ hibernateWorkspace,
68
69
  isForceableWorkspaceDeleteError,
69
70
  runDeleteWorkspace,
70
71
  runMoveWorkspace,
@@ -390,6 +391,9 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
390
391
  backend.disposeSession(effect.tabId)
391
392
  return
392
393
  }
394
+ case 'hibernate-workspace':
395
+ hibernateWorkspace(ctx, effect.workspaceId)
396
+ return
393
397
  case 'restart-tab':
394
398
  restartTabSession(
395
399
  backend,
@@ -5,10 +5,12 @@ import { logInputDebug } from '../debug/input-log'
5
5
  import { createPrefixedId } from '../platform/id'
6
6
  import {
7
7
  assistantAcceptsPromptArg,
8
+ buildAssistantSessionArgs,
8
9
  getAllAssistantOptions,
9
10
  getAssistantOption,
10
11
  isCommandAvailable,
11
12
  parseCommand,
13
+ stripInjectedSessionArgs,
12
14
  } from '../pty/command-registry'
13
15
  import { createTerminalBounds } from '../state/layout-resize'
14
16
  import {
@@ -65,6 +67,10 @@ export function createTabSession(
65
67
  buffer: '',
66
68
  command: customCommand ?? option.command,
67
69
  id: createTabId(),
70
+ // Minted up front, before the CLI has ever run, so the very first spawn can
71
+ // claim it. Only for assistants that can be told their session id — a shell
72
+ // tab would just carry a uuid nothing reads.
73
+ sessionId: option.session ? crypto.randomUUID() : undefined,
68
74
  status: 'starting',
69
75
  terminalModes: createDefaultTerminalModes(),
70
76
  title: option.label,
@@ -75,7 +81,7 @@ export function createTabSession(
75
81
  /** Everything `startTabSession` needs from the side-effect context. */
76
82
  type StartTabSessionContext = Pick<
77
83
  SideEffectContext,
78
- 'backend' | 'clearStartupGrace' | 'dispatch' | 'startStartupGrace'
84
+ 'backend' | 'clearStartupGrace' | 'dispatch' | 'startStartupGrace' | 'state'
79
85
  >
80
86
 
81
87
  export interface StartTabSessionOptions {
@@ -95,7 +101,7 @@ export interface StartTabSessionOptions {
95
101
 
96
102
  export function startTabSession(
97
103
  ctx: StartTabSessionContext,
98
- tab: Pick<TabSession, 'id' | 'assistant' | 'title' | 'command' | 'workspaceId'>,
104
+ tab: Pick<TabSession, 'id' | 'assistant' | 'title' | 'command' | 'sessionId' | 'workspaceId'>,
99
105
  { autoRenameCandidate = true, cols, cwd, extraArgs, rows }: StartTabSessionOptions
100
106
  ): void {
101
107
  const { backend, clearStartupGrace, dispatch } = ctx
@@ -111,6 +117,17 @@ export function startTabSession(
111
117
  ctx.startStartupGrace(tab.id, STARTUP_GRACE_MS)
112
118
 
113
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)
125
+ // Ahead of `extraArgs` because that is where an initial prompt goes, and the
126
+ // prompt is positional — a flag after it would be read as part of it.
127
+ const sessionArgs =
128
+ tab.sessionId == null
129
+ ? []
130
+ : buildAssistantSessionArgs(tab.assistant, ctx.state.customCommands, tab.sessionId)
114
131
 
115
132
  if (!isCommandAvailable(executable)) {
116
133
  clearStartupGrace(tab.id)
@@ -123,7 +140,7 @@ export function startTabSession(
123
140
  }
124
141
 
125
142
  backend.createSession({
126
- args: extraArgs ? [...args, ...extraArgs] : args,
143
+ args: [...baseArgs, ...sessionArgs, ...(extraArgs ?? [])],
127
144
  assistant: tab.assistant,
128
145
  autoRenameCandidate,
129
146
  cols,
@@ -214,6 +214,50 @@ function disposeWorkspaceTabs(ctx: SideEffectContext, workspaceId: string): void
214
214
  }
215
215
  }
216
216
 
217
+ /**
218
+ * Put every idle assistant tab in a workspace to sleep: kill the PTY, keep the
219
+ * frozen screen, resume on focus. The RAM a workspace holds is its assistant
220
+ * processes — several hundred MB each — and none of it is aimux's own.
221
+ *
222
+ * A tab mid-turn is never touched. The lot is deliberately partial rather than
223
+ * all-or-nothing: one busy tab should not veto the other three, and the toast
224
+ * says what stayed up so the skip is never silent.
225
+ *
226
+ * ponytail: `activity` comes from reading the assistant's screen, so it sees a
227
+ * turn in progress but not a background task the assistant spawned. Manual-only
228
+ * for exactly that reason — you know what you left running; a timer would not.
229
+ */
230
+ export function hibernateWorkspace(ctx: SideEffectContext, workspaceId: string): void {
231
+ const tabs = ctx.state.tabs.filter(
232
+ (tab) => tab.workspaceId === workspaceId && tab.role !== 'setup'
233
+ )
234
+ const sleepable = tabs.filter(
235
+ (tab) => (tab.status === 'running' || tab.status === 'starting') && tab.activity === 'idle'
236
+ )
237
+ const busy = tabs.filter(
238
+ (tab) => (tab.status === 'running' || tab.status === 'starting') && tab.activity !== 'idle'
239
+ )
240
+
241
+ for (const tab of sleepable) {
242
+ ctx.clearIdleTimer(tab.id)
243
+ ctx.clearStartupGrace(tab.id)
244
+ ctx.backend.disposeSession(tab.id)
245
+ ctx.dispatch({ tabId: tab.id, type: 'hibernate-tab' })
246
+ }
247
+
248
+ if (sleepable.length === 0) {
249
+ toast.info(
250
+ busy.length > 0 ? `Nothing to hibernate — ${busy.length} tab(s) busy` : 'Nothing to hibernate'
251
+ )
252
+ return
253
+ }
254
+ toast.success(
255
+ busy.length > 0
256
+ ? `Hibernated ${sleepable.length} tab(s), skipped ${busy.length} busy`
257
+ : `Hibernated ${sleepable.length} tab(s)`
258
+ )
259
+ }
260
+
217
261
  export async function runDeleteWorkspace(
218
262
  ctx: SideEffectContext,
219
263
  projectId: string,
@@ -415,7 +459,11 @@ export function removeWorkspaceRecordFromProject(
415
459
  }
416
460
 
417
461
  export function isForceableWorkspaceDeleteError(message: string): boolean {
418
- return /active assistant tabs|dirty|uncommitted|modified|untracked|not clean|contains.*changes/i.test(
462
+ // `force delete` is the marker `removeGitWorktree` appends to every refusal
463
+ // git raises: matching git's own wording never worked, since a broken worktree
464
+ // link fatals differently ("is not a working tree", "validation failed", …)
465
+ // depending on which part of the link broke.
466
+ return /active assistant tabs|dirty|uncommitted|modified|untracked|not clean|contains.*changes|force delete/i.test(
419
467
  message
420
468
  )
421
469
  }
package/src/app.tsx CHANGED
@@ -49,7 +49,11 @@ import { ALL_SETTING_ROWS } from './settings/sections'
49
49
  import { hydrateSettings } from './settings/settings-store'
50
50
  import { aiUsageStore } from './state/ai-usage-store'
51
51
  import { appStore, useAppStore } from './state/app-store'
52
- import { setActiveDispatch, setActiveSideEffectRunner } from './state/dispatch-ref'
52
+ import {
53
+ runSideEffectGlobal,
54
+ setActiveDispatch,
55
+ setActiveSideEffectRunner,
56
+ } from './state/dispatch-ref'
53
57
  import { findMostRecentProject, loadProjectCatalog } from './state/project-catalog'
54
58
  import { getActiveWorkspacePath } from './state/project-workspaces'
55
59
  import { loadSnippetCatalog, mergeConfigSnippets } from './state/snippet-catalog'
@@ -484,6 +488,19 @@ export function App({
484
488
 
485
489
  useSetupRunner(state, sideEffectCtx)
486
490
 
491
+ // Waking is the counterpart of hibernating, and focus is the only signal it
492
+ // needs: you looked at the tab, so you want it back. Keyed on the id as well
493
+ // as the flag so switching between two sleeping tabs wakes the second one.
494
+ // `restart-tab` already does the whole job — dispose, reset, respawn — and the
495
+ // respawn now carries `--resume`, so the conversation comes back with it.
496
+ const wakeTabRef = useRef(activeTab)
497
+ wakeTabRef.current = activeTab
498
+ useEffect(() => {
499
+ const tab = wakeTabRef.current
500
+ if (tab?.hibernated !== true) return
501
+ runSideEffectGlobal({ tab, type: 'restart-tab' })
502
+ }, [activeTab?.id, activeTab?.hibernated])
503
+
487
504
  function processKeyResult(result: KeyResult, modeId: ModeId): void {
488
505
  for (const action of result.actions) {
489
506
  dispatch(action)
@@ -1,5 +1,6 @@
1
1
  import { $ } from 'bun'
2
2
  import { existsSync } from 'node:fs'
3
+ import { rm } from 'node:fs/promises'
3
4
 
4
5
  import { isInsideAimuxWorktreeRoot } from '../platform/worktree-paths'
5
6
 
@@ -227,7 +228,22 @@ export async function removeGitWorktree({
227
228
  await $`git -C ${repoPath} worktree prune`.quiet().nothrow()
228
229
  return
229
230
  }
230
- throw new Error(result.stderr.toString().trim() || 'failed to remove git worktree')
231
+ // git refuses on states it cannot repair — a half-finished removal or a moved
232
+ // repo leaves a directory git no longer links to ("is not a working tree"),
233
+ // and no retry ever fixes it, so the row was undeletable forever. Force means
234
+ // "delete it regardless": finish the job ourselves. The path guard above
235
+ // already pinned the target inside the Aimux worktree root.
236
+ if (force) {
237
+ await rm(targetPath, { force: true, recursive: true })
238
+ await $`git -C ${repoPath} worktree prune`.quiet().nothrow()
239
+ return
240
+ }
241
+ // Every refusal git can raise here is force-recoverable — the directory lives
242
+ // under the Aimux worktree root and nothing else owns it — so the message
243
+ // carries the offer, and `isForceableWorkspaceDeleteError` keys off that
244
+ // phrase instead of trying to enumerate git's wording.
245
+ const stderr = result.stderr.toString().trim() || 'failed to remove git worktree'
246
+ throw new Error(`${stderr} — force delete to remove it anyway`)
231
247
  }
232
248
 
233
249
  export async function listGitWorktrees(repoPath: string): Promise<GitWorktreeInfo[]> {
@@ -108,6 +108,7 @@ export type SideEffect =
108
108
  keepConflicts?: boolean
109
109
  }
110
110
  | { type: 'load-workspace-move-stats' }
111
+ | { type: 'hibernate-workspace'; workspaceId: string }
111
112
  | { type: 'toggle-transparent' }
112
113
  | { type: 'toggle-mode' }
113
114
  | { type: 'open-file-in-editor'; path: string }
@@ -1,4 +1,5 @@
1
- import { basename } from 'node:path'
1
+ import { homedir } from 'node:os'
2
+ import { basename, join } from 'node:path'
2
3
 
3
4
  import type { AssistantId } from '../state/types'
4
5
 
@@ -18,12 +19,41 @@ export interface AssistantModelSpec {
18
19
  buildEffortArgs?: (effort: string) => string[]
19
20
  }
20
21
 
22
+ /**
23
+ * How an assistant names a resumable conversation on the command line. Same
24
+ * philosophy as `AssistantModelSpec`: the vendor's flag syntax lives on the
25
+ * assistant definition, not in a resolver.
26
+ *
27
+ * Two builders because the two directions are different flags, not one flag
28
+ * with two meanings — `claude --session-id <uuid>` *claims* an id for a new
29
+ * conversation and errors with "Session ID … is already in use" if it exists,
30
+ * while `claude --resume <uuid>` refuses an id that does not. An assistant that
31
+ * can resume but cannot be told its id up front (codex: `codex resume <id>`,
32
+ * with no way to fix the id at spawn) has no entry here — its id would have to
33
+ * be discovered after the fact, which is a different mechanism.
34
+ */
35
+ export interface AssistantSessionSpec {
36
+ /** Args that pin a fresh conversation to `sessionId`. */
37
+ buildSessionArgs: (sessionId: string) => string[]
38
+ /** Args that reopen the conversation `sessionId` names. */
39
+ buildResumeArgs: (sessionId: string) => string[]
40
+ /**
41
+ * Whether the vendor has a stored conversation under this id. Asking the
42
+ * filesystem is what lets one spawn path serve both directions: an id with a
43
+ * transcript resumes, an id without one is claimed fresh. Without it, a tab
44
+ * you opened and never typed into would resume into "No conversation found"
45
+ * and exit — and an exiting PTY takes the tab with it.
46
+ */
47
+ hasConversation: (sessionId: string) => boolean
48
+ }
49
+
21
50
  export interface AssistantOption {
22
51
  id: AssistantId
23
52
  label: string
24
53
  command: string
25
54
  description: string
26
55
  model?: AssistantModelSpec
56
+ session?: AssistantSessionSpec
27
57
  /**
28
58
  * The CLI starts an interactive session with a positional prompt argument
29
59
  * (`claude "…"`, `codex "…"`). When it does, handing the prompt over at spawn
@@ -41,6 +71,23 @@ export interface AssistantOption {
41
71
  acceptsPromptArg?: boolean
42
72
  }
43
73
 
74
+ /**
75
+ * A machine that has never run `claude` has no `~/.claude/projects`, and
76
+ * `scanSync` reports a missing cwd by throwing ENOENT rather than yielding
77
+ * nothing. "No directory" and "directory without this transcript" are the same
78
+ * answer here — there is no conversation to resume — so the throw is folded back
79
+ * into the false it should have been. Without this, the very first assistant tab
80
+ * on a fresh install dies at spawn.
81
+ */
82
+ function hasClaudeTranscript(sessionId: string): boolean {
83
+ const cwd = join(homedir(), '.claude', 'projects')
84
+ try {
85
+ return new Bun.Glob(`*/${sessionId}.jsonl`).scanSync({ cwd }).next().done !== true
86
+ } catch {
87
+ return false
88
+ }
89
+ }
90
+
44
91
  const DEFAULT_SHELL =
45
92
  process.env.SHELL != null && process.env.SHELL !== '' ? process.env.SHELL : 'sh'
46
93
  const SHELL_NAME = DEFAULT_SHELL.split('/').pop() ?? 'shell'
@@ -56,6 +103,14 @@ export const ASSISTANT_OPTIONS: AssistantOption[] = [
56
103
  buildEffortArgs: (effort) => ['--effort', effort],
57
104
  buildModelArgs: (model) => ['--model', model],
58
105
  },
106
+ session: {
107
+ buildResumeArgs: (sessionId) => ['--resume', sessionId],
108
+ buildSessionArgs: (sessionId) => ['--session-id', sessionId],
109
+ // `~/.claude/projects/<cwd-slug>/<uuid>.jsonl`. Globbing the slug away
110
+ // beats deriving it: the uuid alone is unique, so we never have to model
111
+ // how the vendor mangles a path into a directory name.
112
+ hasConversation: (sessionId) => hasClaudeTranscript(sessionId),
113
+ },
59
114
  },
60
115
  {
61
116
  acceptsPromptArg: true,
@@ -149,11 +204,88 @@ export function assistantAcceptsPromptArg(
149
204
  ): boolean {
150
205
  const option = getAllAssistantOptions(customCommands).find((entry) => entry.id === assistant)
151
206
  if (option?.acceptsPromptArg !== true) return false
152
- const custom = customCommands[assistant]
207
+ return runsVendorProgram(option, customCommands)
208
+ }
209
+
210
+ /**
211
+ * Whether the configured command still runs the program the assistant's flags
212
+ * were written for. Extra flags are fine; a wrapper script is not, because any
213
+ * flag we append lands on the wrapper rather than the vendor CLI.
214
+ */
215
+ function runsVendorProgram(
216
+ option: AssistantOption,
217
+ customCommands: Record<string, string>
218
+ ): boolean {
219
+ const custom = customCommands[option.id]
153
220
  if (custom == null || custom === '') return true
154
221
  return basename(parseCommand(custom).executable) === option.command
155
222
  }
156
223
 
224
+ /**
225
+ * The args that tie a spawn to `sessionId` — resume when the vendor already has
226
+ * a conversation under that id, claim it otherwise.
227
+ *
228
+ * One function for both directions on purpose: every spawn site (new tab,
229
+ * split, Ctrl+r, waking a hibernated tab) wants the same thing — "be this
230
+ * conversation" — and the filesystem, not the call site, is what knows whether
231
+ * that conversation exists yet. A caller that had to pick would get it wrong
232
+ * exactly once: on the tab that was opened and never typed into.
233
+ *
234
+ * Empty for an assistant with no session support, and for a custom command
235
+ * that already bakes in its own session flags — doubling `--resume` is an error
236
+ * the vendor would report at spawn, long after the useful stack is gone.
237
+ */
238
+ export function buildAssistantSessionArgs(
239
+ assistant: AssistantId,
240
+ customCommands: Record<string, string>,
241
+ sessionId: string
242
+ ): string[] {
243
+ if (sessionId === '') return []
244
+ const option = getAllAssistantOptions(customCommands).find((entry) => entry.id === assistant)
245
+ if (!option?.session) return []
246
+ if (!runsVendorProgram(option, customCommands)) return []
247
+ const custom = customCommands[assistant] ?? ''
248
+ if (/(^|\s)(-r|--resume|--session-id|--continue|-c)(\s|$)/.test(custom)) return []
249
+ return option.session.hasConversation(sessionId)
250
+ ? option.session.buildResumeArgs(sessionId)
251
+ : option.session.buildSessionArgs(sessionId)
252
+ }
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
+
157
289
  export function isCommandAvailable(command: string): boolean {
158
290
  return Bun.which(command) !== null
159
291
  }
@@ -153,6 +153,7 @@ export type TabAction =
153
153
  | { type: 'reorder-active-tab'; delta: number }
154
154
  | { type: 'reorder-tabs'; orderedTabIds: string[] }
155
155
  | { type: 'reset-tab-project'; tabId: string }
156
+ | { type: 'hibernate-tab'; tabId: string }
156
157
  | {
157
158
  type: 'rename-tab'
158
159
  tabId: string
@@ -62,6 +62,7 @@ export function serializeProject(state: AppState): ProjectSnapshotV1 {
62
62
  errorMessage: tab.errorMessage,
63
63
  exitCode: tab.exitCode,
64
64
  id: tab.id,
65
+ sessionId: tab.sessionId,
65
66
  status: tab.status === 'disconnected' ? 'running' : tab.status,
66
67
  terminalModes: tab.terminalModes,
67
68
  title: tab.title,
@@ -105,6 +106,21 @@ function pruneOrphanedTabs(
105
106
  )
106
107
  }
107
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
+
108
124
  export function restoreTabsFromProject(
109
125
  snapshot: ProjectSnapshotV1 | undefined,
110
126
  options: RestoreOptions = {}
@@ -129,6 +145,7 @@ export function restoreTabsFromProject(
129
145
  errorMessage: tab.errorMessage,
130
146
  exitCode: tab.exitCode,
131
147
  id: tab.id,
148
+ sessionId: tab.sessionId ?? recoverSessionId(tab.command),
132
149
  status: forceDisconnected ? getDisconnectedStatus(tab.status) : tab.status,
133
150
  terminalModes: tab.terminalModes,
134
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 }
@@ -531,6 +542,7 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
531
542
  buffer: '',
532
543
  errorMessage: undefined,
533
544
  exitCode: undefined,
545
+ hibernated: undefined,
534
546
  status: 'starting',
535
547
  terminalModes: createDefaultTerminalModes(),
536
548
  viewport: undefined,
@@ -538,6 +550,20 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
538
550
  },
539
551
  action.tabId
540
552
  )
553
+ case 'hibernate-tab':
554
+ // Deliberately keeps `buffer` and `viewport`: the frozen screen is the
555
+ // whole point — you should still see what the assistant last said. That is
556
+ // the one thing separating this from `reset-tab-project`, which wipes both
557
+ // because it is about to draw a live PTY over them.
558
+ return {
559
+ ...state,
560
+ tabs: updateTab(state.tabs, action.tabId, (tab) => ({
561
+ ...tab,
562
+ activity: 'idle',
563
+ hibernated: true,
564
+ status: 'disconnected',
565
+ })),
566
+ }
541
567
  case 'append-tab-buffer':
542
568
  return {
543
569
  ...state,
@@ -151,6 +151,8 @@ export interface PersistedTabSnapshot {
151
151
  assistant: AssistantId
152
152
  title: string
153
153
  command: string
154
+ /** Optional and additive: an older build ignores it and spawns fresh. */
155
+ sessionId?: string
154
156
  status: Exclude<LegacyPersistedTabStatus, 'disconnected'>
155
157
  buffer: string
156
158
  viewport?: TerminalSnapshot
@@ -246,6 +248,25 @@ export interface TabSession {
246
248
  viewport?: TerminalSnapshot
247
249
  terminalModes: TerminalModeState
248
250
  command: string
251
+ /**
252
+ * Names the assistant conversation this tab owns, so a respawn reopens it
253
+ * instead of starting over. Minted when the tab is created and handed to the
254
+ * CLI at spawn — we pick the id rather than discover it afterwards, because
255
+ * discovery cannot tell two tabs sharing a cwd apart.
256
+ *
257
+ * Absent on tabs created before this existed, and on assistants with no
258
+ * session support; both spawn exactly as they always did.
259
+ */
260
+ sessionId?: string
261
+ /**
262
+ * Deliberately put to sleep: the PTY is gone, the frozen viewport stays, and
263
+ * focusing the tab resumes it. Distinct from the `disconnected` it rides on —
264
+ * that also means "restored from a snapshot after a restart" — and only used
265
+ * to tell the two apart in the sidebar glyph, the pane overlay, and the wake.
266
+ * Ephemeral — never persisted, never on the wire: a restart turns every tab
267
+ * disconnected anyway, and Ctrl+r resumes them just the same.
268
+ */
269
+ hibernated?: boolean
249
270
  errorMessage?: string
250
271
  exitCode?: number
251
272
  workspaceId?: string
@@ -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}
@@ -62,6 +62,10 @@ export const WorkspaceRow = memo(function WorkspaceRow({
62
62
  // Only the working case animates, so the timer is off for every other row —
63
63
  // and off entirely when a sprite is drawing this row instead.
64
64
  const spinner = useBusySpinner(activity.working && sprite === null)
65
+ // A primitive, so the selector stays referentially stable across renders.
66
+ const hasSleepingTabs = useAppStore((s) =>
67
+ s.tabs.some((tab) => tab.workspaceId === workspace.id && tab.hibernated === true)
68
+ )
65
69
 
66
70
  const handleMouseDown = useCallback(
67
71
  (event: OtuiMouseEvent) => {
@@ -102,6 +106,10 @@ export const WorkspaceRow = memo(function WorkspaceRow({
102
106
  }),
103
107
  ],
104
108
  ]
109
+ entries.push([
110
+ 'Hibernate',
111
+ () => runSideEffectGlobal({ type: 'hibernate-workspace', workspaceId: workspace.id }),
112
+ ])
105
113
  if (workspace.source !== 'primary') {
106
114
  entries.push([
107
115
  'Remove workspace',
@@ -184,6 +192,12 @@ export const WorkspaceRow = memo(function WorkspaceRow({
184
192
  } else if (activity.done) {
185
193
  statusGlyph = '● '
186
194
  statusColor = t.success
195
+ } else if (hasSleepingTabs) {
196
+ // Last, because anything else is news and this is the absence of it. Without
197
+ // a marker a hibernated workspace is indistinguishable from an idle one, and
198
+ // you would not know which rows still hold a running assistant.
199
+ statusGlyph = 'z '
200
+ statusColor = t.textMuted
187
201
  }
188
202
 
189
203
  return (
@@ -573,7 +573,11 @@ export function TerminalPane({
573
573
  // re-introducing the shifted-content / dead-row bug.
574
574
  <box position="absolute" bottom={0} left={0} backgroundColor={editorBg}>
575
575
  {tab?.status === 'disconnected' ? (
576
- <text fg={t.warning}>Restored snapshot. Press Ctrl+r to restart this project.</text>
576
+ <text fg={t.warning}>
577
+ {tab.hibernated === true
578
+ ? 'Hibernated. Resuming…'
579
+ : 'Restored snapshot. Press Ctrl+r to restart this tab.'}
580
+ </text>
577
581
  ) : null}
578
582
  {tab?.errorMessage != null && tab?.errorMessage !== '' ? (
579
583
  <text fg={t.error}>{tab.errorMessage}</text>