@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 +2 -2
- package/src/app-runtime/side-effects.ts +4 -0
- package/src/app-runtime/tab-actions.ts +20 -3
- package/src/app-runtime/workspace-actions.ts +49 -1
- package/src/app.tsx +18 -1
- package/src/git/worktree.ts +17 -1
- package/src/input/modes/types.ts +1 -0
- package/src/pty/command-registry.ts +134 -2
- package/src/state/actions.ts +1 -0
- package/src/state/project-persistence.ts +17 -0
- package/src/state/reducers/tab-state.ts +29 -3
- package/src/state/types.ts +21 -0
- package/src/ui/components/git/pane/pr-checks-panel.tsx +7 -2
- package/src/ui/components/layout/sidebar/workspace-row.tsx +14 -0
- package/src/ui/components/layout/terminal-pane.tsx +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brimveyn/aimux",
|
|
3
|
-
"version": "1.23.
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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 {
|
|
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)
|
package/src/git/worktree.ts
CHANGED
|
@@ -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
|
-
|
|
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[]> {
|
package/src/input/modes/types.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
}
|
package/src/state/actions.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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(
|
|
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,
|
package/src/state/types.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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}>
|
|
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>
|