@brimveyn/aimux 1.19.3 → 1.19.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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",
3
+ "version": "1.19.5",
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.1",
67
+ "@brimveyn/aimux-config": "0.8.3",
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>(['claude', 'codex', 'opencode'])
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
- const options = getAllAssistantOptions({})
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 ?? option.command
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 cwd = cwdRaw === undefined ? undefined : resolvePath(cwdRaw)
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 worktreeId: explicit flag wins, otherwise the workspace's
95
- // currently-active worktree, otherwise undefined (= no grouping).
96
- const worktreeFlag =
97
- typeof ctx.args.flags.worktree === 'string' ? ctx.args.flags.worktree : undefined
98
- let worktreeId: string | undefined = worktreeFlag ?? workspace.activeWorktreeId
99
- if (worktreeFlag !== undefined) {
100
- const known = workspace.worktrees?.some((w) => w.id === worktreeFlag) ?? false
101
- if (!known) {
102
- const ids = workspace.worktrees?.map((w) => w.id).join(', ') ?? '(none)'
103
- throw new Error(`unknown worktree id: ${worktreeFlag} (known: ${ids})`)
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
- worktreeId = worktreeFlag
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