@brimveyn/aimux 1.19.3 → 1.19.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/README.md +1 -1
- package/package.json +2 -2
- package/src/auto-commit/headless-commands.ts +14 -2
- package/src/cli/commands/tab/await-turn.ts +159 -0
- package/src/cli/commands/tab/await.ts +97 -0
- package/src/cli/commands/tab/create.ts +108 -17
- package/src/cli/commands/tab/prompt-io.ts +25 -0
- package/src/cli/commands/tab/run.ts +16 -147
- package/src/cli/commands/tab/send.ts +27 -1
- package/src/cli/commands/worktree/create-core.ts +96 -0
- package/src/cli/commands/worktree/create.ts +7 -80
- package/src/cli/flags.ts +8 -1
- package/src/cli/index.ts +3 -2
- package/src/cli/registry.ts +2 -0
- package/src/config.ts +16 -7
- package/src/doctor.ts +5 -4
- package/src/index.tsx +1 -1
- package/src/pty/assistant-question-extractor.ts +1 -0
- package/src/pty/assistant-status-detector.ts +45 -2
- package/src/pty/command-registry.ts +11 -0
- package/src/state/types.ts +7 -1
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ snippets, themes, and fully configurable keymaps.
|
|
|
13
13
|
## Features
|
|
14
14
|
|
|
15
15
|
- multi-workspace workflow with a dedicated workspace picker
|
|
16
|
-
- tabs for `claude`, `codex`, `opencode`, and `terminal`
|
|
16
|
+
- tabs for `claude`, `codex`, `opencode`, `grok`, and `terminal`
|
|
17
17
|
- split panes with pane focus and resize shortcuts
|
|
18
18
|
- persistent workspaces with saved layout and tab state
|
|
19
19
|
- profile-isolated config, catalogs, daemon sockets, and runtime state
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brimveyn/aimux",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.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",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"bump:terminal_manager": "bun run scripts/bump-protocol.ts terminal-manager"
|
|
65
65
|
},
|
|
66
66
|
"dependencies": {
|
|
67
|
-
"@brimveyn/aimux-config": "0.8.
|
|
67
|
+
"@brimveyn/aimux-config": "0.8.2",
|
|
68
68
|
"@opentui/core": "^0.1.90",
|
|
69
69
|
"@opentui/react": "^0.1.90",
|
|
70
70
|
"@resvg/resvg-wasm": "^2.6.2",
|
|
@@ -3,9 +3,14 @@ export interface HeadlessInvocation {
|
|
|
3
3
|
args: string[]
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
-
export type SupportedProvider = 'claude' | 'codex' | 'opencode'
|
|
6
|
+
export type SupportedProvider = 'claude' | 'codex' | 'opencode' | 'grok'
|
|
7
7
|
|
|
8
|
-
const SUPPORTED: ReadonlySet<string> = new Set<SupportedProvider>([
|
|
8
|
+
const SUPPORTED: ReadonlySet<string> = new Set<SupportedProvider>([
|
|
9
|
+
'claude',
|
|
10
|
+
'codex',
|
|
11
|
+
'opencode',
|
|
12
|
+
'grok',
|
|
13
|
+
])
|
|
9
14
|
|
|
10
15
|
export function isSupportedProvider(id: string): id is SupportedProvider {
|
|
11
16
|
return SUPPORTED.has(id)
|
|
@@ -32,6 +37,13 @@ export function buildHeadlessInvocation(
|
|
|
32
37
|
case 'opencode': {
|
|
33
38
|
return { args: ['run', prompt], executable: 'opencode' }
|
|
34
39
|
}
|
|
40
|
+
case 'grok': {
|
|
41
|
+
// Headless: grok -p "<prompt>" [-m <model>]. Model appended after prompt value to
|
|
42
|
+
// match documented example shape `grok -p "..." -m my-model`. No output-format flag.
|
|
43
|
+
const args: string[] = ['-p', prompt]
|
|
44
|
+
if (model != null && model !== '') args.push('-m', model)
|
|
45
|
+
return { args, executable: 'grok' }
|
|
46
|
+
}
|
|
35
47
|
default:
|
|
36
48
|
return null
|
|
37
49
|
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared turn-lifecycle waiter behind `tab run` (submit + await) and `tab await`
|
|
3
|
+
* (await only). It subscribes to the daemon's v13 events
|
|
4
|
+
* (`tabStatus`/`tabTurnComplete`/`tabQuestion`/`tabExit`/`tabError`) and settles
|
|
5
|
+
* on the first terminal signal or the overall timeout. Settlement rides the
|
|
6
|
+
* daemon's edge-triggered events — the daemon applies the idle settle window
|
|
7
|
+
* before emitting `tabTurnComplete`, and `tabQuestion` fires only on a real
|
|
8
|
+
* transition into `waiting-input` with daemon-captured text — so this never
|
|
9
|
+
* scrapes the screen and can't misread a working footer as a prompt.
|
|
10
|
+
*/
|
|
11
|
+
import type { QuestionKind } from '../../../state/types'
|
|
12
|
+
import type { DaemonClient } from '../../client/daemon-client'
|
|
13
|
+
|
|
14
|
+
import { EXIT_OK, EXIT_QUESTION, EXIT_RUNTIME, EXIT_TIMEOUT } from '../../output'
|
|
15
|
+
|
|
16
|
+
/** Overall cap on a single turn — 15 min, long enough for a heavy build task. */
|
|
17
|
+
export const DEFAULT_TIMEOUT_MS = 900_000
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The four terminal shapes of a turn. A discriminated union so the JSON emitted
|
|
21
|
+
* and the exit code returned derive from one value, and the outcome→exit map is
|
|
22
|
+
* unit-testable without a live daemon. `durationMs` is measured from the moment
|
|
23
|
+
* the turn is armed (post-submit for `tab run`, from attach for `tab await`).
|
|
24
|
+
*/
|
|
25
|
+
export type TurnOutcome =
|
|
26
|
+
| { durationMs: number; outcome: 'completed' }
|
|
27
|
+
| { durationMs: number; error: string; outcome: 'error' }
|
|
28
|
+
| {
|
|
29
|
+
durationMs: number
|
|
30
|
+
kind: QuestionKind
|
|
31
|
+
options?: string[]
|
|
32
|
+
outcome: 'question'
|
|
33
|
+
question: string
|
|
34
|
+
}
|
|
35
|
+
| { durationMs: number; outcome: 'timeout' }
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Map an outcome to its process exit code. Pure and total over the union so a
|
|
39
|
+
* driver's `case $?` stays exhaustive: 0 completed, 10 question/permission
|
|
40
|
+
* (worker blocked wanting input), 3 the tab errored/exited, 124 overall cap.
|
|
41
|
+
*/
|
|
42
|
+
export function turnOutcomeExitCode(outcome: TurnOutcome): number {
|
|
43
|
+
switch (outcome.outcome) {
|
|
44
|
+
case 'completed':
|
|
45
|
+
return EXIT_OK
|
|
46
|
+
case 'question':
|
|
47
|
+
return EXIT_QUESTION
|
|
48
|
+
case 'error':
|
|
49
|
+
return EXIT_RUNTIME
|
|
50
|
+
case 'timeout':
|
|
51
|
+
return EXIT_TIMEOUT
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface AwaitTurnOptions {
|
|
56
|
+
daemon: DaemonClient
|
|
57
|
+
tabId: string
|
|
58
|
+
timeoutMs: number
|
|
59
|
+
/**
|
|
60
|
+
* Seed the uptake guard. `tab run` passes `false` — only a post-submit
|
|
61
|
+
* `working` transition validates completion, so a lingering pre-submit idle
|
|
62
|
+
* can't be misread as "done". `tab await` passes `true` when the attach replay
|
|
63
|
+
* already shows the tab `working`: no future `working` transition will fire for
|
|
64
|
+
* a turn already in flight, so an unseeded guard would hang to timeout.
|
|
65
|
+
*/
|
|
66
|
+
assumeWorking: boolean
|
|
67
|
+
/**
|
|
68
|
+
* Runs once all subscriptions are armed; the duration clock resets when it
|
|
69
|
+
* resolves. `tab run` submits the prompt here. A rejection settles as an
|
|
70
|
+
* `error` outcome (the tab likely died mid-write). `tab await` omits it.
|
|
71
|
+
*/
|
|
72
|
+
onArmed?: () => Promise<void>
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Subscribe to the turn-lifecycle events and resolve with the first terminal
|
|
77
|
+
* outcome. Callers own emitting the JSON and returning `turnOutcomeExitCode`.
|
|
78
|
+
*/
|
|
79
|
+
// `async` only to satisfy promise-function-async; the body has no `await`, so
|
|
80
|
+
// the Promise executor below still runs synchronously and subscriptions are
|
|
81
|
+
// armed before this returns (callers rely on emitting right after the call).
|
|
82
|
+
export async function awaitTurn(opts: AwaitTurnOptions): Promise<TurnOutcome> {
|
|
83
|
+
const { assumeWorking, daemon, onArmed, tabId, timeoutMs } = opts
|
|
84
|
+
return new Promise<TurnOutcome>((resolve) => {
|
|
85
|
+
// Subscribe BEFORE arming: these events fire only on transitions, so a late
|
|
86
|
+
// subscription would race the worker starting its turn.
|
|
87
|
+
let start = Date.now()
|
|
88
|
+
let sawWorking = assumeWorking
|
|
89
|
+
|
|
90
|
+
const settle = (outcome: TurnOutcome): void => {
|
|
91
|
+
cleanup()
|
|
92
|
+
resolve(outcome)
|
|
93
|
+
}
|
|
94
|
+
const durationMs = (): number => Date.now() - start
|
|
95
|
+
|
|
96
|
+
const offStatus = daemon.on('tabStatus', (p) => {
|
|
97
|
+
if (p.tabId !== tabId) return
|
|
98
|
+
if (p.status === 'working') sawWorking = true
|
|
99
|
+
})
|
|
100
|
+
const offTurn = daemon.on('tabTurnComplete', (p) => {
|
|
101
|
+
if (p.tabId !== tabId) return
|
|
102
|
+
// Ignore end-of-turn until the worker actually started working, so a
|
|
103
|
+
// lingering pre-arm idle can't be mis-read as completion.
|
|
104
|
+
if (!sawWorking) return
|
|
105
|
+
settle({ durationMs: durationMs(), outcome: 'completed' })
|
|
106
|
+
})
|
|
107
|
+
const offQuestion = daemon.on('tabQuestion', (p) => {
|
|
108
|
+
if (p.tabId !== tabId) return
|
|
109
|
+
// A question is honoured immediately — it can legitimately arrive before
|
|
110
|
+
// `working` (the worker asks before doing anything).
|
|
111
|
+
settle({
|
|
112
|
+
durationMs: durationMs(),
|
|
113
|
+
kind: p.kind,
|
|
114
|
+
options: p.options,
|
|
115
|
+
outcome: 'question',
|
|
116
|
+
question: p.prompt,
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
const offExit = daemon.on('tabExit', (p) => {
|
|
120
|
+
if (p.tabId !== tabId) return
|
|
121
|
+
settle({ durationMs: durationMs(), error: `exit ${p.exitCode}`, outcome: 'error' })
|
|
122
|
+
})
|
|
123
|
+
const offError = daemon.on('tabError', (p) => {
|
|
124
|
+
if (p.tabId !== tabId) return
|
|
125
|
+
settle({ durationMs: durationMs(), error: p.message, outcome: 'error' })
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
const timer = setTimeout(() => {
|
|
129
|
+
settle({ durationMs: durationMs(), outcome: 'timeout' })
|
|
130
|
+
}, timeoutMs)
|
|
131
|
+
|
|
132
|
+
const cleanup = (): void => {
|
|
133
|
+
offStatus()
|
|
134
|
+
offTurn()
|
|
135
|
+
offQuestion()
|
|
136
|
+
offExit()
|
|
137
|
+
offError()
|
|
138
|
+
clearTimeout(timer)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Arm after subscribing, then reset the clock so `durationMs` measures the
|
|
142
|
+
// worker's turn rather than our attach/write overhead. On failure the tab
|
|
143
|
+
// likely died — surface an error outcome rather than sitting to the timeout.
|
|
144
|
+
if (onArmed !== undefined) {
|
|
145
|
+
void (async (): Promise<void> => {
|
|
146
|
+
try {
|
|
147
|
+
await onArmed()
|
|
148
|
+
start = Date.now()
|
|
149
|
+
} catch (error) {
|
|
150
|
+
settle({
|
|
151
|
+
durationMs: durationMs(),
|
|
152
|
+
error: error instanceof Error ? error.message : String(error),
|
|
153
|
+
outcome: 'error',
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
})()
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `aimux tab await <tabId>` — block until a tab's in-flight turn ends or the
|
|
3
|
+
* worker asks, without submitting anything. It's the standalone half of
|
|
4
|
+
* `tab run` for turns you started by hand (a `tab send --enter`, or a worker you
|
|
5
|
+
* nudged), sharing `awaitTurn` so the outcome JSON and exit codes are identical.
|
|
6
|
+
*
|
|
7
|
+
* The turn-lifecycle events fire only on transitions, so `awaitTurn` is seeded
|
|
8
|
+
* from the attach replay: a tab already `working` won't emit another `working`
|
|
9
|
+
* transition (assume it), an already-`waiting-input` tab's `tabQuestion` already
|
|
10
|
+
* fired (short-circuit with a best-effort snapshot tail), and an `idle` tab is
|
|
11
|
+
* awaited for a *fresh* working→idle cycle so a stale, already-finished turn is
|
|
12
|
+
* never re-reported as "completed".
|
|
13
|
+
*/
|
|
14
|
+
import type { CliCommand } from '../../registry'
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
IPC_CAPABILITY_QUESTION_EVENTS,
|
|
18
|
+
IPC_CAPABILITY_THIN_ATTACH,
|
|
19
|
+
IPC_CAPABILITY_TURN_LIFECYCLE,
|
|
20
|
+
} from '../../../ipc/protocol'
|
|
21
|
+
import { SHARED_FLAGS } from '../../flags'
|
|
22
|
+
import { writeJson } from '../../output'
|
|
23
|
+
import { snapshotTailLines } from '../../snapshot-render'
|
|
24
|
+
import { awaitTurn, DEFAULT_TIMEOUT_MS, type TurnOutcome, turnOutcomeExitCode } from './await-turn'
|
|
25
|
+
|
|
26
|
+
/** Lines of rendered tail to attach as the question text on a replay short-circuit. */
|
|
27
|
+
const QUESTION_TAIL_LINES = 25
|
|
28
|
+
|
|
29
|
+
export const tabAwait: CliCommand = {
|
|
30
|
+
args: [{ name: 'tabId', required: true }],
|
|
31
|
+
flags: [
|
|
32
|
+
...SHARED_FLAGS,
|
|
33
|
+
{
|
|
34
|
+
description: 'overall turn cap in milliseconds (default 900000 = 15 min)',
|
|
35
|
+
kind: 'number',
|
|
36
|
+
name: 'timeout',
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
group: 'tab',
|
|
40
|
+
run: async (ctx) => {
|
|
41
|
+
const tabId = ctx.args.positionals[0]
|
|
42
|
+
if (typeof tabId !== 'string' || tabId.length === 0) {
|
|
43
|
+
throw new Error('tabId is required')
|
|
44
|
+
}
|
|
45
|
+
const timeoutMs =
|
|
46
|
+
typeof ctx.args.flags.timeout === 'number' ? ctx.args.flags.timeout : DEFAULT_TIMEOUT_MS
|
|
47
|
+
|
|
48
|
+
const workspace = ctx.getWorkspace()
|
|
49
|
+
const daemon = await ctx.getDaemon()
|
|
50
|
+
if (
|
|
51
|
+
!daemon.hasCapability(IPC_CAPABILITY_THIN_ATTACH) ||
|
|
52
|
+
!daemon.hasCapability(IPC_CAPABILITY_TURN_LIFECYCLE) ||
|
|
53
|
+
!daemon.hasCapability(IPC_CAPABILITY_QUESTION_EVENTS)
|
|
54
|
+
) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
'daemon predates tab await (turnLifecycle/questionEvents) — restart aimux to pick up the new daemon'
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const attach = await daemon.attach({ cols: 0, rows: 0, sessionId: workspace.id, thin: true })
|
|
61
|
+
const tab = attach.tabs.find((t) => t.id === tabId)
|
|
62
|
+
if (!tab) {
|
|
63
|
+
// Exit 3 (runtime) via runCli — NOT 4, which is reserved for
|
|
64
|
+
// daemon-unreachable. A driver reads "tab not found" as the re-spawn signal.
|
|
65
|
+
throw new Error(`tab not found: ${tabId}`)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (tab.activity === 'waiting-input') {
|
|
69
|
+
// The tabQuestion event fired before we attached and won't re-fire.
|
|
70
|
+
// Reconstruct a best-effort prompt from the rendered tail (the real
|
|
71
|
+
// kind/options aren't recoverable from a replay).
|
|
72
|
+
const question =
|
|
73
|
+
tab.viewport && tab.viewport.lines.length > 0
|
|
74
|
+
? snapshotTailLines(tab.viewport, QUESTION_TAIL_LINES, { trim: true }).join('\n')
|
|
75
|
+
: ''
|
|
76
|
+
const outcome: TurnOutcome = {
|
|
77
|
+
durationMs: 0,
|
|
78
|
+
kind: 'question',
|
|
79
|
+
outcome: 'question',
|
|
80
|
+
question,
|
|
81
|
+
}
|
|
82
|
+
writeJson(outcome)
|
|
83
|
+
return turnOutcomeExitCode(outcome)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const outcome = await awaitTurn({
|
|
87
|
+
assumeWorking: tab.activity === 'working',
|
|
88
|
+
daemon,
|
|
89
|
+
tabId,
|
|
90
|
+
timeoutMs,
|
|
91
|
+
})
|
|
92
|
+
writeJson(outcome)
|
|
93
|
+
return turnOutcomeExitCode(outcome)
|
|
94
|
+
},
|
|
95
|
+
summary: "Block until a tab's in-flight turn completes or the worker asks",
|
|
96
|
+
verb: 'await',
|
|
97
|
+
}
|
|
@@ -2,28 +2,60 @@ import { resolve as resolvePath } from 'node:path'
|
|
|
2
2
|
|
|
3
3
|
import type { CliCommand } from '../../registry'
|
|
4
4
|
|
|
5
|
+
import { loadConfig } from '../../../config'
|
|
5
6
|
import {
|
|
6
7
|
IPC_CAPABILITY_CREATE_TAB_SIZE_FALLBACK,
|
|
7
8
|
IPC_CAPABILITY_THIN_ATTACH,
|
|
8
9
|
} from '../../../ipc/protocol'
|
|
9
10
|
import { createPrefixedId } from '../../../platform/id'
|
|
10
11
|
import {
|
|
12
|
+
type AssistantOption,
|
|
11
13
|
buildAssistantModelArgs,
|
|
12
14
|
getAllAssistantOptions,
|
|
13
15
|
parseCommand,
|
|
14
16
|
} from '../../../pty/command-registry'
|
|
15
17
|
import { SHARED_FLAGS } from '../../flags'
|
|
16
18
|
import { EXIT_OK, writeJson } from '../../output'
|
|
19
|
+
import { createWorkspaceWorktree } from '../worktree/create-core'
|
|
17
20
|
|
|
18
21
|
const FALLBACK_COLS = 200
|
|
19
22
|
const FALLBACK_ROWS = 60
|
|
20
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Resolve the cwd a spawned tab should run in. Precedence: explicit `--cwd`
|
|
26
|
+
* (resolved to absolute) > the resolved worktree's path > undefined (the
|
|
27
|
+
* daemon's default). Worktree record paths are already absolute.
|
|
28
|
+
*/
|
|
29
|
+
export function resolveTabCwd(
|
|
30
|
+
cwdFlag: string | undefined,
|
|
31
|
+
worktreeRecord: { path: string } | undefined
|
|
32
|
+
): string | undefined {
|
|
33
|
+
if (cwdFlag !== undefined) return resolvePath(cwdFlag)
|
|
34
|
+
return worktreeRecord?.path
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolve the base command a tab launches. Mirrors the UI's precedence
|
|
39
|
+
* (`src/app-runtime/side-effects.ts`): an explicit `--command` override wins,
|
|
40
|
+
* else the workspace's persisted `customCommands[assistantId]` (so CLI workers
|
|
41
|
+
* inherit e.g. `claude --dangerously-skip-permissions`), else the builtin
|
|
42
|
+
* default. `getAllAssistantOptions` deliberately does NOT apply builtin
|
|
43
|
+
* overrides, so this lookup must be explicit.
|
|
44
|
+
*/
|
|
45
|
+
export function resolveAssistantCommand(
|
|
46
|
+
commandOverride: string | undefined,
|
|
47
|
+
customCommands: Record<string, string>,
|
|
48
|
+
option: AssistantOption
|
|
49
|
+
): string {
|
|
50
|
+
return commandOverride ?? customCommands[option.id] ?? option.command
|
|
51
|
+
}
|
|
52
|
+
|
|
21
53
|
export const tabCreate: CliCommand = {
|
|
22
54
|
args: [],
|
|
23
55
|
flags: [
|
|
24
56
|
...SHARED_FLAGS,
|
|
25
57
|
{
|
|
26
|
-
description: 'assistant id (claude, codex, opencode, terminal, ...)',
|
|
58
|
+
description: 'assistant id (claude, codex, opencode, grok, terminal, ...)',
|
|
27
59
|
kind: 'string',
|
|
28
60
|
name: 'assistant',
|
|
29
61
|
},
|
|
@@ -49,6 +81,17 @@ export const tabCreate: CliCommand = {
|
|
|
49
81
|
kind: 'string',
|
|
50
82
|
name: 'worktree',
|
|
51
83
|
},
|
|
84
|
+
{
|
|
85
|
+
description: 'create a fresh worktree for this tab (optionally named: --new-worktree=<name>)',
|
|
86
|
+
kind: 'optional-string',
|
|
87
|
+
name: 'new-worktree',
|
|
88
|
+
},
|
|
89
|
+
{ description: 'base ref for --new-worktree (default HEAD)', kind: 'string', name: 'base' },
|
|
90
|
+
{
|
|
91
|
+
description: 'branch for --new-worktree (default aimux/<name>)',
|
|
92
|
+
kind: 'string',
|
|
93
|
+
name: 'branch',
|
|
94
|
+
},
|
|
52
95
|
],
|
|
53
96
|
group: 'tab',
|
|
54
97
|
run: async (ctx) => {
|
|
@@ -56,7 +99,12 @@ export const tabCreate: CliCommand = {
|
|
|
56
99
|
if (typeof assistantId !== 'string' || assistantId.length === 0) {
|
|
57
100
|
throw new Error('--assistant is required')
|
|
58
101
|
}
|
|
59
|
-
|
|
102
|
+
// Load the workspace's persisted customCommands so CLI-spawned tabs honor
|
|
103
|
+
// the same assistant commands the UI uses (e.g. skip-permissions flags), and
|
|
104
|
+
// so purely-custom assistant ids resolve. loadConfig degrades to {} on a
|
|
105
|
+
// missing/invalid config — no new failure mode.
|
|
106
|
+
const { customCommands } = loadConfig()
|
|
107
|
+
const options = getAllAssistantOptions(customCommands)
|
|
60
108
|
const option = options.find((o) => o.id === assistantId)
|
|
61
109
|
if (!option) {
|
|
62
110
|
throw new Error(
|
|
@@ -77,10 +125,28 @@ export const tabCreate: CliCommand = {
|
|
|
77
125
|
)
|
|
78
126
|
}
|
|
79
127
|
|
|
80
|
-
const command = commandOverride
|
|
128
|
+
const command = resolveAssistantCommand(commandOverride, customCommands, option)
|
|
81
129
|
const title = typeof ctx.args.flags.title === 'string' ? ctx.args.flags.title : option.label
|
|
82
130
|
const cwdRaw = typeof ctx.args.flags.cwd === 'string' ? ctx.args.flags.cwd : undefined
|
|
83
|
-
const
|
|
131
|
+
const tabId = createPrefixedId('tab')
|
|
132
|
+
|
|
133
|
+
// `--new-worktree[=<name>]`: create a fresh worktree and run the tab in it.
|
|
134
|
+
// A bare flag parses as `true` (name derived below); the `=` form names it.
|
|
135
|
+
const newWorktreeFlag = ctx.args.flags['new-worktree']
|
|
136
|
+
const newWorktree = newWorktreeFlag !== undefined
|
|
137
|
+
const worktreeFlag =
|
|
138
|
+
typeof ctx.args.flags.worktree === 'string' ? ctx.args.flags.worktree : undefined
|
|
139
|
+
const baseFlag = typeof ctx.args.flags.base === 'string' ? ctx.args.flags.base : undefined
|
|
140
|
+
const branchFlag = typeof ctx.args.flags.branch === 'string' ? ctx.args.flags.branch : undefined
|
|
141
|
+
if (newWorktree && worktreeFlag !== undefined) {
|
|
142
|
+
throw new Error('--new-worktree creates its own; use --worktree <id> to co-locate instead')
|
|
143
|
+
}
|
|
144
|
+
if (newWorktree && cwdRaw !== undefined) {
|
|
145
|
+
throw new Error('--new-worktree sets the cwd to the new worktree; drop --cwd')
|
|
146
|
+
}
|
|
147
|
+
if (!newWorktree && (baseFlag !== undefined || branchFlag !== undefined)) {
|
|
148
|
+
throw new Error('--base / --branch require --new-worktree (use `worktree create` otherwise)')
|
|
149
|
+
}
|
|
84
150
|
|
|
85
151
|
const workspace = ctx.getWorkspace()
|
|
86
152
|
const daemon = await ctx.getDaemon()
|
|
@@ -91,20 +157,42 @@ export const tabCreate: CliCommand = {
|
|
|
91
157
|
)
|
|
92
158
|
}
|
|
93
159
|
|
|
94
|
-
// Resolve
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
let
|
|
99
|
-
if (
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
160
|
+
// Resolve the worktree the tab belongs to + its record (for the cwd default
|
|
161
|
+
// and the output). Three modes: create a fresh one, use an explicit id, or
|
|
162
|
+
// fall back to the workspace's active worktree.
|
|
163
|
+
let worktreeId: string | undefined
|
|
164
|
+
let worktreeRecord: { branch?: string; name?: string; path: string } | undefined
|
|
165
|
+
if (newWorktree) {
|
|
166
|
+
const worktreeName =
|
|
167
|
+
typeof newWorktreeFlag === 'string' && newWorktreeFlag !== ''
|
|
168
|
+
? newWorktreeFlag
|
|
169
|
+
: `${assistantId}-${tabId.slice(-6)}`
|
|
170
|
+
const record = await createWorkspaceWorktree({
|
|
171
|
+
base: baseFlag ?? 'HEAD',
|
|
172
|
+
branch: branchFlag ?? `aimux/${worktreeName}`,
|
|
173
|
+
daemon,
|
|
174
|
+
name: worktreeName,
|
|
175
|
+
workspace,
|
|
176
|
+
})
|
|
177
|
+
worktreeId = record.id
|
|
178
|
+
worktreeRecord = record
|
|
179
|
+
} else {
|
|
180
|
+
worktreeId = worktreeFlag ?? workspace.activeWorktreeId
|
|
181
|
+
if (worktreeFlag !== undefined) {
|
|
182
|
+
const known = workspace.worktrees?.some((w) => w.id === worktreeFlag) ?? false
|
|
183
|
+
if (!known) {
|
|
184
|
+
const ids = workspace.worktrees?.map((w) => w.id).join(', ') ?? '(none)'
|
|
185
|
+
throw new Error(`unknown worktree id: ${worktreeFlag} (known: ${ids})`)
|
|
186
|
+
}
|
|
104
187
|
}
|
|
105
|
-
|
|
188
|
+
worktreeRecord =
|
|
189
|
+
worktreeId !== undefined ? workspace.worktrees?.find((w) => w.id === worktreeId) : undefined
|
|
106
190
|
}
|
|
107
191
|
|
|
192
|
+
// Default the PTY cwd to the resolved worktree's path so a worker spawned
|
|
193
|
+
// into a worktree actually runs inside it. An explicit --cwd always wins.
|
|
194
|
+
const cwd = resolveTabCwd(cwdRaw, worktreeRecord)
|
|
195
|
+
|
|
108
196
|
// Thin-attach so we don't clobber the UI's dimensions on the same session.
|
|
109
197
|
await daemon.attach({ cols: 0, rows: 0, sessionId: workspace.id, thin: true })
|
|
110
198
|
|
|
@@ -113,7 +201,6 @@ export const tabCreate: CliCommand = {
|
|
|
113
201
|
// assistant has no control for a requested dimension.
|
|
114
202
|
const modelArgs = buildAssistantModelArgs(option, { effort, model })
|
|
115
203
|
const args = [...baseArgs, ...modelArgs]
|
|
116
|
-
const tabId = createPrefixedId('tab')
|
|
117
204
|
|
|
118
205
|
// cols/rows = 0 means "fall back to the session's last attached size" on
|
|
119
206
|
// v11 daemons. Without that capability we have nothing reasonable to put
|
|
@@ -135,12 +222,16 @@ export const tabCreate: CliCommand = {
|
|
|
135
222
|
const resolvedCommand = [executable, ...args].join(' ')
|
|
136
223
|
writeJson({
|
|
137
224
|
assistant: assistantId,
|
|
225
|
+
branch: worktreeRecord?.branch ?? null,
|
|
138
226
|
command: resolvedCommand,
|
|
227
|
+
cwd: cwd ?? null,
|
|
139
228
|
effort: effort ?? null,
|
|
140
229
|
model: model ?? null,
|
|
230
|
+
name: worktreeRecord?.name ?? null,
|
|
231
|
+
path: worktreeRecord?.path ?? null,
|
|
141
232
|
tabId,
|
|
142
233
|
title,
|
|
143
|
-
worktreeId,
|
|
234
|
+
worktreeId: worktreeId ?? null,
|
|
144
235
|
})
|
|
145
236
|
return EXIT_OK
|
|
146
237
|
},
|
|
@@ -17,6 +17,31 @@ import { bracketedPaste, notationToBytes } from '../../chord'
|
|
|
17
17
|
*/
|
|
18
18
|
export const PASTE_SUBMIT_SETTLE_MS = 50
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Resolve prompt text from exactly one source — `--prompt-file`, `--stdin`, or
|
|
22
|
+
* a positional `[text]`. Requiring exactly one prevents an orchestrator from
|
|
23
|
+
* silently sending the wrong buffer when two are set (e.g. a stale positional
|
|
24
|
+
* plus a fresh `--prompt-file`). Used by `tab run`; `tab send` keeps its own
|
|
25
|
+
* looser guard (zero sources is valid there).
|
|
26
|
+
*/
|
|
27
|
+
export async function resolvePromptText(
|
|
28
|
+
promptFile: string | undefined,
|
|
29
|
+
fromStdin: boolean,
|
|
30
|
+
positionalText: string | undefined
|
|
31
|
+
): Promise<string> {
|
|
32
|
+
const sources = [promptFile !== undefined, fromStdin, positionalText !== undefined].filter(
|
|
33
|
+
(present) => present
|
|
34
|
+
).length
|
|
35
|
+
if (sources !== 1) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
'provide exactly one prompt source: --prompt-file <f>, --stdin, or a [text] positional'
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
if (promptFile !== undefined) return Bun.file(promptFile).text()
|
|
41
|
+
if (fromStdin) return Bun.stdin.text()
|
|
42
|
+
return positionalText ?? ''
|
|
43
|
+
}
|
|
44
|
+
|
|
20
45
|
/**
|
|
21
46
|
* Lower a chord/paste buffer of bytes into the string the protocol expects.
|
|
22
47
|
* Every byte we emit is < 0x80 (control chars or printable ASCII), so a
|
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
* timeout trips. Exactly one JSON object is emitted and the exit code encodes
|
|
8
8
|
* the outcome, so a driver can branch without re-snapshotting the screen.
|
|
9
9
|
*/
|
|
10
|
-
import type { QuestionKind } from '../../../state/types'
|
|
11
10
|
import type { CliCommand } from '../../registry'
|
|
12
11
|
|
|
13
12
|
import {
|
|
@@ -16,73 +15,9 @@ import {
|
|
|
16
15
|
IPC_CAPABILITY_TURN_LIFECYCLE,
|
|
17
16
|
} from '../../../ipc/protocol'
|
|
18
17
|
import { SHARED_FLAGS } from '../../flags'
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
/** Overall cap on a single turn — 15 min, long enough for a heavy build task. */
|
|
23
|
-
const DEFAULT_TIMEOUT_MS = 900_000
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* The four terminal shapes of a `tab run`. Modelled as a discriminated union so
|
|
27
|
-
* the JSON we emit and the exit code we return are derived from one value, and
|
|
28
|
-
* so the outcome→exit mapping can be unit-tested without a live daemon.
|
|
29
|
-
* `durationMs` is measured from prompt submit, not attach, so it reflects the
|
|
30
|
-
* worker's think time rather than our connection overhead.
|
|
31
|
-
*/
|
|
32
|
-
export type RunOutcome =
|
|
33
|
-
| { durationMs: number; outcome: 'completed' }
|
|
34
|
-
| { durationMs: number; error: string; outcome: 'error' }
|
|
35
|
-
| {
|
|
36
|
-
durationMs: number
|
|
37
|
-
kind: QuestionKind
|
|
38
|
-
options?: string[]
|
|
39
|
-
outcome: 'question'
|
|
40
|
-
question: string
|
|
41
|
-
}
|
|
42
|
-
| { durationMs: number; outcome: 'timeout' }
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Map an outcome to its process exit code. Pure and total over the union so a
|
|
46
|
-
* driver's `case $?` stays exhaustive: 0 completed, 10 question/permission
|
|
47
|
-
* (worker is blocked and wants input), 3 the tab errored/exited, 124 we hit the
|
|
48
|
-
* overall cap.
|
|
49
|
-
*/
|
|
50
|
-
export function outcomeExitCode(outcome: RunOutcome): number {
|
|
51
|
-
switch (outcome.outcome) {
|
|
52
|
-
case 'completed':
|
|
53
|
-
return EXIT_OK
|
|
54
|
-
case 'question':
|
|
55
|
-
return EXIT_QUESTION
|
|
56
|
-
case 'error':
|
|
57
|
-
return EXIT_RUNTIME
|
|
58
|
-
case 'timeout':
|
|
59
|
-
return EXIT_TIMEOUT
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Resolve the prompt text from exactly one source. We require exactly one of
|
|
65
|
-
* `--prompt-file`, `--stdin`, or the positional `[text]` so an orchestrator
|
|
66
|
-
* never silently sends the wrong buffer when two sources are set (e.g. a stale
|
|
67
|
-
* positional plus a fresh `--prompt-file`).
|
|
68
|
-
*/
|
|
69
|
-
async function resolvePromptText(
|
|
70
|
-
promptFile: string | undefined,
|
|
71
|
-
fromStdin: boolean,
|
|
72
|
-
positionalText: string | undefined
|
|
73
|
-
): Promise<string> {
|
|
74
|
-
const sources = [promptFile !== undefined, fromStdin, positionalText !== undefined].filter(
|
|
75
|
-
(present) => present
|
|
76
|
-
).length
|
|
77
|
-
if (sources !== 1) {
|
|
78
|
-
throw new Error(
|
|
79
|
-
'provide exactly one prompt source: --prompt-file <f>, --stdin, or a [text] positional'
|
|
80
|
-
)
|
|
81
|
-
}
|
|
82
|
-
if (promptFile !== undefined) return Bun.file(promptFile).text()
|
|
83
|
-
if (fromStdin) return Bun.stdin.text()
|
|
84
|
-
return positionalText ?? ''
|
|
85
|
-
}
|
|
18
|
+
import { writeJson } from '../../output'
|
|
19
|
+
import { awaitTurn, DEFAULT_TIMEOUT_MS, turnOutcomeExitCode } from './await-turn'
|
|
20
|
+
import { buildPromptPayload, resolvePromptText, writePromptPayload } from './prompt-io'
|
|
86
21
|
|
|
87
22
|
export const tabRun: CliCommand = {
|
|
88
23
|
args: [{ name: 'tabId', required: true }, { name: 'text' }],
|
|
@@ -136,86 +71,20 @@ export const tabRun: CliCommand = {
|
|
|
136
71
|
|
|
137
72
|
const payload = buildPromptPayload(text, false)
|
|
138
73
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
cleanup()
|
|
151
|
-
writeJson(outcome)
|
|
152
|
-
resolve(outcomeExitCode(outcome))
|
|
153
|
-
}
|
|
154
|
-
const durationMs = (): number => Date.now() - start
|
|
155
|
-
|
|
156
|
-
const offStatus = daemon.on('tabStatus', (p) => {
|
|
157
|
-
if (p.tabId !== tabId) return
|
|
158
|
-
if (p.status === 'working') sawWorking = true
|
|
159
|
-
})
|
|
160
|
-
const offTurn = daemon.on('tabTurnComplete', (p) => {
|
|
161
|
-
if (p.tabId !== tabId) return
|
|
162
|
-
// Ignore end-of-turn until the worker actually started working, so a
|
|
163
|
-
// lingering pre-submit idle can't be mis-read as completion.
|
|
164
|
-
if (!sawWorking) return
|
|
165
|
-
settle({ durationMs: durationMs(), outcome: 'completed' })
|
|
166
|
-
})
|
|
167
|
-
const offQuestion = daemon.on('tabQuestion', (p) => {
|
|
168
|
-
if (p.tabId !== tabId) return
|
|
169
|
-
// A question is honoured immediately — it can legitimately arrive
|
|
170
|
-
// before `working` (the worker asks before doing anything).
|
|
171
|
-
settle({
|
|
172
|
-
durationMs: durationMs(),
|
|
173
|
-
kind: p.kind,
|
|
174
|
-
options: p.options,
|
|
175
|
-
outcome: 'question',
|
|
176
|
-
question: p.prompt,
|
|
177
|
-
})
|
|
178
|
-
})
|
|
179
|
-
const offExit = daemon.on('tabExit', (p) => {
|
|
180
|
-
if (p.tabId !== tabId) return
|
|
181
|
-
settle({ durationMs: durationMs(), error: `exit ${p.exitCode}`, outcome: 'error' })
|
|
182
|
-
})
|
|
183
|
-
const offError = daemon.on('tabError', (p) => {
|
|
184
|
-
if (p.tabId !== tabId) return
|
|
185
|
-
settle({ durationMs: durationMs(), error: p.message, outcome: 'error' })
|
|
186
|
-
})
|
|
187
|
-
|
|
188
|
-
const timer = setTimeout(() => {
|
|
189
|
-
settle({ durationMs: durationMs(), outcome: 'timeout' })
|
|
190
|
-
}, timeoutMs)
|
|
191
|
-
|
|
192
|
-
const cleanup = (): void => {
|
|
193
|
-
offStatus()
|
|
194
|
-
offTurn()
|
|
195
|
-
offQuestion()
|
|
196
|
-
offExit()
|
|
197
|
-
offError()
|
|
198
|
-
clearTimeout(timer)
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// Submit after subscribing, then reset the clock so `durationMs` measures
|
|
202
|
-
// the worker's turn rather than our attach/write overhead. On a write
|
|
203
|
-
// failure the tab likely died — surface it as an error outcome rather
|
|
204
|
-
// than sitting idle until the timeout.
|
|
205
|
-
const submit = async (): Promise<void> => {
|
|
206
|
-
try {
|
|
207
|
-
await writePromptPayload(daemon, tabId, payload, appendEnter)
|
|
208
|
-
start = Date.now()
|
|
209
|
-
} catch (error) {
|
|
210
|
-
settle({
|
|
211
|
-
durationMs: durationMs(),
|
|
212
|
-
error: error instanceof Error ? error.message : String(error),
|
|
213
|
-
outcome: 'error',
|
|
214
|
-
})
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
void submit()
|
|
74
|
+
// Submit inside `onArmed` so subscriptions are live before the worker starts
|
|
75
|
+
// its turn; `assumeWorking: false` keeps the uptake guard, so a lingering
|
|
76
|
+
// pre-submit idle can't be misread as completion.
|
|
77
|
+
const outcome = await awaitTurn({
|
|
78
|
+
assumeWorking: false,
|
|
79
|
+
daemon,
|
|
80
|
+
onArmed: async () => {
|
|
81
|
+
await writePromptPayload(daemon, tabId, payload, appendEnter)
|
|
82
|
+
},
|
|
83
|
+
tabId,
|
|
84
|
+
timeoutMs,
|
|
218
85
|
})
|
|
86
|
+
writeJson(outcome)
|
|
87
|
+
return turnOutcomeExitCode(outcome)
|
|
219
88
|
},
|
|
220
89
|
summary: 'Submit a prompt and block until the turn completes or the worker asks',
|
|
221
90
|
verb: 'run',
|
|
@@ -23,6 +23,11 @@ export const tabSend: CliCommand = {
|
|
|
23
23
|
kind: 'boolean',
|
|
24
24
|
name: 'stdin',
|
|
25
25
|
},
|
|
26
|
+
{
|
|
27
|
+
description: 'read the payload from this file instead of <text>',
|
|
28
|
+
kind: 'string',
|
|
29
|
+
name: 'prompt-file',
|
|
30
|
+
},
|
|
26
31
|
{
|
|
27
32
|
description:
|
|
28
33
|
'after submitting, block until the tab transitions to working (uptake confirmed)',
|
|
@@ -44,6 +49,8 @@ export const tabSend: CliCommand = {
|
|
|
44
49
|
}
|
|
45
50
|
|
|
46
51
|
const fromStdin = ctx.args.flags.stdin === true
|
|
52
|
+
const promptFile =
|
|
53
|
+
typeof ctx.args.flags['prompt-file'] === 'string' ? ctx.args.flags['prompt-file'] : undefined
|
|
47
54
|
const asKeys = ctx.args.flags.keys === true
|
|
48
55
|
const appendEnter = ctx.args.flags.enter === true
|
|
49
56
|
const awaitSubmit = ctx.args.flags['await-submit'] === true
|
|
@@ -60,7 +67,26 @@ export const tabSend: CliCommand = {
|
|
|
60
67
|
throw new Error('--await-submit requires --enter')
|
|
61
68
|
}
|
|
62
69
|
|
|
63
|
-
|
|
70
|
+
// At most one payload source. Unlike `tab run`, zero sources is valid here
|
|
71
|
+
// (`tab send <tab> --enter` submits an empty line), so we only reject
|
|
72
|
+
// conflicting combinations rather than requiring exactly one.
|
|
73
|
+
if (promptFile !== undefined) {
|
|
74
|
+
if (fromStdin) throw new Error('--prompt-file cannot be combined with --stdin')
|
|
75
|
+
if (asKeys)
|
|
76
|
+
throw new Error('--prompt-file cannot be combined with --keys (chords go in <text>)')
|
|
77
|
+
if (ctx.args.positionals[1] !== undefined) {
|
|
78
|
+
throw new Error('--prompt-file cannot be combined with a <text> positional')
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let text: string
|
|
83
|
+
if (promptFile !== undefined) {
|
|
84
|
+
text = await Bun.file(promptFile).text()
|
|
85
|
+
} else if (fromStdin) {
|
|
86
|
+
text = await Bun.stdin.text()
|
|
87
|
+
} else {
|
|
88
|
+
text = ctx.args.positionals[1] ?? ''
|
|
89
|
+
}
|
|
64
90
|
if (asKeys && text === '') {
|
|
65
91
|
throw new Error('--keys requires the chord notation as <text>')
|
|
66
92
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { SessionRecord, WorktreeRecord } from '../../../state/types'
|
|
2
|
+
import type { DaemonClient } from '../../client/daemon-client'
|
|
3
|
+
|
|
4
|
+
import { createGitWorktree, removeGitWorktree } from '../../../git/worktree'
|
|
5
|
+
import { IPC_CAPABILITY_WORKTREE_LIFECYCLE_EVENTS } from '../../../ipc/protocol'
|
|
6
|
+
import { createPrefixedId } from '../../../platform/id'
|
|
7
|
+
import {
|
|
8
|
+
assertSafeAimuxWorktreePath,
|
|
9
|
+
ensureAimuxWorktreeRoot,
|
|
10
|
+
makeWorktreePath,
|
|
11
|
+
} from '../../../platform/worktree-paths'
|
|
12
|
+
|
|
13
|
+
export interface CreateWorktreeParams {
|
|
14
|
+
/** Base ref for the branch (callers default to 'HEAD'). */
|
|
15
|
+
base: string
|
|
16
|
+
/** Branch name (callers default to `aimux/<name>`). */
|
|
17
|
+
branch: string
|
|
18
|
+
daemon: DaemonClient
|
|
19
|
+
name: string
|
|
20
|
+
workspace: SessionRecord
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Create a git worktree + its catalog record for a workspace. Shared by
|
|
25
|
+
* `worktree create` and `tab create --new-worktree`. Checks the daemon
|
|
26
|
+
* capability BEFORE touching disk, and rolls back the on-disk worktree if
|
|
27
|
+
* catalog registration fails (so `worktree list` never surfaces an orphan).
|
|
28
|
+
* Throws on any failure; returns the registered record on success.
|
|
29
|
+
*/
|
|
30
|
+
export async function createWorkspaceWorktree(
|
|
31
|
+
params: CreateWorktreeParams
|
|
32
|
+
): Promise<WorktreeRecord> {
|
|
33
|
+
const { base, branch, daemon, name, workspace } = params
|
|
34
|
+
|
|
35
|
+
const primary = workspace.worktrees?.find((w) => w.source === 'primary')
|
|
36
|
+
if (!primary) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`workspace "${workspace.name}" has no primary worktree — set --project when creating it`
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Check the daemon's capability BEFORE mutating disk — otherwise a capability
|
|
43
|
+
// mismatch would leave a git worktree on disk with no catalog record.
|
|
44
|
+
if (!daemon.hasCapability(IPC_CAPABILITY_WORKTREE_LIFECYCLE_EVENTS)) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
'daemon predates worktreeLifecycleEvents capability — restart aimux to pick up the new daemon'
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const worktreeId = createPrefixedId('worktree')
|
|
51
|
+
const targetPath = makeWorktreePath({
|
|
52
|
+
repoRoot: primary.repoRoot,
|
|
53
|
+
worktreeId,
|
|
54
|
+
worktreeName: name,
|
|
55
|
+
})
|
|
56
|
+
await ensureAimuxWorktreeRoot()
|
|
57
|
+
await assertSafeAimuxWorktreePath(targetPath)
|
|
58
|
+
|
|
59
|
+
await createGitWorktree({
|
|
60
|
+
baseRef: base,
|
|
61
|
+
branchName: branch,
|
|
62
|
+
repoPath: primary.repoRoot,
|
|
63
|
+
targetPath,
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const now = new Date().toISOString()
|
|
67
|
+
const record: WorktreeRecord = {
|
|
68
|
+
baseRef: base,
|
|
69
|
+
branch,
|
|
70
|
+
createdAt: now,
|
|
71
|
+
createdByAimux: true,
|
|
72
|
+
id: worktreeId,
|
|
73
|
+
name,
|
|
74
|
+
path: targetPath,
|
|
75
|
+
repoRoot: primary.repoRoot,
|
|
76
|
+
source: 'aimux-temp',
|
|
77
|
+
updatedAt: now,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
await daemon.expectOk('addWorktreeRecord', { sessionId: workspace.id, worktree: record })
|
|
82
|
+
} catch (error) {
|
|
83
|
+
// Catalog registration failed — roll back the on-disk worktree so
|
|
84
|
+
// `worktree list` doesn't perpetually surface an orphan. Swallow rollback
|
|
85
|
+
// errors: report the original failure, the real problem to surface.
|
|
86
|
+
try {
|
|
87
|
+
await removeGitWorktree({ force: true, repoPath: primary.repoRoot, targetPath })
|
|
88
|
+
} catch {
|
|
89
|
+
// Best-effort rollback; leave the git-side worktree if it can't be removed
|
|
90
|
+
// cleanly. `worktree list` will flag it as gitTracked with no catalog.
|
|
91
|
+
}
|
|
92
|
+
throw error
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return record
|
|
96
|
+
}
|
|
@@ -1,16 +1,8 @@
|
|
|
1
|
-
import type { WorktreeRecord } from '../../../state/types'
|
|
2
1
|
import type { CliCommand } from '../../registry'
|
|
3
2
|
|
|
4
|
-
import { createGitWorktree, removeGitWorktree } from '../../../git/worktree'
|
|
5
|
-
import { IPC_CAPABILITY_WORKTREE_LIFECYCLE_EVENTS } from '../../../ipc/protocol'
|
|
6
|
-
import { createPrefixedId } from '../../../platform/id'
|
|
7
|
-
import {
|
|
8
|
-
assertSafeAimuxWorktreePath,
|
|
9
|
-
ensureAimuxWorktreeRoot,
|
|
10
|
-
makeWorktreePath,
|
|
11
|
-
} from '../../../platform/worktree-paths'
|
|
12
3
|
import { SHARED_FLAGS } from '../../flags'
|
|
13
4
|
import { EXIT_OK, writeJson } from '../../output'
|
|
5
|
+
import { createWorkspaceWorktree } from './create-core'
|
|
14
6
|
|
|
15
7
|
export const worktreeCreate: CliCommand = {
|
|
16
8
|
args: [],
|
|
@@ -31,80 +23,15 @@ export const worktreeCreate: CliCommand = {
|
|
|
31
23
|
const base = typeof ctx.args.flags.base === 'string' ? ctx.args.flags.base : 'HEAD'
|
|
32
24
|
|
|
33
25
|
const workspace = ctx.getWorkspace()
|
|
34
|
-
const primary = workspace.worktrees?.find((w) => w.source === 'primary')
|
|
35
|
-
if (!primary) {
|
|
36
|
-
throw new Error(
|
|
37
|
-
`workspace "${workspace.name}" has no primary worktree — set --project when creating it`
|
|
38
|
-
)
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Check the daemon's capability BEFORE mutating disk — otherwise a
|
|
42
|
-
// capability mismatch would leave a git worktree on disk with no
|
|
43
|
-
// catalog record to track it.
|
|
44
26
|
const daemon = await ctx.getDaemon()
|
|
45
|
-
|
|
46
|
-
throw new Error(
|
|
47
|
-
'daemon predates worktreeLifecycleEvents capability — restart aimux to pick up the new daemon'
|
|
48
|
-
)
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const worktreeId = createPrefixedId('worktree')
|
|
52
|
-
const targetPath = makeWorktreePath({
|
|
53
|
-
repoRoot: primary.repoRoot,
|
|
54
|
-
worktreeId,
|
|
55
|
-
worktreeName: name,
|
|
56
|
-
})
|
|
57
|
-
await ensureAimuxWorktreeRoot()
|
|
58
|
-
await assertSafeAimuxWorktreePath(targetPath)
|
|
59
|
-
|
|
60
|
-
await createGitWorktree({
|
|
61
|
-
baseRef: base,
|
|
62
|
-
branchName: branch,
|
|
63
|
-
repoPath: primary.repoRoot,
|
|
64
|
-
targetPath,
|
|
65
|
-
})
|
|
66
|
-
|
|
67
|
-
const now = new Date().toISOString()
|
|
68
|
-
const record: WorktreeRecord = {
|
|
69
|
-
baseRef: base,
|
|
70
|
-
branch,
|
|
71
|
-
createdAt: now,
|
|
72
|
-
createdByAimux: true,
|
|
73
|
-
id: worktreeId,
|
|
74
|
-
name,
|
|
75
|
-
path: targetPath,
|
|
76
|
-
repoRoot: primary.repoRoot,
|
|
77
|
-
source: 'aimux-temp',
|
|
78
|
-
updatedAt: now,
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
try {
|
|
82
|
-
await daemon.expectOk('addWorktreeRecord', { sessionId: workspace.id, worktree: record })
|
|
83
|
-
} catch (error) {
|
|
84
|
-
// Catalog registration failed — roll back the on-disk worktree so
|
|
85
|
-
// `worktree list` doesn't perpetually surface an orphan. Swallow
|
|
86
|
-
// rollback errors: report the original failure, which is the real
|
|
87
|
-
// problem the operator needs to see.
|
|
88
|
-
try {
|
|
89
|
-
await removeGitWorktree({
|
|
90
|
-
force: true,
|
|
91
|
-
repoPath: primary.repoRoot,
|
|
92
|
-
targetPath,
|
|
93
|
-
})
|
|
94
|
-
} catch {
|
|
95
|
-
// Best-effort rollback; leave the git-side worktree if it can't be
|
|
96
|
-
// removed cleanly. `worktree list --workspace` will flag it as
|
|
97
|
-
// `gitTracked: true, catalog: no` on the next inspection.
|
|
98
|
-
}
|
|
99
|
-
throw error
|
|
100
|
-
}
|
|
27
|
+
const record = await createWorkspaceWorktree({ base, branch, daemon, name, workspace })
|
|
101
28
|
|
|
102
29
|
writeJson({
|
|
103
|
-
branch,
|
|
104
|
-
id:
|
|
105
|
-
name,
|
|
106
|
-
path:
|
|
107
|
-
repoRoot:
|
|
30
|
+
branch: record.branch,
|
|
31
|
+
id: record.id,
|
|
32
|
+
name: record.name,
|
|
33
|
+
path: record.path,
|
|
34
|
+
repoRoot: record.repoRoot,
|
|
108
35
|
})
|
|
109
36
|
return EXIT_OK
|
|
110
37
|
},
|
package/src/cli/flags.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
export interface FlagSpec {
|
|
8
8
|
name: string
|
|
9
|
-
kind: 'string' | 'number' | 'boolean'
|
|
9
|
+
kind: 'string' | 'number' | 'boolean' | 'optional-string'
|
|
10
10
|
description?: string
|
|
11
11
|
}
|
|
12
12
|
|
|
@@ -59,6 +59,13 @@ export function parseArgs(
|
|
|
59
59
|
flags[name] = true
|
|
60
60
|
continue
|
|
61
61
|
}
|
|
62
|
+
if (spec.kind === 'optional-string') {
|
|
63
|
+
// Value binds ONLY in the `=` form (`--flag=value`). A bare `--flag`
|
|
64
|
+
// must not swallow the next token (it may be a positional), so it
|
|
65
|
+
// parses as boolean `true`.
|
|
66
|
+
flags[name] = eq === -1 ? true : token.slice(eq + 1)
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
62
69
|
const raw = eq === -1 ? argv[++i] : token.slice(eq + 1)
|
|
63
70
|
if (raw === undefined) {
|
|
64
71
|
throw new CliUsageError(`flag --${name} requires a value`)
|
package/src/cli/index.ts
CHANGED
|
@@ -20,8 +20,8 @@ const EXIT_CODES_BLOCK = [
|
|
|
20
20
|
' 2 usage error (bad flags, unknown command, missing argument)',
|
|
21
21
|
' 3 runtime error (server replied with error, command failed)',
|
|
22
22
|
' 4 daemon unreachable (socket missing and autostart failed)',
|
|
23
|
-
' 10 question (tab run: worker is blocked on a question/permission)',
|
|
24
|
-
' 124 timeout (tab wait, tab tail --timeout, workspace switch --wait)',
|
|
23
|
+
' 10 question (tab run / tab await: worker is blocked on a question/permission)',
|
|
24
|
+
' 124 timeout (tab run, tab await, tab wait, tab tail --timeout, workspace switch --wait)',
|
|
25
25
|
].join('\n')
|
|
26
26
|
|
|
27
27
|
const OUTPUT_CONTRACT_BLOCK = [
|
|
@@ -52,6 +52,7 @@ function formatArgs(args: readonly ArgSpec[]): string {
|
|
|
52
52
|
function flagValueHint(flag: FlagSpec): string {
|
|
53
53
|
if (flag.kind === 'boolean') return ''
|
|
54
54
|
if (flag.kind === 'number') return ' <n>'
|
|
55
|
+
if (flag.kind === 'optional-string') return `[=<${flag.name}>]`
|
|
55
56
|
return ` <${flag.name}>`
|
|
56
57
|
}
|
|
57
58
|
|
package/src/cli/registry.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { CliContext } from './context'
|
|
2
2
|
import type { ArgSpec, FlagSpec } from './flags'
|
|
3
3
|
|
|
4
|
+
import { tabAwait } from './commands/tab/await'
|
|
4
5
|
import { tabClose } from './commands/tab/close'
|
|
5
6
|
import { tabCreate } from './commands/tab/create'
|
|
6
7
|
import { tabFocus } from './commands/tab/focus'
|
|
@@ -33,6 +34,7 @@ export const COMMANDS: readonly CliCommand[] = [
|
|
|
33
34
|
tabCreate,
|
|
34
35
|
tabSend,
|
|
35
36
|
tabRun,
|
|
37
|
+
tabAwait,
|
|
36
38
|
tabFocus,
|
|
37
39
|
tabClose,
|
|
38
40
|
tabSnapshot,
|
package/src/config.ts
CHANGED
|
@@ -13,7 +13,14 @@ function migrateThemeId(value: unknown): ThemeId | undefined {
|
|
|
13
13
|
return resolveLegacyThemeId(value)
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Path to the active profile's config file, resolved at CALL time. It must not
|
|
18
|
+
* be a module-level constant: `runCli` applies `--profile` (via `AIMUX_PROFILE`)
|
|
19
|
+
* after this module is imported, so a frozen path would read the wrong profile.
|
|
20
|
+
*/
|
|
21
|
+
export function getConfigPath(): string {
|
|
22
|
+
return `${getProfileConfigDir()}/aimux.json`
|
|
23
|
+
}
|
|
17
24
|
|
|
18
25
|
export interface PersistedGitPane {
|
|
19
26
|
diffModeRatio?: number
|
|
@@ -238,12 +245,13 @@ function isCustomCommandsRecord(value: unknown): value is Record<string, string>
|
|
|
238
245
|
}
|
|
239
246
|
|
|
240
247
|
export function loadConfigResult(): ConfigLoadResult {
|
|
248
|
+
const configPath = getConfigPath()
|
|
241
249
|
try {
|
|
242
|
-
if (!existsSync(
|
|
250
|
+
if (!existsSync(configPath)) {
|
|
243
251
|
return { config: DEFAULT_CONFIG, issues: [], source: 'defaults' }
|
|
244
252
|
}
|
|
245
253
|
|
|
246
|
-
const raw = readFileSync(
|
|
254
|
+
const raw = readFileSync(configPath, 'utf8')
|
|
247
255
|
const parsed = JSON.parse(raw) as {
|
|
248
256
|
version?: number
|
|
249
257
|
customCommands?: unknown
|
|
@@ -346,7 +354,7 @@ export function loadConfigResult(): ConfigLoadResult {
|
|
|
346
354
|
const validWorktreeTemplates = parseWorktreeTemplates(parsed.worktreeTemplates, issues)
|
|
347
355
|
|
|
348
356
|
if (issues.length > 0) {
|
|
349
|
-
logDebug('config.load.validationIssue', { issues, path:
|
|
357
|
+
logDebug('config.load.validationIssue', { issues, path: configPath })
|
|
350
358
|
}
|
|
351
359
|
|
|
352
360
|
return {
|
|
@@ -371,7 +379,7 @@ export function loadConfigResult(): ConfigLoadResult {
|
|
|
371
379
|
}
|
|
372
380
|
} catch (error) {
|
|
373
381
|
const message = error instanceof Error ? error.message : String(error)
|
|
374
|
-
logDebug('config.load.error', { error: message, path:
|
|
382
|
+
logDebug('config.load.error', { error: message, path: configPath })
|
|
375
383
|
return {
|
|
376
384
|
config: DEFAULT_CONFIG,
|
|
377
385
|
issues: [`failed to load config: ${message}`],
|
|
@@ -385,14 +393,15 @@ export function loadConfig(): AimuxConfig {
|
|
|
385
393
|
}
|
|
386
394
|
|
|
387
395
|
export function saveConfig(config: AimuxConfig): boolean {
|
|
396
|
+
const configPath = getConfigPath()
|
|
388
397
|
try {
|
|
389
398
|
mkdirSync(getProfileConfigDir(), { recursive: true })
|
|
390
|
-
writeFileSync(
|
|
399
|
+
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
|
|
391
400
|
return true
|
|
392
401
|
} catch (error) {
|
|
393
402
|
logDebug('config.save.error', {
|
|
394
403
|
error: error instanceof Error ? error.message : String(error),
|
|
395
|
-
path:
|
|
404
|
+
path: configPath,
|
|
396
405
|
})
|
|
397
406
|
return false
|
|
398
407
|
}
|
package/src/doctor.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { getConfigPath, loadConfigResult } from './config'
|
|
4
4
|
import { ASSISTANT_OPTIONS, isCommandAvailable, parseCommand } from './pty/command-registry'
|
|
5
5
|
|
|
6
6
|
export interface DoctorCheck {
|
|
@@ -22,11 +22,12 @@ function getConfigDetails(configResult: ReturnType<typeof loadConfigResult>): st
|
|
|
22
22
|
return configResult.issues.join('; ')
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
const configPath = getConfigPath()
|
|
26
|
+
if (existsSync(configPath)) {
|
|
27
|
+
return `loaded ${configPath}`
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
return `using defaults (${
|
|
30
|
+
return `using defaults (${configPath} not found)`
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
function getAssistantDetails(
|
package/src/index.tsx
CHANGED
|
@@ -83,7 +83,7 @@ if (command === '--help' || command === '-h' || command === 'help') {
|
|
|
83
83
|
'',
|
|
84
84
|
'Common CLI verbs at a glance',
|
|
85
85
|
' aimux tab list Enumerate tabs (+ activeTabId)',
|
|
86
|
-
' aimux tab create --assistant <id> [--title …] Spawn claude / codex / opencode / terminal / …',
|
|
86
|
+
' aimux tab create --assistant <id> [--title …] Spawn claude / codex / opencode / grok / terminal / …',
|
|
87
87
|
' aimux tab send <tabId> [text] [--enter|--keys|--stdin] Type, chord, or paste into a tab',
|
|
88
88
|
' aimux tab focus <tabId> Bring a tab to the foreground',
|
|
89
89
|
' aimux tab close <tabId> Terminate a tab',
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* `src/detect.rs` in that repo.
|
|
7
7
|
*
|
|
8
8
|
* The detector classifies a terminal session as `working`, `waiting-input`,
|
|
9
|
-
* or `idle`. Built-in CLIs (claude, codex, opencode)
|
|
9
|
+
* or `idle`. Built-in CLIs (claude, codex, opencode, grok) have dedicated classify* functions.
|
|
10
10
|
* tables. Custom CLIs fall back to a generic heuristic that (a) recognises
|
|
11
11
|
* common shells as always-idle and (b) uses pane-tail change velocity plus
|
|
12
12
|
* generic y/n / confirm prompt patterns.
|
|
@@ -95,7 +95,7 @@ export class AssistantStatusDetector {
|
|
|
95
95
|
export function extractTailLines(viewport: TerminalSnapshot, lineCount: number): string[] {
|
|
96
96
|
const isScrolledToBottom = viewport.viewportY === viewport.baseY
|
|
97
97
|
const lines = isScrolledToBottom ? viewport.lines : (viewport.tailLines ?? viewport.lines)
|
|
98
|
-
// Full-screen TUIs (claude, opencode) paint in the alternate buffer and
|
|
98
|
+
// Full-screen TUIs (claude, opencode, grok) paint in the alternate buffer and
|
|
99
99
|
// often leave the last rows blank, putting their status bar higher up.
|
|
100
100
|
// Skip trailing blank rows before taking the last `lineCount`.
|
|
101
101
|
let end = lines.length
|
|
@@ -138,6 +138,8 @@ function classifyBuiltin(
|
|
|
138
138
|
return classifyCodex(haystack)
|
|
139
139
|
case 'opencode':
|
|
140
140
|
return classifyOpencode(haystack)
|
|
141
|
+
case 'grok':
|
|
142
|
+
return classifyGrok(haystack, rawTail)
|
|
141
143
|
default:
|
|
142
144
|
return null
|
|
143
145
|
}
|
|
@@ -212,6 +214,47 @@ function classifyOpencode(haystack: string): TabActivity {
|
|
|
212
214
|
return 'idle'
|
|
213
215
|
}
|
|
214
216
|
|
|
217
|
+
function classifyGrok(haystack: string, _rawTail: string): TabActivity {
|
|
218
|
+
// Waiting for user decision / input (plan approval, Q&A, permissions, confirms).
|
|
219
|
+
// These are the distinctive Grok Build TUI states that must produce 'waiting-input'
|
|
220
|
+
// so the rest of the system (turn lifecycle, question events, UI chips, orchestrators)
|
|
221
|
+
// treats grok the same as claude/codex/opencode.
|
|
222
|
+
if (
|
|
223
|
+
haystack.includes('waiting on answers') ||
|
|
224
|
+
haystack.includes('pprove') || // stylized "[ a ] pprove [ c ] omment [ q ] uit plan"
|
|
225
|
+
haystack.includes('omment') ||
|
|
226
|
+
haystack.includes('uit plan') ||
|
|
227
|
+
(haystack.includes('approve') &&
|
|
228
|
+
(haystack.includes('comment') || haystack.includes('quit') || haystack.includes('plan'))) ||
|
|
229
|
+
haystack.includes('enter :select') ||
|
|
230
|
+
haystack.includes('enter to select') ||
|
|
231
|
+
haystack.includes('enter submit') ||
|
|
232
|
+
haystack.includes('do you want') ||
|
|
233
|
+
haystack.includes('would you like') ||
|
|
234
|
+
haystack.includes('permission required') ||
|
|
235
|
+
haystack.includes('permission to') ||
|
|
236
|
+
haystack.includes('approve?') ||
|
|
237
|
+
haystack.includes('allow?')
|
|
238
|
+
) {
|
|
239
|
+
return 'waiting-input'
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Agent actively reasoning or executing (mirrors claude spinner + interrupt logic).
|
|
243
|
+
// "Thought for Xs" is the primary visible trace while Grok thinks/plans.
|
|
244
|
+
if (
|
|
245
|
+
haystack.includes('thought for') || // "Thought for 3.4s", etc.
|
|
246
|
+
haystack.includes('thinking…') ||
|
|
247
|
+
haystack.includes('thinking ...') ||
|
|
248
|
+
haystack.includes('esc to interrupt') ||
|
|
249
|
+
haystack.includes('esc interrupt') ||
|
|
250
|
+
haystack.includes('esc: interrupt')
|
|
251
|
+
) {
|
|
252
|
+
return 'working'
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return 'idle'
|
|
256
|
+
}
|
|
257
|
+
|
|
215
258
|
const GENERIC_WAITING_PATTERNS: string[] = [
|
|
216
259
|
'[y/n]',
|
|
217
260
|
'(y/n)',
|
|
@@ -59,6 +59,17 @@ export const ASSISTANT_OPTIONS: AssistantOption[] = [
|
|
|
59
59
|
buildModelArgs: (model) => ['--model', model],
|
|
60
60
|
},
|
|
61
61
|
},
|
|
62
|
+
{
|
|
63
|
+
command: 'grok',
|
|
64
|
+
description: 'xAI Grok Build CLI',
|
|
65
|
+
id: 'grok',
|
|
66
|
+
label: 'Grok',
|
|
67
|
+
model: {
|
|
68
|
+
// Grok supports -m (and likely --model) plus --effort (alias --reasoning-effort).
|
|
69
|
+
buildEffortArgs: (effort) => ['--effort', effort],
|
|
70
|
+
buildModelArgs: (model) => ['-m', model],
|
|
71
|
+
},
|
|
72
|
+
},
|
|
62
73
|
{
|
|
63
74
|
command: 'agy',
|
|
64
75
|
description: 'Antigravity CLI',
|
package/src/state/types.ts
CHANGED
|
@@ -4,7 +4,13 @@ import type { ThemedToken } from 'shiki'
|
|
|
4
4
|
import type { WorktreeTemplate } from '../config'
|
|
5
5
|
import type { LayoutNode, SplitDirection } from './layout-tree'
|
|
6
6
|
|
|
7
|
-
export type BuiltinAssistantId =
|
|
7
|
+
export type BuiltinAssistantId =
|
|
8
|
+
| 'claude'
|
|
9
|
+
| 'codex'
|
|
10
|
+
| 'opencode'
|
|
11
|
+
| 'grok'
|
|
12
|
+
| 'terminal'
|
|
13
|
+
| 'antigravity'
|
|
8
14
|
|
|
9
15
|
export type AssistantId = BuiltinAssistantId | (string & {})
|
|
10
16
|
|