@brimveyn/aimux 1.20.1 → 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/README.md +9 -1
- package/package.json +1 -1
- package/skills/aimux-orchestrator/SKILL.md +39 -5
- package/skills/aimux-orchestrator/references/prompts.md +11 -0
- package/src/auto-rename/coordinator.ts +206 -32
- package/src/auto-rename/heuristic-title.ts +57 -0
- package/src/auto-rename/prompt-capture.ts +19 -0
- package/src/auto-rename/prompt-gate.ts +82 -0
- package/src/auto-rename/title-format.ts +38 -0
- package/src/auto-rename/title-runner.ts +32 -21
- package/src/cli/client/workspace-resolver.ts +61 -8
- package/src/cli/commands/tab/await.ts +1 -1
- package/src/cli/commands/tab/close.ts +1 -1
- package/src/cli/commands/tab/create.ts +27 -3
- package/src/cli/commands/tab/focus.ts +1 -1
- package/src/cli/commands/tab/prompt-io.ts +6 -1
- package/src/cli/commands/tab/run.ts +10 -2
- package/src/cli/commands/tab/send.ts +12 -7
- package/src/cli/commands/tab/snapshot.ts +2 -1
- package/src/cli/commands/tab/tail.ts +1 -1
- package/src/cli/commands/tab/wait.ts +2 -1
- package/src/cli/commands/worker/await.ts +5 -4
- package/src/cli/commands/worker/doctor.ts +25 -1
- package/src/cli/commands/worker/list.ts +50 -8
- package/src/cli/commands/worker/prompt.ts +31 -6
- package/src/cli/commands/worker/run.ts +56 -10
- package/src/cli/commands/worker/shared.ts +312 -52
- package/src/cli/commands/worker/stop.ts +39 -10
- package/src/cli/commands/worker/submit.ts +40 -0
- package/src/cli/commands/workspace/close.ts +1 -1
- package/src/cli/commands/workspace/create.ts +2 -1
- package/src/cli/commands/workspace/switch.ts +1 -1
- package/src/cli/commands/worktree/create-core.ts +27 -7
- package/src/cli/commands/worktree/create.ts +18 -3
- package/src/cli/commands/worktree/remove.ts +3 -1
- package/src/cli/completion/entry.ts +181 -0
- package/src/cli/completion/install.ts +222 -0
- package/src/cli/completion/plan.ts +216 -0
- package/src/cli/completion/scripts.ts +147 -0
- package/src/cli/completion/sources.ts +74 -0
- package/src/cli/context.ts +15 -0
- package/src/cli/flags.ts +45 -2
- package/src/cli/index.ts +20 -10
- package/src/cli/output.ts +3 -0
- package/src/cli/registry.ts +2 -0
- package/src/daemon/daemon.ts +11 -0
- package/src/doctor.ts +4 -0
- package/src/git/worktree.ts +15 -1
- package/src/index.tsx +35 -11
- package/src/platform/worktree-paths.ts +21 -1
package/README.md
CHANGED
|
@@ -36,6 +36,12 @@ bun install -g @brimveyn/aimux
|
|
|
36
36
|
|
|
37
37
|
Requires [Bun](https://bun.sh).
|
|
38
38
|
|
|
39
|
+
Shell completion (bash, zsh, fish) installs itself the first time you launch
|
|
40
|
+
the TUI — one file in your shell's completions directory, no dotfile edits.
|
|
41
|
+
Run `aimux doctor` to see where it landed, or
|
|
42
|
+
`aimux completion install --shell zsh` to place it yourself. Opt out with
|
|
43
|
+
`AIMUX_NO_COMPLETION_INSTALL=1`. See [docs/reference/cli.md](docs/reference/cli.md#aimux-completion).
|
|
44
|
+
|
|
39
45
|
## Quick Start
|
|
40
46
|
|
|
41
47
|
Create the default profile and install the typed config package into it:
|
|
@@ -170,7 +176,9 @@ See [`docs/reference/cli.md`](docs/reference/cli.md) for behavior details.
|
|
|
170
176
|
|
|
171
177
|
For agent orchestration, prefer the named `aimux worker` commands. They combine
|
|
172
178
|
isolated worktree creation, prompt dispatch, authoritative turn waiting, fleet
|
|
173
|
-
inspection, and guarded cleanup without shell wrappers or `jq`.
|
|
179
|
+
inspection, and guarded cleanup without shell wrappers or `jq`. Pin the target
|
|
180
|
+
with `--workspace` (or `AIMUX_WORKSPACE`) for anything long-running: the default
|
|
181
|
+
follows whichever workspace the UI opened last.
|
|
174
182
|
|
|
175
183
|
## Runtime Model
|
|
176
184
|
|
package/package.json
CHANGED
|
@@ -12,7 +12,7 @@ Own the plan and quality gate. Let aimux own worker transport and lifecycle.
|
|
|
12
12
|
Run once:
|
|
13
13
|
|
|
14
14
|
```bash
|
|
15
|
-
aimux worker doctor
|
|
15
|
+
aimux worker doctor --workspace <target>
|
|
16
16
|
aimux worker --help
|
|
17
17
|
```
|
|
18
18
|
|
|
@@ -20,6 +20,14 @@ Stop if the doctor reports missing worker capabilities, the wrong workspace, or
|
|
|
20
20
|
an unavailable assistant. Restart/update aimux as instructed; do not fall back
|
|
21
21
|
to screen scraping or the legacy shell wrappers.
|
|
22
22
|
|
|
23
|
+
**Pin the workspace and verify the repo before dispatching anything.** Without
|
|
24
|
+
`--workspace` (or `AIMUX_WORKSPACE`) aimux targets whichever workspace the UI
|
|
25
|
+
opened last, and that can change under you mid-run — including to a different
|
|
26
|
+
project. Check `checks.workspace.repoRoot` in the doctor output against the
|
|
27
|
+
repository you mean, then pass `--workspace <id|name>` on **every** call (all
|
|
28
|
+
examples below do). Every worker response echoes `workspace.repoRoot`; read it on
|
|
29
|
+
the first dispatch instead of assuming.
|
|
30
|
+
|
|
23
31
|
## Orchestration loop
|
|
24
32
|
|
|
25
33
|
1. Parse the plan into independently reviewable units and dependencies.
|
|
@@ -29,19 +37,20 @@ to screen scraping or the legacy shell wrappers.
|
|
|
29
37
|
|
|
30
38
|
```bash
|
|
31
39
|
aimux worker run \
|
|
40
|
+
--workspace <target> \
|
|
32
41
|
--name feat-auth \
|
|
33
42
|
--assistant claude \
|
|
34
43
|
--prompt-file /tmp/feat-auth.md \
|
|
35
44
|
--detach
|
|
36
45
|
```
|
|
37
46
|
|
|
38
|
-
5. Inspect the fleet with `aimux worker list
|
|
39
|
-
6. Await a detached worker with `aimux worker await feat-auth
|
|
47
|
+
5. Inspect the fleet with `aimux worker list --workspace <target>`.
|
|
48
|
+
6. Await a detached worker with `aimux worker await feat-auth --workspace <target>`.
|
|
40
49
|
7. If a worker asks a question, answer only when the plan already determines the
|
|
41
50
|
answer:
|
|
42
51
|
|
|
43
52
|
```bash
|
|
44
|
-
aimux worker prompt feat-auth --prompt-file /tmp/answer.md
|
|
53
|
+
aimux worker prompt feat-auth --workspace <target> --replace --prompt-file /tmp/answer.md
|
|
45
54
|
```
|
|
46
55
|
|
|
47
56
|
Escalate design choices, irreversible operations, deployments, pushes,
|
|
@@ -54,9 +63,32 @@ migrations, spending, and external communication to the human.
|
|
|
54
63
|
10. After integration, close and clean up:
|
|
55
64
|
|
|
56
65
|
```bash
|
|
57
|
-
aimux worker stop feat-auth --cleanup-worktree
|
|
66
|
+
aimux worker stop feat-auth --workspace <target> --cleanup-worktree
|
|
58
67
|
```
|
|
59
68
|
|
|
69
|
+
## Dispatch failure modes
|
|
70
|
+
|
|
71
|
+
Read these before the first dispatch; each one otherwise reads as a lost fleet.
|
|
72
|
+
|
|
73
|
+
- **`status: "pending-submit"` (exit 11)** — the prompt is sitting unsubmitted in
|
|
74
|
+
the worker's composer. The worker is alive and healthy. Recover with
|
|
75
|
+
`aimux worker submit <name> --workspace <target>`; never re-dispatch, which
|
|
76
|
+
would double the fleet onto the same branches. Widen the confirmation window
|
|
77
|
+
with `--uptake-timeout <ms>` for slow-booting assistants.
|
|
78
|
+
- **An empty `worker list`** — read the `workspace` field in the response. An
|
|
79
|
+
empty fleet in the wrong workspace is not a dead fleet. Add
|
|
80
|
+
`--all-workspaces` to answer "are they really gone?" in one call;
|
|
81
|
+
`git worktree list` in the target repo is the on-disk cross-check.
|
|
82
|
+
- **Composer contamination** — a human typing in a worker's tab leaves text that
|
|
83
|
+
`worker prompt` would append to, merging both into one incoherent instruction.
|
|
84
|
+
Use `--replace` (clears with `<C-u>`) for every correction, and
|
|
85
|
+
`aimux tab snapshot <tabId>` when a worker's behaviour doesn't match the
|
|
86
|
+
prompt you believe you sent.
|
|
87
|
+
- **Fresh worktrees are not provisioned** — a new worktree has no installed
|
|
88
|
+
dependencies and no generated files. Every dispatch prompt must tell the worker
|
|
89
|
+
to bootstrap before running any gate (see `references/prompts.md`), or it will
|
|
90
|
+
report environment failures as its own.
|
|
91
|
+
|
|
60
92
|
## Isolation
|
|
61
93
|
|
|
62
94
|
`worker run` creates a fresh worktree by default. Keep that default when writes
|
|
@@ -78,6 +110,8 @@ may overlap or scope is uncertain.
|
|
|
78
110
|
- Prefer `--prompt-file` for multiline prompts; it avoids shell quoting hazards.
|
|
79
111
|
- Use `--detach` for parallel launch. Without it, `worker run` waits and returns
|
|
80
112
|
a completed/question/timeout/error outcome.
|
|
113
|
+
- Pass `--workspace` on every call, and confirm the `repoRoot` the first response
|
|
114
|
+
reports is the repository you intend to change.
|
|
81
115
|
|
|
82
116
|
## Quality and safety
|
|
83
117
|
|
|
@@ -23,6 +23,12 @@
|
|
|
23
23
|
|
|
24
24
|
- <machine-checkable criterion>
|
|
25
25
|
|
|
26
|
+
## Environment
|
|
27
|
+
|
|
28
|
+
This is a fresh worktree: it has no installed dependencies and no generated
|
|
29
|
+
files. Run <the repo's install + codegen commands> before any verification gate,
|
|
30
|
+
and report a bootstrap failure as a bootstrap failure — never as a failing gate.
|
|
31
|
+
|
|
26
32
|
## Verification
|
|
27
33
|
|
|
28
34
|
Run the repository's tests, typecheck, lint, and build commands that apply.
|
|
@@ -32,6 +38,11 @@ Report changed files, commands run, results, and remaining risks.
|
|
|
32
38
|
Tell the worker to read repository instructions before editing. Do not ask it to
|
|
33
39
|
merge, push, deploy, or clean up its worktree.
|
|
34
40
|
|
|
41
|
+
Keep the **Environment** block. A fresh `git worktree` shares history, not build
|
|
42
|
+
state: no `node_modules`, no generated clients or route files. Without it workers
|
|
43
|
+
run the gates in a half-provisioned tree, fail for environment reasons, and report
|
|
44
|
+
those failures as if they were their own.
|
|
45
|
+
|
|
35
46
|
## Correction
|
|
36
47
|
|
|
37
48
|
```markdown
|
|
@@ -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
|
|
40
|
-
private
|
|
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.
|
|
46
|
-
this.
|
|
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.
|
|
52
|
-
|
|
53
|
-
|
|
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.
|
|
62
|
-
if (!tab
|
|
63
|
-
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
77
|
-
|
|
183
|
+
state.controller = controller
|
|
184
|
+
state.attempts++
|
|
78
185
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
+
}
|