@brimveyn/aimux 1.20.2 → 1.20.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.20.2",
3
+ "version": "1.20.3",
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",
@@ -1,13 +1,18 @@
1
1
  import type { AssistantId } from '../state/types'
2
2
 
3
3
  import { isSupportedProvider } from '../auto-commit/headless-commands'
4
+ import { heuristicTitle } from './heuristic-title'
4
5
  import { PromptCapture } from './prompt-capture'
6
+ import { classifyPrompt } from './prompt-gate'
5
7
  import { generateTabTitle, type TitleSpawnFn } from './title-runner'
6
8
 
7
9
  export interface AutoRenameConfigSnapshot {
8
10
  enabled: boolean
9
11
  timeoutMs: number
10
12
  models: Partial<Record<string, string>>
13
+ settleMs?: number
14
+ maxAttempts?: number
15
+ minPromptWords?: number
11
16
  }
12
17
 
13
18
  export interface AutoRenameTab {
@@ -27,6 +32,35 @@ export interface AutoRenameCoordinatorOptions {
27
32
  spawn?: TitleSpawnFn
28
33
  }
29
34
 
35
+ const DEFAULT_SETTLE_MS = 2_500
36
+ const DEFAULT_MAX_ATTEMPTS = 3
37
+ const DEFAULT_MIN_PROMPT_WORDS = 3
38
+ /** Opening prompts folded into one title request. */
39
+ const MAX_PENDING_PROMPTS = 3
40
+ /** Headless CLIs running at once, so opening a burst of tabs does not fork one per tab. */
41
+ const MAX_CONCURRENT_GENERATIONS = 2
42
+
43
+ interface TabRenameState {
44
+ capture: PromptCapture
45
+ /** Title-worthy prompts collected for the next generation, oldest first. */
46
+ prompts: string[]
47
+ /** First title-worthy prompt seen, kept as the source for the local fallback. */
48
+ firstPrompt: string | null
49
+ attempts: number
50
+ settleTimer: ReturnType<typeof setTimeout> | null
51
+ controller: AbortController | null
52
+ /**
53
+ * True once a provider hook delivered a real prompt. Hook payloads are ground
54
+ * truth — dialog keystrokes never reach them — so keystroke reconstruction is
55
+ * ignored from then on.
56
+ */
57
+ hookDriven: boolean
58
+ }
59
+
60
+ function normalize(text: string): string {
61
+ return text.replaceAll(/\s+/gu, ' ').trim().toLowerCase()
62
+ }
63
+
30
64
  export function initialAutoRenameStatus(
31
65
  config: AutoRenameConfigSnapshot,
32
66
  assistant: AssistantId,
@@ -35,63 +69,203 @@ export function initialAutoRenameStatus(
35
69
  return config.enabled && candidate && isSupportedProvider(assistant) ? 'eligible' : undefined
36
70
  }
37
71
 
72
+ /**
73
+ * Owns the "name this tab after what it is doing" behaviour.
74
+ *
75
+ * Two properties drive the design:
76
+ *
77
+ * - A tab only leaves `eligible` on success, on an exhausted attempt budget, or
78
+ * on a manual rename. Anything transient — an unreadable keystroke stream, a
79
+ * failed CLI, a timeout — leaves the tab armed for the next prompt, so a tab
80
+ * never gets stuck on its assistant label because of one bad submission.
81
+ * - Nothing is titled from a prompt that carries no intent, and a prompt that
82
+ * does starts a settle window rather than an immediate request, so a rapid
83
+ * multi-message opening produces one title from the whole opening.
84
+ */
38
85
  export class AutoRenameCoordinator {
39
- private readonly captures = new Map<string, PromptCapture>()
40
- private readonly controllers = new Map<string, AbortController>()
86
+ private readonly states = new Map<string, TabRenameState>()
87
+ private running = 0
88
+ private readonly waiting: (() => void)[] = []
41
89
 
42
90
  constructor(private readonly options: AutoRenameCoordinatorOptions) {}
43
91
 
44
92
  register(tab: AutoRenameTab): void {
45
- if (tab.autoRenameStatus === 'eligible' && !this.captures.has(tab.id)) {
46
- this.captures.set(tab.id, new PromptCapture())
93
+ if (tab.autoRenameStatus === 'eligible' && !this.states.has(tab.id)) {
94
+ this.states.set(tab.id, createState())
47
95
  }
48
96
  }
49
97
 
50
98
  unregister(tabId: string): void {
51
- this.controllers.get(tabId)?.abort()
52
- this.controllers.delete(tabId)
53
- this.captures.delete(tabId)
99
+ const state = this.states.get(tabId)
100
+ if (!state) return
101
+ state.controller?.abort()
102
+ if (state.settleTimer) clearTimeout(state.settleTimer)
103
+ this.states.delete(tabId)
54
104
  }
55
105
 
56
106
  manualRename(tabId: string): void {
57
107
  this.unregister(tabId)
58
108
  }
59
109
 
110
+ /** Keystroke fallback: reconstruct submissions from the bytes written to the PTY. */
60
111
  observeWrite(tabId: string, input: string): void {
61
- const tab = this.options.getTab(tabId)
62
- if (!tab || tab.autoRenameStatus !== 'eligible') return
63
- let capture = this.captures.get(tabId)
64
- if (!capture) {
65
- capture = new PromptCapture()
66
- this.captures.set(tabId, capture)
67
- }
68
- const result = capture.feed(input)
112
+ const tab = this.eligibleTab(tabId)
113
+ if (!tab) return
114
+ const state = this.ensureState(tabId)
115
+ if (state.hookDriven) return
116
+
117
+ const result = state.capture.feed(input)
69
118
  if (result.type === 'pending') return
119
+ // A submission we could not reconstruct faithfully is dropped, not counted:
120
+ // history recall, Tab completion and unknown escapes must not cost the tab
121
+ // its only chance at a name.
122
+ if (result.prompt === null) return
123
+ this.acceptPrompt(tabId, state, result.prompt)
124
+ }
125
+
126
+ /**
127
+ * Ground truth from a provider hook (Claude's `UserPromptSubmit`): the exact
128
+ * prompt text, delivered only for real submissions.
129
+ */
130
+ observePrompt(tabId: string, prompt: string): void {
131
+ const tab = this.eligibleTab(tabId)
132
+ if (!tab) return
133
+ const state = this.ensureState(tabId)
134
+ if (!state.hookDriven) {
135
+ state.hookDriven = true
136
+ state.capture.reset()
137
+ }
138
+ this.acceptPrompt(tabId, state, prompt)
139
+ }
140
+
141
+ private eligibleTab(tabId: string): AutoRenameTab | undefined {
142
+ const tab = this.options.getTab(tabId)
143
+ return tab?.autoRenameStatus === 'eligible' ? tab : undefined
144
+ }
145
+
146
+ private ensureState(tabId: string): TabRenameState {
147
+ const existing = this.states.get(tabId)
148
+ if (existing) return existing
149
+ const created = createState()
150
+ this.states.set(tabId, created)
151
+ return created
152
+ }
153
+
154
+ private acceptPrompt(tabId: string, state: TabRenameState, prompt: string): void {
155
+ const minWords = this.options.config.minPromptWords ?? DEFAULT_MIN_PROMPT_WORDS
156
+ if (classifyPrompt(prompt, minWords) === 'skip') return
157
+
158
+ state.firstPrompt ??= prompt
159
+ appendPrompt(state, prompt)
160
+ // A generation already in flight covers this prompt's window; a settled
161
+ // title is never rewritten.
162
+ if (state.controller) return
163
+ this.scheduleGeneration(tabId, state)
164
+ }
165
+
166
+ private scheduleGeneration(tabId: string, state: TabRenameState): void {
167
+ if (state.settleTimer) clearTimeout(state.settleTimer)
168
+ const settleMs = this.options.config.settleMs ?? DEFAULT_SETTLE_MS
169
+ const timer = setTimeout(() => {
170
+ state.settleTimer = null
171
+ void this.generate(tabId, state)
172
+ }, settleMs)
173
+ // Never hold the process open for a pending title.
174
+ timer.unref?.()
175
+ state.settleTimer = timer
176
+ }
177
+
178
+ private async generate(tabId: string, state: TabRenameState): Promise<void> {
179
+ const tab = this.eligibleTab(tabId)
180
+ if (!tab || this.states.get(tabId) !== state || state.prompts.length === 0) return
70
181
 
71
- this.captures.delete(tabId)
72
- this.options.updateTab(tabId, { autoRenameStatus: 'attempted' })
73
- const prompt = result.prompt
74
- if (prompt === null) return
75
182
  const controller = new AbortController()
76
- this.controllers.set(tabId, controller)
77
- const originalTitle = tab.title
183
+ state.controller = controller
184
+ state.attempts++
78
185
 
79
- void (async () => {
80
- const title = await generateTabTitle({
81
- firstPrompt: prompt,
186
+ const release = await this.acquireSlot()
187
+ let result: Awaited<ReturnType<typeof generateTabTitle>>
188
+ try {
189
+ // The tab may have been closed or renamed while queued behind another
190
+ // generation; drop out before spending a process on it.
191
+ if (this.states.get(tabId) !== state || controller.signal.aborted) return
192
+ result = await generateTabTitle({
193
+ firstPrompt: state.prompts.join('\n\n'),
82
194
  model: this.options.config.models[tab.assistant],
83
195
  provider: tab.assistant,
84
196
  signal: controller.signal,
85
197
  spawn: this.options.spawn,
86
198
  timeoutMs: this.options.config.timeoutMs,
87
199
  })
88
- if (this.controllers.get(tabId) !== controller) return
89
- this.controllers.delete(tabId)
90
- if (title == null || title === '') return
91
- const current = this.options.getTab(tabId)
92
- if (!current || current.autoRenameStatus !== 'attempted' || current.title !== originalTitle)
93
- return
94
- this.options.updateTab(tabId, { autoRenameStatus: 'attempted', title })
95
- })()
200
+ } finally {
201
+ release()
202
+ }
203
+
204
+ if (this.states.get(tabId) !== state) return
205
+ state.controller = null
206
+
207
+ if (result.status === 'ok') {
208
+ this.finish(tabId, result.title)
209
+ return
210
+ }
211
+
212
+ const maxAttempts = this.options.config.maxAttempts ?? DEFAULT_MAX_ATTEMPTS
213
+ // `unavailable` means no retry can ever succeed; otherwise keep the tab
214
+ // armed so the next prompt tries again with more context.
215
+ if (result.status === 'failed' && state.attempts < maxAttempts) return
216
+
217
+ this.finish(tabId, heuristicTitle(state.firstPrompt ?? state.prompts[0] ?? ''))
218
+ }
219
+
220
+ /** Apply the final outcome and stop watching the tab. */
221
+ private finish(tabId: string, title: string | null): void {
222
+ this.unregister(tabId)
223
+ this.options.updateTab(tabId, {
224
+ autoRenameStatus: 'attempted',
225
+ ...(title != null && title !== '' ? { title } : {}),
226
+ })
227
+ }
228
+
229
+ private async acquireSlot(): Promise<() => void> {
230
+ if (this.running >= MAX_CONCURRENT_GENERATIONS) {
231
+ await new Promise<void>((resolve) => this.waiting.push(resolve))
232
+ }
233
+ this.running++
234
+ let released = false
235
+ return () => {
236
+ if (released) return
237
+ released = true
238
+ this.running--
239
+ this.waiting.shift()?.()
240
+ }
96
241
  }
97
242
  }
243
+
244
+ function createState(): TabRenameState {
245
+ return {
246
+ attempts: 0,
247
+ capture: new PromptCapture(),
248
+ controller: null,
249
+ firstPrompt: null,
250
+ hookDriven: false,
251
+ prompts: [],
252
+ settleTimer: null,
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Add a prompt, keeping the pending set free of restatements of the same text.
258
+ * The keystroke and hook paths can both report one submission, and a user often
259
+ * rephrases; either way the title request should see the content once.
260
+ */
261
+ function appendPrompt(state: TabRenameState, prompt: string): void {
262
+ const incoming = normalize(prompt)
263
+ if (incoming === '') return
264
+ // Already covered by a pending prompt (the same submission seen through both
265
+ // the keystroke and hook paths, or a restatement of it).
266
+ if (state.prompts.some((existing) => normalize(existing).includes(incoming))) return
267
+ // Supersede the shorter pending prompts this one subsumes.
268
+ state.prompts = state.prompts.filter((existing) => !incoming.includes(normalize(existing)))
269
+ if (state.prompts.length >= MAX_PENDING_PROMPTS) return
270
+ state.prompts.push(prompt)
271
+ }
@@ -0,0 +1,57 @@
1
+ // Last-resort title, derived locally from the prompt when every model
2
+ // generation attempt failed (binary missing, non-zero exit, timeout,
3
+ // unusable output). A rough title beats a tab that stays "Claude" forever.
4
+
5
+ import { clampTitle } from './title-format'
6
+
7
+ /** Openers that carry no intent; dropped so the title starts at the verb. */
8
+ const LEADING_FILLERS = [
9
+ /^(?:hey|hi|hello|yo|salut|bonjour|coucou)\b[\s,]*/iu,
10
+ /^(?:please|pls|plz|stp|svp)\b[\s,]*/iu,
11
+ /^(?:s'il te (?:plait|plaît)|s'il vous (?:plait|plaît))\b[\s,]*/iu,
12
+ /^(?:can|could|would|will)\s+you\s+(?:please\s+)?/iu,
13
+ /^(?:i(?:'d| would)\s+like\s+you\s+to|i\s+want\s+you\s+to|i\s+need\s+you\s+to)\s+/iu,
14
+ /^(?:j'aimerais\s+que\s+tu|je\s+voudrais\s+que\s+tu|peux[- ]tu|pourrais[- ]tu|tu\s+peux)\s+/iu,
15
+ /^(?:let'?s|on\s+va)\s+/iu,
16
+ /^(?:ok|okay|alors|bon|donc|so|now)\b[\s,]*/iu,
17
+ ]
18
+
19
+ /** Markdown noise that would otherwise land inside the title. */
20
+ const LEADING_MARKUP = /^(?:[-*+>#]+|\d+[.)])\s*/u
21
+
22
+ export function heuristicTitle(prompt: string): string | null {
23
+ // First prose line: fenced code is pasted context, never the request itself.
24
+ let inFence = false
25
+ let firstLine: string | undefined
26
+ for (const raw of prompt.split(/\r?\n/u)) {
27
+ const line = raw.trim()
28
+ if (line.startsWith('```')) {
29
+ inFence = !inFence
30
+ continue
31
+ }
32
+ if (inFence || line === '') continue
33
+ firstLine = line
34
+ break
35
+ }
36
+ if (firstLine == null) return null
37
+
38
+ let text = firstLine.replaceAll('`', '').replace(LEADING_MARKUP, '').trim()
39
+ // Fillers stack ("hey, could you please …"), so keep peeling until stable.
40
+ let stripped = true
41
+ while (stripped) {
42
+ stripped = false
43
+ for (const filler of LEADING_FILLERS) {
44
+ const next = text.replace(filler, '')
45
+ if (next !== text) {
46
+ text = next.trim()
47
+ stripped = true
48
+ }
49
+ }
50
+ }
51
+
52
+ // Only the opening clause describes the task; the rest is detail.
53
+ const clause = text.split(/(?<=[.!?;:])\s|\s+(?:and|then|puis|et)\s+/iu)[0] ?? text
54
+ const title = clampTitle(clause)
55
+ if (title == null) return null
56
+ return title.charAt(0).toLocaleUpperCase() + title.slice(1)
57
+ }
@@ -121,6 +121,14 @@ export class PromptCapture {
121
121
  this.escapeBuffer += char
122
122
  const sequence = this.escapeBuffer
123
123
 
124
+ // Alt/Shift+Enter — a newline inside the prompt, not a submission and not a
125
+ // reason to distrust what we captured.
126
+ if (sequence.length === 2 && (char === '\r' || char === '\n')) {
127
+ this.escapeBuffer = ''
128
+ this.insert('\n')
129
+ return
130
+ }
131
+
124
132
  if (
125
133
  sequence.length === 2 &&
126
134
  sequence !== '\x1b[' &&
@@ -169,6 +177,17 @@ export class PromptCapture {
169
177
 
170
178
  const final = sequence.at(-1) ?? ''
171
179
  const parameters = sequence.slice(2, -1)
180
+ // Modified Enter under the kitty keyboard protocol (`CSI 13;2 u`) or
181
+ // xterm's modifyOtherKeys (`CSI 27;2;13 ~`): another way to type a newline
182
+ // inside the prompt.
183
+ const fields = parameters.split(';')
184
+ if (
185
+ (final === 'u' && fields[0] === '13') ||
186
+ (final === '~' && fields[0] === '27' && fields[2] === '13')
187
+ ) {
188
+ this.insert('\n')
189
+ return
190
+ }
172
191
  if ((final === 'C' || final === 'D') && parameters.includes(';')) {
173
192
  this.invalidateCurrentSubmission()
174
193
  return
@@ -0,0 +1,82 @@
1
+ // Decides whether a submitted prompt says anything about what the tab is for.
2
+ //
3
+ // The first Enter in an assistant tab is very often not a task: a trust-folder
4
+ // dialog, a theme picker, a slash-command menu, a permission answer, or a bare
5
+ // "y". Titling from those is the "renamed too early / wrong intent" failure, so
6
+ // they are skipped — and skipping is free: it never consumes a rename attempt.
7
+
8
+ import { usesUnspacedScript } from './title-format'
9
+
10
+ /** Answers that only advance a dialog; never a description of the work. */
11
+ const CONFIRMATIONS: ReadonlySet<string> = new Set([
12
+ 'a',
13
+ 'accept',
14
+ 'again',
15
+ 'allow',
16
+ 'annuler',
17
+ 'cancel',
18
+ 'continue',
19
+ 'continue please',
20
+ 'd',
21
+ 'do it',
22
+ 'encore',
23
+ 'exit',
24
+ 'go',
25
+ 'go ahead',
26
+ 'go on',
27
+ 'k',
28
+ 'merci',
29
+ 'n',
30
+ 'next',
31
+ 'no',
32
+ 'non',
33
+ 'nope',
34
+ 'ok',
35
+ 'okay',
36
+ 'oui',
37
+ 'proceed',
38
+ 'quit',
39
+ 'retry',
40
+ 'skip',
41
+ 'stop',
42
+ 'sure',
43
+ 'thanks',
44
+ 'thank you',
45
+ 'undo',
46
+ 'vas-y',
47
+ 'y',
48
+ 'yep',
49
+ 'yes',
50
+ 'yes please',
51
+ ])
52
+
53
+ /** `/model`, `/init`, `/clear`… — a command, not a request. `/home/x/y.ts` is not one. */
54
+ const SLASH_COMMAND = /^\/[a-z][\w:-]*(?:\s|$)/iu
55
+ /** `!ls -la` runs a shell command in Claude Code. */
56
+ const SHELL_ESCAPE = /^!/u
57
+ /** `#` memorizes a note in Claude Code. */
58
+ const MEMORY_NOTE = /^#/u
59
+
60
+ export type PromptVerdict = 'title-worthy' | 'skip'
61
+
62
+ export function classifyPrompt(prompt: string, minWords: number): PromptVerdict {
63
+ const trimmed = prompt.trim()
64
+ if (trimmed === '') return 'skip'
65
+ if (SLASH_COMMAND.test(trimmed) || SHELL_ESCAPE.test(trimmed) || MEMORY_NOTE.test(trimmed)) {
66
+ return 'skip'
67
+ }
68
+
69
+ const normalized = trimmed
70
+ .replaceAll(/\s+/gu, ' ')
71
+ .replace(/[.!?,;:…]+$/u, '')
72
+ .toLowerCase()
73
+ if (CONFIRMATIONS.has(normalized)) return 'skip'
74
+ // Menu selections: a lone digit, or "1" / "2." style picks.
75
+ if (/^\d{1,2}$/u.test(normalized)) return 'skip'
76
+
77
+ // Languages without spaces between words defeat the word count; fall back to
78
+ // a character floor so Japanese or Chinese prompts are not all skipped.
79
+ if (usesUnspacedScript(trimmed)) return trimmed.length >= 4 ? 'title-worthy' : 'skip'
80
+
81
+ return trimmed.split(/\s+/u).filter(Boolean).length >= minWords ? 'title-worthy' : 'skip'
82
+ }
@@ -0,0 +1,38 @@
1
+ // Shared shaping rules for tab titles, whether they come from a model
2
+ // (`title-runner`) or from the local fallback (`heuristic-title`). Both must
3
+ // produce the same silhouette: 2 to 6 words, at most 48 characters, no
4
+ // trailing punctuation.
5
+
6
+ export const MAX_TITLE_WORDS = 6
7
+ export const MAX_TITLE_LENGTH = 48
8
+
9
+ /** Scripts that do not separate words with spaces, where a one-"word" title is legitimate. */
10
+ export function usesUnspacedScript(text: string): boolean {
11
+ return /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}\p{Script=Thai}]/u.test(
12
+ text
13
+ )
14
+ }
15
+
16
+ /**
17
+ * Collapse whitespace, drop trailing punctuation, and clamp to the title
18
+ * budget. Returns null when nothing usable is left.
19
+ */
20
+ export function clampTitle(raw: string): string | null {
21
+ const clean = raw
22
+ .replaceAll(/\s+/gu, ' ')
23
+ .replace(/[.!?,;:…]+$/u, '')
24
+ .trim()
25
+ const words = clean.split(' ').filter(Boolean)
26
+ const unspaced = usesUnspacedScript(clean)
27
+ if (words.length < 2 && !unspaced) return null
28
+
29
+ let title = words.slice(0, MAX_TITLE_WORDS).join(' ')
30
+ if (title.length > MAX_TITLE_LENGTH) {
31
+ title = title
32
+ .slice(0, MAX_TITLE_LENGTH)
33
+ .replace(/\s+\S*$/u, '')
34
+ .trim()
35
+ }
36
+ if (title === '') return null
37
+ return title.split(' ').filter(Boolean).length < 2 && !unspaced ? null : title
38
+ }
@@ -1,10 +1,21 @@
1
1
  import { buildHeadlessInvocation, type HeadlessInvocation } from '../auto-commit/headless-commands'
2
+ import { clampTitle } from './title-format'
2
3
 
3
4
  export type TitleSpawnFn = (
4
5
  invocation: HeadlessInvocation,
5
6
  signal: AbortSignal
6
7
  ) => Promise<{ stdout: string; exitCode: number } | null>
7
8
 
9
+ /**
10
+ * `failed` is retryable — a later prompt may well produce a usable title.
11
+ * `unavailable` is not: the provider has no headless mode or its binary is not
12
+ * installed, so the coordinator should stop burning attempts and fall back.
13
+ */
14
+ export type TitleResult =
15
+ | { status: 'ok'; title: string }
16
+ | { status: 'failed' }
17
+ | { status: 'unavailable' }
18
+
8
19
  export function buildTitlePrompt(firstPrompt: string): string {
9
20
  return [
10
21
  'Create a concise tab title for the user request below.',
@@ -23,24 +34,16 @@ export function sanitizeGeneratedTitle(raw: string): string | null {
23
34
  if (first == null || first === '') return null
24
35
 
25
36
  const unlabelled = first.replace(/^TITLE\s*:\s*/iu, '').replaceAll(/^["'“”‘’]+|["'“”‘’]+$/gu, '')
26
- const clean = unlabelled
27
- .replaceAll(/\s+/gu, ' ')
28
- .replace(/[.!?,;:…]+$/u, '')
29
- .trim()
30
- const words = clean.split(' ').filter(Boolean)
31
- const usesUnspacedScript = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u.test(clean)
32
- if (words.length < 2 && !usesUnspacedScript) return null
37
+ return clampTitle(unlabelled)
38
+ }
33
39
 
34
- let title = words.slice(0, 6).join(' ')
35
- if (title.length > 48) {
36
- title = title
37
- .slice(0, 48)
38
- .replace(/\s+\S*$/u, '')
39
- .trim()
40
+ function executableOnPath(executable: string): boolean {
41
+ try {
42
+ return typeof Bun !== 'undefined' && Bun.which(executable) != null
43
+ } catch {
44
+ // Never let a lookup failure mask a provider that would have worked.
45
+ return true
40
46
  }
41
- return title === '' || (title.split(' ').filter(Boolean).length < 2 && !usesUnspacedScript)
42
- ? null
43
- : title
44
47
  }
45
48
 
46
49
  export async function generateTabTitle(options: {
@@ -50,21 +53,29 @@ export async function generateTabTitle(options: {
50
53
  timeoutMs: number
51
54
  signal: AbortSignal
52
55
  spawn?: TitleSpawnFn
53
- }): Promise<string | null> {
56
+ isExecutableAvailable?: (executable: string) => boolean
57
+ }): Promise<TitleResult> {
54
58
  const invocation = buildHeadlessInvocation(
55
59
  options.provider,
56
60
  buildTitlePrompt(options.firstPrompt),
57
61
  options.model
58
62
  )
59
- if (!invocation) return null
63
+ if (!invocation) return { status: 'unavailable' }
64
+
65
+ // A caller-supplied spawn does not go through PATH, so only probe it for the
66
+ // real one. Probing lets a missing CLI fail instantly instead of after the
67
+ // full timeout, once per tab instead of once per attempt.
68
+ const available = options.isExecutableAvailable ?? (options.spawn ? null : executableOnPath)
69
+ if (available && !available(invocation.executable)) return { status: 'unavailable' }
60
70
 
61
71
  const signal = AbortSignal.any([options.signal, AbortSignal.timeout(options.timeoutMs)])
62
72
  try {
63
73
  const result = await (options.spawn ?? defaultSpawn)(invocation, signal)
64
- if (!result || result.exitCode !== 0 || signal.aborted) return null
65
- return sanitizeGeneratedTitle(result.stdout)
74
+ if (!result || result.exitCode !== 0 || signal.aborted) return { status: 'failed' }
75
+ const title = sanitizeGeneratedTitle(result.stdout)
76
+ return title == null ? { status: 'failed' } : { status: 'ok', title }
66
77
  } catch {
67
- return null
78
+ return { status: 'failed' }
68
79
  }
69
80
  }
70
81
 
@@ -514,6 +514,17 @@ export async function runDaemon(): Promise<void> {
514
514
  payload: event.payload,
515
515
  receivedAt: event.receivedAt,
516
516
  })
517
+ // `UserPromptSubmit` carries the exact prompt at the exact moment it is
518
+ // submitted, so auto-rename prefers it over reconstructing keystrokes:
519
+ // trust dialogs, menus and completions never produce one.
520
+ if (event.hookEventName === 'UserPromptSubmit') {
521
+ const prompt = event.payload.prompt
522
+ const parentToolUseId = event.payload.parent_tool_use_id
523
+ const fromSubagent = typeof parentToolUseId === 'string' && parentToolUseId.length > 0
524
+ if (typeof prompt === 'string' && !fromSubagent) {
525
+ autoRename.observePrompt(event.paneId, prompt)
526
+ }
527
+ }
517
528
  },
518
529
  })
519
530
  try {