@brimveyn/aimux 1.20.1 → 1.20.2
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/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/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
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure completion planner. Given the shell's current words + cursor index it
|
|
3
|
+
* decides WHAT should be completed — never how, and never with I/O. Dynamic
|
|
4
|
+
* sources come back as a plan the caller resolves (see `sources.ts`), which
|
|
5
|
+
* keeps this whole file unit-testable and free of daemon/filesystem access.
|
|
6
|
+
*
|
|
7
|
+
* The candidate lists are derived from `COMMANDS`, the same registry that
|
|
8
|
+
* generates `--help`, so completion cannot drift from the documented CLI.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { CompletionSource, DynamicCompletionSource, FlagSpec } from '../flags'
|
|
12
|
+
|
|
13
|
+
import { COMMANDS, resolveCommand } from '../registry'
|
|
14
|
+
|
|
15
|
+
export interface CompletionCandidate {
|
|
16
|
+
description?: string
|
|
17
|
+
value: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type CompletionPlan =
|
|
21
|
+
/** Ready-to-print candidates, already filtered and prefixed. */
|
|
22
|
+
| { candidates: CompletionCandidate[]; kind: 'candidates' }
|
|
23
|
+
/** Needs live state. `prefix` is re-applied to each resolved value. */
|
|
24
|
+
| { kind: 'dynamic'; prefix: string; source: DynamicCompletionSource; word: string }
|
|
25
|
+
/** Hand off to the shell's own filename completion. */
|
|
26
|
+
| { kind: 'files' }
|
|
27
|
+
/** Free text — offer nothing. */
|
|
28
|
+
| { kind: 'none' }
|
|
29
|
+
|
|
30
|
+
const NONE: CompletionPlan = { kind: 'none' }
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Top-level (non-group) commands, mirroring the branches in `src/index.tsx`.
|
|
34
|
+
* `daemon` / `terminal-manager` / `__complete` are internal and stay hidden.
|
|
35
|
+
*/
|
|
36
|
+
export const TOP_LEVEL_COMMANDS: readonly CompletionCandidate[] = [
|
|
37
|
+
{ description: 'Print setup diagnostics', value: 'doctor' },
|
|
38
|
+
{ description: 'Self-update to the latest release', value: 'update' },
|
|
39
|
+
{ description: 'Print the package version', value: 'version' },
|
|
40
|
+
{ description: 'Restart the IPC daemon (PTYs survive)', value: 'restart-daemon' },
|
|
41
|
+
{ description: 'Restart the terminal manager (kills PTYs)', value: 'restart-terminal-manager' },
|
|
42
|
+
{ description: 'Print or install a shell completion script', value: 'completion' },
|
|
43
|
+
{ description: 'Show CLI help', value: 'help' },
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
const COMPLETION_SUBCOMMANDS: readonly CompletionCandidate[] = [
|
|
47
|
+
{ description: 'Print the bash completion script', value: 'bash' },
|
|
48
|
+
{ description: 'Print the fish completion script', value: 'fish' },
|
|
49
|
+
{ description: 'Print the zsh completion script', value: 'zsh' },
|
|
50
|
+
{ description: 'Install the script for the detected shell', value: 'install' },
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
const GROUP_DESCRIPTIONS: Record<string, string> = {
|
|
54
|
+
tab: 'Drive individual tabs (create, send, snapshot, await)',
|
|
55
|
+
worker: 'Named agent workers — the preferred orchestration surface',
|
|
56
|
+
workspace: 'Workspaces (sessions) in the profile catalog',
|
|
57
|
+
worktree: 'Git worktrees attached to the active workspace',
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function groupCandidates(): CompletionCandidate[] {
|
|
61
|
+
const seen = new Set<string>()
|
|
62
|
+
const candidates: CompletionCandidate[] = []
|
|
63
|
+
for (const command of COMMANDS) {
|
|
64
|
+
if (seen.has(command.group)) continue
|
|
65
|
+
seen.add(command.group)
|
|
66
|
+
candidates.push({ description: GROUP_DESCRIPTIONS[command.group], value: command.group })
|
|
67
|
+
}
|
|
68
|
+
return candidates
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function filtered(candidates: readonly CompletionCandidate[], word: string): CompletionPlan {
|
|
72
|
+
const matches = candidates
|
|
73
|
+
.filter((candidate) => candidate.value.startsWith(word))
|
|
74
|
+
.map((candidate) => ({ ...candidate }))
|
|
75
|
+
return { candidates: matches, kind: 'candidates' }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function fromSource(
|
|
79
|
+
source: CompletionSource | undefined,
|
|
80
|
+
word: string,
|
|
81
|
+
prefix: string
|
|
82
|
+
): CompletionPlan {
|
|
83
|
+
if (!source) return NONE
|
|
84
|
+
switch (source.kind) {
|
|
85
|
+
case 'dynamic':
|
|
86
|
+
return { kind: 'dynamic', prefix, source: source.source, word }
|
|
87
|
+
case 'file':
|
|
88
|
+
return { kind: 'files' }
|
|
89
|
+
case 'values': {
|
|
90
|
+
const matches = source.values
|
|
91
|
+
.filter((value) => value.startsWith(word))
|
|
92
|
+
.map((value) => ({ value: `${prefix}${value}` }))
|
|
93
|
+
return { candidates: matches, kind: 'candidates' }
|
|
94
|
+
}
|
|
95
|
+
// 'none' and any future kind fall through to no candidates.
|
|
96
|
+
default:
|
|
97
|
+
return NONE
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface TokenScan {
|
|
102
|
+
/** Set when the last token was a flag still waiting for its value. */
|
|
103
|
+
awaitingValueFor: FlagSpec | null
|
|
104
|
+
positionalCount: number
|
|
105
|
+
stoppedFlags: boolean
|
|
106
|
+
usedFlags: Set<string>
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Walk the tokens BEFORE the cursor the way `parseArgs` would, but tolerant of
|
|
111
|
+
* anything malformed — a half-typed command line is the normal case here.
|
|
112
|
+
*/
|
|
113
|
+
function scanTokens(tokens: readonly string[], flags: readonly FlagSpec[]): TokenScan {
|
|
114
|
+
const byName = new Map(flags.map((flag) => [flag.name, flag]))
|
|
115
|
+
const scan: TokenScan = {
|
|
116
|
+
awaitingValueFor: null,
|
|
117
|
+
positionalCount: 0,
|
|
118
|
+
stoppedFlags: false,
|
|
119
|
+
usedFlags: new Set<string>(),
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
for (const token of tokens) {
|
|
123
|
+
if (scan.awaitingValueFor) {
|
|
124
|
+
scan.awaitingValueFor = null
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
if (!scan.stoppedFlags && token === '--') {
|
|
128
|
+
scan.stoppedFlags = true
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
if (!scan.stoppedFlags && token.startsWith('--')) {
|
|
132
|
+
const eq = token.indexOf('=')
|
|
133
|
+
const name = eq === -1 ? token.slice(2) : token.slice(2, eq)
|
|
134
|
+
scan.usedFlags.add(name)
|
|
135
|
+
const spec = byName.get(name)
|
|
136
|
+
// `optional-string` only ever binds via `=`, so a bare one never awaits.
|
|
137
|
+
if (spec && eq === -1 && (spec.kind === 'string' || spec.kind === 'number')) {
|
|
138
|
+
scan.awaitingValueFor = spec
|
|
139
|
+
}
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
scan.positionalCount++
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return scan
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function planTopLevel(word: string): CompletionPlan {
|
|
149
|
+
if (word.startsWith('-')) {
|
|
150
|
+
return filtered(
|
|
151
|
+
[
|
|
152
|
+
{ description: 'Show CLI help', value: '--help' },
|
|
153
|
+
{ description: 'Print the package version', value: '--version' },
|
|
154
|
+
],
|
|
155
|
+
word
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
return filtered([...groupCandidates(), ...TOP_LEVEL_COMMANDS], word)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* @param words Full command line tokens, including the program name at index 0.
|
|
163
|
+
* @param cword Index into `words` of the token being completed.
|
|
164
|
+
*/
|
|
165
|
+
export function planCompletion(words: readonly string[], cword: number): CompletionPlan {
|
|
166
|
+
const index = Math.max(0, cword)
|
|
167
|
+
const word = words[index] ?? ''
|
|
168
|
+
const argIndex = index - 1
|
|
169
|
+
if (argIndex < 0) return NONE
|
|
170
|
+
|
|
171
|
+
const args = words.slice(1)
|
|
172
|
+
if (argIndex === 0) return planTopLevel(word)
|
|
173
|
+
|
|
174
|
+
const group = args[0] ?? ''
|
|
175
|
+
|
|
176
|
+
if (group === 'completion') {
|
|
177
|
+
return argIndex === 1 ? filtered(COMPLETION_SUBCOMMANDS, word) : NONE
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const verbs = COMMANDS.filter((command) => command.group === group)
|
|
181
|
+
if (verbs.length === 0) return NONE
|
|
182
|
+
|
|
183
|
+
if (argIndex === 1) {
|
|
184
|
+
return filtered(
|
|
185
|
+
verbs.map((command) => ({ description: command.summary, value: command.verb })),
|
|
186
|
+
word
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const command = resolveCommand(group, args[1] ?? '')
|
|
191
|
+
if (!command) return NONE
|
|
192
|
+
|
|
193
|
+
const scan = scanTokens(args.slice(2, argIndex), command.flags)
|
|
194
|
+
|
|
195
|
+
if (scan.awaitingValueFor) {
|
|
196
|
+
return fromSource(scan.awaitingValueFor.complete, word, '')
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (!scan.stoppedFlags && word.startsWith('--')) {
|
|
200
|
+
const eq = word.indexOf('=')
|
|
201
|
+
if (eq !== -1) {
|
|
202
|
+
const name = word.slice(2, eq)
|
|
203
|
+
const spec = command.flags.find((flag) => flag.name === name)
|
|
204
|
+
if (!spec || spec.kind === 'boolean') return NONE
|
|
205
|
+
return fromSource(spec.complete, word.slice(eq + 1), `--${name}=`)
|
|
206
|
+
}
|
|
207
|
+
return filtered(
|
|
208
|
+
command.flags
|
|
209
|
+
.filter((flag) => !scan.usedFlags.has(flag.name))
|
|
210
|
+
.map((flag) => ({ description: flag.description, value: `--${flag.name}` })),
|
|
211
|
+
word
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return fromSource(command.args[scan.positionalCount]?.complete, word, '')
|
|
216
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell completion scripts.
|
|
3
|
+
*
|
|
4
|
+
* They are deliberately THIN: collect the current words, shell out to
|
|
5
|
+
* `aimux __complete`, render the reply. All real logic lives in TypeScript, so
|
|
6
|
+
* upgrading aimux upgrades completion behaviour without regenerating anything
|
|
7
|
+
* the user has installed.
|
|
8
|
+
*
|
|
9
|
+
* Reply protocol (`__complete` stdout):
|
|
10
|
+
* value<TAB>description … zero or more candidate lines
|
|
11
|
+
* :list | :files | :none … exactly one trailing directive line
|
|
12
|
+
*
|
|
13
|
+
* `:files` means "no candidates of ours — use your own filename completion".
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { COMMANDS } from '../registry'
|
|
17
|
+
|
|
18
|
+
export type SupportedShell = 'bash' | 'fish' | 'zsh'
|
|
19
|
+
|
|
20
|
+
export const SUPPORTED_SHELLS: readonly SupportedShell[] = ['bash', 'fish', 'zsh']
|
|
21
|
+
|
|
22
|
+
export const DIRECTIVE_LIST = ':list'
|
|
23
|
+
export const DIRECTIVE_FILES = ':files'
|
|
24
|
+
export const DIRECTIVE_NONE = ':none'
|
|
25
|
+
|
|
26
|
+
export function isSupportedShell(value: string): value is SupportedShell {
|
|
27
|
+
return SUPPORTED_SHELLS.includes(value as SupportedShell)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Long flags that take a path — fish needs these declared up front. */
|
|
31
|
+
function fileFlagNames(): string[] {
|
|
32
|
+
const names = new Set<string>()
|
|
33
|
+
for (const command of COMMANDS) {
|
|
34
|
+
for (const flag of command.flags) {
|
|
35
|
+
if (flag.complete?.kind === 'file') names.add(flag.name)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return [...names].sort()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function bashScript(command: string): string {
|
|
42
|
+
return `# aimux bash completion. Regenerate with: aimux completion bash
|
|
43
|
+
_aimux_complete() {
|
|
44
|
+
local IFS=$'\\n'
|
|
45
|
+
local directive="${DIRECTIVE_LIST}"
|
|
46
|
+
local -a candidates=()
|
|
47
|
+
local line
|
|
48
|
+
|
|
49
|
+
while IFS= read -r line; do
|
|
50
|
+
case "$line" in
|
|
51
|
+
:*) directive="$line" ;;
|
|
52
|
+
"") ;;
|
|
53
|
+
*) candidates+=("\${line%%$'\\t'*}") ;;
|
|
54
|
+
esac
|
|
55
|
+
done < <(${command} __complete --no-descriptions --cword "$COMP_CWORD" -- "\${COMP_WORDS[@]}" 2>/dev/null)
|
|
56
|
+
|
|
57
|
+
if [[ "$directive" == "${DIRECTIVE_FILES}" ]]; then
|
|
58
|
+
# Let readline do filenames itself — it handles quoting and trailing slashes.
|
|
59
|
+
compopt -o default 2>/dev/null
|
|
60
|
+
COMPREPLY=()
|
|
61
|
+
return 0
|
|
62
|
+
fi
|
|
63
|
+
|
|
64
|
+
compopt +o default 2>/dev/null
|
|
65
|
+
if [[ "$directive" == "${DIRECTIVE_NONE}" ]]; then
|
|
66
|
+
COMPREPLY=()
|
|
67
|
+
return 0
|
|
68
|
+
fi
|
|
69
|
+
|
|
70
|
+
COMPREPLY=($(compgen -W "\${candidates[*]}" -- "\${COMP_WORDS[COMP_CWORD]}"))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
complete -F _aimux_complete aimux
|
|
74
|
+
`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function zshScript(command: string): string {
|
|
78
|
+
return `#compdef aimux
|
|
79
|
+
# aimux zsh completion. Regenerate with: aimux completion zsh
|
|
80
|
+
|
|
81
|
+
_aimux_complete() {
|
|
82
|
+
local -a candidates
|
|
83
|
+
local directive="${DIRECTIVE_LIST}"
|
|
84
|
+
local line
|
|
85
|
+
|
|
86
|
+
local -a reply_lines
|
|
87
|
+
reply_lines=("\${(@f)$(${command} __complete --cword $((CURRENT - 1)) -- "\${words[@]}" 2>/dev/null)}")
|
|
88
|
+
|
|
89
|
+
for line in "\${reply_lines[@]}"; do
|
|
90
|
+
case "$line" in
|
|
91
|
+
:*) directive="$line" ;;
|
|
92
|
+
"") ;;
|
|
93
|
+
# _describe takes "value:description"; the reply is tab separated.
|
|
94
|
+
*) candidates+=("\${line/$'\\t'/:}") ;;
|
|
95
|
+
esac
|
|
96
|
+
done
|
|
97
|
+
|
|
98
|
+
if [[ "$directive" == "${DIRECTIVE_FILES}" ]]; then
|
|
99
|
+
_files
|
|
100
|
+
return
|
|
101
|
+
fi
|
|
102
|
+
[[ "$directive" == "${DIRECTIVE_NONE}" ]] && return
|
|
103
|
+
|
|
104
|
+
_describe -t aimux 'aimux' candidates
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
compdef _aimux_complete aimux
|
|
108
|
+
`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function fishScript(command: string): string {
|
|
112
|
+
const fileFlags = fileFlagNames()
|
|
113
|
+
.map((name) => `complete -c aimux -l ${name} -r -F`)
|
|
114
|
+
.join('\n')
|
|
115
|
+
return `# aimux fish completion. Regenerate with: aimux completion fish
|
|
116
|
+
|
|
117
|
+
function __aimux_complete
|
|
118
|
+
# \`commandline -opc\` is every finished token (including "aimux"), so its
|
|
119
|
+
# count IS the 0-based index of the token being typed. An empty current
|
|
120
|
+
# token expands to nothing, which the resolver reads as "".
|
|
121
|
+
set -l tokens (commandline -opc)
|
|
122
|
+
set -l current (commandline -ct)
|
|
123
|
+
${command} __complete --cword (count $tokens) -- $tokens $current 2>/dev/null |
|
|
124
|
+
string match --invert --regex '^:'
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
complete -c aimux -f -a '(__aimux_complete)'
|
|
128
|
+
|
|
129
|
+
# Flags that take a path get fish's own file completion.
|
|
130
|
+
${fileFlags}
|
|
131
|
+
`
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @param command How the script should invoke aimux. Override it in dev to
|
|
136
|
+
* point at a checkout (e.g. `bun run /path/to/aimux/src/index.tsx`).
|
|
137
|
+
*/
|
|
138
|
+
export function renderCompletionScript(shell: SupportedShell, command = 'aimux'): string {
|
|
139
|
+
switch (shell) {
|
|
140
|
+
case 'bash':
|
|
141
|
+
return bashScript(command)
|
|
142
|
+
case 'fish':
|
|
143
|
+
return fishScript(command)
|
|
144
|
+
case 'zsh':
|
|
145
|
+
return zshScript(command)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dynamic completion sources — the half of completion that needs live state.
|
|
3
|
+
*
|
|
4
|
+
* Two hard rules, because this code runs on every TAB press:
|
|
5
|
+
* 1. Never block. Anything that could hang (a daemon round-trip) must be
|
|
6
|
+
* wrapped in a deadline by its resolver.
|
|
7
|
+
* 2. Never fail loudly. A source that throws yields zero candidates; the
|
|
8
|
+
* shell must never see a stack trace where a completion list belongs.
|
|
9
|
+
*
|
|
10
|
+
* Phase 1 implements only the sources that read local state (built-in
|
|
11
|
+
* assistants, the workspace catalog). Daemon-backed sources — tabs, workers,
|
|
12
|
+
* worktrees — and git refs return nothing until phase 2 wires them up.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { DynamicCompletionSource } from '../flags'
|
|
16
|
+
import type { CompletionCandidate } from './plan'
|
|
17
|
+
|
|
18
|
+
import { ASSISTANT_OPTIONS } from '../../pty/command-registry'
|
|
19
|
+
|
|
20
|
+
function assistantCandidates(): CompletionCandidate[] {
|
|
21
|
+
return ASSISTANT_OPTIONS.map((option) => ({
|
|
22
|
+
description: option.description,
|
|
23
|
+
value: option.id,
|
|
24
|
+
}))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function workspaceCandidates(): Promise<CompletionCandidate[]> {
|
|
28
|
+
// Imported lazily: the session catalog pulls in config + state modules that
|
|
29
|
+
// the static-source paths (groups, verbs, flags) have no reason to load.
|
|
30
|
+
const { listWorkspaces } = await import('../client/workspace-resolver')
|
|
31
|
+
const sessions = listWorkspaces()
|
|
32
|
+
const nameCounts = new Map<string, number>()
|
|
33
|
+
for (const session of sessions) {
|
|
34
|
+
nameCounts.set(session.name, (nameCounts.get(session.name) ?? 0) + 1)
|
|
35
|
+
}
|
|
36
|
+
return sessions.map((session) =>
|
|
37
|
+
// Ambiguous names can't be resolved by `--workspace`, so offer the id.
|
|
38
|
+
(nameCounts.get(session.name) ?? 0) > 1
|
|
39
|
+
? { description: session.name, value: session.id }
|
|
40
|
+
: { description: session.id, value: session.name }
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function resolveSource(source: DynamicCompletionSource): Promise<CompletionCandidate[]> {
|
|
45
|
+
switch (source) {
|
|
46
|
+
case 'assistant':
|
|
47
|
+
return assistantCandidates()
|
|
48
|
+
case 'workspace':
|
|
49
|
+
return await workspaceCandidates()
|
|
50
|
+
// Phase 2: 'tab' | 'worker' | 'worktree' need a daemon round-trip under a
|
|
51
|
+
// deadline; 'git-ref' needs a `git for-each-ref` in the workspace repo.
|
|
52
|
+
default:
|
|
53
|
+
return []
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolve a dynamic source, filter by the partial word, and re-apply the
|
|
59
|
+
* prefix the planner stripped (e.g. `--workspace=`). Always resolves.
|
|
60
|
+
*/
|
|
61
|
+
export async function resolveDynamicCandidates(
|
|
62
|
+
source: DynamicCompletionSource,
|
|
63
|
+
word: string,
|
|
64
|
+
prefix: string
|
|
65
|
+
): Promise<CompletionCandidate[]> {
|
|
66
|
+
try {
|
|
67
|
+
const candidates = await resolveSource(source)
|
|
68
|
+
return candidates
|
|
69
|
+
.filter((candidate) => candidate.value.startsWith(word))
|
|
70
|
+
.map((candidate) => ({ ...candidate, value: `${prefix}${candidate.value}` }))
|
|
71
|
+
} catch {
|
|
72
|
+
return []
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/cli/context.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { SessionRecord } from '../state/types'
|
|
2
2
|
import type { DaemonClient } from './client/daemon-client'
|
|
3
|
+
import type { WorkspaceOrigin } from './client/workspace-resolver'
|
|
3
4
|
import type { ParsedArgs } from './flags'
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -13,4 +14,18 @@ export interface CliContext {
|
|
|
13
14
|
getDaemon: () => Promise<DaemonClient>
|
|
14
15
|
/** Resolved workspace, populated on first call to `workspace()`. */
|
|
15
16
|
getWorkspace: () => SessionRecord
|
|
17
|
+
/**
|
|
18
|
+
* Every catalogued workspace. Commands that must answer "is my worker really
|
|
19
|
+
* gone, or did the active workspace move?" need the whole catalog, not just
|
|
20
|
+
* the resolved record. Optional so test fixtures can inject one.
|
|
21
|
+
*/
|
|
22
|
+
getWorkspaces?: () => SessionRecord[]
|
|
23
|
+
/**
|
|
24
|
+
* Where `getWorkspace()` came from — `flag` (`--workspace`), `env`
|
|
25
|
+
* (`AIMUX_WORKSPACE`), or `active` (most recently opened session). Only
|
|
26
|
+
* `active` can drift under a command while the UI switches workspaces, so
|
|
27
|
+
* commands that must not follow the UI branch on this. Optional so test
|
|
28
|
+
* fixtures can build a minimal context.
|
|
29
|
+
*/
|
|
30
|
+
getWorkspaceOrigin?: () => WorkspaceOrigin
|
|
16
31
|
}
|
package/src/cli/flags.ts
CHANGED
|
@@ -4,15 +4,48 @@
|
|
|
4
4
|
* and `--` to end flag parsing. Unknown flags produce a usage error.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* A dynamic completion source — resolved at TAB time from live state (catalog
|
|
9
|
+
* file, config, or the daemon) rather than from a fixed list. Resolution is
|
|
10
|
+
* always best-effort: a source that can't be reached yields no candidates
|
|
11
|
+
* instead of blocking or erroring the shell.
|
|
12
|
+
*/
|
|
13
|
+
export type DynamicCompletionSource =
|
|
14
|
+
| 'assistant'
|
|
15
|
+
| 'git-ref'
|
|
16
|
+
| 'tab'
|
|
17
|
+
| 'worker'
|
|
18
|
+
| 'workspace'
|
|
19
|
+
| 'worktree'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Where a flag value or positional draws its shell-completion candidates.
|
|
23
|
+
* Declared on the spec next to `description` so `--help` and completion are
|
|
24
|
+
* generated from one source and can never drift apart.
|
|
25
|
+
*
|
|
26
|
+
* - `values` — fixed vocabulary, zero I/O
|
|
27
|
+
* - `dynamic` — resolved from live state, best-effort
|
|
28
|
+
* - `file` — hand off to the shell's own filename completion
|
|
29
|
+
* - `none` — free text (prompts, models, branch names we can't enumerate)
|
|
30
|
+
*/
|
|
31
|
+
export type CompletionSource =
|
|
32
|
+
| { kind: 'dynamic'; source: DynamicCompletionSource }
|
|
33
|
+
| { kind: 'file' }
|
|
34
|
+
| { kind: 'none' }
|
|
35
|
+
| { kind: 'values'; values: readonly string[] }
|
|
36
|
+
|
|
7
37
|
export interface FlagSpec {
|
|
8
38
|
name: string
|
|
9
39
|
kind: 'string' | 'number' | 'boolean' | 'optional-string'
|
|
10
40
|
description?: string
|
|
41
|
+
/** Completion source for this flag's VALUE. Boolean flags take none. */
|
|
42
|
+
complete?: CompletionSource
|
|
11
43
|
}
|
|
12
44
|
|
|
13
45
|
export interface ArgSpec {
|
|
14
46
|
name: string
|
|
15
47
|
required?: boolean
|
|
48
|
+
complete?: CompletionSource
|
|
16
49
|
}
|
|
17
50
|
|
|
18
51
|
export interface ParsedArgs {
|
|
@@ -102,7 +135,17 @@ export function parseArgs(
|
|
|
102
135
|
* consistency with future formats.
|
|
103
136
|
*/
|
|
104
137
|
export const SHARED_FLAGS: readonly FlagSpec[] = [
|
|
105
|
-
{
|
|
106
|
-
|
|
138
|
+
{
|
|
139
|
+
complete: { kind: 'dynamic', source: 'workspace' },
|
|
140
|
+
description: 'workspace id or name',
|
|
141
|
+
kind: 'string',
|
|
142
|
+
name: 'workspace',
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
complete: { kind: 'none' },
|
|
146
|
+
description: 'runtime profile override (sets AIMUX_PROFILE)',
|
|
147
|
+
kind: 'string',
|
|
148
|
+
name: 'profile',
|
|
149
|
+
},
|
|
107
150
|
{ description: 'always-on JSON output (kept for consistency)', kind: 'boolean', name: 'json' },
|
|
108
151
|
]
|
package/src/cli/index.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { DaemonClient } from './client/daemon-client'
|
|
|
2
2
|
import type { CliContext } from './context'
|
|
3
3
|
|
|
4
4
|
import { connectToDaemon, DaemonUnreachableError } from './client/bootstrap'
|
|
5
|
-
import {
|
|
5
|
+
import { listWorkspaces, resolveWorkspaceWithOrigin } from './client/workspace-resolver'
|
|
6
6
|
import { type ArgSpec, CliUsageError, type FlagSpec, parseArgs, SHARED_FLAGS } from './flags'
|
|
7
7
|
import {
|
|
8
8
|
EXIT_DAEMON_UNREACHABLE,
|
|
@@ -21,6 +21,7 @@ const EXIT_CODES_BLOCK = [
|
|
|
21
21
|
' 3 runtime error (server replied with error, command failed)',
|
|
22
22
|
' 4 daemon unreachable (socket missing and autostart failed)',
|
|
23
23
|
' 10 question (tab run / tab await: worker is blocked on a question/permission)',
|
|
24
|
+
' 11 pending submit (worker holds an unsubmitted prompt — see `worker submit`)',
|
|
24
25
|
' 124 timeout (tab run, tab await, tab wait, tab tail --timeout, workspace switch --wait)',
|
|
25
26
|
].join('\n')
|
|
26
27
|
|
|
@@ -99,14 +100,20 @@ function printHelp(): void {
|
|
|
99
100
|
'',
|
|
100
101
|
'Env:',
|
|
101
102
|
' AIMUX_PROFILE Runtime profile (state dir, socket paths); --profile overrides.',
|
|
103
|
+
' AIMUX_WORKSPACE Pin the target workspace (id or name); --workspace overrides.',
|
|
102
104
|
'',
|
|
103
105
|
'Agent recipe:',
|
|
104
106
|
' # create an isolated named worker, dispatch, and await one structured outcome',
|
|
105
|
-
'
|
|
107
|
+
' # --workspace pins the target repo so a UI workspace switch cannot redirect it',
|
|
108
|
+
' aimux worker run --workspace myrepo --name fixup --assistant claude "explain this repo"',
|
|
106
109
|
'',
|
|
107
110
|
'Maintenance:',
|
|
108
111
|
' aimux doctor | update | restart-daemon | restart-terminal-manager | version',
|
|
109
112
|
'',
|
|
113
|
+
'Shell completion:',
|
|
114
|
+
' Installed automatically on first launch (AIMUX_NO_COMPLETION_INSTALL=1 opts out).',
|
|
115
|
+
' aimux completion install | aimux completion <bash|zsh|fish>',
|
|
116
|
+
'',
|
|
110
117
|
].join('\n')
|
|
111
118
|
)
|
|
112
119
|
}
|
|
@@ -218,11 +225,18 @@ export async function runCli(argv: readonly string[]): Promise<number> {
|
|
|
218
225
|
|
|
219
226
|
const state: {
|
|
220
227
|
daemon: DaemonClient | null
|
|
221
|
-
workspace: ReturnType<typeof
|
|
228
|
+
workspace: ReturnType<typeof resolveWorkspaceWithOrigin> | null
|
|
222
229
|
} = {
|
|
223
230
|
daemon: null,
|
|
224
231
|
workspace: null,
|
|
225
232
|
}
|
|
233
|
+
const resolveOnce = (): ReturnType<typeof resolveWorkspaceWithOrigin> => {
|
|
234
|
+
if (state.workspace) return state.workspace
|
|
235
|
+
const workspaceFlag =
|
|
236
|
+
typeof parsed.flags.workspace === 'string' ? parsed.flags.workspace : undefined
|
|
237
|
+
state.workspace = resolveWorkspaceWithOrigin(workspaceFlag)
|
|
238
|
+
return state.workspace
|
|
239
|
+
}
|
|
226
240
|
const ctx: CliContext = {
|
|
227
241
|
args: parsed,
|
|
228
242
|
getDaemon: async () => {
|
|
@@ -230,13 +244,9 @@ export async function runCli(argv: readonly string[]): Promise<number> {
|
|
|
230
244
|
state.daemon = await connectToDaemon()
|
|
231
245
|
return state.daemon
|
|
232
246
|
},
|
|
233
|
-
getWorkspace: () =>
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
typeof parsed.flags.workspace === 'string' ? parsed.flags.workspace : undefined
|
|
237
|
-
state.workspace = resolveWorkspace(workspaceFlag)
|
|
238
|
-
return state.workspace
|
|
239
|
-
},
|
|
247
|
+
getWorkspace: () => resolveOnce().record,
|
|
248
|
+
getWorkspaceOrigin: () => resolveOnce().origin,
|
|
249
|
+
getWorkspaces: () => listWorkspaces(),
|
|
240
250
|
}
|
|
241
251
|
|
|
242
252
|
try {
|
package/src/cli/output.ts
CHANGED
|
@@ -23,10 +23,13 @@ export function writeError(message: string): void {
|
|
|
23
23
|
// 3 runtime error (server replied with `error`, command failed)
|
|
24
24
|
// 4 daemon unreachable (socket missing and autostart failed)
|
|
25
25
|
// 10 question (`tab run`: worker is blocked on a question/permission)
|
|
26
|
+
// 11 pending submit (`worker run --detach`: prompt is in the composer but no
|
|
27
|
+
// turn started — recoverable with `worker submit`, unlike a real error)
|
|
26
28
|
// 124 timeout (`tab wait`, `tab tail --timeout`, `workspace switch --wait`)
|
|
27
29
|
export const EXIT_OK = 0
|
|
28
30
|
export const EXIT_USAGE = 2
|
|
29
31
|
export const EXIT_RUNTIME = 3
|
|
30
32
|
export const EXIT_DAEMON_UNREACHABLE = 4
|
|
31
33
|
export const EXIT_QUESTION = 10
|
|
34
|
+
export const EXIT_PENDING_SUBMIT = 11
|
|
32
35
|
export const EXIT_TIMEOUT = 124
|
package/src/cli/registry.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { workerList } from './commands/worker/list'
|
|
|
17
17
|
import { workerPrompt } from './commands/worker/prompt'
|
|
18
18
|
import { workerRun } from './commands/worker/run'
|
|
19
19
|
import { workerStop } from './commands/worker/stop'
|
|
20
|
+
import { workerSubmit } from './commands/worker/submit'
|
|
20
21
|
import { workspaceClose } from './commands/workspace/close'
|
|
21
22
|
import { workspaceCreate } from './commands/workspace/create'
|
|
22
23
|
import { workspaceList } from './commands/workspace/list'
|
|
@@ -56,6 +57,7 @@ export const COMMANDS: readonly CliCommand[] = [
|
|
|
56
57
|
worktreeRemove,
|
|
57
58
|
workerRun,
|
|
58
59
|
workerPrompt,
|
|
60
|
+
workerSubmit,
|
|
59
61
|
workerAwait,
|
|
60
62
|
workerList,
|
|
61
63
|
workerStop,
|
package/src/doctor.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
|
|
3
|
+
import { completionStatus } from './cli/completion/install'
|
|
3
4
|
import { getConfigPath, loadConfigResult } from './config'
|
|
4
5
|
import { ASSISTANT_OPTIONS, isCommandAvailable, parseCommand } from './pty/command-registry'
|
|
5
6
|
|
|
@@ -73,6 +74,9 @@ export function buildDoctorReport(): DoctorReport {
|
|
|
73
74
|
ok: configResult.issues.length === 0,
|
|
74
75
|
})
|
|
75
76
|
|
|
77
|
+
const completion = completionStatus()
|
|
78
|
+
checks.push({ details: completion.detail, name: 'completion', ok: completion.ok })
|
|
79
|
+
|
|
76
80
|
for (const option of ASSISTANT_OPTIONS) {
|
|
77
81
|
const configuredCommand = config.customCommands[option.id] ?? option.command
|
|
78
82
|
const { args, executable } = parseCommand(configuredCommand)
|
package/src/git/worktree.ts
CHANGED
|
@@ -59,6 +59,16 @@ export async function listLocalBranches(cwd: string): Promise<string[]> {
|
|
|
59
59
|
.filter((line) => line !== '')
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
// Resolve a ref to its SHA in a specific repository, or undefined when it does
|
|
63
|
+
// not exist there. The repo argument matters more than it looks: the same ref
|
|
64
|
+
// name ("main", "HEAD") resolves in almost every repo, so a caller that picked
|
|
65
|
+
// the wrong repository gets a *successful* resolution and no signal at all.
|
|
66
|
+
export async function resolveGitRef(repoPath: string, ref: string): Promise<string | undefined> {
|
|
67
|
+
const result = await $`git -C ${repoPath} rev-parse --verify --quiet ${ref}`.quiet().nothrow()
|
|
68
|
+
if (result.exitCode !== 0) return undefined
|
|
69
|
+
return result.text().trim() || undefined
|
|
70
|
+
}
|
|
71
|
+
|
|
62
72
|
export async function createGitWorktree({
|
|
63
73
|
baseRef,
|
|
64
74
|
branchName,
|
|
@@ -74,7 +84,11 @@ export async function createGitWorktree({
|
|
|
74
84
|
.quiet()
|
|
75
85
|
.nothrow()
|
|
76
86
|
if (result.exitCode !== 0) {
|
|
77
|
-
|
|
87
|
+
// Always name the repository. A bare "fatal: not a valid object name: 'X'"
|
|
88
|
+
// reads as a ref-resolution bug when the real cause is that git ran in a
|
|
89
|
+
// repository the caller did not mean to target.
|
|
90
|
+
const stderr = result.stderr.toString().trim()
|
|
91
|
+
throw new Error(`${stderr || 'failed to create git worktree'} (in ${repoPath})`)
|
|
78
92
|
}
|
|
79
93
|
}
|
|
80
94
|
|